Compare commits

...

11 Commits

Author SHA1 Message Date
2d7ad55a0f Ny installer 2026-04-28 08:37:27 +02:00
324c94fde2 Diverse rettelser 2026-04-25 21:28:31 +02:00
8d4c4a81c1 Opdateret downloadfil 2026-04-24 10:29:41 +02:00
09412073cd dll filer 2026-04-24 09:31:03 +02:00
25c2dd9e78 Søging på hjemmeside 2026-04-23 08:51:37 +02:00
115cc92d6a Ny installer igen 2026-04-22 16:53:51 +02:00
c3453d8d55 Ny installer 2026-04-22 12:59:13 +02:00
d28aafb2c6 Ny installer 2026-04-22 10:13:20 +02:00
b695a4858b Sync alternativer 2026-04-22 10:00:12 +02:00
37b49c1fed Rettet 2026-04-21 19:27:11 +02:00
545cdc6866 Bedre tag sync 2026-04-21 19:18:19 +02:00
20 changed files with 1327 additions and 204 deletions

View File

@@ -0,0 +1,121 @@
"""
alt_dance_ratings.py — Community alternativ-dans ratings endpoint.
"""
import uuid as _uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import User, Song, Dance, CommunityDanceAlt, DanceAltRating
router = APIRouter(prefix="/alt-ratings", tags=["alt-ratings"])
class SubmitAltRequest(BaseModel):
song_id: str # server song UUID
dance_name: str
rating: int # 1-5
@router.post("/submit")
def submit_alt_rating(
req: SubmitAltRequest,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Indsend eller opdater rating for en alternativ-dans på en sang."""
if not 1 <= req.rating <= 5:
raise HTTPException(400, "Rating skal være 1-5")
song = db.query(Song).filter_by(id=req.song_id).first()
if not song:
raise HTTPException(404, "Sang ikke fundet")
dance = db.query(Dance).filter(
Dance.name.ilike(req.dance_name)
).first()
if not dance:
raise HTTPException(404, "Dans ikke fundet")
# Find eller opret community alt-dans
alt = db.query(CommunityDanceAlt).filter_by(
song_mbid=song.mbid or None,
song_title=song.title,
song_artist=song.artist,
alt_dance_id=dance.id,
).first()
if not alt:
alt = CommunityDanceAlt(
id=str(_uuid.uuid4()),
song_mbid=song.mbid or None,
song_title=song.title,
song_artist=song.artist,
alt_dance_id=dance.id,
submitted_by=me.id,
avg_rating=float(req.rating),
rating_count=1,
)
db.add(alt)
db.flush()
# Opdater eller indsæt brugerens rating
existing_rating = db.query(DanceAltRating).filter_by(
alternative_id=alt.id,
user_id=me.id,
).first()
if existing_rating:
old_score = existing_rating.score
existing_rating.score = req.rating
# Opdater gennemsnit
total = alt.avg_rating * alt.rating_count - old_score + req.rating
alt.avg_rating = total / alt.rating_count
else:
db.add(DanceAltRating(
id=str(_uuid.uuid4()),
alternative_id=alt.id,
user_id=me.id,
score=req.rating,
))
# Opdater gennemsnit
total = alt.avg_rating * alt.rating_count + req.rating
alt.rating_count += 1
alt.avg_rating = total / alt.rating_count
db.commit()
return {"status": "ok", "avg_rating": alt.avg_rating, "rating_count": alt.rating_count}
@router.get("/for-song/{song_id}")
def get_alt_ratings_for_song(
song_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Hent community alternativ-danse med ratings for en sang."""
song = db.query(Song).filter_by(id=song_id).first()
if not song:
raise HTTPException(404, "Sang ikke fundet")
alts = db.query(CommunityDanceAlt).filter(
(CommunityDanceAlt.song_mbid == song.mbid) if song.mbid else
((CommunityDanceAlt.song_title == song.title) &
(CommunityDanceAlt.song_artist == song.artist))
).all()
result = []
for alt in alts:
my_rating = db.query(DanceAltRating).filter_by(
alternative_id=alt.id,
user_id=me.id,
).first()
result.append({
"dance_name": alt.alt_dance.name,
"avg_rating": round(alt.avg_rating, 1),
"rating_count": alt.rating_count,
"my_rating": my_rating.score if my_rating else None,
})
return result

View File

@@ -10,6 +10,7 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
@@ -53,6 +54,7 @@ class SongAltDanceData(BaseModel):
dance_name: str dance_name: str
level_name: str = "" level_name: str = ""
note: str = "" note: str = ""
user_rating: Optional[int] = None
class PlaylistSongData(BaseModel): class PlaylistSongData(BaseModel):
song_local_id: str song_local_id: str
@@ -72,12 +74,13 @@ class PlaylistData(BaseModel):
songs: list[PlaylistSongData] = [] songs: list[PlaylistSongData] = []
class PushPayload(BaseModel): class PushPayload(BaseModel):
songs: list[SongData] = [] songs: list[SongData] = []
dances: list[DanceData] = [] dances: list[DanceData] = []
song_dances: list[SongDanceData] = [] song_dances: list[SongDanceData] = []
song_alts: list[SongAltDanceData] = [] song_alts: list[SongAltDanceData] = []
playlists: list[PlaylistData] = [] playlists: list[PlaylistData] = []
deleted_playlists: list[str] = [] # server-IDs (Project.id) deleted_playlists: list[str] = [] # server-IDs (Project.id)
songs_with_dances_synced: list[str] = [] # sang-IDs der er fuldt synkroniseret
# ── Hjælpefunktion: find eller opret sang globalt ───────────────────────────── # ── Hjælpefunktion: find eller opret sang globalt ─────────────────────────────
@@ -185,11 +188,17 @@ def push(
db.flush() db.flush()
dance_id_map[key] = dance.id dance_id_map[key] = dance.id
# ── Sang-dans tags ──────────────────────────────────────────────────────── # ── Sang-dans tags — synkroniser fuldt per sang ──────────────────────────
# Slet eksisterende tags for sange der er med i push, genindsæt fra klient
synced_song_ids = set()
for sd in payload.song_dances: for sd in payload.song_dances:
song_id = song_id_map.get(sd.song_local_id) song_id = song_id_map.get(sd.song_local_id)
if not song_id: if not song_id:
continue continue
if song_id not in synced_song_ids:
db.execute(_sa.text("DELETE FROM song_dances WHERE song_id=:sid"),
{"sid": song_id})
synced_song_ids.add(song_id)
level_id = level_map.get(sd.level_name.lower()) if sd.level_name else None level_id = level_map.get(sd.level_name.lower()) if sd.level_name else None
key = f"{sd.dance_name.lower()}|{level_id}" key = f"{sd.dance_name.lower()}|{level_id}"
dance_id = dance_id_map.get(key) dance_id = dance_id_map.get(key)
@@ -201,6 +210,13 @@ def push(
), {"id": str(uuid.uuid4()), "song_id": song_id, ), {"id": str(uuid.uuid4()), "song_id": song_id,
"dance_id": dance_id, "dance_order": sd.dance_order}) "dance_id": dance_id, "dance_order": sd.dance_order})
# Sange der er fuldt synkroniseret men har ingen dans-tags — slet på server
for local_id in payload.songs_with_dances_synced:
song_id = song_id_map.get(local_id)
if song_id and song_id not in synced_song_ids:
db.execute(_sa.text("DELETE FROM song_dances WHERE song_id=:sid"),
{"sid": song_id})
for sa in payload.song_alts: for sa in payload.song_alts:
song_id = song_id_map.get(sa.song_local_id) song_id = song_id_map.get(sa.song_local_id)
if not song_id: if not song_id:
@@ -210,6 +226,47 @@ def push(
dance_id = dance_id_map.get(key) dance_id = dance_id_map.get(key)
if not dance_id: if not dance_id:
continue continue
# Opdater community rating hvis bruger har givet en vurdering
if sa.user_rating and 1 <= sa.user_rating <= 5:
from app.models import CommunityDanceAlt, DanceAltRating
song_obj = db.query(Song).filter_by(id=song_id).first()
if song_obj:
alt = db.query(CommunityDanceAlt).filter_by(
song_title=song_obj.title,
song_artist=song_obj.artist,
alt_dance_id=dance_id,
).first()
if not alt:
alt = CommunityDanceAlt(
id=str(uuid.uuid4()),
song_mbid=song_obj.mbid or None,
song_title=song_obj.title,
song_artist=song_obj.artist,
alt_dance_id=dance_id,
submitted_by=me.id,
avg_rating=float(sa.user_rating),
rating_count=1,
)
db.add(alt)
db.flush()
existing_r = db.query(DanceAltRating).filter_by(
alternative_id=alt.id, user_id=me.id
).first()
if existing_r:
old_score = existing_r.score
existing_r.score = sa.user_rating
total = alt.avg_rating * alt.rating_count - old_score + sa.user_rating
alt.avg_rating = total / alt.rating_count
else:
db.add(DanceAltRating(
id=str(uuid.uuid4()),
alternative_id=alt.id,
user_id=me.id,
score=sa.user_rating,
))
total = alt.avg_rating * alt.rating_count + sa.user_rating
alt.rating_count += 1
alt.avg_rating = total / alt.rating_count
db.execute(_sa.text( db.execute(_sa.text(
"INSERT IGNORE INTO song_alt_dances (id, song_id, dance_id, note) " "INSERT IGNORE INTO song_alt_dances (id, song_id, dance_id, note) "
"VALUES (:id, :song_id, :dance_id, :note)" "VALUES (:id, :song_id, :dance_id, :note)"
@@ -387,10 +444,30 @@ def pull(
"dance_order": sd.dance_order, "dance_order": sd.dance_order,
}) })
# Community alternativ-danse (top 500 mest ratede)
from app.models import CommunityDanceAlt, DanceAltRating
community_alts = []
for alt in db.query(CommunityDanceAlt).order_by(
CommunityDanceAlt.avg_rating.desc()
).limit(500).all():
my_rating = db.query(DanceAltRating).filter_by(
alternative_id=alt.id, user_id=me.id
).first()
community_alts.append({
"song_mbid": alt.song_mbid or "",
"song_title": alt.song_title,
"song_artist": alt.song_artist,
"dance_name": alt.alt_dance.name if alt.alt_dance else "",
"avg_rating": round(alt.avg_rating, 1),
"rating_count": alt.rating_count,
"my_rating": my_rating.score if my_rating else None,
})
return { return {
"levels": levels, "levels": levels,
"dances": dances, "dances": dances,
"shared": shared, "shared": shared,
"my_playlists": my_playlists, "my_playlists": my_playlists,
"song_tags": song_tags, "song_tags": song_tags,
"community_alts": community_alts,
} }

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LineDance Player</title> <title>LineDance Player — Danselister</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;700&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
@@ -61,6 +61,25 @@
.hero h1 em { color: var(--accent); font-style: normal; } .hero h1 em { color: var(--accent); font-style: normal; }
.hero p { color: var(--muted); font-size: 1rem; } .hero p { color: var(--muted); font-size: 1rem; }
/* Tag-søgning */
.search-row {
display: flex; gap: .6rem; flex-wrap: wrap;
margin-bottom: 1.25rem; align-items: center;
}
.search-input {
background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; padding: .4rem .8rem; color: var(--text);
font-family: var(--sans); font-size: .85rem; outline: none;
transition: border-color .15s; flex: 1; min-width: 180px; max-width: 320px;
}
.search-input:focus { border-color: var(--accent); }
.tag-btn {
font-family: var(--mono); font-size: .72rem; padding: .2rem .6rem;
border-radius: 4px; border: 1px solid var(--border);
background: transparent; color: var(--muted); cursor: pointer; transition: all .15s;
}
.tag-btn:hover, .tag-btn.active { background: rgba(232,160,32,.12); color: var(--accent); border-color: rgba(232,160,32,.3); }
.section { max-width: 900px; margin: 0 auto; padding: 0 2rem 4rem; } .section { max-width: 900px; margin: 0 auto; padding: 0 2rem 4rem; }
.section-title { .section-title {
font-family: var(--mono); font-size: .72rem; letter-spacing: .15em; font-family: var(--mono); font-size: .72rem; letter-spacing: .15em;
@@ -80,9 +99,11 @@
} }
.card.clickable:hover { border-color: var(--accent); transform: translateY(-2px); } .card.clickable:hover { border-color: var(--accent); transform: translateY(-2px); }
.card.clickable:hover::before { transform: scaleX(1); } .card.clickable:hover::before { transform: scaleX(1); }
.card.locked { border-color: rgba(107,112,128,.4); opacity: .75; }
.card-title { font-weight: 600; font-size: .95rem; margin-bottom: .3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .card-title { font-weight: 600; font-size: .95rem; margin-bottom: .3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-owner { font-size: .78rem; color: var(--muted); font-family: var(--mono); margin-bottom: .75rem; } .card-owner { font-size: .78rem; color: var(--muted); font-family: var(--mono); margin-bottom: .75rem; }
.card-meta { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; } .card-meta { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.card-tags { display: flex; gap: .35rem; flex-wrap: wrap; margin-top: .5rem; }
.badge { .badge {
font-family: var(--mono); font-size: .68rem; padding: .18rem .45rem; font-family: var(--mono); font-size: .68rem; padding: .18rem .45rem;
border-radius: 4px; border: 1px solid; border-radius: 4px; border: 1px solid;
@@ -90,7 +111,8 @@
.badge.orange { background: rgba(232,160,32,.12); color: var(--accent); border-color: rgba(232,160,32,.3); } .badge.orange { background: rgba(232,160,32,.12); color: var(--accent); border-color: rgba(232,160,32,.3); }
.badge.green { background: rgba(46,204,113,.12); color: var(--green); border-color: rgba(46,204,113,.3); } .badge.green { background: rgba(46,204,113,.12); color: var(--green); border-color: rgba(46,204,113,.3); }
.badge.muted { background: rgba(107,112,128,.12); color: var(--muted); border-color: rgba(107,112,128,.3); } .badge.muted { background: rgba(107,112,128,.12); color: var(--muted); border-color: rgba(107,112,128,.3); }
.card-actions { display: flex; gap: .5rem; margin-top: .75rem; padding-top: .75rem; border-top: 1px solid var(--border); } .badge.red { background: rgba(231,76,60,.12); color: var(--red); border-color: rgba(231,76,60,.3); }
.card-actions { display: flex; gap: .5rem; margin-top: .75rem; padding-top: .75rem; border-top: 1px solid var(--border); flex-wrap: wrap; }
#detail { #detail {
display: none; position: fixed; inset: 0; display: none; position: fixed; inset: 0;
@@ -139,6 +161,39 @@
.msg.error { background: rgba(231,76,60,.12); color: var(--red); border: 1px solid rgba(231,76,60,.3); } .msg.error { background: rgba(231,76,60,.12); color: var(--red); border: 1px solid rgba(231,76,60,.3); }
.msg.success { background: rgba(46,204,113,.12); color: var(--green); border: 1px solid rgba(46,204,113,.3); } .msg.success { background: rgba(46,204,113,.12); color: var(--green); border: 1px solid rgba(46,204,113,.3); }
/* Mine danselister — sidebar layout */
.mine-layout { display: flex; gap: 1.5rem; align-items: flex-start; }
.mine-sidebar {
width: 180px; flex-shrink: 0;
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: .75rem; position: sticky; top: 80px;
}
.mine-sidebar-title {
font-family: var(--mono); font-size: .65rem; letter-spacing: .15em;
text-transform: uppercase; color: var(--muted);
padding-bottom: .5rem; margin-bottom: .5rem;
border-bottom: 1px solid var(--border);
}
.mine-tag-btn {
display: block; width: 100%; text-align: left;
font-size: .8rem; padding: .3rem .5rem; border-radius: 5px;
border: none; background: none; color: var(--muted);
cursor: pointer; transition: all .15s;
}
.mine-tag-btn:hover { color: var(--text); background: rgba(255,255,255,.04); }
.mine-tag-btn.active { color: var(--accent); background: rgba(232,160,32,.1); font-weight: 500; }
.mine-tag-btn .mine-tag-count {
float: right; font-family: var(--mono); font-size: .68rem; color: var(--muted);
}
.mine-grid-wrap { flex: 1; min-width: 0; }
.mine-search { margin-bottom: .75rem; }
.mine-search input {
width: 100%; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; padding: .4rem .8rem; color: var(--text);
font-family: var(--sans); font-size: .85rem; outline: none; transition: border-color .15s;
}
.mine-search input:focus { border-color: var(--accent); }
.empty { text-align: center; padding: 3rem 1rem; color: var(--muted); font-size: .9rem; grid-column: 1/-1; } .empty { text-align: center; padding: 3rem 1rem; color: var(--muted); font-size: .9rem; grid-column: 1/-1; }
.spinner { width: 28px; height: 28px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto .75rem; } .spinner { width: 28px; height: 28px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto .75rem; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
@@ -158,26 +213,44 @@
</header> </header>
<div class="tabs"> <div class="tabs">
<div class="tab active" data-tab="public">Public playlister</div> <div class="tab active" data-tab="public">Offentlige danselister</div>
<div class="tab" id="tab-mine" data-tab="mine" style="display:none">Mine playlister</div> <div class="tab" id="tab-mine" data-tab="mine" style="display:none">Mine danselister</div>
</div> </div>
<div class="hero"> <div class="hero">
<h1 id="hero-title">Public<br><em>playlister</em></h1> <h1 id="hero-title">Offentlige<br><em>danselister</em></h1>
<p id="hero-sub">Browse og kopiér playlister delt af LineDance Player-brugere.</p> <p id="hero-sub">Browse og kopiér danselister delt af LineDance Player-brugere.</p>
</div> </div>
<div class="section"> <div class="section">
<div id="pane-public"> <div id="pane-public">
<div class="section-title">Alle public playlister</div> <div class="search-row">
<input class="search-input" id="search-public" placeholder="Søg på navn eller tag..." oninput="filterPublic()">
<div id="tag-btns" style="display:flex;gap:.4rem;flex-wrap:wrap;"></div>
</div>
<div class="section-title" id="public-title">Alle offentlige danselister</div>
<div id="grid-public" class="grid"> <div id="grid-public" class="grid">
<div class="empty"><div class="spinner"></div>Henter playlister...</div> <div class="empty"><div class="spinner"></div>Henter danselister...</div>
</div> </div>
</div> </div>
<div id="pane-mine" style="display:none"> <div id="pane-mine" style="display:none">
<div class="section-title">Mine playlister</div> <div class="section-title" id="mine-title">Mine danselister</div>
<div id="grid-mine" class="grid"> <div class="mine-layout">
<div class="empty"><div class="spinner"></div></div> <div class="mine-sidebar">
<div class="mine-sidebar-title">Tags</div>
<button class="mine-tag-btn active" data-tag="" onclick="setMineTag('')">
Alle <span class="mine-tag-count" id="mine-all-count"></span>
</button>
<div id="mine-tag-list"></div>
</div>
<div class="mine-grid-wrap">
<div class="mine-search">
<input id="search-mine" placeholder="Søg danseliste..." oninput="filterMine()">
</div>
<div id="grid-mine" class="grid">
<div class="empty"><div class="spinner"></div></div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -213,6 +286,7 @@
</div> </div>
<div id="login-modal"> <div id="login-modal">
<div class="login-box">
<h3>Log ind</h3> <h3>Log ind</h3>
<div id="login-msg"></div> <div id="login-msg"></div>
<div class="form-row"><label>Brugernavn eller e-mail</label><input type="text" id="inp-user" placeholder="dit@email.dk"></div> <div class="form-row"><label>Brugernavn eller e-mail</label><input type="text" id="inp-user" placeholder="dit@email.dk"></div>
@@ -231,6 +305,10 @@ let token = localStorage.getItem('ld_token') || '';
let username = localStorage.getItem('ld_user') || ''; let username = localStorage.getItem('ld_user') || '';
let currentPlaylistId = null; let currentPlaylistId = null;
let currentTab = 'public'; let currentTab = 'public';
let allPublicLists = [];
let activeTag = '';
let allMineLists = [];
let activeMineTag = '';
function updateAuthUI() { function updateAuthUI() {
document.getElementById('btn-login').style.display = token ? 'none' : ''; document.getElementById('btn-login').style.display = token ? 'none' : '';
@@ -267,6 +345,8 @@ document.getElementById('btn-do-login').onclick = async () => {
localStorage.setItem('ld_token', token); localStorage.setItem('ld_user', username); localStorage.setItem('ld_token', token); localStorage.setItem('ld_user', username);
document.getElementById('login-modal').classList.remove('open'); document.getElementById('login-modal').classList.remove('open');
updateAuthUI(); updateAuthUI();
// Skift til mine danselister ved login
switchTab('mine');
loadMyPlaylists(); loadMyPlaylists();
} catch(e) { } catch(e) {
msg.innerHTML = `<div class="msg error">${e.message}</div>`; msg.innerHTML = `<div class="msg error">${e.message}</div>`;
@@ -280,37 +360,174 @@ function switchTab(tab) {
document.getElementById('pane-public').style.display = tab === 'public' ? '' : 'none'; document.getElementById('pane-public').style.display = tab === 'public' ? '' : 'none';
document.getElementById('pane-mine').style.display = tab === 'mine' ? '' : 'none'; document.getElementById('pane-mine').style.display = tab === 'mine' ? '' : 'none';
if (tab === 'public') { if (tab === 'public') {
document.getElementById('hero-title').innerHTML = 'Public<br><em>playlister</em>'; document.getElementById('hero-title').innerHTML = 'Offentlige<br><em>danselister</em>';
document.getElementById('hero-sub').textContent = 'Browse og kopiér playlister delt af LineDance Player-brugere.'; document.getElementById('hero-sub').textContent = 'Browse og kopiér danselister delt af LineDance Player-brugere.';
} else { } else {
document.getElementById('hero-title').innerHTML = 'Mine<br><em>playlister</em>'; document.getElementById('hero-title').innerHTML = 'Mine<br><em>danselister</em>';
document.getElementById('hero-sub').textContent = 'Administrér synlighed på dine playlister.'; document.getElementById('hero-sub').textContent = 'Administrér dine danselister.';
} }
} }
document.querySelectorAll('.tab').forEach(t => t.onclick = () => switchTab(t.dataset.tab)); document.querySelectorAll('.tab').forEach(t => t.onclick = () => switchTab(t.dataset.tab));
async function loadPublicPlaylists() { // ── Tag-søgning ───────────────────────────────────────────────────────────────
function filterPublic() {
const q = document.getElementById('search-public').value.trim().toLowerCase();
const filtered = allPublicLists.filter(p => {
const matchText = !q ||
p.name.toLowerCase().includes(q) ||
(p.tags || '').toLowerCase().includes(q) ||
(p.owner || '').toLowerCase().includes(q);
const matchTag = !activeTag ||
(p.tags || '').toLowerCase().split(',').map(t => t.trim()).includes(activeTag);
return matchText && matchTag;
});
renderPublicGrid(filtered);
const n = filtered.length;
document.getElementById('public-title').textContent =
(q || activeTag) ? `${n} danseliste${n !== 1 ? 'r' : ''} fundet` : 'Alle offentlige danselister';
}
function setTag(tag) {
activeTag = (activeTag === tag) ? '' : tag;
document.querySelectorAll('.tag-btn').forEach(b =>
b.classList.toggle('active', b.dataset.tag === activeTag));
filterPublic();
}
function renderPublicGrid(lists) {
const grid = document.getElementById('grid-public'); const grid = document.getElementById('grid-public');
try { if (!lists.length) {
const r = await fetch(`${API}/sharing/public`); grid.innerHTML = '<div class="empty">Ingen danselister matcher søgningen.</div>';
const lists = await r.json(); return;
if (!lists.length) { grid.innerHTML = '<div class="empty">Ingen public playlister endnu.</div>'; return; } }
grid.innerHTML = lists.map(p => ` grid.innerHTML = lists.map(p => {
const tags = (p.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const tagHtml = tags.map(t =>
`<span class="badge muted" style="cursor:pointer" onclick="setTag('${esc(t)}')">${esc(t)}</span>`
).join('');
return `
<div class="card clickable fade-in" data-id="${p.id}"> <div class="card clickable fade-in" data-id="${p.id}">
<div class="card-title">${esc(p.name)}</div> <div class="card-title">${esc(p.name)}</div>
<div class="card-owner">@ ${esc(p.owner)}</div> <div class="card-owner">@ ${esc(p.owner)}</div>
<div class="card-meta"> <div class="card-meta">
<span class="badge orange">${p.song_count} sange</span> <span class="badge orange">${p.song_count} sange</span>
<span class="badge green">public</span> <span class="badge green">offentlig</span>
</div> </div>
</div>`).join(''); ${tagHtml ? `<div class="card-tags">${tagHtml}</div>` : ''}
grid.querySelectorAll('.card').forEach(c => </div>`;
c.onclick = () => openDetail(c.dataset.id, false)); }).join('');
grid.querySelectorAll('.card').forEach(c =>
c.onclick = () => openDetail(c.dataset.id, false));
}
function buildTagButtons(lists) {
const tagSet = new Set();
lists.forEach(p => (p.tags || '').split(',').forEach(t => {
const tt = t.trim().toLowerCase();
if (tt) tagSet.add(tt);
}));
const tags = [...tagSet].sort();
const container = document.getElementById('tag-btns');
container.innerHTML = tags.map(t =>
`<button class="tag-btn" data-tag="${esc(t)}" onclick="setTag('${esc(t)}')">${esc(t)}</button>`
).join('');
}
async function loadPublicPlaylists() {
const grid = document.getElementById('grid-public');
try {
const r = await fetch(`${API}/sharing/public`);
allPublicLists = await r.json();
buildTagButtons(allPublicLists);
renderPublicGrid(allPublicLists);
} catch(e) { } catch(e) {
grid.innerHTML = `<div class="empty">Kunne ikke hente playlister.<br>${e.message}</div>`; grid.innerHTML = `<div class="empty">Kunne ikke hente danselister.<br>${e.message}</div>`;
} }
} }
// ── Mine danselister ──────────────────────────────────────────────────────────
function buildMineSidebar(lists) {
const tagCounts = {};
lists.forEach(p => (p.tags || '').split(',').forEach(t => {
const tt = t.trim().toLowerCase();
if (tt) tagCounts[tt] = (tagCounts[tt] || 0) + 1;
}));
document.getElementById('mine-all-count').textContent = lists.length;
const container = document.getElementById('mine-tag-list');
container.innerHTML = Object.entries(tagCounts)
.sort((a,b) => a[0].localeCompare(b[0]))
.map(([tag, count]) => `
<button class="mine-tag-btn${activeMineTag === tag ? ' active' : ''}"
data-tag="${esc(tag)}" onclick="setMineTag('${esc(tag)}')">
${esc(tag)} <span class="mine-tag-count">${count}</span>
</button>`).join('');
}
function setMineTag(tag) {
activeMineTag = tag;
document.querySelectorAll('.mine-tag-btn').forEach(b =>
b.classList.toggle('active', b.dataset.tag === tag));
filterMine();
}
function filterMine() {
const q = (document.getElementById('search-mine')?.value || '').trim().toLowerCase();
const filtered = allMineLists.filter(p => {
const matchText = !q ||
p.name.toLowerCase().includes(q) ||
(p.tags || '').toLowerCase().includes(q);
const matchTag = !activeMineTag ||
(p.tags || '').toLowerCase().split(',').map(t => t.trim()).includes(activeMineTag);
return matchText && matchTag;
});
renderMineGrid(filtered);
const n = filtered.length;
document.getElementById('mine-title').textContent =
(q || activeMineTag) ? `${n} danseliste${n !== 1 ? 'r' : ''} fundet` : 'Mine danselister';
}
function renderMineGrid(lists) {
const grid = document.getElementById('grid-mine');
if (!lists.length) {
grid.innerHTML = '<div class="empty">Ingen danselister matcher søgningen.</div>';
return;
}
grid.innerHTML = lists.map(p => {
const vis = p.visibility || 'private';
const locked = p.locked || false;
const bc = locked ? 'muted' : vis === 'public' ? 'green' : vis === 'shared' ? 'orange' : 'muted';
const bl = locked ? '🔒 låst' : vis === 'public' ? 'offentlig' : vis === 'shared' ? 'delt' : 'privat';
const sc = p.song_count || (p.songs || []).length || 0;
const tags = (p.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const tagHtml = tags.map(t =>
`<span class="badge muted" style="cursor:pointer" onclick="setMineTag('${esc(t)}')">${esc(t)}</span>`
).join('');
return `
<div class="card fade-in${locked ? ' locked' : ''}">
<div class="card-title">${locked ? '🔒 ' : ''}${esc(p.name)}</div>
<div class="card-meta">
<span class="badge orange">${sc} sange</span>
<span class="badge ${bc}" id="vis-badge-${p.id}">${bl}</span>
</div>
${tagHtml ? `<div class="card-tags">${tagHtml}</div>` : ''}
<div class="card-actions">
<button class="btn sm" onclick="openDetail('${p.id}',true)">Se sange</button>
${!locked ? `
<button class="btn sm${vis==='public'?' danger':''}" id="vis-btn-${p.id}"
onclick="toggleVis('${p.id}','${vis}')">
${vis === 'public' ? 'Gør privat' : 'Gør offentlig'}
</button>
<button class="btn sm danger" onclick="confirmLock('${p.id}','${esc(p.name)}')" title="Lås permanent">🔒</button>
` : ''}
<a class="btn sm" href="/live.html?id=${p.id}" target="_blank" title="Storskærm">📺</a>
<button class="btn sm" onclick="showQR('${p.id}','${esc(p.name)}')" title="QR-kode">QR</button>
</div>
</div>`;
}).join('');
}
async function loadMyPlaylists() { async function loadMyPlaylists() {
const grid = document.getElementById('grid-mine'); const grid = document.getElementById('grid-mine');
grid.innerHTML = '<div class="empty"><div class="spinner"></div></div>'; grid.innerHTML = '<div class="empty"><div class="spinner"></div></div>';
@@ -319,33 +536,15 @@ async function loadMyPlaylists() {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
if (!r.ok) throw new Error('Ikke autoriseret'); if (!r.ok) throw new Error('Ikke autoriseret');
const lists = await r.json(); allMineLists = await r.json();
if (!lists.length) { grid.innerHTML = '<div class="empty">Ingen playlister endnu.</div>'; return; } if (!allMineLists.length) {
grid.innerHTML = lists.map(p => { document.getElementById('grid-mine').innerHTML = '<div class="empty">Ingen danselister endnu.</div>';
const vis = p.visibility || 'private'; return;
const bc = vis === 'public' ? 'green' : vis === 'shared' ? 'orange' : 'muted'; }
const bl = vis === 'public' ? 'public' : vis === 'shared' ? 'delt' : 'privat'; buildMineSidebar(allMineLists);
const sc = p.song_count || (p.songs || []).length || 0; filterMine();
return `
<div class="card fade-in">
<div class="card-title">${esc(p.name)}</div>
<div class="card-meta">
<span class="badge orange">${sc} sange</span>
<span class="badge ${bc}" id="vis-badge-${p.id}">${bl}</span>
</div>
<div class="card-actions">
<button class="btn sm" onclick="openDetail('${p.id}',true)">Se sange</button>
<button class="btn sm${vis==='public'?' danger':''}" id="vis-btn-${p.id}"
onclick="toggleVis('${p.id}','${vis}')">
${vis === 'public' ? 'Gør privat' : 'Gør public'}
</button>
<a class="btn sm" href="/live.html?id=${p.id}" target="_blank" title="Åbn storskærm">📺</a>
<button class="btn sm" onclick="showQR('${p.id}','${esc(p.name)}')" title="QR-kode">QR</button>
</div>
</div>`;
}).join('');
} catch(e) { } catch(e) {
grid.innerHTML = `<div class="empty">Kunne ikke hente playlister.<br>${e.message}</div>`; grid.innerHTML = `<div class="empty">Kunne ikke hente danselister.<br>${e.message}</div>`;
} }
} }
@@ -356,21 +555,28 @@ async function toggleVis(id, current) {
method: 'PATCH', headers: { 'Authorization': `Bearer ${token}` } method: 'PATCH', headers: { 'Authorization': `Bearer ${token}` }
}); });
if (!r.ok) throw new Error('Fejl'); if (!r.ok) throw new Error('Fejl');
const badge = document.getElementById(`vis-badge-${id}`); loadMyPlaylists();
const btn = document.getElementById(`vis-btn-${id}`);
if (newVis === 'public') {
badge.className = 'badge green'; badge.textContent = 'public';
btn.className = 'btn sm danger'; btn.textContent = 'Gør privat';
btn.onclick = () => toggleVis(id, 'public');
} else {
badge.className = 'badge muted'; badge.textContent = 'privat';
btn.className = 'btn sm'; btn.textContent = 'Gør public';
btn.onclick = () => toggleVis(id, 'private');
}
loadPublicPlaylists(); loadPublicPlaylists();
} catch(e) { alert('Fejl: ' + e.message); } } catch(e) { alert('Fejl: ' + e.message); }
} }
function confirmLock(id, name) {
if (!confirm(`Lås "${name}" permanent?\n\nEn låst danseliste kan ikke længere redigeres eller opdateres fra appen. Dette kan ikke fortrydes.`)) return;
lockPlaylist(id);
}
async function lockPlaylist(id) {
try {
const r = await fetch(`${API}/projects/${id}/lock`, {
method: 'POST', headers: { 'Authorization': `Bearer ${token}` }
});
if (!r.ok) throw new Error((await r.json()).detail || 'Fejl');
loadMyPlaylists();
} catch(e) { alert('Fejl: ' + e.message); }
}
// ── Detail-visning ────────────────────────────────────────────────────────────
async function openDetail(id, isOwn) { async function openDetail(id, isOwn) {
currentPlaylistId = id; currentPlaylistId = id;
document.getElementById('btn-copy').style.display = isOwn ? 'none' : ''; document.getElementById('btn-copy').style.display = isOwn ? 'none' : '';
@@ -424,6 +630,8 @@ document.getElementById('btn-copy').onclick = async () => {
} catch(e) { btn.textContent = '⚠ ' + e.message; btn.disabled = false; } } catch(e) { btn.textContent = '⚠ ' + e.message; btn.disabled = false; }
}; };
// ── QR ───────────────────────────────────────────────────────────────────────
let currentQRUrl = ''; let currentQRUrl = '';
function showQR(id, name) { function showQR(id, name) {
@@ -433,13 +641,10 @@ function showQR(id, name) {
document.getElementById('qr-url').textContent = url; document.getElementById('qr-url').textContent = url;
document.getElementById('copy-msg').textContent = ''; document.getElementById('copy-msg').textContent = '';
document.getElementById('qr-modal').style.display = 'flex'; document.getElementById('qr-modal').style.display = 'flex';
// Tegn QR med et simpelt bibliotek
const canvas = document.getElementById('qr-canvas'); const canvas = document.getElementById('qr-canvas');
if (window.QRious) { if (window.QRious) {
new QRious({ element: canvas, value: url, size: 220, backgroundAlpha: 0, foreground: '#eceef4' }); new QRious({ element: canvas, value: url, size: 220, backgroundAlpha: 0, foreground: '#eceef4' });
} else { } else {
// Fallback: vis bare URL hvis bibliotek ikke er loadet
canvas.style.display = 'none'; canvas.style.display = 'none';
} }
} }
@@ -459,4 +664,4 @@ loadPublicPlaylists();
if (token) loadMyPlaylists(); if (token) loadMyPlaylists();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -44,6 +44,25 @@ echo.
echo OK: dist\LineDancePlayer\ er klar echo OK: dist\LineDancePlayer\ er klar
echo. echo.
:: ── Kopiér VLC DLL-filer ind i app-mappen ─────────────────────────────────────
echo Kopierer VLC DLL-filer...
set "VLC_PATH="
if exist "C:\Program Files\VideoLAN\VLC\libvlc.dll" set "VLC_PATH=C:\Program Files\VideoLAN\VLC"
if exist "C:\Program Files (x86)\VideoLAN\VLC\libvlc.dll" set "VLC_PATH=C:\Program Files (x86)\VideoLAN\VLC"
if defined VLC_PATH (
copy /Y "!VLC_PATH!\libvlc.dll" "dist\LineDancePlayer\libvlc.dll" >nul
copy /Y "!VLC_PATH!\libvlccore.dll" "dist\LineDancePlayer\libvlccore.dll" >nul
:: Kopiér også plugins-mappen som VLC kræver
if exist "!VLC_PATH!\plugins" (
xcopy /E /I /Y /Q "!VLC_PATH!\plugins" "dist\LineDancePlayer\plugins" >nul
)
echo OK: VLC DLL-filer kopieret fra !VLC_PATH!
) else (
echo ADVARSEL: VLC ikke fundet - brugere skal have VLC installeret selv
)
echo.
:: ── NSIS installer ──────────────────────────────────────────────────────────── :: ── NSIS installer ────────────────────────────────────────────────────────────
echo [4/4] Bygger NSIS installer... echo [4/4] Bygger NSIS installer...
echo. echo.
@@ -120,4 +139,4 @@ if exist "dist\LineDancePlayer-Setup.exe" (
echo Kør makensis installer.nsi manuelt naar NSIS er installeret echo Kør makensis installer.nsi manuelt naar NSIS er installeret
) )
echo. echo.
pause pause

View File

@@ -2,10 +2,32 @@
block_cipher = None block_cipher = None
import os, glob
# Find VLC installation
VLC_PATH = None
for _p in [
r'C:\Program Files\VideoLAN\VLC',
r'C:\Program Files (x86)\VideoLAN\VLC',
]:
if os.path.exists(os.path.join(_p, 'libvlc.dll')):
VLC_PATH = _p
break
VLC_BINARIES = []
if VLC_PATH:
VLC_BINARIES = [
(os.path.join(VLC_PATH, 'libvlc.dll'), '.'),
(os.path.join(VLC_PATH, 'libvlccore.dll'), '.'),
]
for _dll in glob.glob(os.path.join(VLC_PATH, 'plugins', '**', '*.dll'), recursive=True):
_rel = os.path.relpath(os.path.dirname(_dll), VLC_PATH)
VLC_BINARIES.append((_dll, _rel))
a = Analysis( a = Analysis(
['main.py'], ['main.py'],
pathex=['.'], pathex=['.'],
binaries=[], binaries=VLC_BINARIES,
datas=[ datas=[
('translations', 'translations'), ('translations', 'translations'),
], ],
@@ -20,7 +42,7 @@ a = Analysis(
'ui.scan_worker', 'ui.bpm_worker', 'ui.tag_editor', 'ui.scan_worker', 'ui.bpm_worker', 'ui.tag_editor',
'ui.settings_dialog', 'ui.playlist_browser', 'ui.settings_dialog', 'ui.playlist_browser',
'ui.playlist_info_dialog', 'ui.dance_info_dialog', 'ui.playlist_info_dialog', 'ui.dance_info_dialog',
'ui.dance_picker_dialog', 'ui.share_dialog', 'ui.dance_picker_dialog', 'ui.alt_dance_picker_dialog', 'ui.share_dialog',
'ui.register_dialog', 'ui.register_dialog',
'player.player', 'player.player',
'local.local_db', 'local.scanner', 'local.file_watcher', 'local.local_db', 'local.scanner', 'local.file_watcher',

View File

@@ -51,6 +51,12 @@ Section "LineDance Player" SecMain
SetOutPath "$INSTDIR" SetOutPath "$INSTDIR"
File "dist\LineDancePlayer\LineDancePlayer.exe" File "dist\LineDancePlayer\LineDancePlayer.exe"
; VLC DLL-filer og plugins er nu pakket direkte af PyInstaller
File /nonfatal "dist\LineDancePlayer\libvlc.dll"
File /nonfatal "dist\LineDancePlayer\libvlccore.dll"
SetOutPath "$INSTDIR\plugins"
File /nonfatal /r "dist\LineDancePlayer\plugins\*"
SetOutPath "$INSTDIR\_internal" SetOutPath "$INSTDIR\_internal"
File /r "dist\LineDancePlayer\_internal\*" File /r "dist\LineDancePlayer\_internal\*"
@@ -85,34 +91,7 @@ Section "LineDance Player" SecMain
SectionEnd SectionEnd
; ── VLC tjek ────────────────────────────────────────────────────────────────── ; VLC DLL-filer er bundlet med appen — intet VLC-tjek nødvendigt
Section -VLCCheck
; Tjek alle kendte VLC registry-stier
ReadRegStr $0 HKLM "SOFTWARE\VideoLAN\VLC" ""
ReadRegStr $1 HKCU "SOFTWARE\VideoLAN\VLC" ""
ReadRegStr $2 HKLM "SOFTWARE\WOW6432Node\VideoLAN\VLC" ""
; Tjek også om vlc.exe eksisterer
IfFileExists "$PROGRAMFILES\VideoLAN\VLC\vlc.exe" VLCFound 0
IfFileExists "$PROGRAMFILES64\VideoLAN\VLC\vlc.exe" VLCFound 0
${If} $0 != ""
Goto VLCFound
${EndIf}
${If} $1 != ""
Goto VLCFound
${EndIf}
${If} $2 != ""
Goto VLCFound
${EndIf}
; VLC ikke fundet
MessageBox MB_YESNO|MB_ICONINFORMATION \
"LineDance Player bruger VLC til afspilning.$\n$\nVLC ser ikke ud til at vaere installeret.$\n$\nVil du aabne download-siden nu?$\n$\n(Du kan installere VLC senere)" \
IDNO VLCSkip
ExecShell "open" "https://www.videolan.org/vlc/"
VLCSkip:
Goto VLCDone
VLCFound:
VLCDone:
SectionEnd
; ── Afinstaller ─────────────────────────────────────────────────────────────── ; ── Afinstaller ───────────────────────────────────────────────────────────────
Section "Uninstall" Section "Uninstall"

View File

@@ -213,11 +213,23 @@ def run_acoustid_scan(db_path: str, api_key: str = "", on_progress=None, stop_ev
if result: if result:
mbid = result.get("mbid", "") mbid = result.get("mbid", "")
acoustid = result.get("acoustid", "") acoustid = result.get("acoustid", "")
# Opdater acoustid altid, men kun mbid hvis det ikke allerede bruges
conn.execute( conn.execute(
"UPDATE songs SET mbid=?, acoustid=? WHERE id=?", "UPDATE songs SET acoustid=? WHERE id=?",
(mbid or None, acoustid or None, row["id"]) (acoustid or None, row["id"])
) )
conn.commit() if mbid:
try:
conn.execute(
"UPDATE songs SET mbid=? WHERE id=? AND (mbid IS NULL OR mbid='')",
(mbid, row["id"])
)
conn.commit()
except Exception:
conn.rollback()
logger.debug(f"MBID {mbid[:8]} allerede i brug — springer over")
else:
conn.commit()
found += 1 found += 1
total_found += 1 total_found += 1
logger.info( logger.info(

View File

@@ -113,17 +113,23 @@ CREATE TABLE IF NOT EXISTS song_dances (
-- Alternativ-dans tags -- Alternativ-dans tags
CREATE TABLE IF NOT EXISTS song_alt_dances ( CREATE TABLE IF NOT EXISTS song_alt_dances (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE, song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
dance_id INTEGER NOT NULL REFERENCES dances(id), dance_id INTEGER NOT NULL REFERENCES dances(id),
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'local', user_rating INTEGER, -- 1-5 stjerner, NULL = ikke vurderet
created_at TEXT NOT NULL DEFAULT (datetime('now')), source TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(song_id, dance_id) UNIQUE(song_id, dance_id)
); );
CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id); CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id);
CREATE INDEX IF NOT EXISTS idx_song_alt_dances ON song_alt_dances(song_id); CREATE INDEX IF NOT EXISTS idx_song_alt_dances ON song_alt_dances(song_id);
CREATE INDEX IF NOT EXISTS idx_dances_name ON dances(name COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_dances_use_count ON dances(use_count DESC);
CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title);
CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist);
CREATE INDEX IF NOT EXISTS idx_files_song_path ON files(song_id, file_missing);
-- Playlister -- Playlister
CREATE TABLE IF NOT EXISTS playlists ( CREATE TABLE IF NOT EXISTS playlists (
@@ -140,14 +146,15 @@ CREATE TABLE IF NOT EXISTS playlists (
-- Playliste-sange -- Playliste-sange
CREATE TABLE IF NOT EXISTS playlist_songs ( CREATE TABLE IF NOT EXISTS playlist_songs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
song_id TEXT NOT NULL REFERENCES songs(id), song_id TEXT NOT NULL REFERENCES songs(id),
file_id TEXT REFERENCES files(id), file_id TEXT REFERENCES files(id),
position INTEGER NOT NULL, position INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
is_workshop INTEGER NOT NULL DEFAULT 0, is_workshop INTEGER NOT NULL DEFAULT 0,
dance_override TEXT NOT NULL DEFAULT '' dance_override TEXT NOT NULL DEFAULT '',
alt_dance_override TEXT NOT NULL DEFAULT ''
); );
CREATE INDEX IF NOT EXISTS idx_playlist_songs_playlist ON playlist_songs(playlist_id); CREATE INDEX IF NOT EXISTS idx_playlist_songs_playlist ON playlist_songs(playlist_id);
@@ -647,8 +654,8 @@ def get_choreographer_suggestions(prefix: str = "", limit: int = 15) -> list[str
def get_dances_for_song(song_id: str) -> list: def get_dances_for_song(song_id: str) -> list:
"""Hent alle danse tagget på en sang med niveau og koreograf.""" """Hent alle danse tagget på en sang med niveau og koreograf."""
with get_db() as conn: with get_db() as conn:
return conn.execute(""" rows = conn.execute("""
SELECT d.id, d.name, dl.name as level_name, d.choreographer, SELECT d.id, d.name, d.level_id, dl.name as level_name, d.choreographer,
d.video_url, d.stepsheet_url, d.notes, sd.dance_order d.video_url, d.stepsheet_url, d.notes, sd.dance_order
FROM song_dances sd FROM song_dances sd
JOIN dances d ON d.id = sd.dance_id JOIN dances d ON d.id = sd.dance_id
@@ -656,13 +663,14 @@ def get_dances_for_song(song_id: str) -> list:
WHERE sd.song_id = ? WHERE sd.song_id = ?
ORDER BY sd.dance_order ORDER BY sd.dance_order
""", (song_id,)).fetchall() """, (song_id,)).fetchall()
return [dict(r) for r in rows]
def get_alt_dances_for_song(song_id: str) -> list: def get_alt_dances_for_song(song_id: str) -> list:
"""Hent alle alternativ-danse tagget på en sang.""" """Hent alle alternativ-danse tagget på en sang."""
with get_db() as conn: with get_db() as conn:
return conn.execute(""" rows = conn.execute("""
SELECT d.id, d.name, dl.name as level_name, d.choreographer, SELECT d.id, d.name, d.level_id, dl.name as level_name, d.choreographer,
d.video_url, d.stepsheet_url, sad.note d.video_url, d.stepsheet_url, sad.note
FROM song_alt_dances sad FROM song_alt_dances sad
JOIN dances d ON d.id = sad.dance_id JOIN dances d ON d.id = sad.dance_id
@@ -670,6 +678,7 @@ def get_alt_dances_for_song(song_id: str) -> list:
WHERE sad.song_id = ? WHERE sad.song_id = ?
ORDER BY d.name ORDER BY d.name
""", (song_id,)).fetchall() """, (song_id,)).fetchall()
return [dict(r) for r in rows]
def get_or_create_dance(name: str, level_id: int | None, conn, def get_or_create_dance(name: str, level_id: int | None, conn,
choreographer: str = "") -> int: choreographer: str = "") -> int:
@@ -690,4 +699,73 @@ def get_or_create_dance(name: str, level_id: int | None, conn,
"INSERT INTO dances (name, level_id, choreographer) VALUES (?,?,?)", "INSERT INTO dances (name, level_id, choreographer) VALUES (?,?,?)",
(name, level_id, choreo) (name, level_id, choreo)
) )
return cur.lastrowid return cur.lastrowid
def rate_alt_dance(song_id: str, dance_id: int, rating: int | None):
"""Sæt brugerens rating (1-5) på en alternativ-dans. None = fjern rating."""
with get_db() as conn:
conn.execute(
"UPDATE song_alt_dances SET user_rating=? WHERE song_id=? AND dance_id=?",
(rating, song_id, dance_id)
)
def get_alt_dances_for_song_with_ratings(song_id: str) -> list:
"""Hent alternativ-danse med bruger-rating og community-rating."""
with get_db() as conn:
rows = conn.execute("""
SELECT d.id, d.name, d.level_id, dl.name as level_name,
d.choreographer, sad.note, sad.user_rating,
sad.source
FROM song_alt_dances sad
JOIN dances d ON d.id = sad.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE sad.song_id = ?
ORDER BY sad.user_rating DESC NULLS LAST, d.name
""", (song_id,)).fetchall()
return [dict(r) for r in rows]
def get_community_alts_for_song(song_id: str) -> list:
"""Hent community alternativ-danse for en sang med ratings."""
with get_db() as conn:
# Opret tabellen hvis den ikke eksisterer
conn.execute(
"CREATE TABLE IF NOT EXISTS community_alt_dances ("
"id TEXT PRIMARY KEY, song_id TEXT NOT NULL, dance_id INTEGER NOT NULL, "
"avg_rating REAL NOT NULL DEFAULT 0, rating_count INTEGER NOT NULL DEFAULT 0, "
"my_rating INTEGER, UNIQUE(song_id, dance_id))"
)
rows = conn.execute("""
SELECT d.id, d.name, dl.name as level_name, d.choreographer,
cad.avg_rating, cad.rating_count, cad.my_rating
FROM community_alt_dances cad
JOIN dances d ON d.id = cad.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE cad.song_id = ?
ORDER BY cad.avg_rating DESC
""", (song_id,)).fetchall()
return [dict(r) for r in rows]
def refresh_file_availability():
"""Tjek hurtigt om alle kendte filer stadig eksisterer — opdater file_missing.
Køres ved opstart i baggrundstråd."""
from pathlib import Path
try:
with get_db() as conn:
rows = conn.execute(
"SELECT id, local_path, file_missing FROM files"
).fetchall()
for row in rows:
try:
exists = Path(row["local_path"]).exists()
expected = 0 if exists else 1
if row["file_missing"] != expected:
conn.execute(
"UPDATE files SET file_missing=? WHERE id=?",
(expected, row["id"])
)
except Exception:
pass
logger.info("Fil-tilgængelighed opdateret")
except Exception as e:
logger.warning(f"refresh_file_availability fejl: {e}")

View File

@@ -189,7 +189,7 @@ class SyncManager:
song_alts = [] song_alts = []
for row in conn.execute(""" for row in conn.execute("""
SELECT sad.song_id, d.name as dance_name, SELECT sad.song_id, d.name as dance_name,
dl.name as level_name, sad.note dl.name as level_name, sad.note, sad.user_rating
FROM song_alt_dances sad FROM song_alt_dances sad
JOIN dances d ON d.id = sad.dance_id JOIN dances d ON d.id = sad.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id LEFT JOIN dance_levels dl ON dl.id = d.level_id
@@ -199,6 +199,7 @@ class SyncManager:
"dance_name": row["dance_name"], "dance_name": row["dance_name"],
"level_name": row["level_name"] or "", "level_name": row["level_name"] or "",
"note": row["note"] or "", "note": row["note"] or "",
"user_rating": row["user_rating"],
}) })
# Playlister — alle ikke-slettede # Playlister — alle ikke-slettede
@@ -245,14 +246,18 @@ class SyncManager:
).fetchall() ).fetchall()
] ]
# Alle sang-IDs der pusher dans-tags fuldt (inkl. dem med 0 tags)
all_song_ids = [s["local_id"] for s in songs]
conn.close() conn.close()
return { return {
"songs": songs, "songs": songs,
"dances": dances, "dances": dances,
"song_dances": song_dances, "song_dances": song_dances,
"song_alts": song_alts, "song_alts": song_alts,
"playlists": playlists, "playlists": playlists,
"deleted_playlists": deleted, "deleted_playlists": deleted,
"songs_with_dances_synced": all_song_ids,
} }
# ── Gem server-IDs ──────────────────────────────────────────────────────── # ── Gem server-IDs ────────────────────────────────────────────────────────
@@ -476,6 +481,54 @@ class SyncManager:
song_data.get("dance_override","") or "")) song_data.get("dance_override","") or ""))
position += 1 position += 1
# Gem community alternativ-danse lokalt
conn.execute(
"CREATE TABLE IF NOT EXISTS community_alt_dances ("
"id TEXT PRIMARY KEY, song_id TEXT NOT NULL, dance_id INTEGER NOT NULL, "
"avg_rating REAL NOT NULL DEFAULT 0, rating_count INTEGER NOT NULL DEFAULT 0, "
"my_rating INTEGER, UNIQUE(song_id, dance_id))"
)
for ca in data.get("community_alts", []):
if not ca.get("dance_name"):
continue
song_row = None
if ca.get("song_mbid"):
song_row = conn.execute(
"SELECT id FROM songs WHERE mbid=?", (ca["song_mbid"],)
).fetchone()
if not song_row and ca.get("song_title"):
song_row = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=?",
(ca["song_title"], ca.get("song_artist", ""))
).fetchone()
if not song_row:
continue
song_id = song_row["id"]
dance_row = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
(ca["dance_name"],)
).fetchone()
if not dance_row:
cur = conn.execute(
"INSERT OR IGNORE INTO dances (name) VALUES (?)", (ca["dance_name"],)
)
dance_id = cur.lastrowid
else:
dance_id = dance_row["id"]
if not dance_id:
continue
conn.execute(
"INSERT INTO community_alt_dances "
"(id, song_id, dance_id, avg_rating, rating_count, my_rating) "
"VALUES (?,?,?,?,?,?) "
"ON CONFLICT(song_id, dance_id) DO UPDATE SET "
"avg_rating=excluded.avg_rating, rating_count=excluded.rating_count, "
"my_rating=COALESCE(excluded.my_rating, my_rating)",
(str(uuid.uuid4()), song_id, dance_id,
ca.get("avg_rating", 0), ca.get("rating_count", 0),
ca.get("my_rating"))
)
# Importer sang-dans tags fra server # Importer sang-dans tags fra server
for st in data.get("song_tags", []): for st in data.get("song_tags", []):
server_song_id = st.get("song_id", "") server_song_id = st.get("song_id", "")

View File

@@ -410,19 +410,21 @@ def read_dances_from_file(path: str | Path) -> list[str]:
return tags.get("dances", []) return tags.get("dances", [])
def write_dance_to_file(path: str | Path, dances: list[str]) -> bool:
"""Alias for write_dances — skriv danse-liste til fil."""
return write_dances(path, dances)
# ── BPM-analyse ─────────────────────────────────────────────────────────────── # ── BPM-analyse ───────────────────────────────────────────────────────────────
# Formater der ikke understøttes af librosa uden ffmpeg
_BPM_UNSUPPORTED = {".wma", ".ac3", ".dts", ".ra", ".rm", ".rmvb"}
def analyze_bpm(path: str | Path) -> float | None: def analyze_bpm(path: str | Path) -> float | None:
""" """
Analysér BPM fra lydfilen ved hjælp af librosa. Analysér BPM fra lydfilen ved hjælp af librosa.
Returnerer BPM som float eller None ved fejl. Returnerer BPM som float eller None ved fejl.
Tager 2-5 sekunder per sang — kør i baggrundstråd. Tager 2-5 sekunder per sang — kør i baggrundstråd.
""" """
suffix = Path(path).suffix.lower()
if suffix in _BPM_UNSUPPORTED:
logger.debug(f"BPM-analyse ikke understøttet for {suffix}: {path}")
return None
try: try:
import librosa import librosa
# Indlæs kun de første 60 sekunder for hastighed # Indlæs kun de første 60 sekunder for hastighed

View File

@@ -8,7 +8,15 @@ Start:
import sys import sys
import os import os
APP_VERSION = "0.8.3" APP_VERSION = "0.9.5"
# VLC setup — skal ske FØR vlc importeres
if getattr(sys, 'frozen', False):
_app_dir = os.path.dirname(sys.executable)
_libvlc = os.path.join(_app_dir, 'libvlc.dll')
if os.path.exists(_libvlc):
os.environ['PYTHON_VLC_LIB_PATH'] = _libvlc
os.environ['VLC_PLUGIN_PATH'] = os.path.join(_app_dir, 'plugins')
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
@@ -58,4 +66,4 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -0,0 +1,345 @@
"""
alt_dance_picker_dialog.py — Vælg alternativ dans til en sang i playlisten.
Tre sektioner:
🟢 Mine egne alternativ-danse med min rating
🟡 Community alternativ-danse med community + min rating
Alle andre danse
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QListWidget, QListWidgetItem, QWidget,
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QColor
STAR_FULL = ""
STAR_EMPTY = ""
GREEN = "#27ae60"
YELLOW = "#e8a020"
MUTED = "#5a6070"
class StarRatingWidget(QWidget):
"""Klikbar stjerne-rating widget til brug i lister."""
rating_changed = pyqtSignal(int) # 1-5
def __init__(self, rating=None, max_stars=5, color=YELLOW, parent=None):
# YELLOW er ikke defineret endnu ved import — bruges som string nedenfor
super().__init__(parent)
self._rating = rating
self._max = max_stars
self._color = color
self._btns = []
layout = QHBoxLayout(self)
layout.setContentsMargins(2, 0, 2, 0)
layout.setSpacing(1)
for i in range(1, max_stars + 1):
btn = QPushButton("" if rating and i <= rating else "")
btn.setFixedSize(18, 18)
btn.setStyleSheet(f"""
QPushButton {{
font-size: 13px; border: none; background: none; padding: 0;
color: {color if rating and i <= rating else '#5a6070'};
}}
QPushButton:hover {{ color: {color}; }}
""")
btn.clicked.connect(lambda checked, r=i: self._on_click(r))
layout.addWidget(btn)
self._btns.append(btn)
def _on_click(self, r):
self._rating = r
for i, btn in enumerate(self._btns):
filled = i < r
btn.setText("" if filled else "")
btn.setStyleSheet(f"""
QPushButton {{
font-size: 13px; border: none; background: none; padding: 0;
color: {self._color if filled else '#5a6070'};
}}
QPushButton:hover {{ color: {self._color}; }}
""")
self.rating_changed.emit(r)
def get_rating(self):
return self._rating
def make_stars(rating, max_stars=5):
if not rating:
return STAR_EMPTY * max_stars
full = min(max_stars, round(float(rating)))
return STAR_FULL * full + STAR_EMPTY * (max_stars - full)
class AltDancePickerDialog(QDialog):
def __init__(self, song: dict, parent=None):
super().__init__(parent)
self._song = song
self._chosen_dance = ""
self._chosen_rating = None
self._cleared = False
self.setWindowTitle("Vælg alternativ dans")
self.setMinimumWidth(600)
self.setMinimumHeight(520)
self._build_ui()
self._load_suggestions("")
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# Sang-info
title = self._song.get("title", "?")
artist = self._song.get("artist", "")
lbl = QLabel(f"{title} · {artist}" if artist else title)
lbl.setObjectName("track_title")
lbl.setWordWrap(True)
layout.addWidget(lbl)
# Søgefelt
self._edit = QLineEdit()
self._edit.setPlaceholderText("Søg dans-navn...")
self._edit.textChanged.connect(self._on_text_changed)
self._edit.returnPressed.connect(self._on_accept)
layout.addWidget(self._edit)
# Forslagsliste
self._list = QListWidget()
self._list.setMinimumHeight(320)
self._list.itemClicked.connect(self._on_item_clicked)
self._list.itemDoubleClicked.connect(self._on_selected)
layout.addWidget(self._list)
# Info-label
self._info_lbl = QLabel("")
self._info_lbl.setObjectName("result_count")
self._info_lbl.setWordWrap(True)
layout.addWidget(self._info_lbl)
# Debounce timer
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.setInterval(150)
self._timer.timeout.connect(
lambda: self._load_suggestions(self._edit.text().strip())
)
# Knapper
btn_row = QHBoxLayout()
btn_none = QPushButton("✕ Ingen alternativ")
btn_none.clicked.connect(self._on_clear)
btn_row.addWidget(btn_none)
btn_row.addStretch()
btn_cancel = QPushButton("Annuller")
btn_cancel.clicked.connect(self.reject)
btn_row.addWidget(btn_cancel)
btn_ok = QPushButton("✓ Vælg")
btn_ok.setObjectName("btn_play")
btn_ok.clicked.connect(self._on_accept)
btn_row.addWidget(btn_ok)
layout.addLayout(btn_row)
self._edit.setFocus()
def _on_text_changed(self):
self._timer.start()
def _make_sep(self, text):
sep = QListWidgetItem(text)
sep.setForeground(QColor(MUTED))
sep.setFlags(Qt.ItemFlag.ItemIsEnabled)
sep.setData(Qt.ItemDataRole.UserRole, None)
return sep
def _load_suggestions(self, prefix):
try:
from local.local_db import (
get_alt_dances_for_song_with_ratings,
get_community_alts_for_song,
get_dance_suggestions,
)
self._list.clear()
song_id = self._song.get("id", "")
# ── Mine egne alternativ-danse ──
own_alts = get_alt_dances_for_song_with_ratings(song_id)
own_names = {a["name"].lower() for a in own_alts}
matching_own = [a for a in own_alts
if not prefix or prefix.lower() in a["name"].lower()]
if matching_own:
self._list.addItem(self._make_sep(
f"── 🟢 Mine alternativ-danse ──"
))
for a in matching_own:
my_r = a.get("user_rating")
my_s = make_stars(my_r)
name = a["name"]
level = a.get("level_name", "")
disp = f"{name} / {level}" if level else name
# Venstre: navn, højre: mine stjerner
label = f"🟢 {disp:<40} {my_s}"
item = QListWidgetItem()
item.setSizeHint(__import__('PyQt6.QtCore', fromlist=['QSize']).QSize(0, 34))
item.setData(Qt.ItemDataRole.UserRole, {
"name": name, "level": level,
"choreo": a.get("choreographer", ""),
"my_rating": my_r, "comm_rating": None,
"dance_id": a["id"], "is_own": True,
})
self._list.addItem(item)
# Widget med navn + klikbare stjerner
w = QWidget()
wl = QHBoxLayout(w)
wl.setContentsMargins(4, 0, 4, 0)
wl.setSpacing(6)
lbl_name = QLabel(f"🟢 {disp}")
lbl_name.setStyleSheet(f"color: {GREEN};")
wl.addWidget(lbl_name, stretch=1)
stars_w = StarRatingWidget(my_r, color=GREEN)
stars_w.rating_changed.connect(
lambda r, song_id=self._song.get("id",""), d_id=a["id"]:
self._save_rating(song_id, d_id, r)
)
wl.addWidget(stars_w)
self._list.setItemWidget(item, w)
# ── Community alternativ-danse ──
comm_alts = get_community_alts_for_song(song_id)
matching_comm = [c for c in comm_alts
if (not prefix or prefix.lower() in c["name"].lower())
and c["name"].lower() not in own_names]
if matching_comm:
self._list.addItem(self._make_sep("── 🟡 Community ──"))
for c in matching_comm:
comm_r = c.get("avg_rating")
my_r = c.get("my_rating")
from PyQt6.QtCore import QSize
name = c["name"]
level = c.get("level_name", "")
disp = f"{name} / {level}" if level else name
item = QListWidgetItem()
item.setSizeHint(QSize(0, 34))
item.setData(Qt.ItemDataRole.UserRole, {
"name": name, "level": level,
"choreo": c.get("choreographer", ""),
"my_rating": my_r, "comm_rating": comm_r,
"dance_id": c["id"], "is_community": True,
})
self._list.addItem(item)
# Widget: navn + community stjerner (ikke klikbare) + mine (klikbare)
w = QWidget()
wl = QHBoxLayout(w)
wl.setContentsMargins(4, 0, 4, 0)
wl.setSpacing(6)
lbl_name = QLabel(f"🟡 {disp}")
lbl_name.setStyleSheet(f"color: {YELLOW};")
wl.addWidget(lbl_name, stretch=1)
# Community rating — read-only label
comm_lbl = QLabel(make_stars(comm_r) if comm_r else "☆☆☆☆☆")
comm_lbl.setStyleSheet(f"color: {YELLOW}; font-size: 13px;")
comm_lbl.setToolTip(f"Community: {comm_r:.1f}/5" if comm_r else "Ingen community rating")
wl.addWidget(comm_lbl)
# Min rating — klikbar
my_stars_w = StarRatingWidget(my_r, color=GREEN)
my_stars_w.rating_changed.connect(
lambda r, song_id=self._song.get("id",""), d_id=c["id"]:
self._save_rating(song_id, d_id, r)
)
wl.addWidget(my_stars_w)
self._list.setItemWidget(item, w)
# ── Alle danse ──
suggestions = get_dance_suggestions(prefix or "", limit=20)
if suggestions:
self._list.addItem(self._make_sep("── Alle danse ──"))
for s in suggestions:
s = dict(s)
name = s["name"]
is_own = name.lower() in own_names
is_comm = any(c["name"].lower() == name.lower() for c in comm_alts)
icon = "🟢 " if is_own else "🟡 " if is_comm else " "
color = GREEN if is_own else YELLOW if is_comm else "#eceef4"
disp = name
if s.get("level_name"):
disp += f" / {s['level_name']}"
if s.get("choreographer"):
disp += f" · {s['choreographer']}"
item = QListWidgetItem(f"{icon}{disp}")
item.setForeground(QColor(color))
item.setData(Qt.ItemDataRole.UserRole, {
"name": name,
"level": s.get("level_name", ""),
"choreo": s.get("choreographer", ""),
"my_rating": None, "comm_rating": None,
"dance_id": s.get("id"),
})
self._list.addItem(item)
except Exception as e:
import logging
logging.getLogger(__name__).warning(
f"AltDancePicker fejl: {e}", exc_info=True
)
def _on_item_clicked(self, item):
data = item.data(Qt.ItemDataRole.UserRole)
if not data:
return
self._chosen_dance = data.get("name", "")
self._edit.setText(self._chosen_dance)
parts = []
if data.get("level"):
parts.append(data["level"])
if data.get("choreo"):
parts.append(data["choreo"])
info = " · ".join(parts)
comm_r = data.get("comm_rating")
my_r = data.get("my_rating")
if comm_r:
info += f" 🟡 Community: {make_stars(comm_r)} ({comm_r:.1f})"
if my_r:
info += f" 🟢 Min: {make_stars(my_r)}"
self._info_lbl.setText(info)
def _on_selected(self, item):
data = item.data(Qt.ItemDataRole.UserRole)
if not data:
return
self._on_item_clicked(item)
self._on_accept()
def _on_accept(self):
self._chosen_dance = self._edit.text().strip()
self.accept()
def _save_rating(self, song_id: str, dance_id: int, rating: int):
"""Gem rating direkte fra stjerne-widget i listen."""
try:
from local.local_db import get_db
with get_db() as conn:
conn.execute(
"UPDATE song_alt_dances SET user_rating=? WHERE song_id=? AND dance_id=?",
(rating, song_id, dance_id)
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"save_rating fejl: {e}")
def _on_clear(self):
self._chosen_dance = ""
self._chosen_rating = None
self._cleared = True
self.accept()
def get_dance(self) -> str:
return self._chosen_dance
def get_rating(self):
return self._chosen_rating
def was_cleared(self) -> bool:
return self._cleared

View File

@@ -1,5 +1,6 @@
""" """
bpm_worker.py — QThread til BPM-analyse i baggrunden. bpm_worker.py — QThread til BPM-analyse i baggrunden.
Ny v0.9 arkitektur: sange er i songs, filer i files, libraries i libraries.
""" """
import sqlite3 import sqlite3
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
@@ -15,10 +16,10 @@ class BpmScanWorker(QThread):
self._library_id = library_id self._library_id = library_id
self._db_path = db_path self._db_path = db_path
self._scan_all = scan_all self._scan_all = scan_all
self._cancelled = False
def cancel(self): def cancel(self):
self.requestInterruption() self.requestInterruption()
# Afbryd hurtigt ved at sætte et flag
self._cancelled = True self._cancelled = True
def run(self): def run(self):
@@ -28,20 +29,34 @@ class BpmScanWorker(QThread):
from local.tag_reader import analyze_bpm from local.tag_reader import analyze_bpm
conn = sqlite3.connect(self._db_path) conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
# Ny arkitektur: JOIN songs + files + libraries
lib_row = conn.execute(
"SELECT path FROM libraries WHERE id=?", (self._library_id,)
).fetchone()
if not lib_row:
self.finished.emit(0)
conn.close()
return
lib_path = lib_row["path"]
if self._scan_all: if self._scan_all:
songs = conn.execute( songs = conn.execute("""
"SELECT id, local_path FROM songs " SELECT s.id, f.local_path
"WHERE library_id=? AND file_missing=0", FROM songs s
(self._library_id,) JOIN files f ON f.song_id = s.id AND f.file_missing = 0
).fetchall() WHERE f.local_path LIKE ?
""", (lib_path + "%",)).fetchall()
else: else:
songs = conn.execute( songs = conn.execute("""
"SELECT id, local_path FROM songs " SELECT s.id, f.local_path
"WHERE library_id=? AND file_missing=0 " FROM songs s
"AND (bpm IS NULL OR bpm=0)", JOIN files f ON f.song_id = s.id AND f.file_missing = 0
(self._library_id,) WHERE f.local_path LIKE ?
).fetchall() AND (s.bpm IS NULL OR s.bpm = 0)
""", (lib_path + "%",)).fetchall()
total = len(songs) total = len(songs)
done = 0 done = 0
@@ -61,9 +76,9 @@ class BpmScanWorker(QThread):
pass pass
done += 1 done += 1
self.progress.emit(done, total) self.progress.emit(done, total)
time.sleep(0.01) # Yield så GUI ikke hænger time.sleep(0.01)
conn.close() conn.close()
self.finished.emit(done) self.finished.emit(done)
except Exception as e: except Exception:
self.finished.emit(0) self.finished.emit(0)

View File

@@ -85,16 +85,41 @@ class DancePickerDialog(QDialog):
def _load_suggestions(self, prefix: str): def _load_suggestions(self, prefix: str):
try: try:
from local.local_db import get_dance_suggestions from local.local_db import get_dance_suggestions
from PyQt6.QtGui import QColor
suggestions = get_dance_suggestions(prefix or "", limit=25) suggestions = get_dance_suggestions(prefix or "", limit=25)
self._list.clear() self._list.clear()
# Vis eksisterende danse øverst hvis ingen prefix # Allerøverst: mulighed for at fjerne dans
if not prefix and self._existing_dances: no_dance = QListWidgetItem("✕ Ingen dans")
for name in self._existing_dances: no_dance.setForeground(QColor("#5a6070"))
item = QListWidgetItem(f"{name}") no_dance.setData(Qt.ItemDataRole.UserRole, {"name": ""})
item.setData(Qt.ItemDataRole.UserRole, {"name": name}) self._list.addItem(no_dance)
item.setForeground(__import__('PyQt6.QtGui', fromlist=['QColor']).QColor("#e8a020"))
self._list.addItem(item) # Øverst: danse registreret på denne sang
if self._existing_dances:
# Filtrer på prefix hvis der skrives
matching = [d for d in self._existing_dances
if not prefix or prefix.lower() in d.lower()]
if matching:
# Separator-header
sep = QListWidgetItem("── Registreret på denne sang ──")
sep.setForeground(QColor("#5a6070"))
sep.setFlags(Qt.ItemFlag.ItemIsEnabled) # synlig men ikke valgbar
sep.setData(Qt.ItemDataRole.UserRole, None)
self._list.addItem(sep)
for name in matching:
item = QListWidgetItem(f"{name}")
item.setData(Qt.ItemDataRole.UserRole, {"name": name})
item.setForeground(QColor("#e8a020"))
self._list.addItem(item)
# Separator for alle danse
if suggestions:
sep2 = QListWidgetItem("── Alle danse ──")
sep2.setForeground(QColor("#5a6070"))
sep2.setFlags(Qt.ItemFlag.ItemIsEnabled) # synlig men ikke valgbar
sep2.setData(Qt.ItemDataRole.UserRole, None)
self._list.addItem(sep2)
for s in suggestions: for s in suggestions:
s = dict(s) s = dict(s)
@@ -119,7 +144,9 @@ class DancePickerDialog(QDialog):
logging.getLogger(__name__).warning(f'Dans-forslag fejl: {e}', exc_info=True) logging.getLogger(__name__).warning(f'Dans-forslag fejl: {e}', exc_info=True)
def _on_item_clicked(self, item: QListWidgetItem): def _on_item_clicked(self, item: QListWidgetItem):
data = item.data(Qt.ItemDataRole.UserRole) or {} data = item.data(Qt.ItemDataRole.UserRole)
if not data: # separator — ignorer
return
name = data.get("name", "") name = data.get("name", "")
level = data.get("level", "") level = data.get("level", "")
choreo = data.get("choreo", "") choreo = data.get("choreo", "")
@@ -133,13 +160,15 @@ class DancePickerDialog(QDialog):
self._info_lbl.setText(" · ".join(parts) if parts else "") self._info_lbl.setText(" · ".join(parts) if parts else "")
def _on_selected(self, item: QListWidgetItem): def _on_selected(self, item: QListWidgetItem):
data = item.data(Qt.ItemDataRole.UserRole)
if not data: # separator
return
self._on_item_clicked(item) self._on_item_clicked(item)
self._on_accept() self._on_accept()
def _on_accept(self): def _on_accept(self):
self._chosen_dance = self._edit.text().strip() self._chosen_dance = self._edit.text().strip()
if self._chosen_dance: self.accept() # tillad tom streng = ingen dans
self.accept()
def get_dance(self) -> str: def get_dance(self) -> str:
return self._chosen_dance return self._chosen_dance

View File

@@ -549,9 +549,11 @@ class LibraryPanel(QWidget):
self._bpm_worker.start() self._bpm_worker.start()
def _refresh_library(self): def _refresh_library(self):
"""Genindlæs bibliotek fra database.""" """Opdater fil-tilgængelighed og genindlæs bibliotek."""
mw = self.window() mw = self.window()
if hasattr(mw, "_reload_library"): if hasattr(mw, "_run_availability_check"):
mw._run_availability_check()
elif hasattr(mw, "_reload_library"):
mw._reload_library() mw._reload_library()
def _manage_libraries(self): def _manage_libraries(self):
@@ -580,4 +582,4 @@ class LibraryPanel(QWidget):
if folder: if folder:
mw = self.window() mw = self.window()
if hasattr(mw, "add_library_path"): if hasattr(mw, "add_library_path"):
mw.add_library_path(folder) mw.add_library_path(folder)

View File

@@ -379,6 +379,12 @@ class MainWindow(QMainWindow):
self._sync_periodic.timeout.connect(self._manual_sync) self._sync_periodic.timeout.connect(self._manual_sync)
self._sync_periodic.start() self._sync_periodic.start()
# Periodisk fil-tilgængeligheds-check — opdager USB tilslutning/fjernelse
self._availability_timer = QTimer(self)
self._availability_timer.setInterval(30 * 1000) # hvert 30. sekund
self._availability_timer.timeout.connect(self._run_availability_check)
self._availability_timer.start()
self._library_panel = LibraryPanel() self._library_panel = LibraryPanel()
self._library_panel.set_preview_player(self._preview_player) self._library_panel.set_preview_player(self._preview_player)
@@ -438,9 +444,30 @@ class MainWindow(QMainWindow):
from local.local_db import init_db from local.local_db import init_db
init_db() init_db()
self._db_ready.emit() self._db_ready.emit()
# Tjek fil-tilgængelighed i separat tråd
import threading
threading.Thread(
target=self._refresh_availability, daemon=True
).start()
except Exception as e: except Exception as e:
pass pass
def _refresh_availability(self):
"""Opdater file_missing for alle kendte filer og genindlæs biblioteket."""
try:
from local.local_db import refresh_file_availability
refresh_file_availability()
QTimer.singleShot(0, self._reload_library)
except Exception:
pass
def _run_availability_check(self):
"""Kør periodisk fil-check i baggrundstråd — opdager USB til/fra."""
import threading
threading.Thread(
target=self._refresh_availability, daemon=True
).start()
def _start_watcher(self): def _start_watcher(self):
"""Start fil-watcher i baggrundstråd — blokerer aldrig GUI.""" """Start fil-watcher i baggrundstråd — blokerer aldrig GUI."""
import threading import threading
@@ -655,6 +682,8 @@ class MainWindow(QMainWindow):
def on_one_finished(count, p): def on_one_finished(count, p):
finished_count[0] += 1 finished_count[0] += 1
self._set_status(f"Scanning færdig — {count} filer", 4000) self._set_status(f"Scanning færdig — {count} filer", 4000)
# Genindlæs biblioteket når scanning er færdig
QTimer.singleShot(200, self._reload_library)
# Ryd færdige workers ud # Ryd færdige workers ud
self._scan_workers = [w for w in self._scan_workers self._scan_workers = [w for w in self._scan_workers
if w.isRunning()] if w.isRunning()]
@@ -663,6 +692,9 @@ class MainWindow(QMainWindow):
worker = ScanWorker(lib["id"], lib["path"], str(DB_PATH), worker = ScanWorker(lib["id"], lib["path"], str(DB_PATH),
overwrite_bpm=False) overwrite_bpm=False)
worker.finished.connect(on_one_finished) worker.finished.connect(on_one_finished)
worker.batch_ready.connect(
lambda n: QTimer.singleShot(0, self._reload_library)
)
worker.start() worker.start()
worker.setPriority(QThread.Priority.LowestPriority) worker.setPriority(QThread.Priority.LowestPriority)
self._scan_workers.append(worker) self._scan_workers.append(worker)
@@ -992,6 +1024,8 @@ class MainWindow(QMainWindow):
if dialog.exec(): if dialog.exec():
# Genindlæs biblioteket så ændringer vises # Genindlæs biblioteket så ændringer vises
QTimer.singleShot(200, self._reload_library) QTimer.singleShot(200, self._reload_library)
# Push ændringer til server med det samme
QTimer.singleShot(500, self._manual_sync)
def _send_mail(self, song: dict): def _send_mail(self, song: dict):
import subprocess, sys, shutil, urllib.parse import subprocess, sys, shutil, urllib.parse

View File

@@ -2,6 +2,45 @@
playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overblik. playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overblik.
""" """
import sys as _sys
from pathlib import Path as _Path
def _is_local_path(path: str) -> bool:
"""Returnerer True hvis stien er på et lokalt/USB-drev, False hvis netværk."""
try:
if _sys.platform == "win32":
import ctypes
drive = path[:3]
# GetDriveType: 2=Removable, 3=Fixed, 4=Remote(netværk), 5=CDROM, 6=RAMdisk
dtype = ctypes.windll.kernel32.GetDriveTypeW(drive)
return dtype not in (4,) # 4 = netværksdrev
else:
# Linux/Mac — tjek /proc/mounts
NETWORK_FS = {
"nfs", "nfs4", "cifs", "smb", "smb2", "smb3",
"fuse.sshfs", "fuse.gvfsd-fuse", "fuse.s3fs",
"davfs", "ncpfs", "afs", "glusterfs", "fuse.glusterfs",
}
try:
with open("/proc/mounts") as f:
mounts = []
for line in f:
parts = line.split()
if len(parts) >= 3:
mounts.append((parts[1], parts[2]))
# Find længste matchende mount-punkt
mounts.sort(key=lambda x: len(x[0]), reverse=True)
for mount_point, fs_type in mounts:
if path.startswith(mount_point):
return fs_type not in NETWORK_FS
except Exception:
pass
return True # Antag lokal
except Exception:
return True # Antag lokal ved fejl
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView, QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
@@ -463,6 +502,7 @@ class PlaylistPanel(QWidget):
"file_missing": file_missing, "file_missing": file_missing,
"dances": dance_names, "dances": dance_names,
"active_dance": active_dance, "active_dance": active_dance,
"alt_dance": row["alt_dance_override"] if "alt_dance_override" in row.keys() else "",
"is_workshop": bool(row["is_workshop"]), "is_workshop": bool(row["is_workshop"]),
}) })
statuses.append(row["status"] or "pending") statuses.append(row["status"] or "pending")
@@ -774,34 +814,99 @@ class PlaylistPanel(QWidget):
) )
if dlg.exec(): if dlg.exec():
chosen = dlg.get_dance() chosen = dlg.get_dance()
choreo = "" # Koreograf redigeres i tag-editoren, ikke her # Dans-valg i playlisten er altid midlertidigt — kun dance_override
if chosen: song["active_dance"] = chosen # tom streng = ingen dans
song["active_dance"] = chosen self._refresh()
song["active_choreo"] = choreo self._sync_dance_to_db(idx, song)
self._refresh()
# Gem permanent hvis sangen ikke allerede har denne dans tagget def _change_alt_dance(self, idx: int, song: dict):
already_tagged = chosen in dances """Lad brugeren vælge alternativ dans til denne sang i playlisten."""
if not already_tagged: from ui.alt_dance_picker_dialog import AltDancePickerDialog
self._save_dance_permanently(idx, song, chosen, choreo) dlg = AltDancePickerDialog(song, parent=self.window())
else: if dlg.exec():
# Midlertidigt — kun dance_override på listen if dlg.was_cleared():
self._sync_dance_to_db(idx, song) chosen = ""
else:
chosen = dlg.get_dance()
rating = dlg.get_rating()
song["alt_dance"] = chosen
self._refresh()
# Gem alt_dance_override på playlist_songs
self._sync_alt_dance_to_db(idx, song, chosen)
# Gem rating hvis givet
if chosen and rating is not None:
self._save_alt_dance_rating(song, chosen, rating)
def _sync_dance_to_db(self, idx: int, song: dict): def _sync_alt_dance_to_db(self, idx: int, song: dict, alt_dance: str):
"""Gem dance_override til playlist_songs (midlertidigt valg).""" """Gem alt_dance_override til playlist_songs."""
if not self._named_playlist_id: if not self._named_playlist_id:
return return
try: try:
from local.local_db import get_db from local.local_db import get_db
with get_db() as conn: with get_db() as conn:
conn.execute( conn.execute(
"UPDATE playlist_songs SET alt_dance_override=? "
"WHERE playlist_id=? AND position=?",
(alt_dance, self._named_playlist_id, idx + 1)
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"alt_dance_to_db fejl: {e}")
def _save_alt_dance_rating(self, song: dict, dance_name: str, rating: int):
"""Gem brugerens rating på en alternativ-dans."""
import uuid
song_id = song.get("id", "")
try:
from local.local_db import get_db
with get_db() as conn:
# Find dance_id
dance_row = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
(dance_name,)
).fetchone()
if not dance_row:
return
dance_id = dance_row["id"]
# Opdater eller indsæt rating
existing = conn.execute(
"SELECT id FROM song_alt_dances WHERE song_id=? AND dance_id=?",
(song_id, dance_id)
).fetchone()
if existing:
conn.execute(
"UPDATE song_alt_dances SET user_rating=? WHERE song_id=? AND dance_id=?",
(rating, song_id, dance_id)
)
else:
conn.execute(
"INSERT INTO song_alt_dances (id, song_id, dance_id, user_rating) VALUES (?,?,?,?)",
(str(uuid.uuid4()), song_id, dance_id, rating)
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"save_alt_dance_rating fejl: {e}")
def _sync_dance_to_db(self, idx: int, song: dict):
"""Gem dance_override til playlist_songs (midlertidigt valg)."""
import logging
_log = logging.getLogger(__name__)
if not self._named_playlist_id:
_log.warning("_sync_dance_to_db: ingen named_playlist_id")
return
try:
from local.local_db import get_db
dance_val = song.get("active_dance") or ""
with get_db() as conn:
rows_affected = conn.execute(
"UPDATE playlist_songs SET dance_override=? " "UPDATE playlist_songs SET dance_override=? "
"WHERE playlist_id=? AND position=?", "WHERE playlist_id=? AND position=?",
(song.get("active_dance", ""), self._named_playlist_id, idx + 1) (dance_val, self._named_playlist_id, idx + 1)
) ).rowcount
except Exception: _log.info(f"dance_override='{dance_val}' gemt på position {idx+1}, {rows_affected} rækker")
pass except Exception as e:
import logging
logging.getLogger(__name__).warning(f"_sync_dance_to_db fejl: {e}")
def _save_dance_permanently(self, idx: int, song: dict, dance_name: str, choreo: str = ""): def _save_dance_permanently(self, idx: int, song: dict, dance_name: str, choreo: str = ""):
""" """
@@ -1072,7 +1177,8 @@ class PlaylistPanel(QWidget):
for song in songs: for song in songs:
path = song.get("local_path", "") path = song.get("local_path", "")
if path and Path(path).exists(): if path and Path(path).exists():
song["availability"] = "green" # Grøn = lokal, Gul = netværk men tilgængeligt
song["availability"] = "green" if _is_local_path(path) else "yellow"
continue continue
# Forsøg auto-match via titel+artist # Forsøg auto-match via titel+artist
@@ -1109,9 +1215,9 @@ class PlaylistPanel(QWidget):
with get_db() as conn: with get_db() as conn:
for song in self._songs: for song in self._songs:
path = song.get("local_path", "") path = song.get("local_path", "")
# Grøn — filen eksisterer lokalt # Grøn = lokal, Gul = netværk men tilgængeligt
if path and Path(path).exists(): if path and Path(path).exists():
song["availability"] = "green" song["availability"] = "green" if _is_local_path(path) else "yellow"
song["file_missing"] = False song["file_missing"] = False
# Opdater files tabellen # Opdater files tabellen
conn.execute( conn.execute(
@@ -1135,7 +1241,7 @@ class PlaylistPanel(QWidget):
if match and Path(match["local_path"]).exists(): if match and Path(match["local_path"]).exists():
song["local_path"] = match["local_path"] song["local_path"] = match["local_path"]
song["file_id"] = match["file_id"] song["file_id"] = match["file_id"]
song["availability"] = "green" song["availability"] = "green" if _is_local_path(match["local_path"]) else "yellow"
song["file_missing"] = False song["file_missing"] = False
# Opdater playlist_songs til at pege på den fundne fil # Opdater playlist_songs til at pege på den fundne fil
if self._named_playlist_id: if self._named_playlist_id:
@@ -1172,6 +1278,7 @@ class PlaylistPanel(QWidget):
act_played = menu.addAction("✓ Sæt til afspillet") act_played = menu.addAction("✓ Sæt til afspillet")
menu.addSeparator() menu.addSeparator()
act_dance = menu.addAction("💃 Vælg dans...") act_dance = menu.addAction("💃 Vælg dans...")
act_alt_dance = menu.addAction("💃 Vælg alternativ dans...")
is_ws = song.get("is_workshop", False) if song else False is_ws = song.get("is_workshop", False) if song else False
act_ws = menu.addAction("🎓 Fjern workshop" if is_ws else "🎓 Markér som workshop") act_ws = menu.addAction("🎓 Fjern workshop" if is_ws else "🎓 Markér som workshop")
menu.addSeparator() menu.addSeparator()
@@ -1202,6 +1309,8 @@ class PlaylistPanel(QWidget):
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save() self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
elif action == act_dance and song: elif action == act_dance and song:
self._change_dance(idx, song) self._change_dance(idx, song)
elif action == act_alt_dance and song:
self._change_alt_dance(idx, song)
elif action == act_ws and song: elif action == act_ws and song:
song["is_workshop"] = not song.get("is_workshop", False) song["is_workshop"] = not song.get("is_workshop", False)
self._sync_ws_to_db(idx, song) self._sync_ws_to_db(idx, song)

View File

@@ -6,9 +6,10 @@ from PyQt6.QtCore import QThread, pyqtSignal
class ScanWorker(QThread): class ScanWorker(QThread):
progress = pyqtSignal(int, int, str) # done, total, filename progress = pyqtSignal(int, int, str) # done, total, filename
finished = pyqtSignal(int, str) # antal, library_path finished = pyqtSignal(int, str) # antal, library_path
error = pyqtSignal(str) error = pyqtSignal(str)
batch_ready = pyqtSignal(int) # antal sange scannet så langt
def __init__(self, library_id: int, library_path: str, def __init__(self, library_id: int, library_path: str,
db_path: str, overwrite_bpm: bool = False): db_path: str, overwrite_bpm: bool = False):
@@ -26,11 +27,15 @@ class ScanWorker(QThread):
def run(self): def run(self):
try: try:
from local.scanner import scan_library from local.scanner import scan_library
self._batch_count = 0
def on_progress(done, total, filename): def on_progress(done, total, filename):
if self.isInterruptionRequested(): if self.isInterruptionRequested():
raise InterruptedError() raise InterruptedError()
self.progress.emit(done, total, filename) self.progress.emit(done, total, filename)
self._batch_count += 1
if self._batch_count % 50 == 0:
self.batch_ready.emit(self._batch_count)
count = scan_library( count = scan_library(
self._library_id, self._library_id,
@@ -44,4 +49,4 @@ class ScanWorker(QThread):
except InterruptedError: except InterruptedError:
pass pass
except Exception as e: except Exception as e:
self.error.emit(str(e)) self.error.emit(str(e))

View File

@@ -591,16 +591,24 @@ class TagEditorDialog(QDialog):
) )
# Skriv danse-navne til filen # Skriv danse-navne til filen
import logging as _logging
_log = _logging.getLogger(__name__)
dance_names = [d["name"] for d in dances]
_log.info(f"Gemmer {len(dances)} danse: {dance_names}, local_path={local_path!r}")
if local_path and can_write_dances(local_path): if local_path and can_write_dances(local_path):
dance_names = [d["name"] for d in dances]
try: try:
if not write_dances(local_path, dance_names): result = write_dances(local_path, dance_names)
_log.info(f"write_dances resultat: {result}")
if not result:
QMessageBox.warning(self, "Advarsel", QMessageBox.warning(self, "Advarsel",
"Gemt i database, men kunne ikke skrive til mp3-filen.\n" "Gemt i database, men kunne ikke skrive til mp3-filen.\n"
"(Filen understøtter ikke dans-tags)") "(Filen understøtter ikke dans-tags)")
except Exception as write_err: except Exception as write_err:
_log.warning(f"write_dances fejl: {write_err}")
QMessageBox.warning(self, "Advarsel", QMessageBox.warning(self, "Advarsel",
f"Gemt i database, men fejl ved skrivning til fil:\n{write_err}") f"Gemt i database, men fejl ved skrivning til fil:\n{write_err}")
else:
_log.info(f"Springer fil-skrivning over: local_path={local_path!r}")
self.accept() self.accept()