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 sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
@@ -53,6 +54,7 @@ class SongAltDanceData(BaseModel):
dance_name: str
level_name: str = ""
note: str = ""
user_rating: Optional[int] = None
class PlaylistSongData(BaseModel):
song_local_id: str
@@ -72,12 +74,13 @@ class PlaylistData(BaseModel):
songs: list[PlaylistSongData] = []
class PushPayload(BaseModel):
songs: list[SongData] = []
dances: list[DanceData] = []
song_dances: list[SongDanceData] = []
song_alts: list[SongAltDanceData] = []
playlists: list[PlaylistData] = []
deleted_playlists: list[str] = [] # server-IDs (Project.id)
songs: list[SongData] = []
dances: list[DanceData] = []
song_dances: list[SongDanceData] = []
song_alts: list[SongAltDanceData] = []
playlists: list[PlaylistData] = []
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 ─────────────────────────────
@@ -185,11 +188,17 @@ def push(
db.flush()
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:
song_id = song_id_map.get(sd.song_local_id)
if not song_id:
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
key = f"{sd.dance_name.lower()}|{level_id}"
dance_id = dance_id_map.get(key)
@@ -201,6 +210,13 @@ def push(
), {"id": str(uuid.uuid4()), "song_id": song_id,
"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:
song_id = song_id_map.get(sa.song_local_id)
if not song_id:
@@ -210,6 +226,47 @@ def push(
dance_id = dance_id_map.get(key)
if not dance_id:
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(
"INSERT IGNORE INTO song_alt_dances (id, song_id, dance_id, note) "
"VALUES (:id, :song_id, :dance_id, :note)"
@@ -380,16 +437,37 @@ def pull(
continue
level = db.query(DanceLevel).filter_by(id=dance.level_id).first() if dance.level_id else None
song_tags.append({
"song_id": sd.song_id,
"dance_name": dance.name,
"level_name": level.name if level else "",
"dance_order": sd.dance_order,
"song_id": sd.song_id,
"dance_name": dance.name,
"choreographer": dance.choreographer or "",
"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 {
"levels": levels,
"dances": dances,
"shared": shared,
"my_playlists": my_playlists,
"song_tags": song_tags,
"levels": levels,
"dances": dances,
"shared": shared,
"my_playlists": my_playlists,
"song_tags": song_tags,
"community_alts": community_alts,
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<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">
<style>
:root {
@@ -61,6 +61,25 @@
.hero h1 em { color: var(--accent); font-style: normal; }
.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-title {
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::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-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-tags { display: flex; gap: .35rem; flex-wrap: wrap; margin-top: .5rem; }
.badge {
font-family: var(--mono); font-size: .68rem; padding: .18rem .45rem;
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.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); }
.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 {
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.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; }
.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); } }
@@ -158,26 +213,44 @@
</header>
<div class="tabs">
<div class="tab active" data-tab="public">Public playlister</div>
<div class="tab" id="tab-mine" data-tab="mine" style="display:none">Mine 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 danselister</div>
</div>
<div class="hero">
<h1 id="hero-title">Public<br><em>playlister</em></h1>
<p id="hero-sub">Browse og kopiér playlister delt af LineDance Player-brugere.</p>
<h1 id="hero-title">Offentlige<br><em>danselister</em></h1>
<p id="hero-sub">Browse og kopiér danselister delt af LineDance Player-brugere.</p>
</div>
<div class="section">
<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 class="empty"><div class="spinner"></div>Henter playlister...</div>
<div class="empty"><div class="spinner"></div>Henter danselister...</div>
</div>
</div>
<div id="pane-mine" style="display:none">
<div class="section-title">Mine playlister</div>
<div id="grid-mine" class="grid">
<div class="empty"><div class="spinner"></div></div>
<div class="section-title" id="mine-title">Mine danselister</div>
<div class="mine-layout">
<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>
@@ -213,6 +286,7 @@
</div>
<div id="login-modal">
<div class="login-box">
<h3>Log ind</h3>
<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>
@@ -231,6 +305,10 @@ let token = localStorage.getItem('ld_token') || '';
let username = localStorage.getItem('ld_user') || '';
let currentPlaylistId = null;
let currentTab = 'public';
let allPublicLists = [];
let activeTag = '';
let allMineLists = [];
let activeMineTag = '';
function updateAuthUI() {
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);
document.getElementById('login-modal').classList.remove('open');
updateAuthUI();
// Skift til mine danselister ved login
switchTab('mine');
loadMyPlaylists();
} catch(e) {
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-mine').style.display = tab === 'mine' ? '' : 'none';
if (tab === 'public') {
document.getElementById('hero-title').innerHTML = 'Public<br><em>playlister</em>';
document.getElementById('hero-sub').textContent = 'Browse og kopiér playlister delt af LineDance Player-brugere.';
document.getElementById('hero-title').innerHTML = 'Offentlige<br><em>danselister</em>';
document.getElementById('hero-sub').textContent = 'Browse og kopiér danselister delt af LineDance Player-brugere.';
} else {
document.getElementById('hero-title').innerHTML = 'Mine<br><em>playlister</em>';
document.getElementById('hero-sub').textContent = 'Administrér synlighed på dine playlister.';
document.getElementById('hero-title').innerHTML = 'Mine<br><em>danselister</em>';
document.getElementById('hero-sub').textContent = 'Administrér dine danselister.';
}
}
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');
try {
const r = await fetch(`${API}/sharing/public`);
const lists = await r.json();
if (!lists.length) { grid.innerHTML = '<div class="empty">Ingen public playlister endnu.</div>'; return; }
grid.innerHTML = lists.map(p => `
if (!lists.length) {
grid.innerHTML = '<div class="empty">Ingen danselister matcher søgningen.</div>';
return;
}
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-title">${esc(p.name)}</div>
<div class="card-owner">@ ${esc(p.owner)}</div>
<div class="card-meta">
<span class="badge orange">${p.song_count} sange</span>
<span class="badge green">public</span>
<span class="badge green">offentlig</span>
</div>
</div>`).join('');
grid.querySelectorAll('.card').forEach(c =>
c.onclick = () => openDetail(c.dataset.id, false));
${tagHtml ? `<div class="card-tags">${tagHtml}</div>` : ''}
</div>`;
}).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) {
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() {
const grid = document.getElementById('grid-mine');
grid.innerHTML = '<div class="empty"><div class="spinner"></div></div>';
@@ -319,33 +536,15 @@ async function loadMyPlaylists() {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!r.ok) throw new Error('Ikke autoriseret');
const lists = await r.json();
if (!lists.length) { grid.innerHTML = '<div class="empty">Ingen playlister endnu.</div>'; return; }
grid.innerHTML = lists.map(p => {
const vis = p.visibility || 'private';
const bc = vis === 'public' ? 'green' : vis === 'shared' ? 'orange' : 'muted';
const bl = vis === 'public' ? 'public' : vis === 'shared' ? 'delt' : 'privat';
const sc = p.song_count || (p.songs || []).length || 0;
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('');
allMineLists = await r.json();
if (!allMineLists.length) {
document.getElementById('grid-mine').innerHTML = '<div class="empty">Ingen danselister endnu.</div>';
return;
}
buildMineSidebar(allMineLists);
filterMine();
} 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}` }
});
if (!r.ok) throw new Error('Fejl');
const badge = document.getElementById(`vis-badge-${id}`);
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');
}
loadMyPlaylists();
loadPublicPlaylists();
} 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) {
currentPlaylistId = id;
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; }
};
// ── QR ───────────────────────────────────────────────────────────────────────
let currentQRUrl = '';
function showQR(id, name) {
@@ -433,13 +641,10 @@ function showQR(id, name) {
document.getElementById('qr-url').textContent = url;
document.getElementById('copy-msg').textContent = '';
document.getElementById('qr-modal').style.display = 'flex';
// Tegn QR med et simpelt bibliotek
const canvas = document.getElementById('qr-canvas');
if (window.QRious) {
new QRious({ element: canvas, value: url, size: 220, backgroundAlpha: 0, foreground: '#eceef4' });
} else {
// Fallback: vis bare URL hvis bibliotek ikke er loadet
canvas.style.display = 'none';
}
}
@@ -459,4 +664,4 @@ loadPublicPlaylists();
if (token) loadMyPlaylists();
</script>
</body>
</html>
</html>

View File

@@ -44,6 +44,25 @@ echo.
echo OK: dist\LineDancePlayer\ er klar
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 ────────────────────────────────────────────────────────────
echo [4/4] Bygger NSIS installer...
echo.
@@ -120,4 +139,4 @@ if exist "dist\LineDancePlayer-Setup.exe" (
echo Kør makensis installer.nsi manuelt naar NSIS er installeret
)
echo.
pause
pause

View File

@@ -2,10 +2,32 @@
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(
['main.py'],
pathex=['.'],
binaries=[],
binaries=VLC_BINARIES,
datas=[
('translations', 'translations'),
],
@@ -20,7 +42,7 @@ a = Analysis(
'ui.scan_worker', 'ui.bpm_worker', 'ui.tag_editor',
'ui.settings_dialog', 'ui.playlist_browser',
'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',
'player.player',
'local.local_db', 'local.scanner', 'local.file_watcher',

View File

@@ -51,6 +51,12 @@ Section "LineDance Player" SecMain
SetOutPath "$INSTDIR"
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"
File /r "dist\LineDancePlayer\_internal\*"
@@ -85,34 +91,7 @@ Section "LineDance Player" SecMain
SectionEnd
; ── VLC tjek ──────────────────────────────────────────────────────────────────
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
; VLC DLL-filer er bundlet med appen — intet VLC-tjek nødvendigt
; ── Afinstaller ───────────────────────────────────────────────────────────────
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:
mbid = result.get("mbid", "")
acoustid = result.get("acoustid", "")
# Opdater acoustid altid, men kun mbid hvis det ikke allerede bruges
conn.execute(
"UPDATE songs SET mbid=?, acoustid=? WHERE id=?",
(mbid or None, acoustid or None, row["id"])
"UPDATE songs SET acoustid=? WHERE 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
total_found += 1
logger.info(

View File

@@ -88,6 +88,7 @@ CREATE TABLE IF NOT EXISTS dance_levels (
);
-- Danse
-- Dans + niveau + koreograf er unik kombination
CREATE TABLE IF NOT EXISTS dances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
@@ -98,7 +99,7 @@ CREATE TABLE IF NOT EXISTS dances (
notes TEXT NOT NULL DEFAULT '',
use_count INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'local',
UNIQUE(name, level_id)
UNIQUE(name, level_id, choreographer)
);
-- Sang-dans tags
@@ -112,17 +113,23 @@ CREATE TABLE IF NOT EXISTS song_dances (
-- Alternativ-dans tags
CREATE TABLE IF NOT EXISTS song_alt_dances (
id TEXT PRIMARY KEY,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
dance_id INTEGER NOT NULL REFERENCES dances(id),
note TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
id TEXT PRIMARY KEY,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
dance_id INTEGER NOT NULL REFERENCES dances(id),
note TEXT NOT NULL DEFAULT '',
user_rating INTEGER, -- 1-5 stjerner, NULL = ikke vurderet
source TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
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_alt_dances ON song_alt_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_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
CREATE TABLE IF NOT EXISTS playlists (
@@ -139,14 +146,15 @@ CREATE TABLE IF NOT EXISTS playlists (
-- Playliste-sange
CREATE TABLE IF NOT EXISTS playlist_songs (
id TEXT PRIMARY KEY,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
song_id TEXT NOT NULL REFERENCES songs(id),
file_id TEXT REFERENCES files(id),
position INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
is_workshop INTEGER NOT NULL DEFAULT 0,
dance_override TEXT NOT NULL DEFAULT ''
id TEXT PRIMARY KEY,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
song_id TEXT NOT NULL REFERENCES songs(id),
file_id TEXT REFERENCES files(id),
position INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
is_workshop INTEGER NOT NULL DEFAULT 0,
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);
@@ -582,4 +590,182 @@ def upsert_dance_levels(levels: list[dict]):
ON CONFLICT(name) DO UPDATE SET
sort_order=excluded.sort_order,
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
_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", [])
if file_dances:
existing_count = conn.execute(
"SELECT COUNT(*) FROM song_dances WHERE song_id=?", (song_id,)
).fetchone()[0]
if existing_count == 0:
import uuid
for order, dance_name in enumerate(file_dances, start=1):
dance_row = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
(dance_name,)
).fetchone()
if not dance_row:
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)
import uuid
# Slet eksisterende og genindsæt fra filen
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
for order, dance_name in enumerate(file_dances, start=1):
dance_row = conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
(dance_name,)
).fetchone()
if not dance_row:
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)
)
conn.commit()
except Exception as e:

View File

@@ -189,7 +189,7 @@ class SyncManager:
song_alts = []
for row in conn.execute("""
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
JOIN dances d ON d.id = sad.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
@@ -199,6 +199,7 @@ class SyncManager:
"dance_name": row["dance_name"],
"level_name": row["level_name"] or "",
"note": row["note"] or "",
"user_rating": row["user_rating"],
})
# Playlister — alle ikke-slettede
@@ -245,14 +246,18 @@ class SyncManager:
).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()
return {
"songs": songs,
"dances": dances,
"song_dances": song_dances,
"song_alts": song_alts,
"playlists": playlists,
"deleted_playlists": deleted,
"songs": songs,
"dances": dances,
"song_dances": song_dances,
"song_alts": song_alts,
"playlists": playlists,
"deleted_playlists": deleted,
"songs_with_dances_synced": all_song_ids,
}
# ── Gem server-IDs ────────────────────────────────────────────────────────
@@ -323,22 +328,30 @@ class SyncManager:
conn.execute("PRAGMA journal_mode=WAL")
try:
# Opdater dans-info
# Synkroniser danse fra server — opret nye, opdater eksisterende
for d in data.get("dances", []):
if not d.get("name"):
continue
choreo = d.get("choreographer", "") or ""
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()
if existing and (d.get("choreographer") or d.get("video_url")):
if existing:
conn.execute("""
UPDATE dances SET
choreographer = CASE WHEN choreographer='' THEN ? ELSE choreographer END,
video_url = CASE WHEN video_url='' THEN ? ELSE video_url END,
stepsheet_url = CASE WHEN stepsheet_url='' THEN ? ELSE stepsheet_url END
WHERE id=?
""", (d.get("choreographer",""), d.get("video_url",""),
d.get("stepsheet_url",""), existing["id"]))
""", (d.get("video_url",""), 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
deleted_server_ids = {
@@ -468,6 +481,97 @@ class SyncManager:
song_data.get("dance_override","") or ""))
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()
except Exception:

View File

@@ -412,12 +412,19 @@ def read_dances_from_file(path: str | Path) -> list[str]:
# ── 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:
"""
Analysér BPM fra lydfilen ved hjælp af librosa.
Returnerer BPM som float eller None ved fejl.
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:
import librosa
# 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
except Exception as e:
logger.warning(f"MBID M4A skrivefejl {path}: {e}")
return False
return False

View File

@@ -8,7 +8,15 @@ Start:
import sys
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__))
@@ -58,4 +66,4 @@ def 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.
Ny v0.9 arkitektur: sange er i songs, filer i files, libraries i libraries.
"""
import sqlite3
from PyQt6.QtCore import QThread, pyqtSignal
@@ -15,10 +16,10 @@ class BpmScanWorker(QThread):
self._library_id = library_id
self._db_path = db_path
self._scan_all = scan_all
self._cancelled = False
def cancel(self):
self.requestInterruption()
# Afbryd hurtigt ved at sætte et flag
self._cancelled = True
def run(self):
@@ -28,20 +29,34 @@ class BpmScanWorker(QThread):
from local.tag_reader import analyze_bpm
conn = sqlite3.connect(self._db_path)
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:
songs = conn.execute(
"SELECT id, local_path FROM songs "
"WHERE library_id=? AND file_missing=0",
(self._library_id,)
).fetchall()
songs = conn.execute("""
SELECT s.id, f.local_path
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
WHERE f.local_path LIKE ?
""", (lib_path + "%",)).fetchall()
else:
songs = conn.execute(
"SELECT id, local_path FROM songs "
"WHERE library_id=? AND file_missing=0 "
"AND (bpm IS NULL OR bpm=0)",
(self._library_id,)
).fetchall()
songs = conn.execute("""
SELECT s.id, f.local_path
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
WHERE f.local_path LIKE ?
AND (s.bpm IS NULL OR s.bpm = 0)
""", (lib_path + "%",)).fetchall()
total = len(songs)
done = 0
@@ -61,9 +76,9 @@ class BpmScanWorker(QThread):
pass
done += 1
self.progress.emit(done, total)
time.sleep(0.01) # Yield så GUI ikke hænger
time.sleep(0.01)
conn.close()
self.finished.emit(done)
except Exception as e:
self.finished.emit(0)
except Exception:
self.finished.emit(0)

View File

@@ -31,54 +31,51 @@ class DanceInfoDialog(QDialog):
def _load_dances(self):
try:
from local.local_db import get_dances_for_song, get_alt_dances_for_song, new_conn
conn = new_conn()
from local.local_db import get_db
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("""
SELECT d.id, d.name, d.level_id, 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()
for row in 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": False,
})
for row in 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": False,
})
alt_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_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
""", (self._song.get("id"),)).fetchall()
# Alternativ-danse
alt_rows = conn.execute("""
SELECT d.id, d.name, d.level_id, d.choreographer,
d.video_url, d.stepsheet_url, d.notes,
dl.name as level_name
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
""", (self._song.get("id"),)).fetchall()
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()
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,
})
except Exception as e:
print(f"DanceInfoDialog load fejl: {e}")
@@ -204,15 +201,14 @@ class DanceInfoDialog(QDialog):
def _save(self):
self._save_to_cache(self._current_idx)
try:
from local.local_db import update_dance_info
for d in self._dances:
update_dance_info(
d["dance_id"],
choreographer = d["choreographer"],
video_url = d["video_url"],
stepsheet_url = d["stepsheet_url"],
notes = d["notes"],
)
from local.local_db import get_db
with get_db() as conn:
for d in self._dances:
conn.execute("""
UPDATE dances SET choreographer=?, video_url=?,
stepsheet_url=?, notes=? WHERE id=?
""", (d["choreographer"], d["video_url"],
d["stepsheet_url"], d["notes"], d["dance_id"]))
self.accept()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
@@ -223,4 +219,4 @@ class DanceInfoDialog(QDialog):
return
if not url.startswith("http"):
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 (
@@ -10,18 +12,18 @@ from PyQt6.QtCore import Qt, QTimer
class DancePickerDialog(QDialog):
def __init__(self, current_dance: str = "", current_choreo: str = "",
song_title: str = "", parent=None):
def __init__(self, current_dance: str = "", song_title: str = "",
existing_dances: list[str] = None, parent=None):
super().__init__(parent)
self._chosen_dance = current_dance
self._chosen_choreo = current_choreo
self._existing_dances = existing_dances or []
self.setWindowTitle("Vælg dans")
self.setMinimumWidth(400)
self.setFixedWidth(440)
self._build_ui(current_dance, current_choreo, song_title)
self._load_dance_suggestions("")
self.setMinimumWidth(420)
self.setFixedWidth(460)
self._build_ui(current_dance, song_title)
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.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
@@ -32,65 +34,38 @@ class DancePickerDialog(QDialog):
lbl.setWordWrap(True)
layout.addWidget(lbl)
# ── Dans ──────────────────────────────────────────────────────────────
lbl2 = QLabel("Dans:")
lbl2.setObjectName("track_meta")
layout.addWidget(lbl2)
layout.addWidget(QLabel("Dans:"))
self._edit_dance = QLineEdit()
self._edit_dance.setText(current_dance)
self._edit_dance.setPlaceholderText("Skriv dans-navn...")
self._edit_dance.selectAll()
self._edit_dance.textChanged.connect(self._on_dance_text_changed)
self._edit_dance.returnPressed.connect(lambda: self._edit_choreo.setFocus())
layout.addWidget(self._edit_dance)
self._edit = QLineEdit()
self._edit.setText(current_dance)
self._edit.setPlaceholderText("Skriv dans-navn...")
self._edit.selectAll()
self._edit.textChanged.connect(self._on_text_changed)
self._edit.returnPressed.connect(self._on_accept)
layout.addWidget(self._edit)
self._dance_list = QListWidget()
self._dance_list.setMaximumHeight(160)
self._dance_list.itemDoubleClicked.connect(self._on_dance_selected)
self._dance_list.itemClicked.connect(
lambda item: self._edit_dance.setText(
item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0]
)
)
layout.addWidget(self._dance_list)
# Forslagsliste
self._list = QListWidget()
self._list.setMinimumHeight(200)
self._list.itemDoubleClicked.connect(self._on_selected)
self._list.itemClicked.connect(self._on_item_clicked)
layout.addWidget(self._list)
# ── Koreograf ─────────────────────────────────────────────────────────
lbl3 = QLabel("Koreograf (valgfri):")
lbl3.setObjectName("track_meta")
layout.addWidget(lbl3)
# Info-label — viser niveau/koreograf for valgt dans
self._info_lbl = QLabel("")
self._info_lbl.setObjectName("result_count")
self._info_lbl.setWordWrap(True)
layout.addWidget(self._info_lbl)
self._edit_choreo = QLineEdit()
self._edit_choreo.setText(current_choreo)
self._edit_choreo.setPlaceholderText("Koreografens navn...")
self._edit_choreo.textChanged.connect(self._on_choreo_text_changed)
self._edit_choreo.returnPressed.connect(self._on_accept)
layout.addWidget(self._edit_choreo)
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())
# 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())
)
self._choreo_timer = QTimer(self)
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 ───────────────────────────────────────────────────────────
# Knapper
btn_row = QHBoxLayout()
btn_row.addStretch()
btn_cancel = QPushButton("Annuller")
@@ -102,62 +77,102 @@ class DancePickerDialog(QDialog):
btn_row.addWidget(btn_ok)
layout.addLayout(btn_row)
self._edit_dance.setFocus()
self._edit.setFocus()
def _on_dance_text_changed(self):
self._dance_timer.start()
def _on_text_changed(self):
self._timer.start()
def _on_choreo_text_changed(self):
self._choreo_timer.start()
def _load_dance_suggestions(self, prefix: str):
def _load_suggestions(self, prefix: str):
try:
from local.local_db import get_dance_suggestions
suggestions = get_dance_suggestions(prefix or "", limit=20)
self._dance_list.clear()
from PyQt6.QtGui import QColor
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:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
if s.get("choreographer"):
label += f" ({s['choreographer']})"
s = dict(s)
name = s["name"]
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.setData(Qt.ItemDataRole.UserRole, s["name"])
item.setData(Qt.ItemDataRole.UserRole + 1, s.get("choreographer", ""))
self._dance_list.addItem(item)
except Exception:
pass
item.setData(Qt.ItemDataRole.UserRole, {
"name": name,
"level": level,
"choreo": choreo,
})
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):
try:
from local.local_db import get_choreographer_suggestions
suggestions = get_choreographer_suggestions(prefix or "", limit=15)
self._choreo_list.clear()
for name in suggestions:
self._choreo_list.addItem(QListWidgetItem(name))
except Exception:
pass
def _on_item_clicked(self, item: QListWidgetItem):
data = item.data(Qt.ItemDataRole.UserRole)
if not data: # separator — ignorer
return
name = data.get("name", "")
level = data.get("level", "")
choreo = data.get("choreo", "")
self._edit.setText(name)
# 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):
name = item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0]
choreo = item.data(Qt.ItemDataRole.UserRole + 1) or ""
self._edit_dance.setText(name)
if choreo and not self._edit_choreo.text().strip():
self._edit_choreo.setText(choreo)
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_selected(self, item: QListWidgetItem):
data = item.data(Qt.ItemDataRole.UserRole)
if not data: # separator
return
self._on_item_clicked(item)
self._on_accept()
def _on_accept(self):
self._chosen_dance = self._edit_dance.text().strip()
self._chosen_choreo = self._edit_choreo.text().strip()
if self._chosen_dance:
self.accept()
self._chosen_dance = self._edit.text().strip()
self.accept() # tillad tom streng = ingen dans
def get_dance(self) -> str:
return self._chosen_dance
# Behold get_choreo for bagudkompatibilitet — returnerer altid ""
def get_choreo(self) -> str:
return self._chosen_choreo
return ""

View File

@@ -549,9 +549,11 @@ class LibraryPanel(QWidget):
self._bpm_worker.start()
def _refresh_library(self):
"""Genindlæs bibliotek fra database."""
"""Opdater fil-tilgængelighed og genindlæs bibliotek."""
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()
def _manage_libraries(self):
@@ -580,4 +582,4 @@ class LibraryPanel(QWidget):
if folder:
mw = self.window()
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.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.set_preview_player(self._preview_player)
@@ -438,9 +444,30 @@ class MainWindow(QMainWindow):
from local.local_db import init_db
init_db()
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:
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):
"""Start fil-watcher i baggrundstråd — blokerer aldrig GUI."""
import threading
@@ -655,6 +682,8 @@ class MainWindow(QMainWindow):
def on_one_finished(count, p):
finished_count[0] += 1
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
self._scan_workers = [w for w in self._scan_workers
if w.isRunning()]
@@ -663,6 +692,9 @@ class MainWindow(QMainWindow):
worker = ScanWorker(lib["id"], lib["path"], str(DB_PATH),
overwrite_bpm=False)
worker.finished.connect(on_one_finished)
worker.batch_ready.connect(
lambda n: QTimer.singleShot(0, self._reload_library)
)
worker.start()
worker.setPriority(QThread.Priority.LowestPriority)
self._scan_workers.append(worker)
@@ -992,6 +1024,8 @@ class MainWindow(QMainWindow):
if dialog.exec():
# Genindlæs biblioteket så ændringer vises
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):
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.
"""
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 (
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
@@ -463,6 +502,7 @@ class PlaylistPanel(QWidget):
"file_missing": file_missing,
"dances": dance_names,
"active_dance": active_dance,
"alt_dance": row["alt_dance_override"] if "alt_dance_override" in row.keys() else "",
"is_workshop": bool(row["is_workshop"]),
})
statuses.append(row["status"] or "pending")
@@ -756,40 +796,193 @@ class PlaylistPanel(QWidget):
def _change_dance(self, idx: int, song: dict):
"""Lad brugeren vælge/skrive hvilken dans der vises for dette nummer."""
from ui.dance_picker_dialog import DancePickerDialog
dances = song.get("dances", [])
current = song.get("active_dance", "")
if not current:
dances = song.get("dances", [])
current = dances[0] if dances else ""
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(
current_dance=current,
current_choreo=current_choreo,
song_title=song.get("title", ""),
existing_dances=dances,
parent=self.window()
)
if dlg.exec():
chosen = dlg.get_dance()
choreo = dlg.get_choreo()
if chosen:
song["active_dance"] = chosen
song["active_choreo"] = choreo
self._refresh()
self._sync_dance_to_db(idx, song)
# Dans-valg i playlisten er altid midlertidigt — kun dance_override
song["active_dance"] = chosen # tom streng = ingen dans
self._refresh()
self._sync_dance_to_db(idx, song)
def _sync_dance_to_db(self, idx: int, song: dict):
"""Gem dance_override til playlist_songs."""
def _change_alt_dance(self, idx: int, song: dict):
"""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:
return
try:
from local.local_db import get_db
with get_db() as conn:
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=? "
"WHERE playlist_id=? AND position=?",
(song.get("active_dance", ""), self._named_playlist_id, idx + 1)
)
except Exception:
pass
(dance_val, self._named_playlist_id, idx + 1)
).rowcount
_log.info(f"dance_override='{dance_val}' gemt på position {idx+1}, {rows_affected} rækker")
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):
"""Gem is_workshop til playlist_songs — både navngiven og aktiv liste."""
@@ -984,7 +1177,8 @@ class PlaylistPanel(QWidget):
for song in songs:
path = song.get("local_path", "")
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
# Forsøg auto-match via titel+artist
@@ -1021,9 +1215,9 @@ class PlaylistPanel(QWidget):
with get_db() as conn:
for song in self._songs:
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():
song["availability"] = "green"
song["availability"] = "green" if _is_local_path(path) else "yellow"
song["file_missing"] = False
# Opdater files tabellen
conn.execute(
@@ -1047,7 +1241,7 @@ class PlaylistPanel(QWidget):
if match and Path(match["local_path"]).exists():
song["local_path"] = match["local_path"]
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
# Opdater playlist_songs til at pege på den fundne fil
if self._named_playlist_id:
@@ -1084,6 +1278,7 @@ class PlaylistPanel(QWidget):
act_played = menu.addAction("✓ Sæt til afspillet")
menu.addSeparator()
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
act_ws = menu.addAction("🎓 Fjern workshop" if is_ws else "🎓 Markér som workshop")
menu.addSeparator()
@@ -1114,6 +1309,8 @@ class PlaylistPanel(QWidget):
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
elif action == act_dance and 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:
song["is_workshop"] = not song.get("is_workshop", False)
self._sync_ws_to_db(idx, song)
@@ -1267,11 +1464,11 @@ class PlaylistPanel(QWidget):
status = self._statuses[i]
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", "")
if not active:
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 ""
# 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_tip = {"green": "Tilgængelig lokalt", "red": "Ikke fundet lokalt"}.get(avail, "")
text = (f"{i+1:>2}. {song.get('title','')}{ws_tag}\n"
f" {song.get('artist','')} · {active}")
text = (f"{i+1:>2}. {active}{ws_tag}\n"
f" {song.get('title','')} · {song.get('artist','')}")
item = QListWidgetItem(f"{icon} {text}")
item.setData(Qt.ItemDataRole.UserRole, i)
item.setData(Qt.ItemDataRole.UserRole + 1, avail_color)

View File

@@ -6,9 +6,10 @@ from PyQt6.QtCore import QThread, pyqtSignal
class ScanWorker(QThread):
progress = pyqtSignal(int, int, str) # done, total, filename
finished = pyqtSignal(int, str) # antal, library_path
error = pyqtSignal(str)
progress = pyqtSignal(int, int, str) # done, total, filename
finished = pyqtSignal(int, str) # antal, library_path
error = pyqtSignal(str)
batch_ready = pyqtSignal(int) # antal sange scannet så langt
def __init__(self, library_id: int, library_path: str,
db_path: str, overwrite_bpm: bool = False):
@@ -26,11 +27,15 @@ class ScanWorker(QThread):
def run(self):
try:
from local.scanner import scan_library
self._batch_count = 0
def on_progress(done, total, filename):
if self.isInterruptionRequested():
raise InterruptedError()
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(
self._library_id,
@@ -44,4 +49,4 @@ class ScanWorker(QThread):
except InterruptedError:
pass
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
self._dance_suggestions = QListWidget()
self._dance_suggestions.setMaximumHeight(120)
self._dance_suggestions.setFixedHeight(150)
self._dance_suggestions.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self._dance_suggestions.itemClicked.connect(
lambda item: self._add_from_suggestion(item, "dance")
@@ -328,7 +328,13 @@ class TagEditorDialog(QDialog):
suggestions = get_dance_suggestions(prefix, limit=20)
list_widget.clear()
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.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
@@ -373,16 +379,21 @@ class TagEditorDialog(QDialog):
suggestions = get_dance_suggestions(prefix, limit=15)
list_widget.clear()
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"):
label += f" · {s['choreographer']}"
parts.append(s["choreographer"])
label = " / ".join(parts)
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
item.setData(Qt.ItemDataRole.UserRole + 2, s.get("choreographer", ""))
list_widget.addItem(item)
except Exception:
pass
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Dans-forslag fejl: {e}", exc_info=True)
def _add_from_suggestion(self, item, panel: str):
"""Tilføj dans fra forslags-listen ved klik."""
@@ -451,7 +462,7 @@ class TagEditorDialog(QDialog):
layout.addWidget(self._new_alt)
self._alt_suggestions = QListWidget()
self._alt_suggestions.setMaximumHeight(120)
self._alt_suggestions.setFixedHeight(150)
self._alt_suggestions.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self._alt_suggestions.itemClicked.connect(
lambda item: self._add_from_suggestion(item, "alt")
@@ -530,7 +541,8 @@ class TagEditorDialog(QDialog):
local_path = self._song.get("local_path", "")
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
# Saml data fra UI
@@ -554,49 +566,53 @@ class TagEditorDialog(QDialog):
"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
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
conn.execute("DELETE FROM song_alt_dances WHERE song_id=?", (song_id,))
# Indsæt hoveddanse
for i, d in enumerate(dances, 1):
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
for i, d in enumerate(dances, 1):
dance_id = get_or_create_dance(d["name"], d["level_id"], conn,
choreographer=d.get("choreographer", ""))
conn.execute(
"INSERT OR IGNORE INTO song_dances (song_id, dance_id, dance_order) "
"VALUES (?,?,?)",
(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()
# 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 (id, song_id, dance_id, note) "
"VALUES (?,?,?,?)",
(str(uuid.uuid4()), song_id, dance_id, a.get("note", ""))
)
# 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):
dance_names = [d["name"] for d in dances]
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",
"Gemt i database, men kunne ikke skrive til mp3-filen.\n"
"(Filen understøtter ikke dans-tags)")
except Exception as write_err:
_log.warning(f"write_dances fejl: {write_err}")
QMessageBox.warning(self, "Advarsel",
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()
except Exception as e:
import traceback
traceback.print_exc()
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")