Compare commits

...

13 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
ec3989e6a4 Merge branch 'main' of ssh://git.ckvist.lan:2222/carsten/LinedanceAfspiller 2026-04-21 16:47:40 +02:00
6ed349277c Bedre sync 2026-04-21 16:47:33 +02:00
22 changed files with 1777 additions and 413 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)"
@@ -380,16 +437,37 @@ def pull(
continue continue
level = db.query(DanceLevel).filter_by(id=dance.level_id).first() if dance.level_id else None level = db.query(DanceLevel).filter_by(id=dance.level_id).first() if dance.level_id else None
song_tags.append({ song_tags.append({
"song_id": sd.song_id, "song_id": sd.song_id,
"dance_name": dance.name, "dance_name": dance.name,
"level_name": level.name if level else "", "choreographer": dance.choreographer or "",
"dance_order": sd.dance_order, "level_name": level.name if level else "",
"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

@@ -88,6 +88,7 @@ CREATE TABLE IF NOT EXISTS dance_levels (
); );
-- Danse -- Danse
-- Dans + niveau + koreograf er unik kombination
CREATE TABLE IF NOT EXISTS dances ( CREATE TABLE IF NOT EXISTS dances (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -98,7 +99,7 @@ CREATE TABLE IF NOT EXISTS dances (
notes TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '',
use_count INTEGER NOT NULL DEFAULT 1, use_count INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'local', source TEXT NOT NULL DEFAULT 'local',
UNIQUE(name, level_id) UNIQUE(name, level_id, choreographer)
); );
-- Sang-dans tags -- Sang-dans tags
@@ -112,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 (
@@ -139,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);
@@ -582,4 +590,182 @@ def upsert_dance_levels(levels: list[dict]):
ON CONFLICT(name) DO UPDATE SET ON CONFLICT(name) DO UPDATE SET
sort_order=excluded.sort_order, sort_order=excluded.sort_order,
description=excluded.description description=excluded.description
""", lvl) """, lvl)
# ── Dans-søgning (til DancePickerDialog) ─────────────────────────────────────
def get_dance_suggestions(prefix: str = "", limit: int = 20) -> list:
"""Hent dans-forslag med niveau og koreograf til autoudfyld."""
with get_db() as conn:
pattern = f"{prefix}%"
return conn.execute("""
SELECT d.id, d.name, d.level_id, dl.name as level_name,
d.choreographer, d.use_count
FROM dances d
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE d.name LIKE ? COLLATE NOCASE
ORDER BY d.use_count DESC, d.name
LIMIT ?
""", (pattern, limit)).fetchall()
def get_choreographer_suggestions(prefix: str = "", limit: int = 15) -> list[str]:
"""Hent koreograf-navne til autoudfyld."""
with get_db() as conn:
pattern = f"{prefix}%"
rows = conn.execute("""
SELECT DISTINCT choreographer FROM dances
WHERE choreographer != '' AND choreographer LIKE ? COLLATE NOCASE
ORDER BY choreographer
LIMIT ?
""", (pattern, limit)).fetchall()
return [r["choreographer"] for r in rows]
# ── Dans-søgning (til DancePickerDialog og DanceInfoDialog) ──────────────────
def get_dance_suggestions(prefix: str = "", limit: int = 20) -> list:
"""Hent dans-forslag med niveau og koreograf til autoudfyld."""
with get_db() as conn:
pattern = f"{prefix}%"
return conn.execute("""
SELECT d.id, d.name, d.level_id, dl.name as level_name,
d.choreographer, d.use_count
FROM dances d
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE d.name LIKE ? COLLATE NOCASE
ORDER BY d.use_count DESC, d.name
LIMIT ?
""", (pattern, limit)).fetchall()
def get_choreographer_suggestions(prefix: str = "", limit: int = 15) -> list[str]:
"""Hent koreograf-navne til autoudfyld."""
with get_db() as conn:
pattern = f"{prefix}%"
rows = conn.execute("""
SELECT DISTINCT choreographer FROM dances
WHERE choreographer != '' AND choreographer LIKE ? COLLATE NOCASE
ORDER BY choreographer
LIMIT ?
""", (pattern, limit)).fetchall()
return [r["choreographer"] for r in rows]
def get_dances_for_song(song_id: str) -> list:
"""Hent alle danse tagget på en sang med niveau og koreograf."""
with get_db() as conn:
rows = conn.execute("""
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
FROM song_dances sd
JOIN dances d ON d.id = sd.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE sd.song_id = ?
ORDER BY sd.dance_order
""", (song_id,)).fetchall()
return [dict(r) for r in rows]
def get_alt_dances_for_song(song_id: str) -> list:
"""Hent alle alternativ-danse tagget på en sang."""
with get_db() as conn:
rows = conn.execute("""
SELECT d.id, d.name, d.level_id, dl.name as level_name, d.choreographer,
d.video_url, d.stepsheet_url, sad.note
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 d.name
""", (song_id,)).fetchall()
return [dict(r) for r in rows]
def get_or_create_dance(name: str, level_id: int | None, conn,
choreographer: str = "") -> int:
"""
Find eller opret dans. Returnerer dance_id.
Dans + niveau + koreograf er unik kombination.
"""
choreo = choreographer or ""
existing = conn.execute(
"SELECT id FROM dances WHERE name=? "
"AND (level_id=? OR (level_id IS NULL AND ? IS NULL)) "
"AND choreographer=?",
(name, level_id, level_id, choreo)
).fetchone()
if existing:
return existing["id"]
cur = conn.execute(
"INSERT INTO dances (name, level_id, choreographer) VALUES (?,?,?)",
(name, level_id, choreo)
)
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

@@ -158,30 +158,28 @@ def scan_library(library_id: int, library_path: str, db_path: str,
# Opret eller opdater fil-post # Opret eller opdater fil-post
_upsert_file_conn(conn, song_id, path_str, file_format, mtime, extra_tags) _upsert_file_conn(conn, song_id, path_str, file_format, mtime, extra_tags)
# Dans-tags fra fil # Dans-tags fra fil — synkroniser altid fra filen
file_dances = tags.get("dances", []) file_dances = tags.get("dances", [])
if file_dances: if file_dances:
existing_count = conn.execute( import uuid
"SELECT COUNT(*) FROM song_dances WHERE song_id=?", (song_id,) # Slet eksisterende og genindsæt fra filen
).fetchone()[0] conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
if existing_count == 0: for order, dance_name in enumerate(file_dances, start=1):
import uuid dance_row = conn.execute(
for order, dance_name in enumerate(file_dances, start=1): "SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
dance_row = conn.execute( (dance_name,)
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1", ).fetchone()
(dance_name,) if not dance_row:
).fetchone() cur = conn.execute(
if not dance_row: "INSERT INTO dances (name) VALUES (?)", (dance_name,)
cur = conn.execute(
"INSERT INTO dances (name) VALUES (?)", (dance_name,)
)
dance_id = cur.lastrowid
else:
dance_id = dance_row["id"]
conn.execute(
"INSERT OR IGNORE INTO song_dances (id, song_id, dance_id, dance_order) VALUES (?,?,?,?)",
(str(uuid.uuid4()), song_id, dance_id, order)
) )
dance_id = cur.lastrowid
else:
dance_id = dance_row["id"]
conn.execute(
"INSERT OR IGNORE INTO song_dances (id, song_id, dance_id, dance_order) VALUES (?,?,?,?)",
(str(uuid.uuid4()), song_id, dance_id, order)
)
conn.commit() conn.commit()
except Exception as e: except Exception as 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 ────────────────────────────────────────────────────────
@@ -323,22 +328,30 @@ class SyncManager:
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
try: try:
# Opdater dans-info # Synkroniser danse fra server — opret nye, opdater eksisterende
for d in data.get("dances", []): for d in data.get("dances", []):
if not d.get("name"): if not d.get("name"):
continue continue
choreo = d.get("choreographer", "") or ""
existing = conn.execute( existing = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE", (d["name"],) "SELECT id FROM dances WHERE name=? COLLATE NOCASE "
"AND choreographer=? LIMIT 1",
(d["name"], choreo)
).fetchone() ).fetchone()
if existing and (d.get("choreographer") or d.get("video_url")): if existing:
conn.execute(""" conn.execute("""
UPDATE dances SET UPDATE dances SET
choreographer = CASE WHEN choreographer='' THEN ? ELSE choreographer END,
video_url = CASE WHEN video_url='' THEN ? ELSE video_url END, video_url = CASE WHEN video_url='' THEN ? ELSE video_url END,
stepsheet_url = CASE WHEN stepsheet_url='' THEN ? ELSE stepsheet_url END stepsheet_url = CASE WHEN stepsheet_url='' THEN ? ELSE stepsheet_url END
WHERE id=? WHERE id=?
""", (d.get("choreographer",""), d.get("video_url",""), """, (d.get("video_url",""), d.get("stepsheet_url",""), existing["id"]))
d.get("stepsheet_url",""), existing["id"])) else:
conn.execute(
"INSERT OR IGNORE INTO dances (name, choreographer, video_url, stepsheet_url, notes) "
"VALUES (?,?,?,?,?)",
(d["name"], choreo,
d.get("video_url",""), d.get("stepsheet_url",""), d.get("notes",""))
)
# Hent soft-slettede server-IDs så vi springer dem over # Hent soft-slettede server-IDs så vi springer dem over
deleted_server_ids = { deleted_server_ids = {
@@ -468,6 +481,97 @@ 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
for st in data.get("song_tags", []):
server_song_id = st.get("song_id", "")
dance_name = st.get("dance_name", "")
dance_order = st.get("dance_order", 1)
choreo = st.get("choreographer", "") or ""
if not server_song_id or not dance_name:
continue
# Find lokal sang
local_song = conn.execute(
"SELECT id FROM songs WHERE id=?", (server_song_id,)
).fetchone()
if not local_song:
continue
# Find dans
dance_row = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE "
"AND choreographer=? LIMIT 1",
(dance_name, choreo)
).fetchone()
if not dance_row:
cur = conn.execute(
"INSERT OR IGNORE INTO dances (name, choreographer) VALUES (?,?)",
(dance_name, choreo)
)
dance_id = cur.lastrowid
else:
dance_id = dance_row["id"]
# Tilføj sang-dans tag hvis ikke allerede der
existing_sd = conn.execute(
"SELECT id FROM song_dances WHERE song_id=? AND dance_id=?",
(server_song_id, dance_id)
).fetchone()
if not existing_sd:
conn.execute(
"INSERT OR IGNORE INTO song_dances (id, song_id, dance_id, dance_order) "
"VALUES (?,?,?,?)",
(str(uuid.uuid4()), server_song_id, dance_id, dance_order)
)
conn.commit() conn.commit()
except Exception: except Exception:

View File

@@ -412,12 +412,19 @@ def read_dances_from_file(path: str | Path) -> list[str]:
# ── 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
@@ -513,4 +520,4 @@ def _write_mbid_m4a(path: Path, mbid: str) -> bool:
return True return True
except Exception as e: except Exception as e:
logger.warning(f"MBID M4A skrivefejl {path}: {e}") logger.warning(f"MBID M4A skrivefejl {path}: {e}")
return False return False

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

@@ -31,54 +31,51 @@ class DanceInfoDialog(QDialog):
def _load_dances(self): def _load_dances(self):
try: try:
from local.local_db import get_dances_for_song, get_alt_dances_for_song, new_conn from local.local_db import get_db
conn = new_conn() with get_db() as conn:
rows = conn.execute("""
SELECT d.id, d.name, d.choreographer,
d.video_url, d.stepsheet_url, d.notes,
dl.name as level_name
FROM song_dances sd
JOIN dances d ON d.id = sd.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE sd.song_id=? ORDER BY sd.dance_order
""", (self._song.get("id"),)).fetchall()
rows = conn.execute(""" for row in rows:
SELECT d.id, d.name, d.level_id, d.choreographer, self._dances.append({
d.video_url, d.stepsheet_url, d.notes, "dance_id": row["id"],
dl.name as level_name "name": row["name"],
FROM song_dances sd "level_name": row["level_name"] or "",
JOIN dances d ON d.id = sd.dance_id "choreographer": row["choreographer"] or "",
LEFT JOIN dance_levels dl ON dl.id = d.level_id "video_url": row["video_url"] or "",
WHERE sd.song_id=? ORDER BY sd.dance_order "stepsheet_url": row["stepsheet_url"] or "",
""", (self._song.get("id"),)).fetchall() "notes": row["notes"] or "",
"is_alt": False,
})
for row in rows: alt_rows = conn.execute("""
self._dances.append({ SELECT d.id, d.name, d.choreographer,
"dance_id": row["id"], d.video_url, d.stepsheet_url, d.notes,
"name": row["name"], dl.name as level_name
"level_name": row["level_name"] or "", FROM song_alt_dances sad
"choreographer": row["choreographer"] or "", JOIN dances d ON d.id = sad.dance_id
"video_url": row["video_url"] or "", LEFT JOIN dance_levels dl ON dl.id = d.level_id
"stepsheet_url": row["stepsheet_url"] or "", WHERE sad.song_id=? ORDER BY d.name
"notes": row["notes"] or "", """, (self._song.get("id"),)).fetchall()
"is_alt": False,
})
# Alternativ-danse for row in alt_rows:
alt_rows = conn.execute(""" self._dances.append({
SELECT d.id, d.name, d.level_id, d.choreographer, "dance_id": row["id"],
d.video_url, d.stepsheet_url, d.notes, "name": row["name"],
dl.name as level_name "level_name": row["level_name"] or "",
FROM song_alt_dances sad "choreographer": row["choreographer"] or "",
JOIN dances d ON d.id = sad.dance_id "video_url": row["video_url"] or "",
LEFT JOIN dance_levels dl ON dl.id = d.level_id "stepsheet_url": row["stepsheet_url"] or "",
WHERE sad.song_id=? ORDER BY d.name "notes": row["notes"] or "",
""", (self._song.get("id"),)).fetchall() "is_alt": True,
})
for row in alt_rows:
self._dances.append({
"dance_id": row["id"],
"name": row["name"],
"level_name": row["level_name"] or "",
"choreographer": row["choreographer"] or "",
"video_url": row["video_url"] or "",
"stepsheet_url": row["stepsheet_url"] or "",
"notes": row["notes"] or "",
"is_alt": True,
})
conn.close()
except Exception as e: except Exception as e:
print(f"DanceInfoDialog load fejl: {e}") print(f"DanceInfoDialog load fejl: {e}")
@@ -204,15 +201,14 @@ class DanceInfoDialog(QDialog):
def _save(self): def _save(self):
self._save_to_cache(self._current_idx) self._save_to_cache(self._current_idx)
try: try:
from local.local_db import update_dance_info from local.local_db import get_db
for d in self._dances: with get_db() as conn:
update_dance_info( for d in self._dances:
d["dance_id"], conn.execute("""
choreographer = d["choreographer"], UPDATE dances SET choreographer=?, video_url=?,
video_url = d["video_url"], stepsheet_url=?, notes=? WHERE id=?
stepsheet_url = d["stepsheet_url"], """, (d["choreographer"], d["video_url"],
notes = d["notes"], d["stepsheet_url"], d["notes"], d["dance_id"]))
)
self.accept() self.accept()
except Exception as e: except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}") QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
@@ -223,4 +219,4 @@ class DanceInfoDialog(QDialog):
return return
if not url.startswith("http"): if not url.startswith("http"):
url = "https://" + url url = "https://" + url
QDesktopServices.openUrl(QUrl(url)) QDesktopServices.openUrl(QUrl(url))

View File

@@ -1,5 +1,7 @@
""" """
dance_picker_dialog.py — Dialog til at vælge dans og koreograf med autoudfyld. dance_picker_dialog.py — Simpel dans-vælger til danselisten.
Viser dansenavn primært. Niveau og koreograf vises som info hvis tilgængeligt.
Ingen redigering af metadata — det hører til i tag-editoren i biblioteket.
""" """
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
@@ -10,18 +12,18 @@ from PyQt6.QtCore import Qt, QTimer
class DancePickerDialog(QDialog): class DancePickerDialog(QDialog):
def __init__(self, current_dance: str = "", current_choreo: str = "", def __init__(self, current_dance: str = "", song_title: str = "",
song_title: str = "", parent=None): existing_dances: list[str] = None, parent=None):
super().__init__(parent) super().__init__(parent)
self._chosen_dance = current_dance self._chosen_dance = current_dance
self._chosen_choreo = current_choreo self._existing_dances = existing_dances or []
self.setWindowTitle("Vælg dans") self.setWindowTitle("Vælg dans")
self.setMinimumWidth(400) self.setMinimumWidth(420)
self.setFixedWidth(440) self.setFixedWidth(460)
self._build_ui(current_dance, current_choreo, song_title) self._build_ui(current_dance, song_title)
self._load_dance_suggestions("") self._load_suggestions("")
def _build_ui(self, current_dance: str, current_choreo: str, song_title: str): def _build_ui(self, current_dance: str, song_title: str):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12) layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8) layout.setSpacing(8)
@@ -32,65 +34,38 @@ class DancePickerDialog(QDialog):
lbl.setWordWrap(True) lbl.setWordWrap(True)
layout.addWidget(lbl) layout.addWidget(lbl)
# ── Dans ────────────────────────────────────────────────────────────── layout.addWidget(QLabel("Dans:"))
lbl2 = QLabel("Dans:")
lbl2.setObjectName("track_meta")
layout.addWidget(lbl2)
self._edit_dance = QLineEdit() self._edit = QLineEdit()
self._edit_dance.setText(current_dance) self._edit.setText(current_dance)
self._edit_dance.setPlaceholderText("Skriv dans-navn...") self._edit.setPlaceholderText("Skriv dans-navn...")
self._edit_dance.selectAll() self._edit.selectAll()
self._edit_dance.textChanged.connect(self._on_dance_text_changed) self._edit.textChanged.connect(self._on_text_changed)
self._edit_dance.returnPressed.connect(lambda: self._edit_choreo.setFocus()) self._edit.returnPressed.connect(self._on_accept)
layout.addWidget(self._edit_dance) layout.addWidget(self._edit)
self._dance_list = QListWidget() # Forslagsliste
self._dance_list.setMaximumHeight(160) self._list = QListWidget()
self._dance_list.itemDoubleClicked.connect(self._on_dance_selected) self._list.setMinimumHeight(200)
self._dance_list.itemClicked.connect( self._list.itemDoubleClicked.connect(self._on_selected)
lambda item: self._edit_dance.setText( self._list.itemClicked.connect(self._on_item_clicked)
item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0] layout.addWidget(self._list)
)
)
layout.addWidget(self._dance_list)
# ── Koreograf ───────────────────────────────────────────────────────── # Info-label — viser niveau/koreograf for valgt dans
lbl3 = QLabel("Koreograf (valgfri):") self._info_lbl = QLabel("")
lbl3.setObjectName("track_meta") self._info_lbl.setObjectName("result_count")
layout.addWidget(lbl3) self._info_lbl.setWordWrap(True)
layout.addWidget(self._info_lbl)
self._edit_choreo = QLineEdit() # Debounce timer
self._edit_choreo.setText(current_choreo) self._timer = QTimer(self)
self._edit_choreo.setPlaceholderText("Koreografens navn...") self._timer.setSingleShot(True)
self._edit_choreo.textChanged.connect(self._on_choreo_text_changed) self._timer.setInterval(150)
self._edit_choreo.returnPressed.connect(self._on_accept) self._timer.timeout.connect(
layout.addWidget(self._edit_choreo) lambda: self._load_suggestions(self._edit.text().strip())
self._choreo_list = QListWidget()
self._choreo_list.setMaximumHeight(100)
self._choreo_list.itemDoubleClicked.connect(self._on_choreo_selected)
self._choreo_list.itemClicked.connect(
lambda item: self._edit_choreo.setText(item.text())
)
layout.addWidget(self._choreo_list)
# ── Debounce timere ───────────────────────────────────────────────────
self._dance_timer = QTimer(self)
self._dance_timer.setSingleShot(True)
self._dance_timer.setInterval(200)
self._dance_timer.timeout.connect(
lambda: self._load_dance_suggestions(self._edit_dance.text().strip())
) )
self._choreo_timer = QTimer(self) # Knapper
self._choreo_timer.setSingleShot(True)
self._choreo_timer.setInterval(200)
self._choreo_timer.timeout.connect(
lambda: self._load_choreo_suggestions(self._edit_choreo.text().strip())
)
# ── Knapper ───────────────────────────────────────────────────────────
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_row.addStretch() btn_row.addStretch()
btn_cancel = QPushButton("Annuller") btn_cancel = QPushButton("Annuller")
@@ -102,62 +77,102 @@ class DancePickerDialog(QDialog):
btn_row.addWidget(btn_ok) btn_row.addWidget(btn_ok)
layout.addLayout(btn_row) layout.addLayout(btn_row)
self._edit_dance.setFocus() self._edit.setFocus()
def _on_dance_text_changed(self): def _on_text_changed(self):
self._dance_timer.start() self._timer.start()
def _on_choreo_text_changed(self): def _load_suggestions(self, prefix: str):
self._choreo_timer.start()
def _load_dance_suggestions(self, prefix: str):
try: try:
from local.local_db import get_dance_suggestions from local.local_db import get_dance_suggestions
suggestions = get_dance_suggestions(prefix or "", limit=20) from PyQt6.QtGui import QColor
self._dance_list.clear() suggestions = get_dance_suggestions(prefix or "", limit=25)
self._list.clear()
# Allerøverst: mulighed for at fjerne dans
no_dance = QListWidgetItem("✕ Ingen dans")
no_dance.setForeground(QColor("#5a6070"))
no_dance.setData(Qt.ItemDataRole.UserRole, {"name": ""})
self._list.addItem(no_dance)
# Ø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:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"] s = dict(s)
if s.get("choreographer"): name = s["name"]
label += f" ({s['choreographer']})" level = s.get("level_name") or ""
choreo = s.get("choreographer") or ""
parts = [name]
if level:
parts.append(level)
if choreo:
parts.append(choreo)
label = " / ".join(parts)
item = QListWidgetItem(label) item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s["name"]) item.setData(Qt.ItemDataRole.UserRole, {
item.setData(Qt.ItemDataRole.UserRole + 1, s.get("choreographer", "")) "name": name,
self._dance_list.addItem(item) "level": level,
except Exception: "choreo": choreo,
pass })
self._list.addItem(item)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f'Dans-forslag fejl: {e}', exc_info=True)
def _load_choreo_suggestions(self, prefix: str): def _on_item_clicked(self, item: QListWidgetItem):
try: data = item.data(Qt.ItemDataRole.UserRole)
from local.local_db import get_choreographer_suggestions if not data: # separator — ignorer
suggestions = get_choreographer_suggestions(prefix or "", limit=15) return
self._choreo_list.clear() name = data.get("name", "")
for name in suggestions: level = data.get("level", "")
self._choreo_list.addItem(QListWidgetItem(name)) choreo = data.get("choreo", "")
except Exception: self._edit.setText(name)
pass # Vis info
parts = []
if level:
parts.append(level)
if choreo:
parts.append(choreo)
self._info_lbl.setText(" · ".join(parts) if parts else "")
def _on_dance_selected(self, item: QListWidgetItem): def _on_selected(self, item: QListWidgetItem):
name = item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0] data = item.data(Qt.ItemDataRole.UserRole)
choreo = item.data(Qt.ItemDataRole.UserRole + 1) or "" if not data: # separator
self._edit_dance.setText(name) return
if choreo and not self._edit_choreo.text().strip(): self._on_item_clicked(item)
self._edit_choreo.setText(choreo) self._on_accept()
self._chosen_dance = name
self._chosen_choreo = self._edit_choreo.text().strip()
self.accept()
def _on_choreo_selected(self, item: QListWidgetItem):
self._edit_choreo.setText(item.text())
self._choreo_list.clear()
def _on_accept(self): def _on_accept(self):
self._chosen_dance = self._edit_dance.text().strip() self._chosen_dance = self._edit.text().strip()
self._chosen_choreo = self._edit_choreo.text().strip() self.accept() # tillad tom streng = ingen dans
if self._chosen_dance:
self.accept()
def get_dance(self) -> str: def get_dance(self) -> str:
return self._chosen_dance return self._chosen_dance
# Behold get_choreo for bagudkompatibilitet — returnerer altid ""
def get_choreo(self) -> str: def get_choreo(self) -> str:
return self._chosen_choreo return ""

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")
@@ -756,40 +796,193 @@ class PlaylistPanel(QWidget):
def _change_dance(self, idx: int, song: dict): def _change_dance(self, idx: int, song: dict):
"""Lad brugeren vælge/skrive hvilken dans der vises for dette nummer.""" """Lad brugeren vælge/skrive hvilken dans der vises for dette nummer."""
from ui.dance_picker_dialog import DancePickerDialog from ui.dance_picker_dialog import DancePickerDialog
dances = song.get("dances", [])
current = song.get("active_dance", "") current = song.get("active_dance", "")
if not current: if not current:
dances = song.get("dances", [])
current = dances[0] if dances else "" current = dances[0] if dances else ""
current_choreo = song.get("active_choreo", "") current_choreo = song.get("active_choreo", "")
# Afgør om valget er permanent eller midlertidigt
# Permanent: ingen dans tagget, eller valgt dans er ikke i de taggede
# Midlertidig: sangen har flere danse og brugeren vælger en af dem
dlg = DancePickerDialog( dlg = DancePickerDialog(
current_dance=current, current_dance=current,
current_choreo=current_choreo,
song_title=song.get("title", ""), song_title=song.get("title", ""),
existing_dances=dances,
parent=self.window() parent=self.window()
) )
if dlg.exec(): if dlg.exec():
chosen = dlg.get_dance() chosen = dlg.get_dance()
choreo = dlg.get_choreo() # 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()
self._sync_dance_to_db(idx, song)
def _sync_dance_to_db(self, idx: int, song: dict): def _change_alt_dance(self, idx: int, song: dict):
"""Gem dance_override til playlist_songs.""" """Lad brugeren vælge alternativ dans til denne sang i playlisten."""
from ui.alt_dance_picker_dialog import AltDancePickerDialog
dlg = AltDancePickerDialog(song, parent=self.window())
if dlg.exec():
if dlg.was_cleared():
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_alt_dance_to_db(self, idx: int, song: dict, alt_dance: str):
"""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 = ""):
"""
Gem dans permanent på sangen:
1. song_dances tabellen
2. ID3-tag i filen (hvis tilgængelig)
3. Opdater sang-dict så listen vises korrekt
"""
import uuid
song_id = song.get("id", "")
local_path = song.get("local_path", "")
try:
from local.local_db import get_db
with get_db() as conn:
# Find eller opret dans
dance_row = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
(dance_name,)
).fetchone()
if dance_row:
dance_id = dance_row["id"]
if choreo:
conn.execute(
"UPDATE dances SET choreographer=? WHERE id=? AND choreographer=''",
(choreo, dance_id)
)
else:
cur = conn.execute(
"INSERT INTO dances (name, choreographer) VALUES (?,?)",
(dance_name, choreo or "")
)
dance_id = cur.lastrowid
# Tilføj til song_dances
existing = conn.execute(
"SELECT id FROM song_dances WHERE song_id=? AND dance_id=?",
(song_id, dance_id)
).fetchone()
if not existing:
# Find næste dance_order
max_order = conn.execute(
"SELECT MAX(dance_order) FROM song_dances WHERE song_id=?",
(song_id,)
).fetchone()[0] or 0
conn.execute(
"INSERT INTO song_dances (id, song_id, dance_id, dance_order) VALUES (?,?,?,?)",
(str(uuid.uuid4()), song_id, dance_id, max_order + 1)
)
# Opdater sang-dict
dances = song.get("dances", [])
if dance_name not in dances:
dances.append(dance_name)
song["dances"] = dances
song["active_dance"] = dance_name
# Gem i ID3-tag hvis filen er tilgængelig
if local_path:
try:
from local.tag_reader import write_dance_to_file
write_dance_to_file(local_path, dances)
except Exception:
pass
# Opdater også dance_override på listen
self._sync_dance_to_db(idx, song)
import logging
logging.getLogger(__name__).info(
f"Dans gemt permanent: '{dance_name}''{song.get('title','?')}'"
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Kunne ikke gemme dans permanent: {e}")
def _sync_ws_to_db(self, idx: int, song: dict): def _sync_ws_to_db(self, idx: int, song: dict):
"""Gem is_workshop til playlist_songs — både navngiven og aktiv liste.""" """Gem is_workshop til playlist_songs — både navngiven og aktiv liste."""
@@ -984,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
@@ -1021,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(
@@ -1047,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:
@@ -1084,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()
@@ -1114,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)
@@ -1267,11 +1464,11 @@ class PlaylistPanel(QWidget):
status = self._statuses[i] status = self._statuses[i]
icon = self.STATUS_ICON.get(status, " ") icon = self.STATUS_ICON.get(status, " ")
# Vis active_dance (override eller første dans) eller alle danse # Dans er primær tekst, sang er sekundær
active = song.get("active_dance", "") active = song.get("active_dance", "")
if not active: if not active:
dances = song.get("dances", []) dances = song.get("dances", [])
active = dances[0] if dances else "ingen dans tagget" active = dances[0] if dances else "ingen dans "
ws_tag = " 🎓" if song.get("is_workshop") else "" ws_tag = " 🎓" if song.get("is_workshop") else ""
# Tilgængeligheds-dot til højre — kun hvis tjekket (ikke yellow) # Tilgængeligheds-dot til højre — kun hvis tjekket (ikke yellow)
@@ -1279,8 +1476,8 @@ class PlaylistPanel(QWidget):
avail_color = {"green": "#27ae60", "red": "#e74c3c"}.get(avail, None) avail_color = {"green": "#27ae60", "red": "#e74c3c"}.get(avail, None)
avail_tip = {"green": "Tilgængelig lokalt", "red": "Ikke fundet lokalt"}.get(avail, "") avail_tip = {"green": "Tilgængelig lokalt", "red": "Ikke fundet lokalt"}.get(avail, "")
text = (f"{i+1:>2}. {song.get('title','')}{ws_tag}\n" text = (f"{i+1:>2}. {active}{ws_tag}\n"
f" {song.get('artist','')} · {active}") f" {song.get('title','')} · {song.get('artist','')}")
item = QListWidgetItem(f"{icon} {text}") item = QListWidgetItem(f"{icon} {text}")
item.setData(Qt.ItemDataRole.UserRole, i) item.setData(Qt.ItemDataRole.UserRole, i)
item.setData(Qt.ItemDataRole.UserRole + 1, avail_color) item.setData(Qt.ItemDataRole.UserRole + 1, avail_color)

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

@@ -164,7 +164,7 @@ class TagEditorDialog(QDialog):
# Forslags-liste # Forslags-liste
self._dance_suggestions = QListWidget() self._dance_suggestions = QListWidget()
self._dance_suggestions.setMaximumHeight(120) self._dance_suggestions.setFixedHeight(150)
self._dance_suggestions.setFocusPolicy(Qt.FocusPolicy.NoFocus) self._dance_suggestions.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self._dance_suggestions.itemClicked.connect( self._dance_suggestions.itemClicked.connect(
lambda item: self._add_from_suggestion(item, "dance") lambda item: self._add_from_suggestion(item, "dance")
@@ -328,7 +328,13 @@ class TagEditorDialog(QDialog):
suggestions = get_dance_suggestions(prefix, limit=20) suggestions = get_dance_suggestions(prefix, limit=20)
list_widget.clear() list_widget.clear()
for s in suggestions: for s in suggestions:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"] s = dict(s)
parts = [s["name"]]
if s.get("level_name"):
parts.append(s["level_name"])
if s.get("choreographer"):
parts.append(s["choreographer"])
label = " / ".join(parts)
item = QListWidgetItem(label) item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id")) item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"]) item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
@@ -373,16 +379,21 @@ class TagEditorDialog(QDialog):
suggestions = get_dance_suggestions(prefix, limit=15) suggestions = get_dance_suggestions(prefix, limit=15)
list_widget.clear() list_widget.clear()
for s in suggestions: for s in suggestions:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"] s = dict(s)
parts = [s["name"]]
if s.get("level_name"):
parts.append(s["level_name"])
if s.get("choreographer"): if s.get("choreographer"):
label += f" · {s['choreographer']}" parts.append(s["choreographer"])
label = " / ".join(parts)
item = QListWidgetItem(label) item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id")) item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"]) item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
item.setData(Qt.ItemDataRole.UserRole + 2, s.get("choreographer", "")) item.setData(Qt.ItemDataRole.UserRole + 2, s.get("choreographer", ""))
list_widget.addItem(item) list_widget.addItem(item)
except Exception: except Exception as e:
pass import logging
logging.getLogger(__name__).warning(f"Dans-forslag fejl: {e}", exc_info=True)
def _add_from_suggestion(self, item, panel: str): def _add_from_suggestion(self, item, panel: str):
"""Tilføj dans fra forslags-listen ved klik.""" """Tilføj dans fra forslags-listen ved klik."""
@@ -451,7 +462,7 @@ class TagEditorDialog(QDialog):
layout.addWidget(self._new_alt) layout.addWidget(self._new_alt)
self._alt_suggestions = QListWidget() self._alt_suggestions = QListWidget()
self._alt_suggestions.setMaximumHeight(120) self._alt_suggestions.setFixedHeight(150)
self._alt_suggestions.setFocusPolicy(Qt.FocusPolicy.NoFocus) self._alt_suggestions.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self._alt_suggestions.itemClicked.connect( self._alt_suggestions.itemClicked.connect(
lambda item: self._add_from_suggestion(item, "alt") lambda item: self._add_from_suggestion(item, "alt")
@@ -530,7 +541,8 @@ class TagEditorDialog(QDialog):
local_path = self._song.get("local_path", "") local_path = self._song.get("local_path", "")
try: try:
from local.local_db import new_conn, get_or_create_dance import uuid
from local.local_db import get_db, get_or_create_dance
from local.tag_reader import write_dances, can_write_dances from local.tag_reader import write_dances, can_write_dances
# Saml data fra UI # Saml data fra UI
@@ -554,49 +566,53 @@ class TagEditorDialog(QDialog):
"note": "", "note": "",
}) })
conn = new_conn() with get_db() as conn:
# Slet eksisterende
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
conn.execute("DELETE FROM song_alt_dances WHERE song_id=?", (song_id,))
# Slet eksisterende # Indsæt hoveddanse
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,)) for i, d in enumerate(dances, 1):
conn.execute("DELETE FROM song_alt_dances WHERE song_id=?", (song_id,)) dance_id = get_or_create_dance(d["name"], d["level_id"], conn,
choreographer=d.get("choreographer", ""))
conn.execute(
"INSERT OR IGNORE INTO song_dances (id, song_id, dance_id, dance_order) "
"VALUES (?,?,?,?)",
(str(uuid.uuid4()), song_id, dance_id, i)
)
# Indsæt hoveddanse # Indsæt alternativ-danse
for i, d in enumerate(dances, 1): for a in alts:
dance_id = get_or_create_dance(d["name"], d["level_id"], conn, dance_id = get_or_create_dance(a["name"], a["level_id"], conn)
choreographer=d.get("choreographer", "")) conn.execute(
conn.execute( "INSERT OR IGNORE INTO song_alt_dances (id, song_id, dance_id, note) "
"INSERT OR IGNORE INTO song_dances (song_id, dance_id, dance_order) " "VALUES (?,?,?,?)",
"VALUES (?,?,?)", (str(uuid.uuid4()), song_id, dance_id, a.get("note", ""))
(song_id, dance_id, i) )
)
# Indsæt alternativ-danse
for a in alts:
dance_id = get_or_create_dance(a["name"], a["level_id"], conn)
conn.execute(
"INSERT OR IGNORE INTO song_alt_dances (song_id, dance_id, note) "
"VALUES (?,?,?)",
(song_id, dance_id, a.get("note", ""))
)
conn.commit()
conn.close()
# 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()
except Exception as e: except Exception as e:
import traceback import traceback
traceback.print_exc() traceback.print_exc()
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}") QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")