Compare commits

..

81 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
2deb0260f0 ny install-fil 2026-04-20 10:53:32 +02:00
8a4c879213 Synk virker 2026-04-20 01:41:24 +02:00
f92af40dd7 db struktur 2026-04-20 00:01:41 +02:00
efc30cdbb2 NY db struktur 2026-04-19 23:45:59 +02:00
a9aa451d63 Sync OK 2026-04-19 22:04:47 +02:00
4df87cf48e Merge branch 'main' of ssh://git.ckvist.lan:2222/carsten/LinedanceAfspiller 2026-04-19 22:02:00 +02:00
f943c5ffba Sync på ID 2026-04-19 22:01:56 +02:00
31dcd9fcfa Exefil 2026-04-19 20:19:42 +02:00
34040af464 web rettelser 2026-04-19 20:09:43 +02:00
9052dd8b0f login dialog 2026-04-19 19:42:57 +02:00
b226795731 Klar v1 2026-04-19 19:28:06 +02:00
9e5ddec184 Bedre sync 2026-04-19 19:15:16 +02:00
fb7622549c rettelser 2026-04-19 17:19:59 +02:00
c966d38f11 bedre sync 2026-04-19 15:31:13 +02:00
0a3a6d44da sync rettet 2026-04-19 13:47:46 +02:00
e149fb3ce2 slette lister 2026-04-19 13:43:11 +02:00
bf26ff6377 Playlist fejl 2026-04-19 13:22:56 +02:00
3f67f7dc3a bedre installer 2026-04-19 13:09:56 +02:00
602f7cc2d4 installer filer 2026-04-19 12:50:20 +02:00
80407e98fb Rettelser og reset 2026-04-19 11:28:28 +02:00
f0a4b4dfa7 DB fejl 2026-04-19 01:11:51 +02:00
24bb71cdd7 DB-flush - Server 2026-04-19 01:02:56 +02:00
e4ab9caab6 En del opdateringer 2026-04-19 00:58:48 +02:00
efe3739626 ny 2026-04-15 23:23:38 +02:00
c5e35f0889 Ny live 2026-04-15 16:58:52 +02:00
920cd8222d Opdateringer 2026-04-15 16:30:03 +02:00
4a206f2f19 Web 2026-04-14 20:20:46 +02:00
cd3ed811f6 Playliste - online 2026-04-14 20:02:15 +02:00
4ad8241c0e Udvidet hjemmeside 2026-04-14 17:20:24 +02:00
55642ecb1b api fejl 2026-04-14 17:14:32 +02:00
d9a321d570 porte 6 2026-04-14 17:08:09 +02:00
58c4e85eaf porte 5 2026-04-14 17:07:45 +02:00
f7b0f16250 porte 4 2026-04-14 17:03:27 +02:00
edfde16c92 porte 3 2026-04-14 17:02:58 +02:00
03f8061601 porte2 2026-04-14 17:02:28 +02:00
ed4fe4e712 porte 2026-04-14 17:01:51 +02:00
4aba2f02a2 Web - 1 2026-04-14 17:00:29 +02:00
460b41a8c5 IDer 2026-04-14 16:21:06 +02:00
287477753e MBID 2026-04-14 15:59:02 +02:00
d4356e7337 Offline check ser ok ud. 2026-04-14 15:05:54 +02:00
66804681da Næster version 2026-04-14 14:05:11 +02:00
9257f198eb event vindue 2026-04-13 16:20:19 +02:00
b805bd77e7 Logger 2026-04-13 16:16:20 +02:00
d056f02f78 Programfejl 2026-04-13 15:46:18 +02:00
3cc2c975f3 Crte tabels 2026-04-13 15:41:40 +02:00
69d1d484a2 Manglede tabeller 2026-04-13 15:37:17 +02:00
b066b6d92c Bedre tag sync 2026-04-13 15:33:01 +02:00
cb204baa50 Bedre sync 2026-04-13 14:22:27 +02:00
e86173f7ec Sync playlister 2026-04-13 13:53:47 +02:00
c3623962c5 Bygger hurtigere 2026-04-13 13:31:27 +02:00
6d3ed85679 Docker 2026-04-13 11:23:49 +02:00
30125aa77b Docker 2026-04-13 11:16:13 +02:00
bbd5690d72 Rettelsaer 2026-04-13 07:23:37 +02:00
45dcedaed4 Windows 2026-04-12 19:19:01 +02:00
1ea5cad01f Build 2026-04-12 16:28:48 +02:00
2812e3182c upx 2026-04-12 16:20:02 +02:00
a9915c0cc9 Mappehåndtering 2026-04-12 14:29:54 +02:00
bdb1f5915a Bedre mappehåndtering 2026-04-12 13:42:05 +02:00
d6cc22dc9a Windows igen 2026-04-12 12:30:31 +02:00
754d82a183 t 2026-04-12 12:20:57 +02:00
358aeca7c8 windows igen 2026-04-12 12:15:48 +02:00
564122df0a Windows 2026-04-12 12:09:13 +02:00
88a3d2f67b Videre 2026-04-12 11:38:21 +02:00
99cab7be86 Igen 2026-04-12 11:34:09 +02:00
57f3c913b4 Næste version 2026-04-12 10:25:41 +02:00
b678787236 Start 2026-04-11 00:38:04 +02:00
99f6a265c0 start 2026-04-11 00:37:27 +02:00
b90bdd851d Ny start 2026-04-11 00:37:01 +02:00
94 changed files with 20433 additions and 0 deletions

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
*.egg
# Virtual environments
venv/
env/
.venv/
.env/
# PyInstaller output
dist/
build/
*.spec.bak
# Miljøvariabler og hemmeligheder
.env
*.env
!.env.example
# Database og brugerdata
*.db
*.db-shm
*.db-wal
~/.linedance/
# IDE
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Test
.pytest_cache/
.coverage
htmlcov/
# Node (til fremtidig web-del)
node_modules/
.next/

View File

@@ -0,0 +1,772 @@
#!/usr/bin/env python3
"""
linedance_tag_analyse.py
Scanner en mappe med MP3-filer og analyserer om de følger mønsteret:
TIT2 = dansens navn
TALB = sangens rigtige titel
TCON = niveau
Verificerer via MusicBrainz API og gemmer resultat i CSV til gennemgang.
Brug:
python linedance_tag_analyse.py /sti/til/musik [--output rapport.csv] [--apply godkendt.csv]
"""
import os
import sys
import csv
import json
import time
import struct
import argparse
import subprocess
import urllib.request
import urllib.parse
from pathlib import Path
# ── Konfiguration ──────────────────────────────────────────────────────────────
ACOUSTID_API_KEY = "6fd9DGNDqG" # Erstat med din egen
ACOUSTID_API_URL = "https://api.acoustid.org/v2/lookup"
MUSICBRAINZ_URL = "https://musicbrainz.org/ws/2/recording"
MB_USER_AGENT = "LineDanceTagFixer/1.0 (dit@email.dk)" # Skal udfyldes
API_DELAY = 1.1 # Sek mellem MusicBrainz-kald (max 1/sek)
ACOUSTID_DELAY = 0.4
MATCH_THRESHOLD = 0.80 # Mindste lighed for "sikker" match
FPCALC_PATH = "fpcalc" # Eller fuld sti f.eks. C:\Tools\fpcalc.exe
# ── Hjælpefunktioner ──────────────────────────────────────────────────────────
def normalize(s: str) -> str:
"""Normaliser streng til sammenligning — lowercase, fjern specialtegn."""
import unicodedata, re
s = unicodedata.normalize("NFKD", s or "")
s = s.encode("ascii", "ignore").decode()
s = re.sub(r"[^a-z0-9 ]", "", s.lower())
s = re.sub(r"\s+", " ", s).strip()
return s
def similarity(a: str, b: str) -> float:
"""Simpel tegnbaseret lighed 0-1."""
a, b = normalize(a), normalize(b)
if not a or not b:
return 0.0
if a == b:
return 1.0
# Longest common subsequence approximation via set overlap
set_a = set(a.split())
set_b = set(b.split())
if not set_a or not set_b:
return 0.0
overlap = len(set_a & set_b)
return overlap / max(len(set_a), len(set_b))
# ── Niveau-normalisering ──────────────────────────────────────────────────────
# Officielle niveauer fra Linedancer Guide to Level Definitions (2017):
# Absolute Beginner → Beginner → Improver → Intermediate → Advanced
#
# Vi gemmer dem på engelsk da det er den internationale standard.
# Mapper alle kendte varianter til officiel betegnelse.
NIVEAU_MAP = {
# ── Absolute Beginner ──
"absolute beginner": "Absolute Beginner",
"abs. beginner": "Absolute Beginner",
"abs beginner": "Absolute Beginner",
"absolute beg": "Absolute Beginner",
"ab": "Absolute Beginner",
"absolut begynder": "Absolute Beginner",
# ── Beginner ──
"beginner": "Beginner",
"beg": "Beginner",
"begin": "Beginner",
"begynder": "Beginner",
"newbie": "Beginner",
"basic": "Beginner",
# ── High Beginner ──
"high beginner": "High Beginner",
"high beg": "High Beginner",
# ── Low Improver ──
"low improver": "Low Improver",
"low imp": "Low Improver",
"beginner/improver": "Low Improver",
"beg/imp": "Low Improver",
"beg/improver": "Low Improver",
# ── Improver ──
"improver": "Improver",
"imp": "Improver",
"easy": "Improver",
"easy intermediate": "Improver",
"easy/intermediate": "Improver",
"easy inter": "Improver",
"let øvet": "Improver",
"let": "Improver",
# ── High Improver ──
"high improver": "High Improver",
"high imp": "High Improver",
"improver/intermediate": "High Improver",
"imp/int": "High Improver",
# ── Low Intermediate ──
"low intermediate": "Low Intermediate",
"low inter": "Low Intermediate",
"low int": "Low Intermediate",
# ── Intermediate ──
"intermediate": "Intermediate",
"inter": "Intermediate",
"int": "Intermediate",
"øvet": "Intermediate",
# ── High Intermediate ──
"high intermediate": "High Intermediate",
"high inter": "High Intermediate",
"high int": "High Intermediate",
"intermediate/advanced": "High Intermediate",
"int/adv": "High Intermediate",
# ── Advanced ──
"advanced": "Advanced",
"adv": "Advanced",
"videregående": "Advanced",
}
def normaliser_niveau(raw: str) -> tuple[str, bool]:
"""
Returner (normaliseret_niveau, fundet).
fundet=True hvis vi genkender niveauet.
"""
if not raw:
return "", False
key = raw.strip().lower()
if key in NIVEAU_MAP:
return NIVEAU_MAP[key], True
# Delvis match — find længste nøgle der er indeholdt
best_match = ""
best_len = 0
for k, v in NIVEAU_MAP.items():
if k in key and len(k) > best_len:
best_match = v
best_len = len(k)
if best_match:
return best_match, True
return raw.strip(), False
def read_id3(path: str) -> dict:
"""Læs relevante ID3v2 tags fra MP3 uden eksterne biblioteker."""
result = {
"tit2": "", "tpe1": "", "talb": "", "tcon": "",
"mbid": "", "linedance_dances": []
}
try:
with open(path, "rb") as f:
data = f.read(65536)
if data[:3] != b"ID3":
return result
major = data[3]
tag_size = ((data[6]&0x7f)<<21)|((data[7]&0x7f)<<14)|((data[8]&0x7f)<<7)|(data[9]&0x7f)
pos = 10
while pos < min(tag_size + 10, len(data) - 10):
if major == 3:
fid = data[pos:pos+4].decode("latin1", errors="replace")
fsize = struct.unpack(">I", data[pos+4:pos+8])[0]
pos += 10
else: # v2.4
fid = data[pos:pos+4].decode("latin1", errors="replace")
fsize = struct.unpack(">I", data[pos+4:pos+8])[0]
pos += 10
if fid == "\x00\x00\x00\x00" or fsize <= 0 or fsize > 100000:
break
content = data[pos:pos+fsize]
pos += fsize
try:
if fid.startswith("T") and len(content) > 1:
enc = content[0]
raw = content[1:]
if enc in (1, 2):
txt = raw.decode("utf-16", errors="replace")
elif enc == 3:
txt = raw.decode("utf-8", errors="replace")
else:
txt = raw.decode("latin1", errors="replace")
txt = txt.strip("\x00").strip()
if fid == "TIT2":
result["tit2"] = txt
elif fid == "TPE1":
result["tpe1"] = txt
elif fid == "TALB":
result["talb"] = txt
elif fid == "TCON":
result["tcon"] = txt
elif fid == "TXXX":
# Format: desc\x00value
parts = txt.split("\x00", 1)
if len(parts) == 2:
desc, val = parts
else:
desc, val = txt, ""
desc_clean = desc.strip()
val_clean = val.strip()
if desc_clean in ("MusicBrainz Recording Id", "MusicBrainz Track Id"):
result["mbid"] = val_clean
elif desc_clean.startswith("LINEDANCE_DANCE_"):
result["linedance_dances"].append(val_clean)
elif fid == "UFID" and len(content) > 4:
# UFID: owner\x00data
null = content.find(b"\x00")
if null >= 0:
owner = content[:null].decode("latin1", errors="replace")
if "musicbrainz" in owner.lower():
result["mbid"] = content[null+1:].decode("utf-8", errors="replace").strip("\x00")
elif fid == "COMM" and len(content) > 4:
enc = content[0]
raw = content[4:]
if enc in (1, 2):
txt = raw.decode("utf-16", errors="replace")
else:
txt = raw.decode("utf-8", errors="replace")
result["comm"] = txt.strip("\x00").strip()
except Exception:
pass
except Exception as e:
result["error"] = str(e)
return result
# ── AcoustID fingerprinting ────────────────────────────────────────────────────
def fingerprint_file(path: str) -> tuple[str, int] | None:
"""Kør fpcalc og returner (fingerprint, duration)."""
fpcalc = find_fpcalc()
if not fpcalc:
return None
try:
r = subprocess.run(
[fpcalc, "-json", path],
capture_output=True, text=True, timeout=30
)
if r.returncode != 0:
return None
d = json.loads(r.stdout)
fp = d.get("fingerprint")
dur = int(d.get("duration", 0))
if fp and dur:
return fp, dur
return None
except FileNotFoundError:
return None
except Exception:
return None
def find_fpcalc() -> str | None:
"""Find fpcalc på systemet — returner sti eller None."""
import shutil
# Prøv konfigureret sti først
if shutil.which(FPCALC_PATH):
return FPCALC_PATH
# Prøv kendte Windows-stier
windows_paths = [
r"C:\Program Files\fpcalc\fpcalc.exe",
r"C:\Tools\fpcalc.exe",
r"C:\fpcalc.exe",
os.path.join(os.path.dirname(__file__), "fpcalc.exe"),
os.path.join(os.path.dirname(__file__), "fpcalc"),
]
for p in windows_paths:
if os.path.isfile(p):
return p
return None
def lookup_acoustid(fingerprint: str, duration: int) -> str | None:
"""Slå fingerprint op i AcoustID og returner MBID."""
try:
params = urllib.parse.urlencode({
"client": ACOUSTID_API_KEY,
"fingerprint": fingerprint,
"duration": duration,
"meta": "recordings",
})
req = urllib.request.Request(
f"{ACOUSTID_API_URL}?{params}",
headers={"User-Agent": MB_USER_AGENT}
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
if data.get("status") != "ok":
return None
results = data.get("results", [])
if not results:
return None
best = max(results, key=lambda r: r.get("score", 0))
if best.get("score", 0) < 0.85:
return None
recordings = best.get("recordings", [])
return recordings[0].get("id") if recordings else None
except Exception:
return None
# ── MusicBrainz opslag ────────────────────────────────────────────────────────
def lookup_musicbrainz(mbid: str) -> dict | None:
"""Slå MBID op i MusicBrainz og returner titel + kunstner."""
try:
url = f"{MUSICBRAINZ_URL}/{mbid}?fmt=json&inc=artists"
req = urllib.request.Request(url, headers={"User-Agent": MB_USER_AGENT})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
title = data.get("title", "")
artists = data.get("artist-credit", [])
artist = ""
if artists:
parts = []
for a in artists:
if isinstance(a, dict) and "artist" in a:
parts.append(a["artist"].get("name", ""))
elif isinstance(a, str):
parts.append(a)
artist = "".join(parts)
return {"title": title, "artist": artist} if title else None
except Exception:
return None
# ── Hoved-analyse ─────────────────────────────────────────────────────────────
def analyse_file(path: str, use_acoustid: bool = True) -> dict:
"""
Analyser én fil og returner en dict med resultat.
Status: SIKKER / SANDSYNLIG / USIKKER / UKENDT
"""
import re
# ── Værdier der ligner niveau men er genre/andet ──────────────────────────
IKKE_NIVEAU = {
"country", "rock", "pop", "jazz", "blues", "latin", "swing",
"line dance", "linedance", "general country", "waltz", "cha cha",
"rumba", "tango", "samba", "foxtrot", "quickstep", "two step",
"west coast swing", "east coast swing",
}
def dans_fra_filnavn(filename):
"""Forsøg at udtrække dansenavn fra filnavn."""
navn = os.path.splitext(filename)[0]
# Mønster 1: starter med (dans) eller [dans]
m = re.match(r"^[\(\[](.*?)[\)\]]", navn)
if m:
kandidat = m.group(1).strip()
if len(kandidat) > 2 and not kandidat.isdigit():
return kandidat
# Mønster 2: slutter med (dans)
m = re.search(r"[\(\[](.*?)[\)\]]\s*$", navn)
if m:
kandidat = m.group(1).strip()
if len(kandidat) > 2 and not kandidat.isdigit():
return kandidat
return ""
result = {
"fil": path,
"filename": os.path.basename(path),
"tit2": "",
"tpe1": "",
"talb": "",
"tcon": "",
"mbid": "",
"mb_title": "",
"mb_artist": "",
"dans_forslag": "",
"niveau_rå": "",
"niveau_forslag": "",
"niveau_genkendt": "",
"rigtig_titel": "",
"rigtig_artist": "",
"match_score": 0.0,
"status": "UKENDT",
"noter": "",
"handling": "",
}
# 1. Læs tags
tags = read_id3(path)
result["tit2"] = tags.get("tit2", "")
result["tpe1"] = tags.get("tpe1", "")
result["talb"] = tags.get("talb", "")
result["tcon"] = tags.get("tcon", "")
result["mbid"] = tags.get("mbid", "")
ld_dances = tags.get("linedance_dances", [])
noter = []
# Normaliser niveau fra TCON — filtrer genre-værdier fra
niveau_rå = result["tcon"]
if niveau_rå.strip().lower() in IKKE_NIVEAU:
niveau_normaliseret, niveau_genkendt = "", False
noter.append(f"TCON er genre, ikke niveau: '{niveau_rå}'")
else:
niveau_normaliseret, niveau_genkendt = normaliser_niveau(niveau_rå)
result["niveau_rå"] = niveau_rå
result["niveau_forslag"] = niveau_normaliseret
result["niveau_genkendt"] = "JA" if niveau_genkendt else ("NEJ" if niveau_rå else "")
if niveau_rå and not niveau_genkendt:
noter.append(f"Ukendt niveau-værdi: '{niveau_rå}'")
if tags.get("error"):
result["status"] = "FEJL"
result["noter"] = tags["error"]
return result
# 2. Hent MBID hvis mangler
mbid = result["mbid"]
if not mbid and use_acoustid:
fp = fingerprint_file(path)
if fp:
fingerprint, duration = fp
time.sleep(ACOUSTID_DELAY)
mbid = lookup_acoustid(fingerprint, duration)
if mbid:
result["mbid"] = mbid
noter.append("MBID fundet via AcoustID")
else:
noter.append("AcoustID: ingen match")
else:
noter.append("fpcalc fejlede")
# 3. MusicBrainz opslag
mb = None
if mbid:
time.sleep(API_DELAY)
mb = lookup_musicbrainz(mbid)
if mb:
result["mb_title"] = mb["title"]
result["mb_artist"] = mb["artist"]
else:
noter.append("MusicBrainz: ingen data for MBID")
# 4. Vurder mønster
tit2 = result["tit2"]
talb = result["talb"]
tpe1 = result["tpe1"]
if mb:
# Sammenlign TALB med MusicBrainz titel
score_title = similarity(talb, mb["title"])
score_artist = similarity(tpe1, mb["artist"])
result["match_score"] = round((score_title + score_artist) / 2, 2)
if score_title >= MATCH_THRESHOLD:
# TALB matcher rigtig titel → TIT2 er sandsynligvis dansen
result["dans_forslag"] = tit2
result["rigtig_titel"] = mb["title"]
result["rigtig_artist"] = mb["artist"]
result["niveau_forslag"] = result["tcon"]
if ld_dances:
noter.append(f"LINEDANCE_DANCE_1 allerede sat: {ld_dances[0]}")
if similarity(tit2, ld_dances[0]) > 0.7:
result["status"] = "ALLEREDE_RETTET"
result["handling"] = ""
else:
noter.append(f"TIT2 og LINEDANCE_DANCE_1 stemmer ikke overens!")
result["status"] = "KONFLIKT"
result["handling"] = "MANUEL"
elif score_title >= 0.95 and score_artist >= MATCH_THRESHOLD:
result["status"] = "SIKKER"
result["handling"] = "RET_AUTOMATISK"
elif score_title >= MATCH_THRESHOLD:
result["status"] = "SANDSYNLIG"
result["handling"] = "GODKEND_MANUEL"
else:
result["status"] = "USIKKER"
result["handling"] = "MANUEL"
else:
noter.append(f"TALB matcher ikke MB titel (score={score_title:.2f}): '{talb}' vs '{mb['title']}'")
result["status"] = "USIKKER"
result["handling"] = "MANUEL"
# Vis alligevel hvad MB siger
result["rigtig_titel"] = mb["title"]
result["rigtig_artist"] = mb["artist"]
else:
# Ingen MB data — vurder ud fra tags alene
filename = os.path.basename(path)
if talb and tit2 and tit2 != talb:
# Klassisk mønster: TIT2=dans, TALB=sang
noter.append("Ingen MBID — mønster muligvis rigtigt men ukontrolleret")
result["dans_forslag"] = tit2
result["rigtig_titel"] = talb
result["status"] = "UKONTROLLERET"
result["handling"] = "GODKEND_MANUEL"
elif not tit2 and not talb:
# Helt tomme tags — prøv filnavn
dans_fn = dans_fra_filnavn(filename)
if dans_fn:
noter.append(f"Dans udtrukket fra filnavn: '{dans_fn}'")
result["dans_forslag"] = dans_fn
result["status"] = "FRA_FILNAVN"
result["handling"] = "GODKEND_MANUEL"
else:
result["status"] = "UKENDT"
result["handling"] = "MANUEL"
elif tit2 and tit2 == talb:
# Titel = album — dans kan være i filnavn
dans_fn = dans_fra_filnavn(filename)
if dans_fn:
noter.append(f"TIT2=TALB, dans fra filnavn: '{dans_fn}'")
result["dans_forslag"] = dans_fn
result["rigtig_titel"] = tit2
result["status"] = "FRA_FILNAVN"
result["handling"] = "GODKEND_MANUEL"
else:
result["status"] = "UKENDT"
result["handling"] = "MANUEL"
else:
result["status"] = "UKENDT"
result["handling"] = "MANUEL"
result["noter"] = "; ".join(noter)
return result
# ── CSV output ─────────────────────────────────────────────────────────────────
FIELDNAMES = [
"status", "handling", "filename",
"dans_forslag", "niveau_forslag", "niveau_genkendt", "niveau_rå",
"rigtig_titel", "rigtig_artist",
"mb_title", "mb_artist", "match_score",
"tit2", "tpe1", "talb", "tcon", "mbid",
"noter", "fil"
]
def write_csv(rows: list[dict], path: str):
with open(path, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=FIELDNAMES, extrasaction="ignore")
w.writeheader()
w.writerows(rows)
print(f"\nGemt: {path}")
# ── Apply: udfør rettelser fra godkendt CSV ───────────────────────────────────
def apply_corrections(csv_path: str, dry_run: bool = True):
"""Læs godkendt CSV og udfør rettelserne."""
import shutil
with open(csv_path, newline="", encoding="utf-8-sig") as f:
rows = list(csv.DictReader(f))
to_fix = [r for r in rows if r.get("handling") in ("RET_AUTOMATISK", "GODKEND_MANUEL")]
print(f"\n{len(to_fix)} filer skal rettes")
if dry_run:
print("(DRY RUN — ingen filer ændres endnu)\n")
ok = fejl = spring = 0
for row in to_fix:
path = row["fil"]
dans = row["dans_forslag"].strip()
titel = row["rigtig_titel"].strip()
artist = row["rigtig_artist"].strip() or row["tpe1"].strip()
if not dans or not titel:
print(f" SPRING: {row['filename']} — mangler dans eller titel")
spring += 1
continue
print(f" {'[DRY]' if dry_run else '[RET]'} {row['filename']}")
print(f" TIT2: '{row['tit2']}''{titel}'")
print(f" TALB: '{row['talb']}' → beholdes")
print(f" LINEDANCE_DANCE_1 → '{dans}'")
if not dry_run:
try:
write_corrections(path, titel, artist, dans)
ok += 1
except Exception as e:
print(f" FEJL: {e}")
fejl += 1
else:
ok += 1
print(f"\nResultat: {ok} ok, {fejl} fejl, {spring} sprunget over")
def write_corrections(path: str, title: str, artist: str, dance: str):
"""Skriv rettede tags til fil med mutagen."""
try:
from mutagen.id3 import ID3, TIT2, TPE1, TXXX, Encoding
tags = ID3(path)
# Sæt rigtig titel
tags["TIT2"] = TIT2(encoding=Encoding.UTF8, text=title)
# Sæt kunstner hvis vi har den
if artist:
tags["TPE1"] = TPE1(encoding=Encoding.UTF8, text=artist)
# Sæt LINEDANCE_DANCE_1
for key in [k for k in tags.keys() if "LINEDANCE_DANCE_" in k]:
del tags[key]
tags.add(TXXX(encoding=Encoding.UTF8, desc="LINEDANCE_DANCE_1", text=dance))
tags.save(path)
except ImportError:
# Fallback: skriv raw ID3 — kræver mutagen
raise RuntimeError("mutagen skal installeres for at skrive tags: pip install mutagen")
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="LineDance tag-analyse og -rettelse")
parser.add_argument("mappe", nargs="?", help="Mappe med MP3-filer")
parser.add_argument("--output", default="linedance_rapport.csv", help="Output CSV")
parser.add_argument("--apply", help="Anvend rettelser fra godkendt CSV")
parser.add_argument("--dry-run", action="store_true", help="Vis hvad der ville ske (med --apply)")
parser.add_argument("--ingen-acoustid", action="store_true", help="Spring AcoustID over (hurtigere)")
parser.add_argument("--max", type=int, default=0, help="Maks antal filer (0=alle)")
args = parser.parse_args()
# Apply-tilstand
if args.apply:
apply_corrections(args.apply, dry_run=args.dry_run)
return
if not args.mappe:
parser.print_help()
return
# Find alle MP3-filer
mp3_filer = []
for root, dirs, files in os.walk(args.mappe):
for f in files:
if f.lower().endswith(".mp3"):
mp3_filer.append(os.path.join(root, f))
mp3_filer.sort()
if args.max:
mp3_filer = mp3_filer[:args.max]
total = len(mp3_filer)
print(f"\nFundet {total} MP3-filer i {args.mappe}")
print(f"Output: {args.output}")
# Tjek fpcalc
if not args.ingen_acoustid:
fpcalc_sti = find_fpcalc()
if fpcalc_sti:
print(f"fpcalc: fundet ({fpcalc_sti})")
print(f"AcoustID API: {ACOUSTID_API_KEY[:6]}...")
else:
print(f"fpcalc: IKKE FUNDET — AcoustID deaktiveres")
print(f" Download fpcalc fra: https://acoustid.org/chromaprint")
print(f" Placer fpcalc.exe i samme mappe som scriptet, eller opdater FPCALC_PATH")
args.ingen_acoustid = True
else:
print("AcoustID: SLÅET FRA")
print(f"\nStarter analyse...\n")
resultater = []
tæller = {"SIKKER": 0, "SANDSYNLIG": 0, "ALLEREDE_RETTET": 0,
"UKONTROLLERET": 0, "FRA_FILNAVN": 0,
"USIKKER": 0, "KONFLIKT": 0, "UKENDT": 0, "FEJL": 0}
for i, path in enumerate(mp3_filer, 1):
navn = os.path.basename(path)
print(f"[{i:4}/{total}] {navn[:60]}", end="", flush=True)
try:
res = analyse_file(path, use_acoustid=not args.ingen_acoustid)
except Exception as e:
res = {"fil": path, "filename": navn, "status": "FEJL", "noter": str(e),
"handling": "", "dans_forslag": "", "rigtig_titel": "", "rigtig_artist": "",
"mb_title": "", "mb_artist": "", "match_score": 0, "tit2": "", "tpe1": "",
"talb": "", "tcon": "", "mbid": ""}
status = res.get("status", "UKENDT")
tæller[status] = tæller.get(status, 0) + 1
resultater.append(res)
print(f"{status}")
# Gem løbende hvert 50. fil
if i % 50 == 0:
write_csv(resultater, args.output)
# Gem endelig rapport
write_csv(resultater, args.output)
# Statistik
print("\n" + "="*55)
print("RAPPORT")
print("="*55)
print(f" SIKKER : {tæller.get('SIKKER',0):4} → kan rettes automatisk")
print(f" SANDSYNLIG : {tæller.get('SANDSYNLIG',0):4} → bør godkendes")
print(f" ALLEREDE_RETTET : {tæller.get('ALLEREDE_RETTET',0):4} → spring over")
print(f" UKONTROLLERET : {tæller.get('UKONTROLLERET',0):4} → ingen MBID, men mønster muligt")
print(f" FRA_FILNAVN : {tæller.get('FRA_FILNAVN',0):4} → dans udtrukket fra filnavn")
print(f" USIKKER : {tæller.get('USIKKER',0):4} → manuel gennemgang")
print(f" KONFLIKT : {tæller.get('KONFLIKT',0):4} → tags modstrider hinanden")
print(f" UKENDT : {tæller.get('UKENDT',0):4} → kan ikke vurderes")
print(f" FEJL : {tæller.get('FEJL',0):4} → teknisk fejl")
print("="*55)
# Niveau-statistik
niveau_værdier = {}
for r in resultater:
= r.get("niveau_rå", "").strip()
if :
niveau_værdier[] = niveau_værdier.get(, 0) + 1
if niveau_værdier:
print("\nNiveau-værdier fundet i TCON:")
for val, antal in sorted(niveau_værdier.items(), key=lambda x: -x[1]):
norm, genkendt = normaliser_niveau(val)
mærke = "" if genkendt else "?"
print(f" {mærke} '{val}' ({antal}x) → '{norm}'")
print(f"\nÅbn {args.output} i Excel og:")
print(" 1. Gennemgå SANDSYNLIG og UKONTROLLERET")
print(" 2. Sæt 'handling' til RET_AUTOMATISK eller SPRING for hver")
print(" 3. Kør: python linedance_tag_analyse.py --apply rapport.csv --dry-run")
print(" 4. Tjek output, kør så uden --dry-run")
print()
if __name__ == "__main__":
main()

2036
Henriks Musik/rapport.csv Normal file

File diff suppressed because it is too large Load Diff

51
full_reset.sh Normal file
View File

@@ -0,0 +1,51 @@
#!/bin/bash
# ================================================================
# full_reset.sh — KOMPLET nulstilling af LineDance-systemet
#
# Kør dette script på APP-SERVEREN:
# bash full_reset.sh
#
# Herefter skal du selv:
# docker compose down && docker compose up -d --build
# ================================================================
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo ""
echo -e "${RED}╔══════════════════════════════════════════════════════╗${NC}"
echo -e "${RED}║ KOMPLET NULSTILLING — LINEDANCE AFSPILLER ║${NC}"
echo -e "${RED}║ Sletter ALT: sange, danse, playlister, synk-data ║${NC}"
echo -e "${RED}╚══════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${YELLOW}Dette kan IKKE fortrydes. Al data går tabt.${NC}"
echo ""
read -p "Skriv 'SLET ALT' for at fortsætte: " confirm
[ "$confirm" = "SLET ALT" ] || { echo "Afbrudt."; exit 1; }
COMPOSE_DIR="/opt/docker/linedanceafspiller/linedance-api"
# ── MySQL: drop og genskab tom database ───────────────────────
echo ""
echo -e "${YELLOW}▶ Dropper og genskaber MySQL-database...${NC}"
docker compose -f "$COMPOSE_DIR/docker-compose.yml" exec -T db \
mysql -u root -proot << 'MYSQL'
DROP DATABASE IF EXISTS linedance;
CREATE DATABASE linedance CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
MYSQL
echo -e "${GREEN} ✓ MySQL klar — tom database oprettet${NC}"
# ── Færdig ────────────────────────────────────────────────────
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ ✓ Server-database nulstillet ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}"
echo ""
echo "Gør nu dette:"
echo " 1. Rebuild og genstart Docker:"
echo " cd $COMPOSE_DIR"
echo " docker compose down && docker compose up -d --build"
echo ""

View File

@@ -0,0 +1,17 @@
# Database
DATABASE_URL=mysql+pymysql://linedanceplayer:KODEORD@mysql.ckvist.lan:3306/linedanceplayer
# Sikkerhed — generer med: python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=skift-denne-til-en-rigtig-nøgle
ACCESS_TOKEN_EXPIRE_MINUTES=10080
# Mail
MAIL_HOST=mail.miraca.dk
MAIL_PORT=587
MAIL_FROM=noreply@linedanceplayer.dk
MAIL_USERNAME=noreply@linedanceplayer.dk
MAIL_PASSWORD=skift-dette
MAIL_TLS=true
# URL til denne server (bruges i verificerings-mails)
BASE_URL=http://localhost:8000

18
linedance-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
# Python
__pycache__/
*.py[cod]
*.pyo
.Python
venv/
.venv/
*.egg-info/
# Environment
.env
# IDE
.vscode/
.idea/
# Logs
*.log

1
linedance-api/=4.0.0 Normal file
View File

@@ -0,0 +1 @@
Requirement already satisfied: bcrypt in ./venv/lib/python3.12/site-packages (5.0.0)

22
linedance-api/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.12-slim
WORKDIR /app
# Installer system-dependencies
RUN apt-get update && apt-get install -y \
default-libmysqlclient-dev \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Installer Python-pakker
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Kopier kode
COPY . .
# Vent på DB og start server
COPY start.sh .
RUN chmod +x start.sh
CMD ["./start.sh"]

39
linedance-api/README.md Normal file
View File

@@ -0,0 +1,39 @@
# LineDance API
## Hurtig start med Docker
```bash
# 1. Kopiér miljøfil
cp .env.example .env
# 2. Rediger .env — sæt stærke kodeord
nano .env
# 3. Start hele stacken
docker compose up -d
# 4. Tjek at alt kører
docker compose ps
docker compose logs api
```
## Tilgængelige services
| Service | URL | Beskrivelse |
|----------|----------------------------|--------------------------|
| API | http://localhost:8000 | FastAPI |
| Docs | http://localhost:8000/docs | Swagger UI |
| Adminer | http://localhost:8080 | Database admin |
| MailHog | http://localhost:8025 | Test-mails |
## Adminer login
- Server: `db`
- Bruger: `linedance`
- Kodeord: (fra .env MYSQL_PASSWORD)
- Database: `linedance`
## Produktion
- Skift `MAIL_HOST` til rigtig SMTP-server
- Sæt `BASE_URL` til dit domæne
- Brug `SECRET_KEY` med mindst 32 tilfældige tegn
- Fjern `adminer` og `mailhog` fra docker-compose

View File

View File

@@ -0,0 +1,24 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str
SECRET_KEY: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 dage
# Mail
MAIL_HOST: str = "mailhog"
MAIL_PORT: int = 1025
MAIL_FROM: str = "noreply@linedance.local"
MAIL_USERNAME: str = ""
MAIL_PASSWORD: str = ""
MAIL_TLS: bool = False
# Base URL til verificerings-links
BASE_URL: str = "http://localhost:8000"
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,21 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True, # genforbinder hvis connection er død
pool_recycle=3600, # genbruger ikke forbindelser ældre end 1 time
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,104 @@
"""
mail.py — Asynkron mail-sending via aiosmtplib.
I udvikling bruges MailHog som SMTP-server.
"""
import asyncio
import secrets
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import aiosmtplib
from app.core.config import settings
def generate_verify_token() -> str:
return secrets.token_urlsafe(32)
async def send_verification_email(email: str, username: str, token: str):
verify_url = f"{settings.BASE_URL}/auth/verify/{token}"
msg = MIMEMultipart("alternative")
msg["Subject"] = "Bekræft din LineDance-konto"
msg["From"] = settings.MAIL_FROM
msg["To"] = email
text = f"""Hej {username},
Tak for at oprette en konto på LineDance Player.
Klik på linket nedenfor for at bekræfte din e-mailadresse:
{verify_url}
Linket udløber ikke — men kontoen er ikke aktiv før du har bekræftet.
Hilsen
LineDance Player
"""
html = f"""<html><body>
<h2>Velkommen til LineDance Player, {username}!</h2>
<p>Klik på knappen nedenfor for at bekræfte din e-mailadresse:</p>
<p>
<a href="{verify_url}"
style="background:#e8a020;color:#111;padding:12px 24px;
border-radius:6px;text-decoration:none;font-weight:bold;">
Bekræft e-mail
</a>
</p>
<p>Eller kopier dette link:<br>
<a href="{verify_url}">{verify_url}</a></p>
<p>Linket udløber ikke.</p>
</body></html>"""
msg.attach(MIMEText(text, "plain", "utf-8"))
msg.attach(MIMEText(html, "html", "utf-8"))
try:
await aiosmtplib.send(
msg,
hostname=settings.MAIL_HOST,
port=settings.MAIL_PORT,
username=settings.MAIL_USERNAME or None,
password=settings.MAIL_PASSWORD or None,
start_tls=settings.MAIL_TLS, # STARTTLS på port 587
use_tls=False,
)
except Exception as e:
print(f"Mail-fejl: {e}")
raise # Vis fejlen i serverlogs
async def send_share_invitation(email: str, owner_name: str,
playlist_name: str, permission: str,
accept_url: str):
perm_text = {"view": "se", "copy": "kopiere", "edit": "redigere"}.get(permission, "se")
msg = MIMEMultipart("alternative")
msg["Subject"] = f"{owner_name} har delt en danseliste med dig"
msg["From"] = settings.MAIL_FROM
msg["To"] = email
html = f"""<html><body>
<h2>Du er inviteret!</h2>
<p>{owner_name} har delt danselisten <strong>{playlist_name}</strong> med dig.</p>
<p>Du har fået adgang til at <strong>{perm_text}</strong> listen.</p>
<p>
<a href="{accept_url}"
style="background:#e8a020;color:#111;padding:12px 24px;
border-radius:6px;text-decoration:none;font-weight:bold;">
Se danseliste
</a>
</p>
</body></html>"""
msg.attach(MIMEText(html, "html", "utf-8"))
try:
await aiosmtplib.send(
msg,
hostname=settings.MAIL_HOST,
port=settings.MAIL_PORT,
use_tls=settings.MAIL_TLS,
)
except Exception as e:
print(f"Mail-fejl (share): {e}")

View File

@@ -0,0 +1,40 @@
import bcrypt
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def hash_password(password: str) -> str:
return bcrypt.hashpw(password[:72].encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain[:72].encode(), hashed.encode())
def create_access_token(data: dict) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
return jwt.encode({**data, "exp": expire}, settings.SECRET_KEY, algorithm="HS256")
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
):
from app.models import User
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
user_id = payload.get("sub")
if not user_id:
raise HTTPException(401, "Ugyldig token")
except JWTError:
raise HTTPException(401, "Ugyldig token")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(401, "Bruger ikke fundet")
return user

67
linedance-api/app/main.py Normal file
View File

@@ -0,0 +1,67 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.database import engine, Base
# Importer ALLE modeller så create_all kender dem
from app.models import (
User, Song, Dance, DanceLevel, Project, ProjectMember, ProjectSong,
PlaylistShare, CommunityDance, CommunityDanceAlt, DanceAltRating,
SongDance, SongAltDance,
)
from app.routers import auth, projects, songs, alternatives, dances, sync, sharing, live
from app.websocket.manager import router as ws_router
# Opret tabeller hvis de ikke findes
Base.metadata.create_all(bind=engine)
app = FastAPI(
title="Linedance API",
version="0.1.0",
description="Backend for linedance-afspiller og projektstyring",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Stram til i produktion
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(projects.router)
app.include_router(songs.router)
app.include_router(alternatives.router)
app.include_router(dances.router)
app.include_router(sync.router)
app.include_router(sharing.router)
app.include_router(live.router)
@app.on_event("startup")
def seed_dance_levels():
"""Opret standard dans-niveauer hvis tabellen er tom."""
from sqlalchemy.orm import Session
from app.models import DanceLevel
with Session(engine) as db:
if db.query(DanceLevel).count() == 0:
defaults = [
DanceLevel(sort_order=10, name="Absolute Beginner", description="Ingen tidligere danse-erfaring kræves"),
DanceLevel(sort_order=20, name="Beginner", description="Lidt tidligere erfaring"),
DanceLevel(sort_order=30, name="High Beginner", description="God begynder, klar til mere"),
DanceLevel(sort_order=40, name="Low Improver", description="Begyndende øvet"),
DanceLevel(sort_order=50, name="Improver", description="Grundlæggende færdigheder på plads"),
DanceLevel(sort_order=60, name="High Improver", description="Stærk øvet, næsten intermediate"),
DanceLevel(sort_order=70, name="Low Intermediate", description="Begyndende intermediate"),
DanceLevel(sort_order=80, name="Intermediate", description="Erfaren danser"),
DanceLevel(sort_order=90, name="High Intermediate", description="Stærk intermediate"),
DanceLevel(sort_order=99, name="Advanced", description="Fuld beherskelse af trin og teknik"),
]
db.add_all(defaults)
db.commit()
app.include_router(ws_router)
@app.get("/")
def root():
return {"status": "ok", "service": "Linedance API"}

View File

@@ -0,0 +1,222 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, Float, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
def new_uuid() -> str:
return str(uuid.uuid4())
def now_utc() -> datetime:
return datetime.now(timezone.utc)
# ── User ──────────────────────────────────────────────────────────────────────
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
full_name: Mapped[str] = mapped_column(String(128), default="")
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
verify_token: Mapped[str|None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner")
memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user")
alt_ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="user")
playlist_shares: Mapped[list["PlaylistShare"]] = relationship("PlaylistShare", foreign_keys="PlaylistShare.shared_with_id", back_populates="shared_with")
# ── Song (global — ikke knyttet til en bruger) ────────────────────────────────
class Song(Base):
__tablename__ = "songs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
title: Mapped[str] = mapped_column(String(255), nullable=False)
artist: Mapped[str] = mapped_column(String(255), default="")
album: Mapped[str] = mapped_column(String(255), default="")
bpm: Mapped[int] = mapped_column(Integer, default=0)
duration_sec: Mapped[int] = mapped_column(Integer, default=0)
mbid: Mapped[str|None] = mapped_column(String(36), nullable=True, unique=True)
acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True)
synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song")
song_dances: Mapped[list["SongDance"]] = relationship("SongDance", back_populates="song", cascade="all, delete-orphan")
song_alt_dances: Mapped[list["SongAltDance"]] = relationship("SongAltDance", back_populates="song", cascade="all, delete-orphan")
# ── Dans-entitet ──────────────────────────────────────────────────────────────
class DanceLevel(Base):
__tablename__ = "dance_levels"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False)
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
description: Mapped[str] = mapped_column(String(255), default="")
class Dance(Base):
__tablename__ = "dances"
__table_args__ = (UniqueConstraint("name", "level_id", name="uq_dance_name_level"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
level_id: Mapped[int|None] = mapped_column(Integer, ForeignKey("dance_levels.id"), nullable=True)
choreographer: Mapped[str] = mapped_column(String(128), default="")
video_url: Mapped[str] = mapped_column(String(512), default="")
stepsheet_url: Mapped[str] = mapped_column(String(512), default="")
notes: Mapped[str] = mapped_column(Text, default="")
use_count: Mapped[int] = mapped_column(Integer, default=1)
synced_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
level: Mapped["DanceLevel|None"] = relationship("DanceLevel")
# ── Project / Playlist ────────────────────────────────────────────────────────
class Project(Base):
__tablename__ = "projects"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str] = mapped_column(Text, default="")
visibility: Mapped[str] = mapped_column(String(16), default="private")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, onupdate=now_utc)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
owner: Mapped["User"] = relationship("User", back_populates="projects")
members: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="project", order_by="ProjectSong.position", cascade="all, delete-orphan")
shares: Mapped[list["PlaylistShare"]] = relationship("PlaylistShare", back_populates="project", cascade="all, delete-orphan")
class ProjectMember(Base):
__tablename__ = "project_members"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
role: Mapped[str] = mapped_column(String(16), default="viewer")
status: Mapped[str] = mapped_column(String(16), default="pending")
invited_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
project: Mapped["Project"] = relationship("Project", back_populates="members")
user: Mapped["User"] = relationship("User", back_populates="memberships")
class ProjectSong(Base):
__tablename__ = "project_songs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(16), default="pending")
is_workshop: Mapped[bool] = mapped_column(Boolean, default=False)
dance_override: Mapped[str] = mapped_column(String(128), default="")
project: Mapped["Project"] = relationship("Project", back_populates="project_songs")
song: Mapped["Song"] = relationship("Song", back_populates="project_songs")
class PlaylistShare(Base):
__tablename__ = "playlist_shares"
__table_args__ = (UniqueConstraint("project_id", "shared_with_id", name="uq_share"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
shared_with_id: Mapped[str|None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
invited_email: Mapped[str] = mapped_column(String(255), default="")
permission: Mapped[str] = mapped_column(String(16), default="view")
accepted_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
project: Mapped["Project"] = relationship("Project", back_populates="shares")
shared_with: Mapped["User|None"] = relationship("User", foreign_keys=[shared_with_id], back_populates="playlist_shares")
# ── Sang-dans tags ────────────────────────────────────────────────────────────
class SongDance(Base):
__tablename__ = "song_dances"
__table_args__ = (UniqueConstraint("song_id", "dance_id", name="uq_song_dance"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False)
dance_order: Mapped[int] = mapped_column(Integer, default=1)
song: Mapped["Song"] = relationship("Song", back_populates="song_dances")
dance: Mapped["Dance"] = relationship("Dance")
class SongAltDance(Base):
__tablename__ = "song_alt_dances"
__table_args__ = (UniqueConstraint("song_id", "dance_id", name="uq_song_alt_dance"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False)
note: Mapped[str] = mapped_column(String(255), default="")
song: Mapped["Song"] = relationship("Song", back_populates="song_alt_dances")
dance: Mapped["Dance"] = relationship("Dance")
# ── Community dans-tags ───────────────────────────────────────────────────────
class CommunityDance(Base):
__tablename__ = "community_dances"
__table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "dance_id", name="uq_comm_dance"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
song_title: Mapped[str] = mapped_column(String(255), default="")
song_artist: Mapped[str] = mapped_column(String(255), default="")
dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False)
submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
dance: Mapped["Dance"] = relationship("Dance")
class CommunityDanceAlt(Base):
__tablename__ = "community_dance_alts"
__table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "alt_dance_id", name="uq_comm_alt"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
song_title: Mapped[str] = mapped_column(String(255), default="")
song_artist: Mapped[str] = mapped_column(String(255), default="")
alt_dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False)
note: Mapped[str] = mapped_column(Text, default="")
submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
avg_rating: Mapped[float] = mapped_column(Float, default=0.0)
rating_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
alt_dance: Mapped["Dance"] = relationship("Dance")
ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="alternative", cascade="all, delete-orphan")
class DanceAltRating(Base):
__tablename__ = "dance_alt_ratings"
__table_args__ = (UniqueConstraint("alternative_id", "user_id", name="uq_rating"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
alternative_id: Mapped[str] = mapped_column(String(36), ForeignKey("community_dance_alts.id"), nullable=False)
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
score: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
alternative: Mapped["CommunityDanceAlt"] = relationship("CommunityDanceAlt", back_populates="ratings")
user: Mapped["User"] = relationship("User", back_populates="alt_ratings")

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

@@ -0,0 +1,3 @@
"""alternatives.py — Placeholder (håndteres via /sync)."""
from fastapi import APIRouter
router = APIRouter(prefix="/alternatives", tags=["alternatives"])

View File

@@ -0,0 +1,151 @@
"""
auth.py — Register, verify, login, profil.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from fastapi.security import OAuth2PasswordRequestForm
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from app.core.database import get_db
from app.core.security import hash_password, verify_password, create_access_token, get_current_user
from app.core.mail import generate_verify_token, send_verification_email
from app.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
# ── Schemas ───────────────────────────────────────────────────────────────────
class UserCreate(BaseModel):
username: str
email: EmailStr
full_name: str = ""
password: str
class UserOut(BaseModel):
id: str
username: str
email: str
full_name: str
is_verified: bool
created_at: datetime
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserOut
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.post("/register", response_model=dict, status_code=201)
async def register(
data: UserCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
# Tjek om brugernavn eller email allerede er i brug
if db.query(User).filter(User.username == data.username).first():
raise HTTPException(400, "Brugernavnet er allerede i brug")
if db.query(User).filter(User.email == data.email).first():
raise HTTPException(400, "E-mailadressen er allerede registreret")
token = generate_verify_token()
user = User(
username=data.username,
email=data.email,
full_name=data.full_name,
password_hash=hash_password(data.password),
is_verified=False,
verify_token=token,
)
db.add(user)
db.commit()
# Send verificerings-mail i baggrunden
background_tasks.add_task(
send_verification_email, data.email, data.username, token
)
return {
"message": f"Konto oprettet. Tjek din e-mail ({data.email}) for at bekræfte.",
"email": data.email,
}
@router.get("/verify/{token}", response_class=HTMLResponse)
def verify_email(token: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.verify_token == token).first()
if not user:
return HTMLResponse("""
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
<h2>❌ Ugyldigt eller udløbet link</h2>
<p>Prøv at registrere dig igen.</p>
</body></html>
""", status_code=400)
user.is_verified = True
user.verify_token = None
db.commit()
return HTMLResponse("""
<html><body style="font-family:sans-serif;text-align:center;padding:60px;
background:#1a1d23;color:#e0e4f0">
<h2 style="color:#e8a020">✓ E-mail bekræftet!</h2>
<p>Din konto er nu aktiv. Du kan logge ind i LineDance Player.</p>
<p style="color:#5a6070;font-size:14px">Du kan lukke dette vindue.</p>
</body></html>
""")
@router.post("/login", response_model=Token)
def login(
form: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
user = db.query(User).filter(
(User.username == form.username) | (User.email == form.username)
).first()
if not user or not verify_password(form.password, user.password_hash):
raise HTTPException(401, "Forkert brugernavn eller kodeord")
if not user.is_verified:
raise HTTPException(403, "E-mailadressen er ikke bekræftet endnu. Tjek din indbakke.")
token = create_access_token({"sub": user.id})
return Token(
access_token=token,
user=UserOut.model_validate(user)
)
@router.get("/me", response_model=UserOut)
def me(current_user: User = Depends(get_current_user)):
return current_user
@router.post("/resend-verification")
async def resend_verification(
email: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db)
):
user = db.query(User).filter(User.email == email).first()
if not user or user.is_verified:
# Svar altid OK — afslør ikke om email eksisterer
return {"message": "Hvis e-mailen er registreret, sendes et nyt link."}
token = generate_verify_token()
user.verify_token = token
db.commit()
background_tasks.add_task(
send_verification_email, user.email, user.username, token
)
return {"message": "Nyt verificerings-link er sendt."}

View File

@@ -0,0 +1,108 @@
"""
dances.py — Endpoints til dans-navne, niveauer og community alternativer.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from pydantic import BaseModel
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import User
router = APIRouter(prefix="/dances", tags=["dances"])
# ── Schemas ───────────────────────────────────────────────────────────────────
class DanceLevelOut(BaseModel):
id: int
sort_order: int
name: str
description: str
model_config = {"from_attributes": True}
class DanceNameOut(BaseModel):
name: str
use_count: int
class DanceNameSubmit(BaseModel):
name: str
class CommunityDanceOut(BaseModel):
id: str
song_mbid: str | None
dance_name: str
level_id: int | None
level_name: str | None
submitted_by: str
use_count: int
class CommunityAltOut(BaseModel):
id: str
song_mbid: str | None
dance_name: str
alt_dance_name: str
level_id: int | None
level_name: str | None
note: str
bayesian_score: float
rating_count: int
my_rating: int | None
# ── Dans-niveauer ─────────────────────────────────────────────────────────────
@router.get("/levels", response_model=list[DanceLevelOut])
def get_levels(db: Session = Depends(get_db)):
"""Hent alle dans-niveauer — bruges til synkronisering i appen."""
from sqlalchemy import text
rows = db.execute(text(
"SELECT id, sort_order, name, description FROM dance_levels ORDER BY sort_order"
)).fetchall()
return [{"id": r[0], "sort_order": r[1], "name": r[2], "description": r[3]} for r in rows]
# ── Dans-navne ────────────────────────────────────────────────────────────────
@router.get("/names", response_model=list[DanceNameOut])
def get_dance_names(prefix: str = "", limit: int = 50, db: Session = Depends(get_db)):
"""Hent kendte dans-navne — bruges til autoudfyld og synkronisering."""
from sqlalchemy import text
pattern = f"{prefix}%"
rows = db.execute(text(
"SELECT name, use_count FROM dance_names "
"WHERE name LIKE :pattern "
"ORDER BY use_count DESC, name "
"LIMIT :limit"
), {"pattern": pattern, "limit": limit}).fetchall()
return [{"name": r[0], "use_count": r[1]} for r in rows]
@router.post("/names", status_code=201)
def submit_dance_name(
data: DanceNameSubmit,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Indsend et dans-navn — opretter eller tæller op."""
from sqlalchemy import text
name = data.name.strip()
if not name:
raise HTTPException(400, "Navn må ikke være tomt")
existing = db.execute(
text("SELECT id FROM dance_names WHERE name = :name COLLATE NOCASE"),
{"name": name}
).fetchone()
if existing:
db.execute(
text("UPDATE dance_names SET use_count = use_count + 1 WHERE id = :id"),
{"id": existing[0]}
)
else:
db.execute(
text("INSERT INTO dance_names (name, use_count) VALUES (:name, 1)"),
{"name": name}
)
db.commit()
return {"detail": "ok"}

View File

@@ -0,0 +1,109 @@
"""
live.py — Live playliste-status til storskærm/mobil.
Appen pusher status hertil, storskærmen poller hvert 5 sek.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional
import json
from datetime import datetime, timezone
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import User, Project
router = APIRouter(prefix="/live", tags=["live"])
# In-memory cache: server_id → {songs, updated_at}
_live_cache: dict = {}
class SongStatus(BaseModel):
title: str
artist: str = ""
status: str = "pending"
position: int
dance: str = ""
duration: int = 0
is_workshop: bool = False
class LiveStatus(BaseModel):
songs: list[SongStatus]
# ── Push fra app ──────────────────────────────────────────────────────────────
@router.post("/{project_id}/status")
def push_status(
project_id: str,
data: LiveStatus,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""App pusher aktuel playliste-status."""
p = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not p:
raise HTTPException(404, "Playliste ikke fundet")
_live_cache[project_id] = {
"name": p.name,
"songs": [s.model_dump() for s in data.songs],
"updated_at": datetime.now(timezone.utc).isoformat(),
}
return {"status": "ok"}
# ── Pull til storskærm ────────────────────────────────────────────────────────
@router.get("/{project_id}")
def get_live_status(project_id: str, db: Session = Depends(get_db)):
"""Storskærm poller dette endpoint — ingen login krævet."""
# Tjek at playlisten eksisterer og er tilgængelig
p = db.query(Project).filter_by(id=project_id).first()
if not p:
raise HTTPException(404, "Playliste ikke fundet")
cached = _live_cache.get(project_id)
if cached:
return cached
# Ingen live data endnu — returner statisk data fra DB
from app.models import ProjectSong, Song
songs = []
for ps in sorted(p.project_songs, key=lambda x: x.position):
song = db.query(Song).filter_by(id=ps.song_id).first()
if not song:
continue
songs.append({
"title": song.title,
"artist": song.artist,
"status": ps.status or "pending",
"position": ps.position,
"dance": ps.dance_override or "",
"duration": song.duration_sec or 0,
})
return {
"name": p.name,
"songs": songs,
"updated_at": None,
}
# ── Liste over aktive live-playlister ─────────────────────────────────────────
@router.get("/")
def list_live(db: Session = Depends(get_db)):
"""Hvilke playlister har aktiv live-data?"""
result = []
for pid, data in _live_cache.items():
playing = next((s for s in data["songs"] if s["status"] == "playing"), None)
result.append({
"id": pid,
"name": data["name"],
"updated_at": data["updated_at"],
"now_playing": playing["title"] if playing else None,
})
return result

View File

@@ -0,0 +1,205 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import User, Project, ProjectMember, ProjectSong, Song
from app.schemas import (
ProjectCreate, ProjectUpdate, ProjectOut,
InviteMember, ProjectSongAdd, ProjectSongStatusUpdate, ProjectSongOut,
)
router = APIRouter(prefix="/projects", tags=["projects"])
def _get_project_or_404(project_id: str, db: Session) -> Project:
p = db.query(Project).filter(Project.id == project_id).first()
if not p:
raise HTTPException(404, "Projekt ikke fundet")
return p
def _assert_role(project: Project, user: User, db: Session, min_role: str = "viewer"):
roles = ["viewer", "editor", "owner"]
if project.owner_id == user.id:
return # ejer har altid adgang
member = db.query(ProjectMember).filter_by(project_id=project.id, user_id=user.id, status="accepted").first()
if not member:
if project.visibility == "public" and min_role == "viewer":
return
raise HTTPException(403, "Du har ikke adgang til dette projekt")
if roles.index(member.role) < roles.index(min_role):
raise HTTPException(403, "Din rolle giver ikke rettighed til dette")
# ── CRUD ──────────────────────────────────────────────────────────────────────
@router.get("/my")
def my_projects(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
"""Brugerens egne playlister med song_count og visibility."""
projects = db.query(Project).filter(Project.owner_id == me.id).order_by(Project.name).all()
return [
{
"id": p.id,
"name": p.name,
"visibility": p.visibility or "private",
"song_count": len(p.project_songs),
}
for p in projects
]
@router.get("/", response_model=list[ProjectOut])
def list_projects(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
owned = db.query(Project).filter(Project.owner_id == me.id).all()
member_ids = [m.project_id for m in db.query(ProjectMember).filter_by(user_id=me.id, status="accepted").all()]
shared = db.query(Project).filter(Project.id.in_(member_ids)).all()
return list({p.id: p for p in owned + shared}.values())
@router.post("/", response_model=ProjectOut, status_code=201)
def create_project(data: ProjectCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
project = Project(owner_id=me.id, **data.model_dump())
db.add(project)
db.commit()
db.refresh(project)
return project
@router.get("/{project_id}", response_model=ProjectOut)
def get_project(project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
p = _get_project_or_404(project_id, db)
_assert_role(p, me, db, "viewer")
return p
@router.patch("/{project_id}", response_model=ProjectOut)
def update_project(project_id: str, data: ProjectUpdate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
p = _get_project_or_404(project_id, db)
_assert_role(p, me, db, "editor")
for field, val in data.model_dump(exclude_none=True).items():
setattr(p, field, val)
db.commit()
db.refresh(p)
return p
@router.delete("/{project_id}", status_code=204)
def delete_project(project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
p = _get_project_or_404(project_id, db)
if p.owner_id != me.id:
raise HTTPException(403, "Kun ejeren kan slette projektet")
db.delete(p)
db.commit()
# ── Invitationer ──────────────────────────────────────────────────────────────
@router.post("/{project_id}/invite", status_code=201)
def invite_member(project_id: str, data: InviteMember, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
p = _get_project_or_404(project_id, db)
if p.owner_id != me.id:
raise HTTPException(403, "Kun ejeren kan invitere")
target = db.query(User).filter(User.username == data.username).first()
if not target:
raise HTTPException(404, f"Brugeren '{data.username}' findes ikke")
if target.id == me.id:
raise HTTPException(400, "Du kan ikke invitere dig selv")
existing = db.query(ProjectMember).filter_by(project_id=project_id, user_id=target.id).first()
if existing:
raise HTTPException(400, "Brugeren er allerede inviteret eller medlem")
member = ProjectMember(project_id=project_id, user_id=target.id, role=data.role, status="pending")
db.add(member)
db.commit()
return {"detail": f"{data.username} er inviteret som {data.role}"}
@router.get("/invitations/pending")
def get_pending_invitations(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
invitations = db.query(ProjectMember).filter_by(user_id=me.id, status="pending").all()
return [
{"invitation_id": inv.id, "project_id": inv.project_id, "role": inv.role, "invited_at": inv.invited_at}
for inv in invitations
]
@router.post("/invitations/{invitation_id}/accept")
def accept_invitation(invitation_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
inv = db.query(ProjectMember).filter_by(id=invitation_id, user_id=me.id).first()
if not inv:
raise HTTPException(404, "Invitation ikke fundet")
inv.status = "accepted"
db.commit()
return {"detail": "Invitation accepteret"}
@router.delete("/invitations/{invitation_id}")
def decline_invitation(invitation_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
inv = db.query(ProjectMember).filter_by(id=invitation_id, user_id=me.id).first()
if not inv:
raise HTTPException(404, "Invitation ikke fundet")
db.delete(inv)
db.commit()
return {"detail": "Invitation afvist"}
# ── Danseliste (ProjectSongs) ─────────────────────────────────────────────────
@router.get("/{project_id}/songs", response_model=list[ProjectSongOut])
def list_project_songs(project_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
p = _get_project_or_404(project_id, db)
_assert_role(p, me, db, "viewer")
return p.project_songs
@router.post("/{project_id}/songs", response_model=ProjectSongOut, status_code=201)
def add_song_to_project(project_id: str, data: ProjectSongAdd, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
p = _get_project_or_404(project_id, db)
_assert_role(p, me, db, "editor")
song = db.query(Song).filter(Song.id == data.song_id).first()
if not song:
raise HTTPException(404, "Sang ikke fundet")
position = data.position
if position is None:
last = db.query(ProjectSong).filter_by(project_id=project_id).order_by(ProjectSong.position.desc()).first()
position = (last.position + 1) if last else 1
ps = ProjectSong(project_id=project_id, song_id=data.song_id, position=position)
db.add(ps)
db.commit()
db.refresh(ps)
return ps
@router.patch("/{project_id}/songs/{ps_id}/status", response_model=ProjectSongOut)
def update_song_status(project_id: str, ps_id: str, data: ProjectSongStatusUpdate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
p = _get_project_or_404(project_id, db)
_assert_role(p, me, db, "editor")
ps = db.query(ProjectSong).filter_by(id=ps_id, project_id=project_id).first()
if not ps:
raise HTTPException(404, "Sang ikke fundet i projektet")
valid = {"pending", "playing", "played", "skipped"}
if data.status not in valid:
raise HTTPException(400, f"Ugyldig status. Vælg én af: {valid}")
ps.status = data.status
db.commit()
db.refresh(ps)
return ps
@router.delete("/{project_id}/songs/{ps_id}", status_code=204)
def remove_song_from_project(project_id: str, ps_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
p = _get_project_or_404(project_id, db)
_assert_role(p, me, db, "editor")
ps = db.query(ProjectSong).filter_by(id=ps_id, project_id=project_id).first()
if not ps:
raise HTTPException(404, "Sang ikke fundet i projektet")
db.delete(ps)
db.commit()

View File

@@ -0,0 +1,220 @@
"""
sharing.py — Forenklet deling af playlister.
Kun ejeren kan redigere. Delte brugere får read-only via sync.
"""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import User, Project, PlaylistShare
router = APIRouter(prefix="/sharing", tags=["sharing"])
class ShareRequest(BaseModel):
email: EmailStr
# ── Del med bruger ────────────────────────────────────────────────────────────
@router.post("/playlists/{project_id}/share", status_code=201)
async def share_playlist(
project_id: str,
data: ShareRequest,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Del en playliste med en bruger — de får listen ved næste sync."""
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet eller du er ikke ejer")
target = db.query(User).filter_by(email=data.email).first()
existing = db.query(PlaylistShare).filter_by(
project_id=project_id, invited_email=data.email
).first()
if existing:
return {"detail": "Allerede delt med denne bruger"}
share = PlaylistShare(
project_id=project_id,
shared_with_id=target.id if target else None,
invited_email=data.email,
permission="view",
)
db.add(share)
db.commit()
# Send invitation-mail
try:
from app.core.mail import send_share_invitation
from app.core.config import settings
background_tasks.add_task(
send_share_invitation,
email=data.email,
owner_name=me.username,
playlist_name=project.name,
permission="view",
accept_url=f"{settings.BASE_URL}/sharing/playlists/{project_id}",
)
except Exception:
pass
return {"detail": f"Delt med {data.email}"}
@router.delete("/playlists/{project_id}/share/{share_id}", status_code=204)
def remove_share(
project_id: str,
share_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet")
share = db.query(PlaylistShare).filter_by(id=share_id, project_id=project_id).first()
if not share:
raise HTTPException(404, "Deling ikke fundet")
db.delete(share)
db.commit()
@router.get("/playlists/{project_id}/shares")
def list_shares(
project_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet")
shares = db.query(PlaylistShare).filter_by(project_id=project_id).all()
return [{"id": s.id, "email": s.invited_email} for s in shares]
# ── Visibility ────────────────────────────────────────────────────────────────
@router.patch("/playlists/{project_id}/visibility")
def set_visibility(
project_id: str,
visibility: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
if visibility not in ("private", "shared", "public"):
raise HTTPException(400, "Brug private, shared eller public")
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if not project:
raise HTTPException(404, "Playliste ikke fundet")
project.visibility = visibility
db.commit()
return {"detail": f"Synlighed: {visibility}"}
# ── Hent playliste-indhold ────────────────────────────────────────────────────
@router.get("/public")
def list_public_playlists(db: Session = Depends(get_db)):
"""Hent alle public playlister — ingen login krævet."""
projects = db.query(Project).filter_by(visibility="public").all()
result = []
for p in projects:
owner = db.query(User).filter_by(id=p.owner_id).first()
result.append({
"id": p.id,
"name": p.name,
"owner": owner.username if owner else "?",
"song_count": len(p.project_songs),
})
return result
@router.post("/playlists/{project_id}/copy", status_code=201)
def copy_playlist(
project_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Kopiér en public playliste til brugerens egen konto."""
p = db.query(Project).filter_by(id=project_id).first()
if not p:
raise HTTPException(404, "Playliste ikke fundet")
if p.visibility != "public":
raise HTTPException(403, "Kun public playlister kan kopieres")
if p.owner_id == me.id:
raise HTTPException(400, "Du ejer allerede denne playliste")
from app.models import Song
owner = db.query(User).filter_by(id=p.owner_id).first()
new_name = f"{p.name} (kopi fra {owner.username if owner else '?'})"
new_p = Project(
owner_id=me.id,
name=new_name,
description=p.description or "",
visibility="private",
)
db.add(new_p)
db.flush()
for ps in p.project_songs:
from app.models import ProjectSong
db.add(ProjectSong(
project_id=new_p.id,
song_id=ps.song_id,
position=ps.position,
status="pending",
is_workshop=ps.is_workshop,
dance_override=ps.dance_override or "",
))
db.commit()
return {"detail": "Kopieret", "id": new_p.id}
@router.get("/playlists/{project_id}")
def get_shared_playlist(
project_id: str,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
p = db.query(Project).filter_by(id=project_id).first()
if not p:
raise HTTPException(404, "Playliste ikke fundet")
if p.owner_id != me.id:
if p.visibility != "public":
share = db.query(PlaylistShare).filter(
PlaylistShare.project_id == project_id,
(PlaylistShare.shared_with_id == me.id) |
(PlaylistShare.invited_email == me.email)
).first()
if not share:
raise HTTPException(403, "Ingen adgang")
from app.models import Song
songs = []
for ps in p.project_songs:
song = db.query(Song).filter_by(id=ps.song_id).first()
if not song:
continue
songs.append({
"title": song.title,
"artist": song.artist,
"position": ps.position,
"status": ps.status,
"is_workshop": ps.is_workshop,
"dance_override": ps.dance_override or "",
})
return {
"id": p.id,
"name": p.name,
"description": p.description or "",
"visibility": p.visibility,
"songs": sorted(songs, key=lambda x: x["position"]),
}

View File

@@ -0,0 +1,29 @@
"""songs.py — Simpel sang-router (basis CRUD)."""
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
router = APIRouter(prefix="/songs", tags=["songs"])
class SongOut(BaseModel):
id: str; title: str; artist: str; album: str
bpm: int; duration_sec: int; file_format: str
class Config: from_attributes = True
@router.get("/", response_model=list[SongOut])
def list_songs(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
return db.query(Song).filter(Song.owner_id == me.id).all()
@router.delete("/{song_id}", status_code=204)
def delete_song(song_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first()
if not song:
raise HTTPException(404, "Sang ikke fundet")
db.delete(song)
db.commit()

View File

@@ -0,0 +1,473 @@
"""
sync.py — Push/pull synkronisering mellem lokal app og server.
POST /sync/push — send lokal data op til server
GET /sync/pull — hent server-data ned til app
"""
import uuid
import logging
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
from app.models import (
User, Song, Dance, DanceLevel, Project, ProjectSong,
PlaylistShare, CommunityDance, SongDance, SongAltDance,
)
router = APIRouter(prefix="/sync", tags=["sync"])
logger = logging.getLogger(__name__)
# ── Schemas ───────────────────────────────────────────────────────────────────
class SongData(BaseModel):
local_id: str
title: str
artist: str = ""
album: str = ""
bpm: int = 0
duration_sec: int = 0
mbid: str = ""
acoustid: str = ""
class DanceData(BaseModel):
name: str
level_name: str = ""
choreographer: str = ""
video_url: str = ""
stepsheet_url: str = ""
notes: str = ""
class SongDanceData(BaseModel):
song_local_id: str
dance_name: str
level_name: str = ""
dance_order: int = 1
class SongAltDanceData(BaseModel):
song_local_id: str
dance_name: str
level_name: str = ""
note: str = ""
user_rating: Optional[int] = None
class PlaylistSongData(BaseModel):
song_local_id: str
song_title: str = ""
song_artist: str = ""
position: int
status: str = "pending"
is_workshop: bool = False
dance_override: str = ""
class PlaylistData(BaseModel):
local_id: str
name: str
description: str = ""
tags: str = ""
visibility: str = "private"
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_with_dances_synced: list[str] = [] # sang-IDs der er fuldt synkroniseret
# ── Hjælpefunktion: find eller opret sang globalt ─────────────────────────────
def _find_or_create_song(db: Session, title: str, artist: str = "",
mbid: str = "", acoustid: str = "",
album: str = "", bpm: int = 0,
duration_sec: int = 0) -> Song:
"""
Match-hierarki:
1. MBID — sikreste
2. AcoustID
3. Titel + artist
4. Opret ny
"""
if mbid:
song = db.query(Song).filter_by(mbid=mbid).first()
if song:
return song
if acoustid:
song = db.query(Song).filter_by(acoustid=acoustid).first()
if song:
# Tilføj mbid hvis den mangler
if mbid and not song.mbid:
song.mbid = mbid
return song
if title:
song = db.query(Song).filter(
Song.title == title,
Song.artist == artist,
).first()
if song:
# Opdater med bedre data hvis tilgængeligt
if mbid and not song.mbid:
song.mbid = mbid
if acoustid and not song.acoustid:
song.acoustid = acoustid
if bpm and not song.bpm:
song.bpm = bpm
return song
# Opret ny global sang
song = Song(
title=title, artist=artist, album=album,
bpm=bpm, duration_sec=duration_sec,
mbid=mbid or None,
acoustid=acoustid or None,
)
db.add(song)
db.flush()
return song
# ── Push ──────────────────────────────────────────────────────────────────────
@router.post("/push")
def push(
payload: PushPayload,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Upload lokal data til server. Returnerer server-IDs."""
import sqlalchemy as _sa
song_id_map = {} # local_id → server Song.id
dance_id_map = {} # "name|level_id" → Dance.id
level_map = {} # level_name.lower() → DanceLevel.id
# ── Dans-niveauer ─────────────────────────────────────────────────────────
for lvl in db.query(DanceLevel).all():
level_map[lvl.name.lower()] = lvl.id
# ── Sange (globale) ───────────────────────────────────────────────────────
for s in payload.songs:
if not s.title:
continue
song = _find_or_create_song(
db, s.title, s.artist,
mbid=s.mbid, acoustid=s.acoustid,
album=s.album, bpm=s.bpm, duration_sec=s.duration_sec,
)
song_id_map[s.local_id] = song.id
# ── Danse ─────────────────────────────────────────────────────────────────
for d in payload.dances:
level_id = level_map.get(d.level_name.lower()) if d.level_name else None
key = f"{d.name.lower()}|{level_id}"
existing = db.query(Dance).filter_by(name=d.name, level_id=level_id).first()
if existing:
if d.choreographer: existing.choreographer = d.choreographer
if d.video_url: existing.video_url = d.video_url
if d.stepsheet_url: existing.stepsheet_url = d.stepsheet_url
dance_id_map[key] = existing.id
else:
dance = Dance(
name=d.name, level_id=level_id,
choreographer=d.choreographer,
video_url=d.video_url,
stepsheet_url=d.stepsheet_url,
notes=d.notes,
)
db.add(dance)
db.flush()
dance_id_map[key] = dance.id
# ── 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)
if not dance_id:
continue
db.execute(_sa.text(
"INSERT IGNORE INTO song_dances (id, song_id, dance_id, dance_order) "
"VALUES (:id, :song_id, :dance_id, :dance_order)"
), {"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:
continue
level_id = level_map.get(sa.level_name.lower()) if sa.level_name else None
key = f"{sa.dance_name.lower()}|{level_id}"
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)"
), {"id": str(uuid.uuid4()), "song_id": song_id,
"dance_id": dance_id, "note": sa.note or ""})
# ── Playlister ────────────────────────────────────────────────────────────
playlist_id_map = {}
for pl in payload.playlists:
# Find eksisterende via server-ID (local_id er api_project_id på klienten)
existing = None
if pl.local_id:
existing = db.query(Project).filter_by(
id=pl.local_id, owner_id=me.id
).first()
if not existing:
existing = db.query(Project).filter_by(
owner_id=me.id, name=pl.name
).first()
if existing:
existing.name = pl.name
existing.description = pl.description
existing.visibility = pl.visibility
if pl.songs:
db.query(ProjectSong).filter_by(project_id=existing.id).delete()
project = existing
else:
project = Project(
owner_id=me.id, name=pl.name,
description=pl.description, visibility=pl.visibility,
)
db.add(project)
db.flush()
playlist_id_map[pl.local_id] = project.id
for ps in pl.songs:
# Find sang via song_id_map eller titel+artist
song_id = song_id_map.get(ps.song_local_id)
if not song_id and ps.song_title:
song = _find_or_create_song(db, ps.song_title, ps.song_artist)
song_id = song.id
if not song_id:
continue
db.add(ProjectSong(
project_id=project.id, song_id=song_id,
position=ps.position, status=ps.status,
is_workshop=ps.is_workshop,
dance_override=ps.dance_override,
))
# ── Slet playlister ───────────────────────────────────────────────────────
for project_id in payload.deleted_playlists:
proj = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if proj:
db.query(ProjectSong).filter_by(project_id=proj.id).delete()
db.delete(proj)
db.commit()
return {
"status": "ok",
"songs_synced": len(song_id_map),
"playlists_synced": len(playlist_id_map),
"song_id_map": {k: str(v) for k, v in song_id_map.items()},
"playlist_id_map": {k: str(v) for k, v in playlist_id_map.items()},
}
# ── Pull ──────────────────────────────────────────────────────────────────────
@router.get("/pull")
def pull(
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Hent server-data til lokal app."""
# Dans-niveauer
levels = [
{"id": l.id, "name": l.name, "sort_order": l.sort_order}
for l in db.query(DanceLevel).order_by(DanceLevel.sort_order).all()
]
# Danse
dances = [
{
"name": d.name,
"level_id": d.level_id,
"choreographer": d.choreographer,
"video_url": d.video_url,
"stepsheet_url": d.stepsheet_url,
"notes": d.notes,
"use_count": d.use_count,
}
for d in db.query(Dance).order_by(Dance.use_count.desc()).limit(500).all()
]
# Delte playlister
shared_ids = {
s.project_id for s in db.query(PlaylistShare).filter(
(PlaylistShare.shared_with_id == me.id) |
(PlaylistShare.invited_email == me.email)
).all()
}
shared = []
for p in db.query(Project).filter(Project.id.in_(shared_ids)).all():
if p.owner_id == me.id:
continue
owner = db.query(User).filter_by(id=p.owner_id).first()
shared.append({
"server_id": p.id,
"name": p.name,
"owner": owner.username if owner else "?",
"songs": [
{
"song_id": str(ps.song_id),
"title": ps.song.title,
"artist": ps.song.artist,
"mbid": ps.song.mbid or "",
"acoustid": ps.song.acoustid or "",
"bpm": ps.song.bpm,
"duration_sec": ps.song.duration_sec,
"position": ps.position,
"status": ps.status,
"is_workshop": ps.is_workshop,
"dance_override": ps.dance_override or "",
}
for ps in sorted(p.project_songs, key=lambda x: x.position)
if ps.song
],
})
# Egne playlister
my_playlists = []
for p in db.query(Project).filter_by(owner_id=me.id).all():
my_playlists.append({
"server_id": p.id,
"name": p.name,
"description": p.description or "",
"songs": [
{
"song_id": str(ps.song_id),
"title": ps.song.title,
"artist": ps.song.artist,
"mbid": ps.song.mbid or "",
"acoustid": ps.song.acoustid or "",
"bpm": ps.song.bpm,
"duration_sec": ps.song.duration_sec,
"position": ps.position,
"status": ps.status,
"is_workshop": ps.is_workshop,
"dance_override": ps.dance_override or "",
}
for ps in sorted(p.project_songs, key=lambda x: x.position)
if ps.song
],
})
logger.info(f"Pull: {len(my_playlists)} playlister for {me.username}")
# Dans-tags (brugerens egne)
song_tags = []
for sd in db.query(SongDance).all():
dance = db.query(Dance).filter_by(id=sd.dance_id).first()
if not dance:
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,
"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,
"community_alts": community_alts,
}

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, EmailStr
# ── Auth ──────────────────────────────────────────────────────────────────────
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserOut(BaseModel):
id: str
username: str
email: str
created_at: datetime
model_config = {"from_attributes": True}
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
# ── Project ───────────────────────────────────────────────────────────────────
class ProjectCreate(BaseModel):
name: str
description: str = ""
is_public: bool = False
class ProjectUpdate(BaseModel):
name: str | None = None
description: str | None = None
is_public: bool | None = None
class ProjectOut(BaseModel):
id: str
owner_id: str
name: str
description: str
is_public: bool
updated_at: datetime
model_config = {"from_attributes": True}
class InviteMember(BaseModel):
username: str
role: str = "viewer" # editor | viewer
# ── Song ──────────────────────────────────────────────────────────────────────
class SongCreate(BaseModel):
title: str
artist: str = ""
local_path: str = ""
bpm: int = 0
duration_sec: int = 0
class SongOut(BaseModel):
id: str
owner_id: str
title: str
artist: str
local_path: str
bpm: int
duration_sec: int
synced_at: datetime
dances: list[SongDanceOut] = []
model_config = {"from_attributes": True}
# ── Dance ─────────────────────────────────────────────────────────────────────
class SongDanceCreate(BaseModel):
dance_name: str
dance_order: int = 1
class SongDanceOut(BaseModel):
id: str
dance_name: str
dance_order: int
model_config = {"from_attributes": True}
class DanceAlternativeCreate(BaseModel):
alt_song_dance_id: str
note: str = ""
class DanceAlternativeOut(BaseModel):
id: str
song_dance_id: str
alt_song_dance_id: str
note: str
model_config = {"from_attributes": True}
# ── ProjectSong ───────────────────────────────────────────────────────────────
class ProjectSongAdd(BaseModel):
song_id: str
position: int | None = None # None = tilføj sidst
class ProjectSongStatusUpdate(BaseModel):
status: str # pending | playing | played | skipped
class ProjectSongOut(BaseModel):
id: str
song_id: str
position: int
status: str
song: SongOut
model_config = {"from_attributes": True}
SongOut.model_rebuild()

View File

@@ -0,0 +1,78 @@
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models import Project, ProjectSong
router = APIRouter(prefix="/ws", tags=["websocket"])
class ConnectionManager:
def __init__(self):
# project_id -> liste af aktive forbindelser
self.rooms: dict[str, list[WebSocket]] = {}
async def connect(self, project_id: str, ws: WebSocket):
await ws.accept()
self.rooms.setdefault(project_id, []).append(ws)
def disconnect(self, project_id: str, ws: WebSocket):
if project_id in self.rooms:
self.rooms[project_id].discard(ws) if hasattr(self.rooms[project_id], 'discard') else None
try:
self.rooms[project_id].remove(ws)
except ValueError:
pass
async def broadcast(self, project_id: str, message: dict):
dead = []
for ws in self.rooms.get(project_id, []):
try:
await ws.send_text(json.dumps(message))
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(project_id, ws)
manager = ConnectionManager()
@router.websocket("/{project_id}")
async def project_live(
project_id: str,
websocket: WebSocket,
db: Session = Depends(get_db),
):
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
await websocket.close(code=4004)
return
await manager.connect(project_id, websocket)
# Send nuværende tilstand med det samme ved opkobling
songs = db.query(ProjectSong).filter_by(project_id=project_id).order_by(ProjectSong.position).all()
await websocket.send_text(json.dumps({
"event": "state",
"project_id": project_id,
"songs": [
{"id": ps.id, "position": ps.position, "status": ps.status, "song_id": ps.song_id}
for ps in songs
],
}))
try:
while True:
await websocket.receive_text() # hold forbindelsen åben
except WebSocketDisconnect:
manager.disconnect(project_id, websocket)
async def notify_status_change(project_id: str, project_song_id: str, new_status: str):
"""Kaldes fra projects-router når en sangs status ændres."""
await manager.broadcast(project_id, {
"event": "status_update",
"project_song_id": project_song_id,
"status": new_status,
})

View File

@@ -0,0 +1,33 @@
services:
api:
build: .
restart: always
ports:
- "8000:8000"
env_file:
- .env
networks:
- linedance
web:
build: ./web
restart: always
ports:
- "8001:8001"
networks:
- linedance
depends_on:
- api
adminer:
image: adminer
restart: always
ports:
- "127.0.0.1:8080:8080" # kun tilgængelig lokalt på serveren
networks:
- linedance
networks:
linedance:
name: linedance

View File

@@ -0,0 +1,29 @@
"""
local/ — Lokalt data-lag til Linedance-afspilleren.
Moduler:
local_db.py — SQLite database (sange, afspilningslister, biblioteker)
tag_reader.py — Læser/skriver metadata fra lydfiler
file_watcher.py — Overvåger mapper og holder SQLite opdateret
Typisk brug ved app-start:
from local.local_db import init_db
from local.file_watcher import get_watcher
# Initialiser database
init_db()
# Start fil-overvågning (on_change kaldes ved ændringer — opdater GUI)
def on_file_change(event_type, path, song_id):
print(f"{event_type}: {path}")
watcher = get_watcher(on_change=on_file_change)
watcher.start()
# Tilføj et bibliotek (scanner automatisk + starter overvågning)
watcher.add_library("/home/carsten/Musik")
# Ved app-luk:
watcher.stop()
"""

View File

@@ -0,0 +1,258 @@
"""
file_watcher.py — Overvåger musikbiblioteker og holder SQLite opdateret.
Bruger watchdog til at reagere på fil-ændringer i realtid.
Kører fuld scan ved opstart for at fange ændringer lavet mens appen var lukket.
"""
import threading
import time
import logging
from pathlib import Path
from typing import Callable
try:
from watchdog.observers import Observer
from watchdog.events import (
FileSystemEventHandler,
FileCreatedEvent,
FileModifiedEvent,
FileDeletedEvent,
FileMovedEvent,
)
WATCHDOG_AVAILABLE = True
except ImportError:
WATCHDOG_AVAILABLE = False
print("Advarsel: watchdog ikke installeret — fil-overvågning deaktiveret")
from local.tag_reader import is_supported, read_tags, get_file_modified_at
from local.local_db import (
get_libraries, add_library, remove_library,
upsert_song, mark_song_missing,
get_all_song_paths_for_library, update_library_scan_time,
)
logger = logging.getLogger(__name__)
class MusicLibraryHandler(FileSystemEventHandler):
"""
Reagerer på ændringer i et musikbibliotek.
Kører i watchdog's baggrundstråd — DB-operationer er thread-safe via WAL.
"""
def __init__(self, library_id: int, on_change: Callable | None = None):
self.library_id = library_id
self.on_change = on_change # valgfrit callback til GUI-opdatering
self._debounce: dict[str, float] = {}
self._debounce_lock = threading.Lock()
def _debounced(self, path: str) -> bool:
"""
Forhindrer at samme fil behandles flere gange på kort tid.
Nogle programmer gemmer filer i flere trin (temp-fil → rename).
"""
now = time.time()
with self._debounce_lock:
last = self._debounce.get(path, 0)
if now - last < 1.5: # 1.5 sekunder cooldown
return False
self._debounce[path] = now
return True
def on_created(self, event):
if event.is_directory or not is_supported(event.src_path):
return
if self._debounced(event.src_path):
self._process_file(event.src_path)
def on_modified(self, event):
if event.is_directory or not is_supported(event.src_path):
return
if self._debounced(event.src_path):
self._process_file(event.src_path)
def on_deleted(self, event):
if event.is_directory or not is_supported(event.src_path):
return
logger.info(f"Fil slettet: {event.src_path}")
mark_song_missing(event.src_path)
if self.on_change:
self.on_change("deleted", event.src_path, None)
def on_moved(self, event):
if event.is_directory:
return
# Behandl som slet + opret
if is_supported(event.src_path):
mark_song_missing(event.src_path)
if is_supported(event.dest_path):
if self._debounced(event.dest_path):
self._process_file(event.dest_path)
def _process_file(self, path: str):
"""Læs tags og gem i SQLite."""
try:
logger.debug(f"Høster tags fra: {path}")
tags = read_tags(path)
tags["library_id"] = self.library_id
song_id = upsert_song(tags)
logger.info(f"Opdateret: {Path(path).name} ({len(tags.get('dances', []))} danse)")
if self.on_change:
self.on_change("upserted", path, song_id)
except Exception as e:
logger.error(f"Fejl ved behandling af {path}: {e}")
class LibraryWatcher:
"""
Styrer watchdog-observere for alle aktive musikbiblioteker.
Én instans per applikation.
"""
def __init__(self, on_change: Callable | None = None):
self.on_change = on_change
self._observer: Observer | None = None
self._running = False
def start(self):
"""Start overvågning af alle aktive biblioteker + kør fuld scan."""
if not WATCHDOG_AVAILABLE:
logger.warning("watchdog ikke tilgængelig — starter kun fuld scan")
self._full_scan_all()
return
self._observer = Observer()
libraries = get_libraries(active_only=True)
for lib in libraries:
path = Path(lib["path"])
if not path.exists():
logger.warning(f"Bibliotek findes ikke: {path}")
continue
handler = MusicLibraryHandler(lib["id"], self.on_change)
self._observer.schedule(handler, str(path), recursive=True)
logger.info(f"Overvåger: {path}")
self._observer.start()
self._running = True
# Fuld scan i baggrundstråd så GUI ikke blokeres
threading.Thread(target=self._full_scan_all, daemon=True).start()
def stop(self):
if self._observer and self._running:
self._observer.stop()
self._observer.join()
self._running = False
def add_library(self, path: str) -> int:
"""Tilføj et nyt bibliotek og start overvågning af det med det samme."""
library_id = add_library(path)
if self._observer and self._running:
handler = MusicLibraryHandler(library_id, self.on_change)
self._observer.schedule(handler, path, recursive=True)
logger.info(f"Tilføjet bibliotek: {path}")
# Scan det nye bibliotek i baggrunden
threading.Thread(
target=self._full_scan_library,
args=(library_id, path),
daemon=True,
).start()
return library_id
def remove_library(self, library_id: int):
"""Deaktiver bibliotek. Watchdog stopper automatisk ved næste restart."""
remove_library(library_id)
# Genstart observer for at fjerne watch (watchdog understøtter ikke unschedule by id)
if self._observer and self._running:
self._observer.unschedule_all()
self._reschedule_all()
def _reschedule_all(self):
"""Genplanlæg alle aktive biblioteker på observeren."""
for lib in get_libraries(active_only=True):
path = Path(lib["path"])
if path.exists():
handler = MusicLibraryHandler(lib["id"], self.on_change)
self._observer.schedule(handler, str(path), recursive=True)
def _full_scan_all(self):
"""Kør fuld scan på alle aktive biblioteker."""
for lib in get_libraries(active_only=True):
path = Path(lib["path"])
if path.exists():
self._full_scan_library(lib["id"], str(path))
def _full_scan_library(self, library_id: int, library_path: str):
"""
Sammenligner filer på disk med SQLite og synkroniserer forskelle.
Tre operationer:
1. Nye filer → indsæt i SQLite
2. Ændrede filer → opdater SQLite (baseret på fil-timestamp)
3. Forsvundne → marker som missing i SQLite
"""
logger.info(f"Fuld scan starter: {library_path}")
base = Path(library_path)
# Hvad SQLite kender til
known = get_all_song_paths_for_library(library_id)
# Hvad der faktisk er på disk
found_paths = set()
processed = 0
errors = 0
for file_path in base.rglob("*"):
if not file_path.is_file() or not is_supported(file_path):
continue
path_str = str(file_path)
found_paths.add(path_str)
disk_modified = get_file_modified_at(file_path)
# Ny fil eller ændret siden sidst
if path_str not in known or known[path_str] != disk_modified:
try:
tags = read_tags(file_path)
tags["library_id"] = library_id
upsert_song(tags)
processed += 1
if self.on_change:
self.on_change("upserted", path_str, None)
except Exception as e:
logger.error(f"Scan-fejl for {file_path}: {e}")
errors += 1
# Marker forsvundne filer
missing_count = 0
for known_path in known:
if known_path not in found_paths:
mark_song_missing(known_path)
missing_count += 1
if self.on_change:
self.on_change("deleted", known_path, None)
update_library_scan_time(library_id)
logger.info(
f"Scan færdig: {library_path}"
f"{processed} opdateret, {missing_count} mangler, {errors} fejl"
)
# ── Singleton til brug i appen ────────────────────────────────────────────────
_watcher: LibraryWatcher | None = None
def get_watcher(on_change: Callable | None = None) -> LibraryWatcher:
"""Returnerer den globale LibraryWatcher-instans."""
global _watcher
if _watcher is None:
_watcher = LibraryWatcher(on_change=on_change)
return _watcher

View File

@@ -0,0 +1,330 @@
"""
local_db.py — Lokal SQLite database til offline brug.
Håndterer:
- Musikbiblioteker (stier der overvåges)
- Sange høstet fra filsystemet
- Lokale afspilningslister (offline-projekter)
- Synkroniseringsstatus mod API
"""
import sqlite3
import threading
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
DB_PATH = Path.home() / ".linedance" / "local.db"
_local = threading.local()
def _get_conn() -> sqlite3.Connection:
"""Returnerer en thread-lokal forbindelse."""
if not hasattr(_local, "conn") or _local.conn is None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL") # bedre concurrent adgang
conn.execute("PRAGMA foreign_keys=ON")
_local.conn = conn
return _local.conn
@contextmanager
def get_db():
conn = _get_conn()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def init_db():
"""Opret alle tabeller hvis de ikke findes."""
with get_db() as conn:
conn.executescript("""
-- Musikbiblioteker der overvåges
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
is_active INTEGER NOT NULL DEFAULT 1,
last_full_scan TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Sange høstet fra filsystemet
CREATE TABLE IF NOT EXISTS songs (
id TEXT PRIMARY KEY,
library_id INTEGER REFERENCES libraries(id),
local_path TEXT NOT NULL UNIQUE,
title TEXT NOT NULL DEFAULT '',
artist TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
bpm INTEGER NOT NULL DEFAULT 0,
duration_sec INTEGER NOT NULL DEFAULT 0,
file_format TEXT NOT NULL DEFAULT '',
file_modified_at TEXT NOT NULL,
file_missing INTEGER NOT NULL DEFAULT 0,
api_song_id TEXT, -- NULL hvis ikke synkroniseret
last_synced_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Danse knyttet til en sang (kun MP3 kan skrive tags)
CREATE TABLE IF NOT EXISTS song_dances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
dance_name TEXT NOT NULL,
dance_order INTEGER NOT NULL DEFAULT 1
);
-- Lokale afspilningslister (offline-projekter)
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
api_project_id TEXT, -- NULL hvis ikke synkroniseret
last_synced_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Sange i en afspilningsliste
CREATE TABLE IF NOT EXISTS playlist_songs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
song_id TEXT NOT NULL REFERENCES songs(id),
position INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending|playing|played|skipped
UNIQUE(playlist_id, position)
);
-- Synkroniseringskø — ændringer der venter på at komme online
CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL, -- 'song'|'playlist'|'playlist_song'
entity_id TEXT NOT NULL,
action TEXT NOT NULL, -- 'create'|'update'|'delete'
payload TEXT NOT NULL, -- JSON
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Indekser til hurtig søgning
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_songs_missing ON songs(file_missing);
CREATE INDEX IF NOT EXISTS idx_songs_library ON songs(library_id);
CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id);
""")
# ── Biblioteker ───────────────────────────────────────────────────────────────
def add_library(path: str) -> int:
with get_db() as conn:
cur = conn.execute(
"INSERT OR IGNORE INTO libraries (path) VALUES (?)", (path,)
)
if cur.lastrowid:
return cur.lastrowid
row = conn.execute("SELECT id FROM libraries WHERE path=?", (path,)).fetchone()
return row["id"]
def get_libraries(active_only: bool = True) -> list[sqlite3.Row]:
with get_db() as conn:
if active_only:
return conn.execute(
"SELECT * FROM libraries WHERE is_active=1 ORDER BY path"
).fetchall()
return conn.execute("SELECT * FROM libraries ORDER BY path").fetchall()
def remove_library(library_id: int):
with get_db() as conn:
conn.execute("UPDATE libraries SET is_active=0 WHERE id=?", (library_id,))
def update_library_scan_time(library_id: int):
now = datetime.now(timezone.utc).isoformat()
with get_db() as conn:
conn.execute(
"UPDATE libraries SET last_full_scan=? WHERE id=?", (now, library_id)
)
# ── Sange ─────────────────────────────────────────────────────────────────────
def upsert_song(song_data: dict) -> str:
"""
Indsæt eller opdater en sang baseret på local_path.
Returnerer song_id.
"""
import uuid
with get_db() as conn:
existing = conn.execute(
"SELECT id FROM songs WHERE local_path=?", (song_data["local_path"],)
).fetchone()
if existing:
song_id = existing["id"]
conn.execute("""
UPDATE songs SET
title=?, artist=?, album=?, bpm=?, duration_sec=?,
file_format=?, file_modified_at=?, file_missing=0
WHERE id=?
""", (
song_data.get("title", ""),
song_data.get("artist", ""),
song_data.get("album", ""),
song_data.get("bpm", 0),
song_data.get("duration_sec", 0),
song_data.get("file_format", ""),
song_data.get("file_modified_at", ""),
song_id,
))
else:
song_id = str(uuid.uuid4())
conn.execute("""
INSERT INTO songs
(id, library_id, local_path, title, artist, album,
bpm, duration_sec, file_format, file_modified_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
""", (
song_id,
song_data.get("library_id"),
song_data["local_path"],
song_data.get("title", ""),
song_data.get("artist", ""),
song_data.get("album", ""),
song_data.get("bpm", 0),
song_data.get("duration_sec", 0),
song_data.get("file_format", ""),
song_data.get("file_modified_at", ""),
))
# Opdater danse hvis de er med i data
if "dances" in song_data:
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
for i, dance_name in enumerate(song_data["dances"], start=1):
conn.execute(
"INSERT INTO song_dances (song_id, dance_name, dance_order) VALUES (?,?,?)",
(song_id, dance_name, i),
)
return song_id
def mark_song_missing(local_path: str):
with get_db() as conn:
conn.execute(
"UPDATE songs SET file_missing=1 WHERE local_path=?", (local_path,)
)
def get_song_by_path(local_path: str) -> sqlite3.Row | None:
with get_db() as conn:
return conn.execute(
"SELECT * FROM songs WHERE local_path=?", (local_path,)
).fetchone()
def search_songs(query: str, limit: int = 50) -> list[sqlite3.Row]:
"""Søg i titel, artist og dansenavne."""
pattern = f"%{query}%"
with get_db() as conn:
return conn.execute("""
SELECT DISTINCT s.* FROM songs s
LEFT JOIN song_dances sd ON sd.song_id = s.id
WHERE s.file_missing = 0
AND (s.title LIKE ? OR s.artist LIKE ? OR s.album LIKE ? OR sd.dance_name LIKE ?)
ORDER BY s.artist, s.title
LIMIT ?
""", (pattern, pattern, pattern, pattern, limit)).fetchall()
def get_songs_for_library(library_id: int) -> list[sqlite3.Row]:
with get_db() as conn:
return conn.execute(
"SELECT * FROM songs WHERE library_id=? ORDER BY artist, title",
(library_id,)
).fetchall()
def get_all_song_paths_for_library(library_id: int) -> dict[str, str]:
"""Returnerer {local_path: file_modified_at} — bruges til fuld scan."""
with get_db() as conn:
rows = conn.execute(
"SELECT local_path, file_modified_at FROM songs WHERE library_id=?",
(library_id,)
).fetchall()
return {row["local_path"]: row["file_modified_at"] for row in rows}
# ── Afspilningslister ─────────────────────────────────────────────────────────
def create_playlist(name: str, description: str = "") -> int:
with get_db() as conn:
cur = conn.execute(
"INSERT INTO playlists (name, description) VALUES (?,?)",
(name, description)
)
return cur.lastrowid
def get_playlists() -> list[sqlite3.Row]:
with get_db() as conn:
return conn.execute(
"SELECT * FROM playlists ORDER BY created_at DESC"
).fetchall()
def add_song_to_playlist(playlist_id: int, song_id: str, position: int | None = None) -> int:
with get_db() as conn:
if position is None:
row = conn.execute(
"SELECT MAX(position) as max_pos FROM playlist_songs WHERE playlist_id=?",
(playlist_id,)
).fetchone()
position = (row["max_pos"] or 0) + 1
cur = conn.execute(
"INSERT INTO playlist_songs (playlist_id, song_id, position) VALUES (?,?,?)",
(playlist_id, song_id, position)
)
return cur.lastrowid
def update_playlist_song_status(playlist_song_id: int, status: str):
valid = {"pending", "playing", "played", "skipped"}
if status not in valid:
raise ValueError(f"Ugyldig status: {status}")
with get_db() as conn:
conn.execute(
"UPDATE playlist_songs SET status=? WHERE id=?",
(status, playlist_song_id)
)
def get_playlist_with_songs(playlist_id: int) -> dict:
with get_db() as conn:
playlist = conn.execute(
"SELECT * FROM playlists WHERE id=?", (playlist_id,)
).fetchone()
if not playlist:
return {}
songs = conn.execute("""
SELECT ps.id as ps_id, ps.position, ps.status,
s.*, GROUP_CONCAT(sd.dance_name ORDER BY sd.dance_order) as dances
FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
LEFT JOIN song_dances sd ON sd.song_id = s.id
WHERE ps.playlist_id = ?
GROUP BY ps.id
ORDER BY ps.position
""", (playlist_id,)).fetchall()
return {"playlist": dict(playlist), "songs": [dict(s) for s in songs]}

View File

@@ -0,0 +1,280 @@
"""
tag_reader.py — Læser og skriver metadata fra lydfiler.
Understøttede formater og danse-tag support:
MP3 — læs + skriv danse (ID3 TXXX-felter)
FLAC — læs + skriv danse (Vorbis Comments)
OGG — læs + skriv danse (Vorbis Comments)
OPUS — læs + skriv danse (Vorbis Comments)
M4A — læs + skriv danse (MP4 custom felt ----:LINEDANCE:DANCE)
WAV — læs metadata, ingen danse-tag support
WMA — læs metadata, ingen danse-tag support
AIFF — læs metadata, ingen danse-tag support
Danse gemmes ALTID i SQLite uanset format.
Fil-skrivning er kun muligt for de formater der understøtter custom tags.
"""
import os
from datetime import datetime, timezone
from pathlib import Path
try:
from mutagen import File as MutagenFile
from mutagen.id3 import ID3, TXXX
from mutagen.flac import FLAC
from mutagen.mp4 import MP4, MP4FreeForm
MUTAGEN_AVAILABLE = True
except ImportError:
MUTAGEN_AVAILABLE = False
print("Advarsel: mutagen ikke installeret — tag-læsning deaktiveret")
# Filtyper vi høster metadata fra
SUPPORTED_EXTENSIONS = {
".mp3", ".flac", ".wav", ".m4a", ".aac",
".ogg", ".opus", ".wma", ".aiff", ".aif",
}
# Formater der understøtter skrivning af danse-tags til fil
WRITABLE_DANCE_FORMATS = {".mp3", ".flac", ".ogg", ".opus", ".m4a"}
# Tag-nøgler brugt på tværs af formater
TXXX_DANCE_PREFIX = "LINEDANCE_DANCE_" # MP3: TXXX:LINEDANCE_DANCE_1
VORBIS_DANCE_KEY = "linedance_dance" # FLAC/OGG: linedance_dance.1
M4A_DANCE_FREEFORM = "----:LINEDANCE:DANCE" # M4A: ----:LINEDANCE:DANCE (liste)
def is_supported(path: str | Path) -> bool:
return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS
def can_write_dances(path: str | Path) -> bool:
"""Returnerer True hvis formatet understøtter skrivning af danse-tags til fil."""
return Path(path).suffix.lower() in WRITABLE_DANCE_FORMATS
def get_file_modified_at(path: str | Path) -> str:
ts = os.path.getmtime(str(path))
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
# ── Læsning ───────────────────────────────────────────────────────────────────
def read_tags(path: str | Path) -> dict:
"""
Læser metadata og danse fra en lydfil.
Returnerer dict med: title, artist, album, bpm, duration_sec,
file_format, file_modified_at, dances, can_write_dances.
"""
path = Path(path)
result = {
"local_path": str(path),
"title": path.stem,
"artist": "",
"album": "",
"bpm": 0,
"duration_sec": 0,
"file_format": path.suffix.lower().lstrip("."),
"file_modified_at": get_file_modified_at(path),
"dances": [],
"can_write_dances": can_write_dances(path),
}
if not MUTAGEN_AVAILABLE:
return result
try:
audio = MutagenFile(str(path), easy=False)
if audio is None:
return result
if hasattr(audio, "info") and audio.info:
result["duration_sec"] = int(getattr(audio.info, "length", 0))
ext = path.suffix.lower()
if ext == ".mp3":
_read_mp3(audio, result)
elif ext == ".flac":
_read_vorbis(audio, result)
elif ext in (".ogg", ".opus"):
_read_vorbis(audio, result)
elif ext in (".m4a", ".aac", ".mp4"):
_read_m4a(audio, result)
else:
_read_generic(audio, result)
except Exception as e:
print(f"Fejl ved læsning af {path}: {e}")
return result
def _read_mp3(audio, result: dict):
tags = audio.tags
if not tags:
return
if "TIT2" in tags:
result["title"] = str(tags["TIT2"].text[0])
if "TPE1" in tags:
result["artist"] = str(tags["TPE1"].text[0])
if "TALB" in tags:
result["album"] = str(tags["TALB"].text[0])
if "TBPM" in tags:
try:
result["bpm"] = int(float(str(tags["TBPM"].text[0])))
except (ValueError, TypeError):
pass
dances = {}
for key, frame in tags.items():
if key.startswith("TXXX:") and TXXX_DANCE_PREFIX in key:
try:
num = int(key.replace(f"TXXX:{TXXX_DANCE_PREFIX}", ""))
dances[num] = str(frame.text[0])
except (ValueError, IndexError):
pass
result["dances"] = [dances[k] for k in sorted(dances.keys())]
def _read_vorbis(audio, result: dict):
"""FLAC og OGG/Opus bruger begge Vorbis Comments."""
tags = audio.tags
if not tags:
return
result["title"] = tags.get("title", [result["title"]])[0]
result["artist"] = tags.get("artist", [""])[0]
result["album"] = tags.get("album", [""])[0]
try:
result["bpm"] = int(tags.get("bpm", [0])[0])
except (ValueError, TypeError):
pass
# Danse gemmes som linedance_dance.1, linedance_dance.2 ...
dances = {}
for key, values in tags.items():
if key.lower().startswith(f"{VORBIS_DANCE_KEY}."):
try:
num = int(key.split(".")[-1])
dances[num] = values[0]
except (ValueError, IndexError):
pass
# Fallback: enkelt felt linedance_dance med komma-separeret liste
if not dances and VORBIS_DANCE_KEY in tags:
result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()]
return
result["dances"] = [dances[k] for k in sorted(dances.keys())]
def _read_m4a(audio, result: dict):
tags = audio.tags
if not tags:
return
if "\xa9nam" in tags:
result["title"] = str(tags["\xa9nam"][0])
if "\xa9ART" in tags:
result["artist"] = str(tags["\xa9ART"][0])
if "\xa9alb" in tags:
result["album"] = str(tags["\xa9alb"][0])
if "tmpo" in tags:
try:
result["bpm"] = int(tags["tmpo"][0])
except (ValueError, TypeError):
pass
# Danse gemmes som ----:LINEDANCE:DANCE — én værdi per dans
if M4A_DANCE_FREEFORM in tags:
result["dances"] = [
v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v)
for v in tags[M4A_DANCE_FREEFORM]
]
def _read_generic(audio, result: dict):
try:
easy = MutagenFile(result["local_path"], easy=True)
if easy and easy.tags:
result["title"] = easy.tags.get("title", [result["title"]])[0]
result["artist"] = easy.tags.get("artist", [""])[0]
result["album"] = easy.tags.get("album", [""])[0]
except Exception:
pass
# ── Skrivning ─────────────────────────────────────────────────────────────────
def write_dances(path: str | Path, dances: list[str]) -> bool:
"""
Skriver danse til filen hvis formatet understøtter det.
Returnerer True ved succes, False hvis formatet ikke understøtter det.
Kaster Exception ved fejl under skrivning.
"""
if not MUTAGEN_AVAILABLE:
return False
path = Path(path)
ext = path.suffix.lower()
if ext not in WRITABLE_DANCE_FORMATS:
return False
if ext == ".mp3":
return _write_mp3_dances(path, dances)
elif ext in (".flac", ".ogg", ".opus"):
return _write_vorbis_dances(path, dances)
elif ext in (".m4a", ".aac"):
return _write_m4a_dances(path, dances)
return False
def _write_mp3_dances(path: Path, dances: list[str]) -> bool:
try:
tags = ID3(str(path))
for key in [k for k in tags.keys() if TXXX_DANCE_PREFIX in k]:
del tags[key]
for i, name in enumerate(dances, start=1):
tags.add(TXXX(encoding=3, desc=f"{TXXX_DANCE_PREFIX}{i}", text=name))
tags.save(str(path))
return True
except Exception as e:
print(f"MP3 skrivefejl {path}: {e}")
return False
def _write_vorbis_dances(path: Path, dances: list[str]) -> bool:
try:
audio = MutagenFile(str(path), easy=False)
if audio is None or audio.tags is None:
return False
# Slet eksisterende danse-felter
keys_to_delete = [k for k in audio.tags.keys() if k.lower().startswith(f"{VORBIS_DANCE_KEY}.")]
for key in keys_to_delete:
del audio.tags[key]
# Skriv nye — ét felt per dans
for i, name in enumerate(dances, start=1):
audio.tags[f"{VORBIS_DANCE_KEY}.{i}"] = name
audio.save()
return True
except Exception as e:
print(f"Vorbis skrivefejl {path}: {e}")
return False
def _write_m4a_dances(path: Path, dances: list[str]) -> bool:
try:
audio = MP4(str(path))
audio.tags[M4A_DANCE_FREEFORM] = [
MP4FreeForm(name.encode("utf-8")) for name in dances
]
audio.save()
return True
except Exception as e:
print(f"M4A skrivefejl {path}: {e}")
return False
# ── Hurtig læsning af kun danse (uden fuld tag-scan) ─────────────────────────
def read_dances_from_file(path: str | Path) -> list[str]:
"""Læser kun danse fra en fil — hurtigere end fuld read_tags()."""
tags = read_tags(path)
return tags.get("dances", [])

View File

@@ -0,0 +1,14 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
sqlalchemy>=2.0.0
pymysql>=1.1.0
alembic>=1.13.0
bcrypt>=4.0.0
python-jose[cryptography]>=3.3.0
pydantic[email]>=2.0.0
pydantic-settings>=2.0.0
python-dotenv>=1.0.0
python-multipart>=0.0.9
aiosmtplib>=3.0.0
jinja2>=3.1.0
cryptography>=42.0.0

24
linedance-api/start.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
echo "Forbinder til database..."
for i in $(seq 1 30); do
python -c "
import pymysql, os, re
url = os.environ.get('DATABASE_URL', '')
m = re.match(r'mysql\+pymysql://([^:]+):([^@]+)@([^:/]+):?(\d+)?/(\w+)', url)
if not m:
exit(1)
user, password, host, port, db = m.groups()
port = int(port or 3306)
try:
conn = pymysql.connect(host=host, port=port, user=user, password=password, database=db)
conn.close()
print('Database OK')
exit(0)
except Exception as e:
print(f'Venter på database... ({e})')
exit(1)
" && break
sleep 2
done
exec uvicorn app.main:app --host 0.0.0.0 --port 8000

View File

@@ -0,0 +1,14 @@
@echo off
echo Starter LineDance API lokalt...
cd /d %~dp0
if not exist venv (
python -m venv venv
venv\Scripts\pip install -r requirements.txt
)
if not exist .env (
copy .env.example .env
echo.
echo VIGTIGT: Rediger .env med dine database-indstillinger!
pause
)
venv\Scripts\uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

View File

@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY public /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -0,0 +1,25 @@
server {
listen 8001;
server_name _;
root /usr/share/nginx/html;
index app.html;
location /api/ {
proxy_pass http://api:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /download/ {
alias /usr/share/nginx/html/download/;
add_header Content-Disposition "attachment";
autoindex off;
}
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/html text/css application/javascript;
}

View File

@@ -0,0 +1,586 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LineDance Player</title>
<meta name="description" content="Professionel afspiller til linedance-arrangører. Styr din danseliste, tag danse og del med holdet.">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,wght@0,300;0,400;0,500;0,700;1,400&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0c0d10;
--surface: #14161b;
--border: #24272f;
--accent: #e8a020;
--accent2: #c47a10;
--text: #eceef4;
--muted: #6b7080;
--green: #2ecc71;
--mono: 'DM Mono', monospace;
--sans: 'DM Sans', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { background: var(--bg); color: var(--text); font-family: var(--sans); line-height: 1.7; overflow-x: hidden; }
/* ── Nav ── */
nav {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
display: flex; align-items: center; padding: 0 2rem; height: 64px;
background: rgba(12,13,16,.92); backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.nav-logo { font-family: var(--mono); font-size: .95rem; letter-spacing: .06em; }
.nav-logo span { color: var(--accent); }
.nav-links { margin-left: auto; display: flex; gap: 2rem; align-items: center; }
.nav-links a { color: var(--muted); text-decoration: none; font-size: .9rem; transition: color .15s; }
.nav-links a:hover { color: var(--text); }
.lang-btn {
font-family: var(--mono); font-size: .75rem; padding: .3rem .7rem;
border: 1px solid var(--border); border-radius: 5px; background: none;
color: var(--muted); cursor: pointer; transition: all .15s;
}
.lang-btn:hover { border-color: var(--accent); color: var(--text); }
.lang-btn.active { border-color: var(--accent); color: var(--accent); }
/* ── Hero ── */
.hero {
min-height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 8rem 2rem 4rem; text-align: center;
position: relative; overflow: hidden;
}
.hero::before {
content: '';
position: absolute; top: 20%; left: 50%; transform: translateX(-50%);
width: 600px; height: 600px;
background: radial-gradient(circle, rgba(232,160,32,.07) 0%, transparent 70%);
pointer-events: none;
}
.hero-tag {
font-family: var(--mono); font-size: .72rem; letter-spacing: .2em;
text-transform: uppercase; color: var(--accent); margin-bottom: 1.5rem;
display: inline-flex; align-items: center; gap: .5rem;
}
.hero-tag::before, .hero-tag::after {
content: ''; display: block; width: 24px; height: 1px; background: var(--accent); opacity: .5;
}
h1 {
font-size: clamp(2.5rem, 7vw, 5rem);
font-weight: 700; line-height: 1.05; margin-bottom: 1.5rem;
letter-spacing: -.02em;
}
h1 em { color: var(--accent); font-style: normal; }
.hero-sub {
font-size: clamp(1rem, 2.5vw, 1.25rem); color: var(--muted);
max-width: 540px; margin: 0 auto 2.5rem;
}
.hero-btns { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: .5rem;
font-family: var(--sans); font-size: .95rem; font-weight: 600;
padding: .75rem 1.75rem; border-radius: 8px;
border: 1px solid var(--border); text-decoration: none;
transition: all .2s; cursor: pointer;
}
.btn.primary { background: var(--accent); border-color: var(--accent); color: #111; }
.btn.primary:hover { background: var(--accent2); border-color: var(--accent2); transform: translateY(-1px); }
.btn.secondary { background: transparent; color: var(--text); }
.btn.secondary:hover { background: var(--surface); border-color: var(--accent); }
.hero-note { font-size: .8rem; color: var(--muted); margin-top: 1rem; }
/* ── Screenshot mockup ── */
.hero-screen {
margin-top: 4rem; position: relative; max-width: 900px; width: 100%;
}
.screen-frame {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden;
box-shadow: 0 40px 80px rgba(0,0,0,.5);
}
.screen-bar {
background: #1a1c22; padding: .5rem 1rem;
display: flex; align-items: center; gap: .5rem;
border-bottom: 1px solid var(--border);
}
.screen-dot { width: 10px; height: 10px; border-radius: 50%; }
.screen-title { font-size: .75rem; color: var(--muted); margin: 0 auto; font-family: var(--mono); }
.screen-body {
display: grid; grid-template-columns: 1fr 1fr; min-height: 280px;
}
.screen-left { padding: 1.25rem; border-right: 1px solid var(--border); }
.screen-right { padding: 1.25rem; }
.screen-section { font-family: var(--mono); font-size: .6rem; letter-spacing: .12em; color: var(--muted); text-transform: uppercase; margin-bottom: .75rem; padding-bottom: .5rem; border-bottom: 1px solid var(--border); }
.screen-song { margin-bottom: .6rem; padding: .5rem .75rem; border-radius: 6px; background: rgba(232,160,32,.08); border: 1px solid rgba(232,160,32,.2); }
.screen-song .s-num { font-size: .65rem; color: var(--accent); font-family: var(--mono); }
.screen-song .s-title { font-size: .78rem; font-weight: 600; }
.screen-song .s-dance { font-size: .65rem; color: var(--accent); }
.screen-song-dim { margin-bottom: .4rem; padding: .4rem .75rem; border-radius: 6px; opacity: .45; }
.screen-song-dim .s-title { font-size: .75rem; }
.screen-song-dim .s-dance { font-size: .63rem; color: var(--muted); }
.screen-lib-item { display: flex; align-items: center; gap: .5rem; padding: .3rem 0; border-bottom: 1px solid var(--border); }
.screen-lib-item:last-child { border: none; }
.screen-lib-item .li-title { font-size: .72rem; flex: 1; }
.screen-lib-item .li-dance { font-size: .63rem; color: var(--accent); font-family: var(--mono); }
.screen-lib-item.selected { background: rgba(255,255,255,.04); border-radius: 4px; padding-left: .4rem; }
/* ── Features ── */
section { padding: 5rem 2rem; max-width: 1100px; margin: 0 auto; }
.section-tag { font-family: var(--mono); font-size: .7rem; letter-spacing: .18em; text-transform: uppercase; color: var(--accent); margin-bottom: 1rem; }
h2 { font-size: clamp(1.8rem, 4vw, 2.8rem); font-weight: 700; line-height: 1.15; margin-bottom: 1rem; letter-spacing: -.02em; }
h2 em { color: var(--accent); font-style: normal; }
.section-sub { color: var(--muted); font-size: 1.05rem; max-width: 540px; margin-bottom: 3rem; }
.features { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.25rem; }
.feature {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 1.5rem; transition: border-color .2s;
position: relative; overflow: hidden;
}
.feature::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: var(--accent); transform: scaleX(0); transition: transform .2s; transform-origin: left;
}
.feature:hover { border-color: rgba(232,160,32,.4); }
.feature:hover::before { transform: scaleX(1); }
.feature-icon { font-size: 1.5rem; margin-bottom: .75rem; }
.feature h3 { font-size: 1rem; font-weight: 600; margin-bottom: .4rem; }
.feature p { font-size: .88rem; color: var(--muted); line-height: 1.6; }
/* ── How it works ── */
.how-section { background: var(--surface); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.how-section section { padding: 5rem 2rem; }
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 2rem; }
.step { text-align: center; }
.step-num {
font-family: var(--mono); font-size: 2.5rem; font-weight: 500;
color: var(--accent); opacity: .3; line-height: 1; margin-bottom: .75rem;
}
.step h3 { font-size: .95rem; font-weight: 600; margin-bottom: .4rem; }
.step p { font-size: .83rem; color: var(--muted); }
/* ── Download ── */
.download-section { text-align: center; }
.download-cards { display: flex; gap: 1.25rem; justify-content: center; flex-wrap: wrap; margin-top: 2.5rem; }
.download-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 2rem 2.5rem; min-width: 220px;
transition: all .2s;
}
.download-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.download-card .platform { font-size: 2rem; margin-bottom: .75rem; }
.download-card h3 { font-size: 1rem; font-weight: 600; margin-bottom: .25rem; }
.download-card .version { font-family: var(--mono); font-size: .72rem; color: var(--muted); margin-bottom: 1.25rem; }
.download-card .coming-soon { font-size: .8rem; color: var(--muted); padding: .5rem 0; }
/* ── Guide ── */
.guide-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; align-items: start; }
@media (max-width: 700px) { .guide-grid { grid-template-columns: 1fr; } .screen-body { grid-template-columns: 1fr; } }
.guide-steps { display: flex; flex-direction: column; gap: 1.5rem; }
.guide-step { display: flex; gap: 1rem; }
.guide-num {
font-family: var(--mono); font-size: .75rem; font-weight: 500;
color: var(--accent); background: rgba(232,160,32,.1);
border: 1px solid rgba(232,160,32,.3); border-radius: 5px;
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
flex-shrink: 0; margin-top: .15rem;
}
.guide-step h4 { font-size: .92rem; font-weight: 600; margin-bottom: .2rem; }
.guide-step p { font-size: .84rem; color: var(--muted); }
.guide-visual {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden; position: sticky; top: 5rem;
}
.guide-visual-bar {
background: #1a1c22; padding: .4rem 1rem; border-bottom: 1px solid var(--border);
font-family: var(--mono); font-size: .65rem; color: var(--muted);
}
.guide-visual-body { padding: 1.25rem; }
.gv-row { display: flex; align-items: center; gap: .6rem; padding: .4rem 0; border-bottom: 1px solid var(--border); }
.gv-row:last-child { border: none; }
.gv-num { font-family: var(--mono); font-size: .65rem; color: var(--muted); width: 1.2rem; }
.gv-dance { font-size: .82rem; font-weight: 600; flex: 1; }
.gv-song { font-size: .72rem; color: var(--muted); }
.gv-status { font-size: .65rem; color: var(--green); }
/* ── Footer ── */
footer {
border-top: 1px solid var(--border); padding: 2rem;
text-align: center; font-size: .82rem; color: var(--muted);
}
footer a { color: var(--accent); text-decoration: none; }
/* ── Animations ── */
.fade-up { opacity: 0; transform: translateY(24px); transition: opacity .6s ease, transform .6s ease; }
.fade-up.visible { opacity: 1; transform: none; }
/* Hide by lang */
[data-lang="en"] { display: none; }
</style>
</head>
<body>
<nav>
<div class="nav-logo">LINE<span>DANCE</span> PLAYER</div>
<div class="nav-links">
<a href="#features" data-lang="da">Funktioner</a>
<a href="#features" data-lang="en" style="display:none">Features</a>
<a href="#guide" data-lang="da">Guide</a>
<a href="#guide" data-lang="en" style="display:none">Guide</a>
<a href="#download" data-lang="da">Download</a>
<a href="#download" data-lang="en" style="display:none">Download</a>
<a href="index.html" data-lang="da">Playlister</a>
<a href="index.html" data-lang="en" style="display:none">Playlists</a>
<button class="lang-btn active" id="btn-da" onclick="setLang('da')">DA</button>
<button class="lang-btn" id="btn-en" onclick="setLang('en')">EN</button>
</div>
</nav>
<!-- HERO -->
<div class="hero">
<div class="hero-tag" data-lang="da">Til linedance-arrangører</div>
<div class="hero-tag" data-lang="en">For linedance organizers</div>
<h1 data-lang="da">Styr din<br><em>danseliste</em></h1>
<h1 data-lang="en">Control your<br><em>dance list</em></h1>
<p class="hero-sub" data-lang="da">LineDance Player er et gratis afspilningsprogram til Windows der gør det nemt at styre musik, dans-tags og danselister til linedance-events.</p>
<p class="hero-sub" data-lang="en">LineDance Player is a free Windows application that makes it easy to manage music, dance tags and playlists for linedance events.</p>
<div class="hero-btns">
<a href="#download" class="btn primary" data-lang="da">⬇ Download til Windows</a>
<a href="#download" class="btn primary" data-lang="en">⬇ Download for Windows</a>
<a href="#features" class="btn secondary" data-lang="da">Se funktioner</a>
<a href="#features" class="btn secondary" data-lang="en">See features</a>
</div>
<p class="hero-note" data-lang="da">Gratis · Open source · Kræver Windows 10/11</p>
<p class="hero-note" data-lang="en">Free · Open source · Requires Windows 10/11</p>
<!-- Skærm-mockup -->
<div class="hero-screen fade-up">
<div class="screen-frame">
<div class="screen-bar">
<div class="screen-dot" style="background:#e74c3c"></div>
<div class="screen-dot" style="background:#f39c12"></div>
<div class="screen-dot" style="background:#2ecc71"></div>
<div class="screen-title">LineDance Player</div>
</div>
<div class="screen-body">
<div class="screen-left">
<div class="screen-section" data-lang="da">Danseliste — Tirsdag hold 1</div>
<div class="screen-section" data-lang="en">Dance list — Tuesday group 1</div>
<div class="screen-song">
<div class="s-num">▶ 3.</div>
<div class="s-title">Roll back the rug</div>
<div class="s-dance">Cut a Rug · Begynder</div>
</div>
<div class="screen-song-dim">
<div class="s-title">The boys and me</div>
<div class="s-dance">Cowboy Strut · Begynder</div>
</div>
<div class="screen-song-dim">
<div class="s-title">Mambo No. 5</div>
<div class="s-dance">Mambo No. 5 · Begynder</div>
</div>
<div class="screen-song-dim">
<div class="s-title">Achy Breaky Heart</div>
<div class="s-dance">Electric Slide · Absolut begynder</div>
</div>
</div>
<div class="screen-right">
<div class="screen-section" data-lang="da">Bibliotek — 185 sange</div>
<div class="screen-section" data-lang="en">Library — 185 songs</div>
<div class="screen-lib-item selected">
<div class="li-title">How Much Beer</div>
<div class="li-dance">How Much Beer - High beginner</div>
</div>
<div class="screen-lib-item">
<div class="li-title">The boys and me</div>
<div class="li-dance">Cowboy Strut - Begynder</div>
</div>
<div class="screen-lib-item">
<div class="li-title">Mama Tried</div>
<div class="li-dance">Mama Tried - High beginner</div>
</div>
<div class="screen-lib-item">
<div class="li-title">You Just Can't See Him from the Road</div>
<div class="li-dance">Sunset Road - Beginner</div>
</div>
<div class="screen-lib-item">
<div class="li-title">Risk It All</div>
<div class="li-dance">Risk It All Rumba - Improver</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- FEATURES -->
<section id="features">
<div class="section-tag" data-lang="da">Funktioner</div>
<div class="section-tag" data-lang="en">Features</div>
<h2 data-lang="da">Alt du har brug for<br><em>til dit event</em></h2>
<h2 data-lang="en">Everything you need<br><em>for your event</em></h2>
<p class="section-sub" data-lang="da">Designet specifikt til linedance-arrangører — fra musikbibliotek til storskærm.</p>
<p class="section-sub" data-lang="en">Designed specifically for linedance organizers — from music library to big screen.</p>
<div class="features">
<div class="feature fade-up">
<div class="feature-icon">🎵</div>
<h3 data-lang="da">Musikbibliotek</h3>
<h3 data-lang="en">Music library</h3>
<p data-lang="da">Scan dine mapper automatisk. MP3, FLAC, M4A og mere. BPM-analyse og dans-tags gemmes direkte i filen.</p>
<p data-lang="en">Automatically scan your folders. MP3, FLAC, M4A and more. BPM analysis and dance tags saved directly in the file.</p>
</div>
<div class="feature fade-up">
<div class="feature-icon">💃</div>
<h3 data-lang="da">Dans-tags</h3>
<h3 data-lang="en">Dance tags</h3>
<p data-lang="da">Tag hver sang med dans, niveau og koreograf. Tags synkroniseres til serveren og deles med andre arrangører.</p>
<p data-lang="en">Tag each song with dance, level and choreographer. Tags sync to the server and are shared with other organizers.</p>
</div>
<div class="feature fade-up">
<div class="feature-icon">📋</div>
<h3 data-lang="da">Danselister</h3>
<h3 data-lang="en">Dance playlists</h3>
<p data-lang="da">Opret og gem danselister. Del med andre arrangører. Kopiér public lister fra hjemmesiden.</p>
<p data-lang="en">Create and save dance playlists. Share with other organizers. Copy public lists from the website.</p>
</div>
<div class="feature fade-up">
<div class="feature-icon">🖥️</div>
<h3 data-lang="da">Storskærm</h3>
<h3 data-lang="en">Big screen</h3>
<p data-lang="da">Live-visning til storskærm og mobil. Vis aktuel dans og næste på programmet — opdateres automatisk.</p>
<p data-lang="en">Live display for big screen and mobile. Shows current dance and what's next — updates automatically.</p>
</div>
<div class="feature fade-up">
<div class="feature-icon">🎧</div>
<h3 data-lang="da">To lydudgange</h3>
<h3 data-lang="en">Two audio outputs</h3>
<p data-lang="da">Hoved-afspiller til salen og preview-afspiller til høretelefonerne. Hør næste sang inden du starter den.</p>
<p data-lang="en">Main player for the hall and preview player for headphones. Listen to the next song before you start it.</p>
</div>
<div class="feature fade-up">
<div class="feature-icon">☁️</div>
<h3 data-lang="da">Cloud-sync</h3>
<h3 data-lang="en">Cloud sync</h3>
<p data-lang="da">Synkronisér bibliotek, tags og playlister til linedanceplayer.dk. Fungerer offline og synkroniserer når du er online.</p>
<p data-lang="en">Sync library, tags and playlists to linedanceplayer.dk. Works offline and syncs when you're online.</p>
</div>
</div>
</section>
<!-- HOW IT WORKS -->
<div class="how-section">
<section>
<div class="section-tag" data-lang="da">Sådan virker det</div>
<div class="section-tag" data-lang="en">How it works</div>
<h2 style="margin-bottom:2.5rem" data-lang="da">Klar på <em>få minutter</em></h2>
<h2 style="margin-bottom:2.5rem" data-lang="en">Ready in <em>minutes</em></h2>
<div class="steps">
<div class="step fade-up">
<div class="step-num">01</div>
<h3 data-lang="da">Download og installer</h3>
<h3 data-lang="en">Download and install</h3>
<p data-lang="da">Kør installationsprogrammet. VLC installeres automatisk hvis det mangler.</p>
<p data-lang="en">Run the installer. VLC is installed automatically if missing.</p>
</div>
<div class="step fade-up">
<div class="step-num">02</div>
<h3 data-lang="da">Tilføj musikmappe</h3>
<h3 data-lang="en">Add music folder</h3>
<p data-lang="da">Peg på din musikmappe — appen scanner automatisk og finder alle sange.</p>
<p data-lang="en">Point to your music folder — the app scans automatically and finds all songs.</p>
</div>
<div class="step fade-up">
<div class="step-num">03</div>
<h3 data-lang="da">Tag dine sange</h3>
<h3 data-lang="en">Tag your songs</h3>
<p data-lang="da">Klik på "Danse" ved siden af en sang og tildel dans, niveau og koreograf.</p>
<p data-lang="en">Click "Dance" next to a song and assign dance, level and choreographer.</p>
</div>
<div class="step fade-up">
<div class="step-num">04</div>
<h3 data-lang="da">Byg din danseliste</h3>
<h3 data-lang="en">Build your dance list</h3>
<p data-lang="da">Træk sange ind i danselisten. Gem og del med dit hold.</p>
<p data-lang="en">Drag songs into the dance list. Save and share with your team.</p>
</div>
<div class="step fade-up">
<div class="step-num">05</div>
<h3 data-lang="da">Start event</h3>
<h3 data-lang="en">Start event</h3>
<p data-lang="da">Tryk "START EVENT" og styr afspilningen. Storskærmen opdateres automatisk.</p>
<p data-lang="en">Press "START EVENT" and control playback. The big screen updates automatically.</p>
</div>
</div>
</section>
</div>
<!-- GUIDE -->
<section id="guide">
<div class="section-tag" data-lang="da">Brugerguide</div>
<div class="section-tag" data-lang="en">User guide</div>
<h2 data-lang="da">Danselisten <em>under eventet</em></h2>
<h2 data-lang="en">The dance list <em>during the event</em></h2>
<p class="section-sub" data-lang="da">Når eventet starter styrer du alt fra ét vindue.</p>
<p class="section-sub" data-lang="en">When the event starts you control everything from one window.</p>
<div class="guide-grid">
<div class="guide-steps">
<div class="guide-step fade-up">
<div class="guide-num">1</div>
<div>
<h4 data-lang="da">Vælg afspilningstilstand</h4>
<h4 data-lang="en">Choose playback mode</h4>
<p data-lang="da">Manuel, auto-demo eller auto-play. Auto-demo afspiller en forsmag på næste sang inden den starter.</p>
<p data-lang="en">Manual, auto-demo or auto-play. Auto-demo plays a preview of the next song before it starts.</p>
</div>
</div>
<div class="guide-step fade-up">
<div class="guide-num">2</div>
<div>
<h4 data-lang="da">Tryk START EVENT</h4>
<h4 data-lang="en">Press START EVENT</h4>
<p data-lang="da">Den første sang indlæses klar. Tryk ▶ for at starte musikken.</p>
<p data-lang="en">The first song is loaded and ready. Press ▶ to start the music.</p>
</div>
</div>
<div class="guide-step fade-up">
<div class="guide-num">3</div>
<div>
<h4 data-lang="da">Følg listen</h4>
<h4 data-lang="en">Follow the list</h4>
<p data-lang="da">Orange = spiller. Blå = næste. Grøn = afspillet. Brug højreklik til at springe over eller ændre status.</p>
<p data-lang="en">Orange = playing. Blue = next. Green = played. Right-click to skip or change status.</p>
</div>
</div>
<div class="guide-step fade-up">
<div class="guide-num">4</div>
<div>
<h4 data-lang="da">Storskærm til deltagerne</h4>
<h4 data-lang="en">Big screen for participants</h4>
<p data-lang="da">Åbn storskærm-linket på en tablet eller TV. Viser aktuel dans og resten af programmet live.</p>
<p data-lang="en">Open the big screen link on a tablet or TV. Shows current dance and the rest of the program live.</p>
</div>
</div>
</div>
<div class="guide-visual fade-up">
<div class="guide-visual-bar">Danseliste — Tirsdag hold 1 · 3/8 afspillet</div>
<div class="guide-visual-body">
<div class="gv-row" style="opacity:.4">
<span class="gv-num">1</span>
<span class="gv-dance">Cut a Rug</span>
<span class="gv-song">Angeleyes</span>
<span class="gv-status"></span>
</div>
<div class="gv-row" style="opacity:.4">
<span class="gv-num">2</span>
<span class="gv-dance">Cowboy Strut</span>
<span class="gv-song">Waterloo</span>
<span class="gv-status"></span>
</div>
<div class="gv-row" style="background:rgba(232,160,32,.08);border-radius:6px;padding:.3rem .5rem;border:1px solid rgba(232,160,32,.2)">
<span class="gv-num" style="color:var(--accent)"></span>
<span class="gv-dance" style="color:var(--accent)">Electric Slide</span>
<span class="gv-song">Dancing Queen</span>
<span class="gv-status"></span>
</div>
<div class="gv-row" style="color:var(--muted)">
<span class="gv-num">4</span>
<span class="gv-dance">Mambo No. 5</span>
<span class="gv-song">Gimme! Gimme!</span>
<span class="gv-status"></span>
</div>
<div class="gv-row" style="color:var(--muted)">
<span class="gv-num">5</span>
<span class="gv-dance">Boot Scootin'</span>
<span class="gv-song">Fernando</span>
<span class="gv-status"></span>
</div>
<div class="gv-row" style="color:var(--muted)">
<span class="gv-num">6</span>
<span class="gv-dance">Tush Push</span>
<span class="gv-song">Super Trouper</span>
<span class="gv-status"></span>
</div>
</div>
</div>
</div>
</section>
<!-- DOWNLOAD -->
<div class="how-section">
<section id="download" class="download-section">
<div class="section-tag" data-lang="da">Download</div>
<div class="section-tag" data-lang="en">Download</div>
<h2 data-lang="da">Kom i gang <em>i dag</em></h2>
<h2 data-lang="en">Get started <em>today</em></h2>
<p class="section-sub" style="margin:0 auto" data-lang="da">Gratis at downloade og bruge. Opret en konto for at synkronisere og dele.</p>
<p class="section-sub" style="margin:0 auto" data-lang="en">Free to download and use. Create an account to sync and share.</p>
<div class="download-cards">
<div class="download-card">
<div class="platform">🪟</div>
<h3>Windows</h3>
<div class="version" id="win-version">Version 1.0 · 64-bit</div>
<a href="/download/LineDancePlayer-Setup.exe" class="btn primary" style="width:100%;justify-content:center" data-lang="da">⬇ Download .exe</a>
<a href="/download/LineDancePlayer-Setup.exe" class="btn primary" style="width:100%;justify-content:center" data-lang="en">⬇ Download .exe</a>
<p style="font-size:.72rem;color:var(--muted);margin-top:.75rem" data-lang="da">Kræver Windows 10/11 · VLC inkluderet</p>
<p style="font-size:.72rem;color:var(--muted);margin-top:.75rem" data-lang="en">Requires Windows 10/11 · VLC included</p>
</div>
<div class="download-card">
<div class="platform">🍎</div>
<h3>macOS</h3>
<div class="version"></div>
<div class="coming-soon" data-lang="da">Kommer senere</div>
<div class="coming-soon" data-lang="en">Coming soon</div>
</div>
<div class="download-card">
<div class="platform">🐧</div>
<h3>Linux</h3>
<div class="version"></div>
<div class="coming-soon" data-lang="da">Klon og kør fra kildekode</div>
<div class="coming-soon" data-lang="en">Clone and run from source</div>
<a href="https://github.com" class="btn secondary" style="width:100%;justify-content:center;margin-top:.75rem;font-size:.82rem">GitHub →</a>
</div>
</div>
</section>
</div>
<footer>
<p>LineDance Player · <a href="index.html" data-lang="da">Playlister</a><a href="index.html" data-lang="en" style="display:none">Playlists</a> · <a href="live.html" data-lang="da">Storskærm</a><a href="live.html" data-lang="en" style="display:none">Live screen</a></p>
</footer>
<script>
// ── Sprog ──────────────────────────────────────────────────────────────────────
let lang = localStorage.getItem('ld_lang') || 'da';
function setLang(l) {
lang = l;
localStorage.setItem('ld_lang', l);
document.querySelectorAll('[data-lang]').forEach(el => {
el.style.display = el.dataset.lang === l ? '' : 'none';
});
document.getElementById('btn-da').classList.toggle('active', l === 'da');
document.getElementById('btn-en').classList.toggle('active', l === 'en');
document.documentElement.lang = l;
}
setLang(lang);
// ── Scroll animations ──────────────────────────────────────────────────────────
const observer = new IntersectionObserver(entries => {
entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible'); });
}, { threshold: 0.1 });
document.querySelectorAll('.fade-up').forEach(el => observer.observe(el));
</script>
</body>
</html>

View File

@@ -0,0 +1 @@
# Placer LineDancePlayer-Setup.exe her

View File

@@ -0,0 +1,667 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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 {
--bg: #0e0f11;
--surface: #16181c;
--border: #2a2d35;
--accent: #e8a020;
--accent2: #c47a10;
--text: #e8eaf0;
--muted: #6b7080;
--green: #2ecc71;
--red: #e74c3c;
--mono: 'DM Mono', monospace;
--sans: 'DM Sans', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: var(--sans); min-height: 100vh; }
header {
border-bottom: 1px solid var(--border);
padding: 0 2rem; display: flex; align-items: center; gap: 1.5rem;
height: 60px; position: sticky; top: 0; background: var(--bg); z-index: 100;
}
.logo { font-family: var(--mono); font-size: 1rem; letter-spacing: .05em; }
.logo span { color: var(--accent); }
nav { margin-left: auto; display: flex; gap: .75rem; align-items: center; }
#user-label { font-size: .85rem; color: var(--muted); }
.btn {
font-family: var(--sans); font-size: .85rem; font-weight: 500;
padding: .4rem 1rem; border-radius: 6px; border: 1px solid var(--border);
background: transparent; color: var(--text); cursor: pointer;
transition: all .15s; text-decoration: none;
display: inline-flex; align-items: center; gap: .4rem;
}
.btn:hover { background: var(--surface); border-color: var(--accent); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #111; font-weight: 700; }
.btn.primary:hover { background: var(--accent2); }
.btn.danger { border-color: var(--red); color: var(--red); }
.btn.danger:hover { background: rgba(231,76,60,.1); }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn.sm { font-size: .75rem; padding: .25rem .6rem; }
.tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 2rem; max-width: 900px; margin: 0 auto; }
.tab {
font-size: .85rem; font-weight: 500; padding: .75rem 1.25rem;
color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent;
margin-bottom: -1px; transition: all .15s;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.hero { padding: 3rem 2rem 2rem; max-width: 900px; margin: 0 auto; }
.hero h1 { font-size: clamp(1.8rem,4vw,3rem); font-weight: 700; line-height: 1.1; margin-bottom: .75rem; }
.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;
color: var(--muted); text-transform: uppercase;
margin-bottom: 1.25rem; padding-bottom: .6rem; border-bottom: 1px solid var(--border);
}
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1rem; }
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 1.25rem; transition: all .2s; position: relative; overflow: hidden;
}
.card.clickable { cursor: pointer; }
.card.clickable::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: var(--accent); transform: scaleX(0); transition: transform .2s; transform-origin: left;
}
.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;
}
.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); }
.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;
background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
z-index: 200; align-items: center; justify-content: center; padding: 2rem;
}
#detail.open { display: flex; }
.detail-box {
background: var(--surface); border: 1px solid var(--border);
border-radius: 14px; width: 100%; max-width: 540px; max-height: 80vh;
display: flex; flex-direction: column; overflow: hidden; animation: fadeUp .25s ease;
}
.detail-header { padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border); display: flex; align-items: flex-start; gap: 1rem; }
.detail-header-text { flex: 1; min-width: 0; }
.detail-header h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: .2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.detail-header p { font-size: .82rem; color: var(--muted); }
.detail-songs { flex: 1; overflow-y: auto; padding: .4rem 0; }
.detail-songs::-webkit-scrollbar { width: 4px; }
.detail-songs::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.song-row { display: flex; align-items: center; gap: .75rem; padding: .55rem 1.5rem; transition: background .1s; }
.song-row:hover { background: rgba(255,255,255,.03); }
.song-num { font-family: var(--mono); font-size: .72rem; color: var(--muted); width: 1.5rem; text-align: right; flex-shrink: 0; }
.song-info { flex: 1; min-width: 0; }
.song-title { font-size: .88rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.song-artist { font-size: .76rem; color: var(--muted); }
.song-dance { font-family: var(--mono); font-size: .7rem; color: var(--accent); flex-shrink: 0; }
.detail-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; gap: .6rem; justify-content: flex-end; }
#login-modal {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
z-index: 300; align-items: center; justify-content: center;
}
#login-modal.open { display: flex; }
.login-box { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 2rem; width: 100%; max-width: 360px; animation: fadeUp .25s ease; }
.login-box h3 { font-size: 1.05rem; margin-bottom: 1.25rem; }
.form-row { margin-bottom: .9rem; }
.form-row label { display: block; font-size: .78rem; color: var(--muted); margin-bottom: .35rem; }
.form-row input {
width: 100%; background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; padding: .45rem .7rem; color: var(--text);
font-family: var(--sans); font-size: .88rem; outline: none; transition: border-color .15s;
}
.form-row input:focus { border-color: var(--accent); }
.msg { font-size: .8rem; padding: .5rem .7rem; border-radius: 6px; margin-bottom: .9rem; }
.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); } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.fade-in { animation: fadeUp .25s ease; }
</style>
</head>
<body>
<header>
<div class="logo">LINE<span>DANCE</span> PLAYER</div>
<nav>
<span id="user-label"></span>
<button class="btn" id="btn-login">Log ind</button>
<button class="btn" id="btn-logout" style="display:none">Log ud</button>
</nav>
</header>
<div class="tabs">
<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">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="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 danselister...</div>
</div>
</div>
<div id="pane-mine" style="display:none">
<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>
<div id="detail">
<div class="detail-box">
<div class="detail-header">
<div class="detail-header-text">
<h2 id="d-title"></h2>
<p id="d-meta"></p>
</div>
<button class="btn sm" id="btn-close-detail"></button>
</div>
<div class="detail-songs" id="d-songs"></div>
<div class="detail-footer">
<button class="btn" id="btn-close-detail2">Luk</button>
<button class="btn primary" id="btn-copy">Kopiér til min konto</button>
</div>
</div>
</div>
<div id="qr-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);backdrop-filter:blur(4px);z-index:300;align-items:center;justify-content:center;">
<div style="background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:2rem;width:100%;max-width:340px;text-align:center;animation:fadeUp .25s ease;">
<h3 id="qr-title" style="font-size:1rem;margin-bottom:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></h3>
<canvas id="qr-canvas" style="margin:0 auto 1rem;display:block;border-radius:8px;"></canvas>
<div id="qr-url" style="font-size:.72rem;color:var(--muted);word-break:break-all;margin-bottom:1.25rem;"></div>
<div style="display:flex;gap:.6rem;justify-content:center;">
<button class="btn sm" onclick="copyLiveUrl()">Kopiér link</button>
<button class="btn sm" onclick="document.getElementById('qr-modal').style.display='none'">Luk</button>
</div>
<div id="copy-msg" style="font-size:.75rem;color:var(--green);margin-top:.6rem;height:1rem;"></div>
</div>
</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>
<div class="form-row"><label>Adgangskode</label><input type="password" id="inp-pass" placeholder="••••••••"></div>
<div style="display:flex;gap:.6rem;justify-content:flex-end;margin-top:1rem">
<button class="btn" id="btn-cancel-login">Annuller</button>
<button class="btn primary" id="btn-do-login">Log ind</button>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrious/4.0.2/qrious.min.js"></script>
<script>
const API = '/api';
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' : '';
document.getElementById('btn-logout').style.display = token ? '' : 'none';
document.getElementById('tab-mine').style.display = token ? '' : 'none';
document.getElementById('user-label').textContent = token ? username : '';
if (!token && currentTab === 'mine') switchTab('public');
}
document.getElementById('btn-login').onclick = () =>
document.getElementById('login-modal').classList.add('open');
document.getElementById('btn-cancel-login').onclick = () =>
document.getElementById('login-modal').classList.remove('open');
document.getElementById('btn-logout').onclick = () => {
token = ''; username = '';
localStorage.removeItem('ld_token'); localStorage.removeItem('ld_user');
updateAuthUI();
};
document.getElementById('inp-pass').onkeydown = e => {
if (e.key === 'Enter') document.getElementById('btn-do-login').click();
};
document.getElementById('btn-do-login').onclick = async () => {
const user = document.getElementById('inp-user').value.trim();
const pass = document.getElementById('inp-pass').value;
const msg = document.getElementById('login-msg');
msg.innerHTML = '';
try {
const fd = new FormData();
fd.append('username', user); fd.append('password', pass);
const r = await fetch(`${API}/auth/login`, { method: 'POST', body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Login fejlede');
token = d.access_token; username = user;
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>`;
}
};
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(t =>
t.classList.toggle('active', t.dataset.tab === 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 = '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>danselister</em>';
document.getElementById('hero-sub').textContent = 'Administrér dine danselister.';
}
}
document.querySelectorAll('.tab').forEach(t => t.onclick = () => switchTab(t.dataset.tab));
// ── 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');
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">offentlig</span>
</div>
${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 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>';
try {
const r = await fetch(`${API}/projects/my`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!r.ok) throw new Error('Ikke autoriseret');
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 danselister.<br>${e.message}</div>`;
}
}
async function toggleVis(id, current) {
const newVis = current === 'public' ? 'private' : 'public';
try {
const r = await fetch(`${API}/sharing/playlists/${id}/visibility?visibility=${newVis}`, {
method: 'PATCH', headers: { 'Authorization': `Bearer ${token}` }
});
if (!r.ok) throw new Error('Fejl');
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' : '';
document.getElementById('detail').classList.add('open');
document.getElementById('d-songs').innerHTML = '<div class="empty"><div class="spinner"></div></div>';
try {
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const r = await fetch(`${API}/sharing/playlists/${id}`, { headers });
const p = await r.json();
document.getElementById('d-title').textContent = p.name;
document.getElementById('d-meta').textContent = `${p.songs.length} sange${p.owner ? ' · @ '+p.owner : ''}`;
document.getElementById('d-songs').innerHTML = p.songs.length
? p.songs.map((s,i) => `
<div class="song-row">
<span class="song-num">${i+1}</span>
<div class="song-info">
<div class="song-title">${esc(s.title)}</div>
<div class="song-artist">${esc(s.artist)}</div>
</div>
${s.dance_override ? `<span class="song-dance">${esc(s.dance_override)}</span>` : ''}
</div>`).join('')
: '<div class="empty">Ingen sange.</div>';
} catch(e) {
document.getElementById('d-songs').innerHTML = '<div class="empty">Kunne ikke hente detaljer.</div>';
}
}
function closeDetail() {
document.getElementById('detail').classList.remove('open');
currentPlaylistId = null;
}
document.getElementById('btn-close-detail').onclick = closeDetail;
document.getElementById('btn-close-detail2').onclick = closeDetail;
document.getElementById('detail').onclick = e => {
if (e.target === document.getElementById('detail')) closeDetail();
};
document.getElementById('btn-copy').onclick = async () => {
if (!token) { document.getElementById('login-modal').classList.add('open'); return; }
const btn = document.getElementById('btn-copy');
btn.disabled = true; btn.textContent = 'Kopierer...';
try {
const r = await fetch(`${API}/sharing/playlists/${currentPlaylistId}/copy`, {
method: 'POST', headers: { 'Authorization': `Bearer ${token}` }
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Fejl');
btn.textContent = '✓ Kopieret!'; btn.style.background = 'var(--green)';
setTimeout(() => { btn.textContent = 'Kopiér til min konto'; btn.style.background = ''; btn.disabled = false; }, 2500);
loadMyPlaylists();
} catch(e) { btn.textContent = '⚠ ' + e.message; btn.disabled = false; }
};
// ── QR ───────────────────────────────────────────────────────────────────────
let currentQRUrl = '';
function showQR(id, name) {
const url = `${location.protocol}//${location.host}/live.html?id=${id}`;
currentQRUrl = url;
document.getElementById('qr-title').textContent = name;
document.getElementById('qr-url').textContent = url;
document.getElementById('copy-msg').textContent = '';
document.getElementById('qr-modal').style.display = 'flex';
const canvas = document.getElementById('qr-canvas');
if (window.QRious) {
new QRious({ element: canvas, value: url, size: 220, backgroundAlpha: 0, foreground: '#eceef4' });
} else {
canvas.style.display = 'none';
}
}
function copyLiveUrl() {
navigator.clipboard.writeText(currentQRUrl).then(() => {
const msg = document.getElementById('copy-msg');
msg.textContent = '✓ Kopieret!';
setTimeout(() => msg.textContent = '', 2000);
});
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
updateAuthUI();
loadPublicPlaylists();
if (token) loadMyPlaylists();
</script>
</body>
</html>

View File

@@ -0,0 +1,529 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LineDance — Live</title>
<style>
:root {
--bg: #0a0b0d;
--surface: #13151a;
--border: #252830;
--accent: #e8a020;
--text: #eceef4;
--muted: #60687a;
--green: #27ae60;
}
.light {
--bg: #f5f4ef;
--surface: #ffffff;
--border: #dddbd3;
--accent: #c47a10;
--text: #1a1c22;
--muted: #7a7a6a;
--green: #1e8449;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg); color: var(--text);
font-family: 'Segoe UI', system-ui, sans-serif;
min-height: 100vh; display: flex; flex-direction: column;
transition: background .3s, color .3s;
}
/* ── Header ── */
header {
padding: .55rem 1.25rem;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.logo { font-size: .75rem; letter-spacing: .1em; color: var(--muted); font-weight: 600; }
.logo b { color: var(--accent); }
.header-right { display: flex; align-items: center; gap: .75rem; }
/* Countdown ring */
.countdown-wrap { display: flex; align-items: center; gap: .35rem; }
.countdown-svg { width: 22px; height: 22px; flex-shrink: 0; }
.countdown-bg { fill: none; stroke: var(--border); stroke-width: 3; }
.countdown-arc {
fill: none; stroke: var(--accent); stroke-width: 3;
stroke-linecap: round; stroke-dasharray: 56.5; stroke-dashoffset: 0;
transform: rotate(-90deg); transform-origin: 50% 50%;
transition: stroke-dashoffset .9s linear;
}
.countdown-num { font-size: .68rem; color: var(--muted); min-width: 1rem; }
.live-dot { display: flex; align-items: center; gap: .35rem; font-size: .7rem; color: var(--muted); }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.dot.active { background: var(--green); animation: pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
.theme-btn {
background: none; border: 1px solid var(--border); border-radius: 5px;
padding: .2rem .5rem; cursor: pointer; color: var(--muted); font-size: .72rem;
transition: all .15s;
}
.theme-btn:hover { border-color: var(--accent); color: var(--text); }
/* ── Now playing ── */
#now-playing { padding: 1.5rem 1.25rem 1rem; flex-shrink: 0; }
.np-label {
font-size: .62rem; letter-spacing: .18em; text-transform: uppercase;
color: var(--accent); font-weight: 700; margin-bottom: .4rem;
}
/* Dans primær — stor */
.np-dance {
font-size: clamp(2.2rem, 8vw, 5rem);
font-weight: 800; line-height: 1; color: var(--text);
letter-spacing: -.02em; margin-bottom: .3rem;
}
.np-dance:empty::before { content: '—'; color: var(--muted); }
/* Nummer under dans */
.np-number {
font-size: clamp(.75rem, 2vw, 1rem);
color: var(--muted); margin-bottom: .2rem;
font-variant-numeric: tabular-nums;
}
.np-number b { color: var(--text); }
/* Sang + artist sekundær */
.np-song {
font-size: clamp(.85rem, 2.2vw, 1.2rem);
color: var(--muted); margin-bottom: .9rem;
}
.np-song span { color: var(--text); }
/* Estimeret tid til næste */
.np-eta {
font-size: clamp(.72rem, 1.8vw, .9rem);
color: var(--muted); margin-bottom: .5rem;
display: flex; align-items: center; gap: .4rem;
}
.np-eta b { color: var(--accent); }
/* ── Fremgangsbar — NEDERST på now-playing ── */
.progress-section { margin-top: .5rem; }
.progress-bar { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: .35rem; }
.progress-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width .4s; width: 0%; }
.progress-text {
font-size: .7rem; color: var(--muted);
display: flex; justify-content: space-between;
}
/* ── Divider ── */
.divider { border: none; border-top: 1px solid var(--border); margin: .4rem 1.25rem; flex-shrink: 0; }
.next-label {
padding: .4rem 1.25rem .2rem;
font-size: .62rem; letter-spacing: .15em; text-transform: uppercase;
color: var(--muted); font-weight: 600; flex-shrink: 0;
}
/* ── Song list ── */
#song-list { flex: 1; overflow-y: auto; padding: .2rem .75rem .5rem; }
#song-list::-webkit-scrollbar { width: 3px; }
#song-list::-webkit-scrollbar-thumb { background: var(--border); }
.song-item {
display: flex; align-items: center; gap: .6rem;
padding: .4rem .5rem; border-radius: 7px; margin-bottom: 1px;
}
.song-item.playing {
background: rgba(232,160,32,.09);
border: 1px solid rgba(232,160,32,.22);
}
.song-item.played { opacity: .4; }
.song-item.skipped { opacity: .25; }
.song-num { font-size: .68rem; color: var(--muted); width: 1.4rem; text-align: right; flex-shrink: 0; font-variant-numeric: tabular-nums; }
.song-check { width: 1rem; flex-shrink: 0; text-align: center; font-size: .72rem; color: var(--muted); }
.song-item.played .song-check { color: var(--green); }
.song-info { flex: 1; min-width: 0; }
.song-dance-name {
font-size: clamp(.8rem, 2vw, .95rem); font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.song-title-sm {
font-size: clamp(.68rem, 1.6vw, .8rem); color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.song-eta-sm {
font-size: .65rem; color: var(--muted); flex-shrink: 0;
font-variant-numeric: tabular-nums; text-align: right; min-width: 2.5rem;
}
/* ── States ── */
#empty, #no-event, #picker {
display: none; flex-direction: column; align-items: center;
justify-content: center; flex: 1; gap: .75rem;
color: var(--muted); text-align: center; padding: 2rem;
}
.spinner { width: 26px; height: 26px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.big-text { font-size: 1rem; color: var(--text); }
.playlist-pick-btn {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: .75rem 1.1rem; width: 100%; max-width: 320px; text-align: left;
cursor: pointer; color: var(--text); font-family: inherit; transition: all .15s;
}
.playlist-pick-btn:hover { border-color: var(--accent); }
.playlist-pick-btn strong { display: block; font-size: .88rem; margin-bottom: .15rem; }
.playlist-pick-btn span { font-size: .74rem; color: var(--muted); }
/* ── Footer ── */
footer {
padding: .45rem 1.25rem; border-top: 1px solid var(--border);
font-size: .67rem; color: var(--muted);
display: flex; justify-content: space-between; align-items: center; flex-shrink: 0;
}
@media (min-width: 600px) {
#now-playing, .next-label, footer { padding-left: 2rem; padding-right: 2rem; }
.divider { margin-left: 2rem; margin-right: 2rem; }
#song-list { padding-left: 1.25rem; padding-right: 1.25rem; }
header { padding-left: 2rem; padding-right: 2rem; }
}
</style>
</head>
<body>
<header>
<div class="logo">LINE<b>DANCE</b></div>
<div class="header-right">
<div class="countdown-wrap" title="Opdateres om...">
<svg class="countdown-svg" viewBox="0 0 20 20">
<circle class="countdown-bg" cx="10" cy="10" r="9"/>
<circle class="countdown-arc" id="countdown-arc" cx="10" cy="10" r="9"/>
</svg>
<span class="countdown-num" id="countdown-num">5</span>
</div>
<div class="live-dot">
<div class="dot" id="live-dot"></div>
<span id="live-label">Forbinder...</span>
</div>
<button class="theme-btn" id="theme-btn" onclick="toggleTheme()">☀ Lyst</button>
</div>
</header>
<div id="picker">
<h2 style="font-size:1rem;color:var(--text)">Vælg playliste</h2>
<p style="max-width:300px">Brug <code>?id=PLAYLIST_ID</code> i URL eller vælg herunder</p>
<div id="picker-list"></div>
</div>
<div id="no-event">
<div style="font-size:2.5rem;opacity:.2">🎵</div>
<div class="big-text">Ingen aktiv playliste</div>
<div>Åbn LineDance Player og start et event</div>
</div>
<div id="empty"><div class="spinner"></div></div>
<div id="now-playing" style="display:none">
<div class="np-label" id="np-label">▶ Spiller nu</div>
<div class="np-dance" id="np-dance"></div>
<div class="np-number" id="np-number"></div>
<div class="np-song" id="np-song"></div>
<div class="np-eta" id="np-eta" style="display:none">
⏱ Næste dans starter ca. kl. <b id="np-eta-val"></b>
</div>
<!-- Fremgangsbar NEDERST -->
<div class="progress-section">
<div class="progress-bar"><div class="progress-fill" id="np-progress"></div></div>
<div class="progress-text">
<span id="np-played">0 afspillet</span>
<span id="np-remaining">0 tilbage</span>
</div>
</div>
</div>
<hr class="divider" id="divider" style="display:none">
<div class="next-label" id="next-label" style="display:none">Kommende</div>
<div id="song-list"></div>
<footer>
<span id="pl-name"></span>
<span id="last-updated"></span>
</footer>
<script>
const API = '/api';
const POLL_SECS = 5;
const BETWEEN_DANCE_SEC = 30; // pause mellem danse (bruges i estimat)
const WORKSHOP_MIN_SEC = 20 * 60; // default workshoptid hvis ingen varighed
let playlistId = new URLSearchParams(location.search).get('id');
let countdownTimer = null;
let countdownVal = POLL_SECS;
let dark = localStorage.getItem('ld_live_theme') !== 'light';
// ── Tema ──────────────────────────────────────────────────────────────────────
function applyTheme() {
document.body.classList.toggle('light', !dark);
document.getElementById('theme-btn').textContent = dark ? '☀ Lyst' : '● Mørkt';
}
function toggleTheme() {
dark = !dark;
localStorage.setItem('ld_live_theme', dark ? 'dark' : 'light');
applyTheme();
}
applyTheme();
// ── Countdown ring ────────────────────────────────────────────────────────────
const CIRC = 56.5;
function startCountdown() {
countdownVal = POLL_SECS;
updateCountdown();
clearInterval(countdownTimer);
countdownTimer = setInterval(() => {
countdownVal--;
if (countdownVal <= 0) countdownVal = POLL_SECS;
updateCountdown();
}, 1000);
}
function updateCountdown() {
const frac = countdownVal / POLL_SECS;
document.getElementById('countdown-arc').style.strokeDashoffset = CIRC * (1 - frac);
document.getElementById('countdown-num').textContent = countdownVal;
}
// ── Hjælpefunktioner ─────────────────────────────────────────────────────────
function fmt(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmtDur(secs) {
if (!secs || secs <= 0) return '';
const m = Math.floor(secs / 60);
const s = Math.round(secs % 60);
if (m === 0) return `${s}s`;
return s === 0 ? `${m} min` : `${m}:${String(s).padStart(2,'0')}`;
}
function fmtClock(date) {
return date.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' });
}
function fmtTime(d) {
if (!d) return '';
return new Date(d).toLocaleTimeString('da-DK', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
}
// Dansenavn — workshop vises som "Workshop"
function getDanceName(song) {
if (song.is_workshop) return 'Workshop';
return song.dance || '';
}
// Estimeret varighed for en sang (bruges til tidsestimat)
function songDuration(song) {
if (song.is_workshop) {
return song.duration > 0 ? song.duration : WORKSHOP_MIN_SEC;
}
return song.duration > 0 ? song.duration : 210; // default 3:30
}
// Beregn estimeret sekunder til en bestemt index
function estimateSecsTo(songs, fromIdx) {
let secs = 0;
for (let i = 0; i < fromIdx; i++) {
const s = songs[i];
if (s.status === 'played' || s.status === 'skipped') continue;
secs += songDuration(s);
secs += BETWEEN_DANCE_SEC; // pause mellem danse
}
return secs;
}
// ── Fetch & render ────────────────────────────────────────────────────────────
async function loadAndRender() {
if (!playlistId) { await showPicker(); return; }
try {
const r = await fetch(`${API}/live/${playlistId}`);
if (!r.ok) { showNoEvent(); return; }
render(await r.json());
} catch(e) { setDot(false, 'Forbindelsesfejl'); }
}
async function showPicker() {
try {
const r = await fetch(`${API}/live/`);
const lists = await r.json();
if (!lists.length) { showNoEvent(); return; }
if (lists.length === 1) { playlistId = lists[0].id; await loadAndRender(); return; }
show('picker');
document.getElementById('picker-list').innerHTML = lists.map(p => `
<button class="playlist-pick-btn" onclick="selectPlaylist('${p.id}')">
<strong>${fmt(p.name)}</strong>
<span>${p.now_playing ? '▶ '+fmt(p.now_playing) : 'Afventer start'}</span>
</button>`).join('');
} catch(e) { showNoEvent(); }
}
function selectPlaylist(id) {
playlistId = id;
history.replaceState(null, '', `?id=${id}`);
loadAndRender();
}
function showNoEvent() {
show('no-event');
setDot(false, 'Ikke aktiv');
}
function show(id) {
['picker','no-event','empty','now-playing'].forEach(x => {
document.getElementById(x).style.display = x === id ? '' : 'none';
});
if (id !== 'now-playing') {
document.getElementById('divider').style.display = 'none';
document.getElementById('next-label').style.display = 'none';
document.getElementById('song-list').innerHTML = '';
}
}
function render(data) {
const songs = data.songs || [];
const hasLive = !!data.updated_at;
document.getElementById('picker').style.display = 'none';
document.getElementById('no-event').style.display = 'none';
document.getElementById('empty').style.display = 'none';
document.getElementById('pl-name').textContent = data.name || '';
document.getElementById('last-updated').textContent =
data.updated_at ? 'Opdateret ' + fmtTime(data.updated_at) : '';
const playing = songs.find(s => s.status === 'playing');
const pending = songs.filter(s => s.status === 'pending');
const playedN = songs.filter(s => s.status === 'played' || s.status === 'skipped').length;
const total = songs.length;
// ── Now playing ────────────────────────────────────────────────────────────
if (songs.length > 0) {
document.getElementById('now-playing').style.display = '';
const current = playing || pending[0];
const done = !playing && pending.length === 0;
const currentIdx = current ? songs.indexOf(current) : -1;
document.getElementById('np-label').textContent =
playing ? '▶ Spiller nu' : done ? '✓ Afsluttet' : '⏸ Pause';
// Dans primær
document.getElementById('np-dance').textContent =
current ? getDanceName(current) : '';
// Nummer under dansen
if (current && currentIdx >= 0) {
document.getElementById('np-number').innerHTML =
`Nr. <b>${currentIdx + 1}</b> af ${total}`;
} else {
document.getElementById('np-number').textContent = '';
}
// Sang sekundær (ikke ved workshop)
if (current && !current.is_workshop) {
const t = current.title || '—';
const a = current.artist || '';
document.getElementById('np-song').innerHTML =
`<span>${fmt(t)}</span>${a ? ' · ' + fmt(a) : ''}`;
} else {
document.getElementById('np-song').textContent = '';
}
// ETA til næste dans — klokkeslæt
const etaEl = document.getElementById('np-eta');
const etaValEl = document.getElementById('np-eta-val');
if (playing && currentIdx >= 0 && data.updated_at) {
const nextIdx = songs.findIndex((s, i) => i > currentIdx && s.status === 'pending');
if (nextIdx >= 0) {
const updatedAt = new Date(data.updated_at);
const remainSecs = songDuration(playing) + BETWEEN_DANCE_SEC;
const nextStart = new Date(updatedAt.getTime() + remainSecs * 1000);
etaEl.style.display = '';
etaValEl.textContent = fmtClock(nextStart);
} else {
etaEl.style.display = 'none';
}
} else {
etaEl.style.display = 'none';
}
// Fremgangsbar
const pct = total > 0 ? Math.round(playedN / total * 100) : 0;
document.getElementById('np-progress').style.width = pct + '%';
document.getElementById('np-played').textContent = `${playedN} afspillet`;
document.getElementById('np-remaining').textContent = `${pending.length} tilbage`;
setDot(hasLive, hasLive ? 'Live' : 'Afventer');
} else {
document.getElementById('now-playing').style.display = 'none';
setDot(false, 'Ingen sange');
}
// ── Songliste ──────────────────────────────────────────────────────────────
const hasList = songs.length > 0;
document.getElementById('divider').style.display = hasList ? '' : 'none';
document.getElementById('next-label').style.display = hasList ? '' : 'none';
// Beregn klokkeslæt for alle sange ud fra updated_at
// Udgangspunkt: updated_at = hvornår nuværende sang startede
const baseTime = data.updated_at ? new Date(data.updated_at) : null;
const playingIdx = songs.findIndex(s => s.status === 'playing');
let cumSecs = 0; // akkumuleret tid fra nuværende sang
// Nuværende sang: start = baseTime, slutter baseTime + songDuration
// Næste sang: starter baseTime + songDuration + BETWEEN_DANCE_SEC
// Osv.
document.getElementById('song-list').innerHTML = songs.map((s, i) => {
const icon = s.status === 'played' ? '✓' :
s.status === 'skipped' ? '—' :
s.status === 'playing' ? '▶' : '';
const cls = 'song-item ' + (s.status || 'pending');
const name = getDanceName(s);
const sub = (!s.is_workshop && s.title) ? s.title + (s.artist ? ' · ' + s.artist : '') : '';
let etaTxt = '';
if (s.status === 'playing' && baseTime) {
// Aktuel sang — viser ikke ETA (den vises i np-eta)
cumSecs = songDuration(s) + BETWEEN_DANCE_SEC;
} else if (s.status === 'pending' && playingIdx >= 0 && i > playingIdx && baseTime) {
// Pending sang efter den aktive — beregn klokkeslæt
const startTime = new Date(baseTime.getTime() + cumSecs * 1000);
etaTxt = 'ca. ' + fmtClock(startTime);
cumSecs += songDuration(s) + BETWEEN_DANCE_SEC;
} else if (s.status === 'pending' && playingIdx < 0 && baseTime) {
// Ingen sang spiller — estimér fra nu
const startTime = new Date(baseTime.getTime() + cumSecs * 1000);
etaTxt = 'ca. ' + fmtClock(startTime);
cumSecs += songDuration(s) + BETWEEN_DANCE_SEC;
}
return `
<div class="${cls}">
<span class="song-num">${i+1}</span>
<span class="song-check">${icon}</span>
<div class="song-info">
<div class="song-dance-name">${fmt(name)}</div>
${sub ? `<div class="song-title-sm">${fmt(sub)}</div>` : ''}
</div>
${etaTxt ? `<span class="song-eta-sm">${etaTxt}</span>` : ''}
</div>`;
}).join('');
}
function setDot(active, label) {
document.getElementById('live-dot').className = 'dot' + (active ? ' active' : '');
document.getElementById('live-label').textContent = label;
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.getElementById('empty').style.display = 'flex';
loadAndRender();
startCountdown();
setInterval(loadAndRender, POLL_SECS * 1000);
</script>
</body>
</html>

94
linedance-app/BUILD.md Normal file
View File

@@ -0,0 +1,94 @@
# LineDance Player — Windows Build Guide
## Forudsætninger
Installer følgende på din Windows-maskine:
1. **Python 3.11+**
https://www.python.org/downloads/
✅ Sæt flueben ved "Add Python to PATH"
2. **VLC** (64-bit)
https://www.videolan.org/vlc/
Kræves både til udvikling og til slutbrugere
3. **NSIS 3.x**
https://nsis.sourceforge.io/Download
Bruges til at bygge `.exe` installationsprogrammet
---
## Første gang: Opsæt miljø
```bat
cd linedance-app
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt
```
---
## Byg installer
```bat
build.bat
```
Det gør automatisk:
1. PyInstaller → `dist\LineDancePlayer\` (hele programmet)
2. NSIS → `dist\LineDancePlayer-Setup.exe` (installer til brugerne)
Tager 2-5 minutter første gang.
---
## Upload til server
```bat
scp dist\LineDancePlayer-Setup.exe bruger@linedanceplayer.dk:/opt/docker/linedanceafspiller/linedance-api/web/public/download/
```
Filen er tilgængelig på:
`https://linedanceplayer.dk/download/LineDancePlayer-Setup.exe`
---
## Ikoner (valgfrit men anbefalet)
Placer disse filer i `installer\` mappen:
| Fil | Størrelse | Beskrivelse |
|-----|-----------|-------------|
| `icon.ico` | 256×256 | Program-ikon (Windows .ico format) |
| `welcome.bmp` | 164×314 | Velkomst-billede i installer |
| `header.bmp` | 150×57 | Header-billede i installer |
Uden ikoner bygges der med standard NSIS-udseende.
---
## Fejlfinding
**PyInstaller fejler med "module not found"**
Tilføj modulet til `hiddenimports` i `build_windows.spec`
**VLC ikke fundet ved kørsel**
Sørg for at VLC er installeret som 64-bit — samme arkitektur som Python
**NSIS fejler**
Kør `makensis /V4 installer.nsi` for detaljeret output
---
## Versionsnummer
Opdater versionsnummeret i `installer.nsi`:
```nsis
!define APP_VERSION "1.0.1"
```
Og i `app.html` på hjemmesiden:
```html
<div class="version" id="win-version">Version 1.0.1 · 64-bit</div>
```

View File

@@ -0,0 +1,47 @@
# Byg LineDance Player til Windows .exe
## Krav
1. **Python 3.11+** installeret
2. **VLC** installeret (skal også være på den maskine der kører .exe)
3. Alle Python-pakker installeret (`pip install -r requirements.txt`)
## Bygge på Windows
```cmd
cd linedance-app
build.bat
```
Det færdige program ligger i `dist\LineDancePlayer\LineDancePlayer.exe`
## Bygge på Linux (til Linux)
```bash
cd linedance-app
./build_linux.sh
```
## Distribuere til andre
Kopiér hele `dist\LineDancePlayer\` mappen — IKKE kun .exe filen!
Mappen indeholder alle nødvendige DLL-filer og biblioteker.
Modtageren skal stadig have **VLC installeret**:
- Windows: https://www.videolan.org/vlc/
- Linux: `sudo apt install vlc`
## Hvis VLC ikke kan findes
PyInstaller kan ikke automatisk inkludere VLC da det er et system-program.
Alternativt kan du kopiere `libvlc.dll` og `libvlccore.dll` fra
`C:\Program Files\VideoLAN\VLC\` ind i `dist\LineDancePlayer\`-mappen.
## Fejlsøgning
Hvis .exe crasher uden fejlbesked, byg med `console=True` i spec-filen
og kør fra kommandoprompten for at se fejlbeskeder.
## Størrelse
Den færdige mappe er typisk 80-150 MB med PyQt6.

View File

@@ -0,0 +1,161 @@
# -*- mode: python ; coding: utf-8 -*-
#
# LineDancePlayer.spec
#
# Byg med: pyinstaller LineDancePlayer.spec
# Output: dist\LineDancePlayer.exe
#
# Kræver: VLC installeret på byggemaskinen
# (typisk C:\Program Files\VideoLAN\VLC)
import os
import sys
from pathlib import Path
# ── Find VLC-installation ─────────────────────────────────────────────────────
def find_vlc_path() -> Path | None:
"""Find VLC på Windows tjekker de mest almindelige installationsstier."""
candidates = [
Path(os.environ.get("PROGRAMFILES", "C:/Program Files")) / "VideoLAN" / "VLC",
Path(os.environ.get("PROGRAMFILES(X86)", "C:/Program Files (x86)")) / "VideoLAN" / "VLC",
Path("C:/Program Files/VideoLAN/VLC"),
Path("C:/Program Files (x86)/VideoLAN/VLC"),
]
# Tjek også PYTHONPATH og registry via python-vlc
try:
import vlc
vlc_path = Path(vlc.plugin_path).parent if vlc.plugin_path else None
if vlc_path and vlc_path.exists():
candidates.insert(0, vlc_path)
except Exception:
pass
for path in candidates:
if path.exists() and (path / "libvlc.dll").exists():
return path
return None
VLC_PATH = find_vlc_path()
if VLC_PATH is None:
print("=" * 60)
print("ADVARSEL: VLC ikke fundet!")
print("Installer VLC fra https://www.videolan.org/vlc/")
print("og kør pyinstaller igen.")
print("=" * 60)
VLC_PATH = Path("C:/Program Files/VideoLAN/VLC") # fallback
print(f"VLC fundet: {VLC_PATH}")
# ── Saml VLC binære filer ─────────────────────────────────────────────────────
vlc_binaries = []
vlc_datas = []
if VLC_PATH.exists():
# Hoved-DLL filer
for dll in ["libvlc.dll", "libvlccore.dll", "libvlc.lib"]:
dll_path = VLC_PATH / dll
if dll_path.exists():
vlc_binaries.append((str(dll_path), "."))
# Plugins-mappe — indeholder codecs, demuxers osv.
plugins_dir = VLC_PATH / "plugins"
if plugins_dir.exists():
vlc_datas.append((str(plugins_dir), "plugins"))
# Locale-filer
locale_dir = VLC_PATH / "locale"
if locale_dir.exists():
vlc_datas.append((str(locale_dir), "locale"))
# ── PyInstaller konfiguration ─────────────────────────────────────────────────
block_cipher = None
a = Analysis(
["main.py"],
pathex=["."],
binaries=vlc_binaries,
datas=[
("ui", "ui"),
("local", "local"),
("player", "player"),
] + vlc_datas,
hiddenimports=[
# PyQt6
"PyQt6.sip",
"PyQt6.QtCore",
"PyQt6.QtGui",
"PyQt6.QtWidgets",
# Lyd og tags
"vlc",
"mutagen",
"mutagen.mp3",
"mutagen.id3",
"mutagen.flac",
"mutagen.mp4",
"mutagen.oggvorbis",
"mutagen.oggopus",
# Fil-overvågning
"watchdog",
"watchdog.observers",
"watchdog.observers.polling",
"watchdog.events",
# Database
"sqlite3",
# Standard
"json",
"pathlib",
"threading",
"urllib.request",
"urllib.parse",
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
# Ting vi ikke bruger — reducerer filstørrelse
"tkinter",
"matplotlib",
"numpy",
"pandas",
"scipy",
"PIL",
"cv2",
"pytest",
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name="LineDancePlayer",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True, # komprimer med UPX hvis tilgængeligt
upx_exclude=[
"libvlc.dll", # VLC må ikke komprimeres — den loader plugins dynamisk
"libvlccore.dll",
],
runtime_tmpdir=None,
console=False, # ingen konsol-vindue
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
# Ikon — kommenter ud hvis du ikke har en .ico fil endnu
# icon="assets/icon.ico",
)

57
linedance-app/README.md Normal file
View File

@@ -0,0 +1,57 @@
# LineDance Player — Desktop App
PyQt6-baseret afspiller til linedance-events.
## Installation
```bash
python -m venv venv
source venv/bin/activate # Linux/Mac
venv\Scripts\activate # Windows
pip install -r requirements.txt
```
VLC skal også være installeret på systemet:
- **Linux**: `sudo apt install vlc`
- **Windows**: Download fra https://www.videolan.org/vlc/
- **Mac**: `brew install vlc`
## Start
```bash
python main.py
```
## Mappestruktur
```
linedance-app/
├── main.py # Entry point
├── requirements.txt
├── local/ # Lokal SQLite + fil-scanning
│ ├── local_db.py # Database operationer
│ ├── tag_reader.py # Læs/skriv MP3-tags
│ └── file_watcher.py # Overvåg mapper med watchdog
├── player/
│ └── player.py # VLC afspiller wrapper
└── ui/
├── main_window.py # Hoved-vindue
├── playlist_panel.py # Danseliste
├── library_panel.py # Musikbibliotek med søgning
├── next_up_bar.py # "Næste sang klar" banner
├── vu_meter.py # VU-meter widget
└── themes.py # Lyst / mørkt tema
```
## Brug
1. Klik **+ MAPPE** i biblioteks-panelet og peg på din musikmappe
2. Appen scanner automatisk alle undermapper og høster tags
3. Dobbeltklik på en sang for at afspille, eller højreklik → Tilføj til danseliste
4. Brug **▶ 10 SEK** knappen til at høre introen inden dansen starter
5. Sangen stopper automatisk når den er færdig — tryk **▶ AFSPIL NÆSTE** for at fortsætte
## Lokal database
Gemmes i `~/.linedance/local.db` — bevares mellem sessioner.

View File

@@ -0,0 +1,25 @@
"""
app_logger.py - Central logging til fil i stedet for konsol.
Paa Windows uden konsol skrives alt til ~/.linedance/app.log
"""
import logging
from pathlib import Path
LOG_PATH = Path.home() / ".linedance" / "app.log"
def setup_logging():
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
handlers = [logging.FileHandler(LOG_PATH, encoding="utf-8")]
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
handlers=handlers,
force=True,
)
logger = logging.getLogger("linedance")

142
linedance-app/build.bat Normal file
View File

@@ -0,0 +1,142 @@
@echo off
setlocal enabledelayedexpansion
echo.
echo ================================================
echo LineDance Player - Windows Build + Installer
echo ================================================
echo.
:: ── Aktiver venv ──────────────────────────────────────────────────────────────
if exist "venv\Scripts\activate.bat" (
call venv\Scripts\activate.bat
) else (
echo ADVARSEL: venv ikke fundet - bruger system Python
)
:: ── Tjek Python ───────────────────────────────────────────────────────────────
python --version >nul 2>&1
if errorlevel 1 (
echo FEJL: Python ikke fundet
pause & exit /b 1
)
:: ── Installer/opdater PyInstaller ─────────────────────────────────────────────
echo [1/4] Installerer PyInstaller...
pip install pyinstaller --quiet
:: ── Ryd gamle builds ──────────────────────────────────────────────────────────
echo [2/4] Rydder gamle builds...
if exist "dist\LineDancePlayer" rmdir /s /q "dist\LineDancePlayer"
if exist "build\LineDancePlayer" rmdir /s /q "build\LineDancePlayer"
:: ── PyInstaller build ─────────────────────────────────────────────────────────
echo [3/4] Bygger med PyInstaller (2-5 minutter)...
echo.
pyinstaller build_windows.spec --clean --noconfirm
if errorlevel 1 (
echo.
echo FEJL: PyInstaller fejlede - se fejlbesked ovenfor
pause & exit /b 1
)
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.
:: Find NSIS
set "NSIS="
if exist "C:\Program Files (x86)\NSIS\makensis.exe" set "NSIS=C:\Program Files (x86)\NSIS\makensis.exe"
if exist "C:\Program Files\NSIS\makensis.exe" set "NSIS=C:\Program Files\NSIS\makensis.exe"
if not defined NSIS (
echo ADVARSEL: NSIS ikke fundet.
echo.
echo Download NSIS fra https://nsis.sourceforge.io/Download
echo og koer derefter: makensis installer.nsi
echo.
echo PyInstaller-buildet ligger klar i dist\LineDancePlayer\
goto :done
)
:: Tjek installer-mappe og billeder
if not exist "installer" mkdir installer
:: Generer et simpelt .ico hvis det mangler (kræver PowerShell)
if not exist "installer\icon.ico" (
echo Genererer standard ikon...
powershell -NoProfile -Command ^
"$bmp = New-Object System.Drawing.Bitmap(64,64); " ^
"$g = [System.Drawing.Graphics]::FromImage($bmp); " ^
"$g.Clear([System.Drawing.Color]::FromArgb(14,15,17)); " ^
"$b = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(232,160,32)); " ^
"$g.FillEllipse($b, 8, 8, 48, 48); " ^
"$bmp.Save('installer\icon.png'); " ^
"$g.Dispose(); $bmp.Dispose()" 2>nul
echo (Sæt et rigtigt icon.ico i installer\ for bedre resultat)
)
:: Byg NSIS — uden ikon-linjer hvis .ico mangler
if exist "installer\icon.ico" (
"%NSIS%" /V2 installer.nsi
) else (
:: Lav midlertidig .nsi uden ikon
powershell -NoProfile -Command ^
"(Get-Content installer.nsi) | " ^
"Where-Object { $_ -notmatch 'MUI_ICON|MUI_UNICON|MUI_HEADERIMAGE' } | " ^
"Set-Content installer_tmp.nsi"
"%NSIS%" /V2 installer_tmp.nsi
del installer_tmp.nsi
)
if errorlevel 1 (
echo.
echo FEJL: NSIS fejlede - se fejlbesked ovenfor
pause & exit /b 1
)
:done
echo.
echo ================================================
echo FAERDIG!
echo ================================================
if exist "dist\LineDancePlayer-Setup.exe" (
echo.
echo Installer: dist\LineDancePlayer-Setup.exe
for %%A in ("dist\LineDancePlayer-Setup.exe") do (
set /a SIZEMB=%%~zA / 1048576
echo Stoerrelse: !SIZEMB! MB
)
echo.
echo Upload til server:
echo scp dist\LineDancePlayer-Setup.exe bruger@linedanceplayer.dk:/opt/docker/linedanceafspiller/linedance-api/web/public/download/
) else (
echo.
echo PyInstaller-build: dist\LineDancePlayer\
echo Kør makensis installer.nsi manuelt naar NSIS er installeret
)
echo.
pause

30
linedance-app/build_linux.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
echo "=== LineDance Player - Linux Build ==="
echo
# Aktiver venv
source venv/bin/activate 2>/dev/null || echo "ADVARSEL: venv ikke aktiveret"
# Installer PyInstaller
pip show pyinstaller > /dev/null 2>&1 || pip install pyinstaller
# Ryd tidligere build
rm -rf dist/LineDancePlayer build/LineDancePlayer
echo "Bygger LineDance Player..."
echo "Dette tager 1-3 minutter..."
echo
pyinstaller build_windows.spec --clean
if [ $? -eq 0 ]; then
echo
echo "=== BUILD FÆRDIG ==="
echo "Programmet ligger i: dist/LineDancePlayer/LineDancePlayer"
echo
echo "HUSK: VLC skal stadig være installeret på maskinen!"
echo " sudo apt install vlc"
else
echo "FEJL: Build mislykkedes!"
exit 1
fi

View File

@@ -0,0 +1,119 @@
# -*- mode: python ; coding: utf-8 -*-
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=VLC_BINARIES,
datas=[
('translations', 'translations'),
],
hiddenimports=[
'PyQt6.sip',
'PyQt6.QtCore',
'PyQt6.QtGui',
'PyQt6.QtWidgets',
'PyQt6.QtNetwork',
'ui.main_window', 'ui.playlist_panel', 'ui.library_panel',
'ui.library_manager', 'ui.themes', 'ui.vu_meter',
'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.alt_dance_picker_dialog', 'ui.share_dialog',
'ui.register_dialog',
'player.player',
'local.local_db', 'local.scanner', 'local.file_watcher',
'local.sync_manager', 'local.linked_playlist',
'translations', 'translations.da', 'translations.en',
'mutagen', 'mutagen.mp3', 'mutagen.id3', 'mutagen.flac',
'mutagen.mp4', 'mutagen.oggvorbis', 'mutagen.ogg',
'mutagen.wave', 'mutagen.aiff', 'mutagen.asf',
'vlc', 'sqlite3',
],
hookspath=[],
runtime_hooks=[],
excludes=[
'tkinter', 'tk', 'tcl',
'matplotlib', 'pandas', 'scipy', 'numpy',
'IPython', 'jupyter', 'PIL', 'cv2', 'sklearn',
'PyQt6.QtWebEngine', 'PyQt6.QtWebEngineWidgets',
'PyQt6.QtWebEngineCore', 'PyQt6.QtMultimedia',
'PyQt6.QtMultimediaWidgets', 'PyQt6.QtBluetooth',
'PyQt6.QtNfc', 'PyQt6.QtPositioning', 'PyQt6.QtLocation',
'PyQt6.QtSensors', 'PyQt6.QtSerialPort', 'PyQt6.QtSql',
'PyQt6.QtTest', 'PyQt6.QtXml', 'PyQt6.QtOpenGL',
'PyQt6.QtOpenGLWidgets', 'PyQt6.Qt3DCore', 'PyQt6.Qt3DRender',
'PyQt6.Qt3DInput', 'PyQt6.Qt3DLogic', 'PyQt6.Qt3DAnimation',
'PyQt6.Qt3DExtras', 'PyQt6.QtCharts', 'PyQt6.QtDataVisualization',
'PyQt6.QtQuick', 'PyQt6.QtQuickWidgets', 'PyQt6.QtQml',
'PyQt6.QtRemoteObjects', 'PyQt6.QtScxml', 'PyQt6.QtStateMachine',
'PyQt6.QtDesigner', 'PyQt6.QtHelp', 'PyQt6.QtPrintSupport',
'unittest', 'doctest', 'pdb', 'pydoc',
],
cipher=block_cipher,
noarchive=False,
)
# Fjern store ubrugte Qt DLL-filer
REMOVE_PATTERNS = [
'Qt6WebEngine', 'Qt6Quick', 'Qt6Qml', 'Qt6Designer',
'Qt6Help', 'Qt6Multimedia', 'Qt6Location', 'Qt6Sensors',
'Qt6Bluetooth', 'Qt6Nfc', 'Qt63D', 'Qt6Charts',
'Qt6DataVisualization', 'Qt6RemoteObjects', 'Qt6Scxml',
'Qt6StateMachine', 'Qt6VirtualKeyboard',
'd3dcompiler', 'opengl32sw',
]
a.binaries = [
(name, path, kind)
for name, path, kind in a.binaries
if not any(p.lower() in name.lower() for p in REMOVE_PATTERNS)
]
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='LineDancePlayer',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
icon='installer\\icon.ico',
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=False,
name='LineDancePlayer',
)

118
linedance-app/installer.nsi Normal file
View File

@@ -0,0 +1,118 @@
; LineDance Player — NSIS Installer Script
; Kræver NSIS 3.x installeret: https://nsis.sourceforge.io/
!define APP_NAME "LineDance Player"
!define APP_EXE "LineDancePlayer.exe"
!define APP_VERSION "1.0.0"
!define APP_PUBLISHER "LineDance Player"
!define APP_URL "https://linedanceplayer.dk"
!define INSTALL_DIR "$LOCALAPPDATA\LineDancePlayer"
!define REG_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\LineDancePlayer"
; ── Generelle indstillinger ───────────────────────────────────────────────────
Name "${APP_NAME}"
OutFile "dist\LineDancePlayer-Setup.exe"
InstallDir "${INSTALL_DIR}"
InstallDirRegKey HKCU "${REG_KEY}" "InstallLocation"
RequestExecutionLevel user ; Ingen admin-rettigheder nødvendige
SetCompressor /SOLID lzma
Unicode True
; ── Moderne UI ────────────────────────────────────────────────────────────────
!include "MUI2.nsh"
!include "FileFunc.nsh"
!define MUI_ABORTWARNING
!define MUI_ICON "installer\icon.ico"
!define MUI_UNICON "installer\icon.ico"
!define MUI_WELCOMEFINISHPAGE_BITMAP "installer\welcome.bmp"
!define MUI_HEADERIMAGE
!define MUI_HEADERIMAGE_BITMAP "installer\header.bmp"
; Farver (orange tema)
!define MUI_BGCOLOR "0C0D10"
!define MUI_TEXTCOLOR "ECEEF4"
; ── Sider ─────────────────────────────────────────────────────────────────────
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "Danish"
!insertmacro MUI_LANGUAGE "English"
; ── Installer ─────────────────────────────────────────────────────────────────
Section "LineDance Player" SecMain
SectionIn RO ; Obligatorisk
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\*"
; Start-menu genvej
CreateDirectory "$SMPROGRAMS\${APP_NAME}"
CreateShortcut "$SMPROGRAMS\${APP_NAME}\${APP_NAME}.lnk" \
"$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
; Skrivebords-genvej
CreateShortcut "$DESKTOP\${APP_NAME}.lnk" \
"$INSTDIR\${APP_EXE}" "" "$INSTDIR\${APP_EXE}" 0
; Gem installationsinfo i registry (til afinstallation)
WriteRegStr HKCU "${REG_KEY}" "DisplayName" "${APP_NAME}"
WriteRegStr HKCU "${REG_KEY}" "DisplayVersion" "${APP_VERSION}"
WriteRegStr HKCU "${REG_KEY}" "Publisher" "${APP_PUBLISHER}"
WriteRegStr HKCU "${REG_KEY}" "URLInfoAbout" "${APP_URL}"
WriteRegStr HKCU "${REG_KEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKCU "${REG_KEY}" "UninstallString" '"$INSTDIR\Uninstall.exe"'
WriteRegStr HKCU "${REG_KEY}" "DisplayIcon" "$INSTDIR\${APP_EXE}"
WriteRegDWORD HKCU "${REG_KEY}" "NoModify" 1
WriteRegDWORD HKCU "${REG_KEY}" "NoRepair" 1
; Beregn installationsstørrelse
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKCU "${REG_KEY}" "EstimatedSize" "$0"
; Skriv afinstaller
WriteUninstaller "$INSTDIR\Uninstall.exe"
SectionEnd
; VLC DLL-filer er bundlet med appen — intet VLC-tjek nødvendigt
; ── Afinstaller ───────────────────────────────────────────────────────────────
Section "Uninstall"
; Slet programfiler
RMDir /r "$INSTDIR"
; Slet genveje
Delete "$SMPROGRAMS\${APP_NAME}\${APP_NAME}.lnk"
RMDir "$SMPROGRAMS\${APP_NAME}"
Delete "$DESKTOP\${APP_NAME}.lnk"
; Slet registry
DeleteRegKey HKCU "${REG_KEY}"
; Spørg om brugerdata skal slettes
IfFileExists "$PROFILE\.linedance\*" AskDelete SkipDelete
AskDelete:
MessageBox MB_YESNO|MB_ICONQUESTION \
"Vil du slette dine lokale data?$\n$\nDette fjerner din musikdatabase, indstillinger og playlister fra:$\n$PROFILE\.linedance$\n$\nVaelg Nej for at beholde dine data." \
IDNO SkipDelete
RMDir /r "$PROFILE\.linedance"
SkipDelete:
SectionEnd

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

View File

@@ -0,0 +1,29 @@
"""
local/ — Lokalt data-lag til Linedance-afspilleren.
Moduler:
local_db.py — SQLite database (sange, afspilningslister, biblioteker)
tag_reader.py — Læser/skriver metadata fra lydfiler
file_watcher.py — Overvåger mapper og holder SQLite opdateret
Typisk brug ved app-start:
from local.local_db import init_db
from local.file_watcher import get_watcher
# Initialiser database
init_db()
# Start fil-overvågning (on_change kaldes ved ændringer — opdater GUI)
def on_file_change(event_type, path, song_id):
print(f"{event_type}: {path}")
watcher = get_watcher(on_change=on_file_change)
watcher.start()
# Tilføj et bibliotek (scanner automatisk + starter overvågning)
watcher.add_library("/home/carsten/Musik")
# Ved app-luk:
watcher.stop()
"""

View File

@@ -0,0 +1,299 @@
"""
acoustid_worker.py — Baggrunds-fingerprinting via AcoustID/Chromaprint.
Finder sange uden MBID, kører fpcalc på dem, spørger AcoustID API
og gemmer MBID + acoustid lokalt. Kører som lavprioritets baggrundstråd.
Kræver fpcalc (Chromaprint) installeret:
Linux: sudo apt install libchromaprint-tools
Windows: download fra https://acoustid.org/chromaprint
"""
import json
import logging
import subprocess
import threading
import time
from pathlib import Path
logger = logging.getLogger(__name__)
# AcoustID API nøgle — kan overskrives i Indstillinger → Afspilning
# Registrér din egen på https://acoustid.org/new-application
ACOUSTID_API_KEY = "6fd9DGNDqG"
ACOUSTID_API_URL = "https://api.acoustid.org/v2/lookup"
# Pause mellem API-kald — rolig baggrundskørsel
API_DELAY = 5.0
# Maks sange per session — fordeles over mange opstart
MAX_PER_SESSION = 20
def find_fpcalc() -> str | None:
"""Find fpcalc på systemet."""
import shutil, sys
# Tjek PATH
path = shutil.which("fpcalc")
if path:
return path
# Windows: tjek ved siden af exe
if sys.platform == "win32":
candidates = [
Path(sys.executable).parent / "fpcalc.exe",
Path("C:/Program Files/Chromaprint/fpcalc.exe"),
Path("C:/Program Files (x86)/Chromaprint/fpcalc.exe"),
]
for c in candidates:
if c.exists():
return str(c)
return None
def fingerprint_file(path: str, fpcalc: str) -> tuple[str, int] | None:
"""
Kør fpcalc på en fil og returnér (fingerprint, duration).
Returnerer None ved fejl.
"""
try:
result = subprocess.run(
[fpcalc, "-json", path],
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
return None
data = json.loads(result.stdout)
fp = data.get("fingerprint", "")
dur = int(data.get("duration", 0))
if fp and dur:
return fp, dur
except Exception as e:
logger.warning(f"fpcalc fejl for {path}: {e}")
return None
def lookup_acoustid(fingerprint: str, duration: int, api_key: str = "") -> dict | None:
"""
Spørg AcoustID API om MBID for et fingerprint.
Returnerer dict med 'mbid' og 'acoustid' eller None.
"""
try:
import urllib.request, urllib.parse, urllib.error
params = urllib.parse.urlencode({
"client": api_key or ACOUSTID_API_KEY,
"fingerprint": fingerprint,
"duration": duration,
"meta": "recordings",
})
url = f"{ACOUSTID_API_URL}?{params}"
req = urllib.request.Request(url)
req.add_header("User-Agent", "LineDancePlayer/1.0")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
logger.warning(f"AcoustID API fejl {e.code}: {body[:200]}")
return None
if data.get("status") != "ok":
logger.warning(f"AcoustID status: {data.get('status')}{data.get('error', {}).get('message','')}")
return None
results = data.get("results", [])
if not results:
return None
# Tag det bedste resultat (højeste score)
best = max(results, key=lambda r: r.get("score", 0))
if best.get("score", 0) < 0.85:
return None # For usikkert
acoustid = best.get("id", "")
recordings = best.get("recordings", [])
mbid = recordings[0].get("id", "") if recordings else ""
return {"acoustid": acoustid, "mbid": mbid} if acoustid else None
except Exception as e:
logger.warning(f"AcoustID API fejl: {e}")
return None
def run_acoustid_scan(db_path: str, api_key: str = "", on_progress=None, stop_event: threading.Event = None):
"""
Gennemgå alle sange uden MBID og forsøg AcoustID fingerprinting.
Kører i batches på MAX_PER_SESSION med BATCH_DELAY pause imellem.
"""
import sqlite3
fpcalc = find_fpcalc()
if not fpcalc:
logger.info("AcoustID: fpcalc ikke fundet — spring over")
if on_progress:
on_progress(0, 0, "fpcalc ikke installeret")
return
key = api_key or ACOUSTID_API_KEY
if not key:
logger.warning("AcoustID: ingen API-nøgle — spring over")
return
logger.info(f"AcoustID: fpcalc fundet: {fpcalc}")
batch_num = 0
total_found = 0
while True:
if stop_event and stop_event.is_set():
logger.info("AcoustID: stoppet af bruger")
break
conn = sqlite3.connect(db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
rows = conn.execute("""
SELECT s.id, s.title, s.artist, f.local_path
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
WHERE (s.mbid IS NULL OR s.mbid = '')
AND f.local_path IS NOT NULL AND f.local_path != ''
ORDER BY RANDOM()
LIMIT ?
""", (MAX_PER_SESSION,)).fetchall()
conn.close()
if not rows:
logger.info(f"AcoustID: alle sange har nu MBID — stopper")
if on_progress:
on_progress(1, 1, f"Færdig — {total_found} sange fik MBID i alt")
break
batch_num += 1
total = len(rows)
done = 0
found = 0
logger.info(f"AcoustID: batch {batch_num}{total} sange")
conn = sqlite3.connect(db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row
for row in rows:
if stop_event and stop_event.is_set():
conn.close()
return
path = row["local_path"]
if not Path(path).exists():
done += 1
continue
if on_progress:
on_progress(done, total, row["title"] or path)
fp_result = fingerprint_file(path, fpcalc)
if not fp_result:
done += 1
time.sleep(0.1)
continue
fingerprint, duration = fp_result
result = lookup_acoustid(fingerprint, duration, key)
time.sleep(API_DELAY)
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 acoustid=? WHERE id=?",
(acoustid or None, row["id"])
)
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(
f"AcoustID: {row['title']} → MBID={mbid[:8] if mbid else ''}"
)
if mbid and path:
try:
from local.tag_reader import write_mbid_to_file
write_mbid_to_file(path, mbid)
except Exception as e:
logger.warning(f"AcoustID: kunne ikke skrive MBID til fil: {e}")
done += 1
conn.close()
logger.info(f"AcoustID: batch {batch_num} færdig — {found}/{total} fik MBID")
if on_progress:
on_progress(done, total, f"Batch {batch_num}: {found}/{total} fik MBID — venter 60 sek")
# Vent et minut inden næste batch
for _ in range(60):
if stop_event and stop_event.is_set():
return
time.sleep(1)
class AcoustIDWorker:
"""
Baggrunds-worker der kører AcoustID fingerprinting
som lavprioritets tråd.
"""
def __init__(self, db_path: str):
self._db_path = db_path
self._thread = None
self._stop_event = threading.Event()
self._running = False
def start(self, api_key: str = "", on_progress=None):
"""Start baggrunds-fingerprinting. Gør ingenting hvis allerede kører."""
if self._running:
return
self._stop_event.clear()
self._running = True
def _run():
try:
run_acoustid_scan(
self._db_path,
api_key=api_key,
on_progress=on_progress,
stop_event=self._stop_event,
)
finally:
self._running = False
self._thread = threading.Thread(target=_run, daemon=True, name="acoustid")
self._thread.start()
logger.info("AcoustID: baggrunds-worker startet")
def stop(self):
"""Stop baggrunds-fingerprinting."""
self._stop_event.set()
def is_running(self) -> bool:
return self._running

View File

@@ -0,0 +1,34 @@
"""
file_watcher.py — Minimal fil-watcher.
Scanning håndteres af scanner.py / ScanWorker.
"""
# Fil-watcher er deaktiveret — scanning sker via ScanWorker i library_manager
# Denne fil beholdes til fremtidig live-overvågning
class LibraryWatcher:
def __init__(self, on_change=None):
self.on_change = on_change
self._running = False
def start(self):
self._running = True
def stop(self):
self._running = False
def add_library(self, path: str) -> int:
from local.local_db import add_library
return add_library(path)
def remove_library(self, library_id: int):
from local.local_db import remove_library
remove_library(library_id)
_watcher_instance = None
def get_watcher(on_change=None) -> LibraryWatcher:
global _watcher_instance
if _watcher_instance is None:
_watcher_instance = LibraryWatcher(on_change=on_change)
return _watcher_instance

View File

@@ -0,0 +1,173 @@
"""
linked_playlist.py — Håndter linkede server-playlister.
Pull ved åbning, push ved gem.
"""
import json
import sqlite3
import urllib.request
import urllib.error
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
class LinkedPlaylistManager:
def __init__(self, db_path: str, server_url: str, token: str):
self._db_path = db_path
self._server_url = server_url.rstrip("/")
self._token = token
def _headers(self):
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {self._token}",
}
def pull(self, playlist_id: int) -> list[dict]:
"""
Hent seneste version fra serveren og opdater lokal liste.
Returnerer sang-liste klar til playlist_panel.
"""
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
pl = conn.execute(
"SELECT api_project_id, server_permission FROM playlists WHERE id=?",
(playlist_id,)
).fetchone()
if not pl or not pl["api_project_id"]:
conn.close()
return []
# Hent fra server
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{pl['api_project_id']}",
headers=self._headers()
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
# Slet eksisterende sange og erstat med server-version
conn.execute(
"DELETE FROM playlist_songs WHERE playlist_id=?", (playlist_id,)
)
songs = []
for song_data in sorted(data.get("songs", []), key=lambda x: x["position"]):
# Match lokalt på titel+artist
local = conn.execute(
"SELECT id, local_path, bpm, duration_sec, file_format, file_missing "
"FROM songs WHERE title=? AND artist=? AND file_missing=0 LIMIT 1",
(song_data["title"], song_data["artist"])
).fetchone()
if local:
conn.execute("""
INSERT OR IGNORE INTO playlist_songs
(playlist_id, song_id, position, status, is_workshop, dance_override)
VALUES (?,?,?,?,?,?)
""", (
playlist_id, local["id"],
song_data["position"], song_data["status"],
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override", ""),
))
# Hent danse
dances = conn.execute("""
SELECT d.name FROM song_dances sd
JOIN dances d ON d.id = sd.dance_id
WHERE sd.song_id=? ORDER BY sd.dance_order
""", (local["id"],)).fetchall()
songs.append({
"id": local["id"],
"title": song_data["title"],
"artist": song_data["artist"],
"album": song_data.get("album", ""),
"bpm": local["bpm"] or 0,
"duration_sec": local["duration_sec"] or 0,
"local_path": local["local_path"],
"file_format": local["file_format"] or "",
"file_missing": False,
"dances": [d["name"] for d in dances],
"active_dance": song_data.get("dance_override", ""),
"is_workshop": bool(song_data.get("is_workshop")),
"status": song_data.get("status", "pending"),
})
conn.commit()
conn.close()
return songs
def push(self, playlist_id: int):
"""Push lokal version til serveren."""
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
pl = conn.execute(
"SELECT api_project_id, server_permission, name FROM playlists WHERE id=?",
(playlist_id,)
).fetchone()
if not pl or not pl["api_project_id"]:
conn.close()
raise Exception("Playlisten er ikke linket til serveren")
if pl["server_permission"] not in ("edit",):
conn.close()
raise Exception(f"Du har ikke rettighed til at redigere denne liste (du har: {pl['server_permission']})")
# Byg payload til sync/push
songs_raw = conn.execute("""
SELECT s.id, s.title, s.artist, s.album, s.bpm, s.duration_sec,
s.file_format, ps.position, ps.status, ps.is_workshop, ps.dance_override
FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id=? ORDER BY ps.position
""", (playlist_id,)).fetchall()
conn.close()
from local.sync_manager import SyncManager
mgr = SyncManager(self._db_path, self._server_url, self._token)
# Byg mini-payload med kun denne playliste
song_ids = [row["id"] for row in songs_raw]
songs_payload = []
for row in songs_raw:
songs_payload.append({
"local_id": str(row["id"]),
"title": row["title"] or "",
"artist": row["artist"] or "",
"album": row["album"] or "",
"bpm": row["bpm"] or 0,
"duration_sec": row["duration_sec"] or 0,
"file_format": row["file_format"] or "",
})
pl_payload = [{
"local_id": str(playlist_id),
"name": pl["name"],
"description": "",
"tags": "",
"visibility": "shared",
"songs": [
{
"song_local_id": str(row["id"]),
"position": int(row["position"]),
"status": row["status"] or "pending",
"is_workshop": bool(row["is_workshop"]),
"dance_override": row["dance_override"] or "",
}
for row in songs_raw
]
}]
result = mgr._post("/sync/push", {
"songs": songs_payload,
"dances": [],
"song_dances": [],
"song_alts": [],
"playlists": pl_payload,
})
return result

View File

@@ -0,0 +1,771 @@
"""
local_db.py — Lokal SQLite database for LineDance Player v0.9
Ny arkitektur:
songs — global katalog (synkroniseret med server, server-UUID som ID)
files — lokalt fil-index (kun denne maskine)
playlist_songs — refererer til song_id + valgfri file_id
"""
import sqlite3
import logging
from contextlib import contextmanager
from pathlib import Path
logger = logging.getLogger(__name__)
DB_PATH = Path.home() / ".linedance" / "local.db"
def get_db_path() -> Path:
return DB_PATH
@contextmanager
def get_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
# ── Schema ────────────────────────────────────────────────────────────────────
SCHEMA = """
-- Musik-biblioteker (mapper brugeren har tilføjet)
CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Global sang-katalog (synkroniseret med server)
-- ID er server-UUID. Sange uden server-ID har et lokalt UUID.
CREATE TABLE IF NOT EXISTS songs (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
artist TEXT NOT NULL DEFAULT '',
album TEXT NOT NULL DEFAULT '',
bpm INTEGER NOT NULL DEFAULT 0,
duration_sec INTEGER NOT NULL DEFAULT 0,
mbid TEXT UNIQUE,
acoustid TEXT,
server_synced INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Lokalt fil-index (kun denne maskine)
CREATE TABLE IF NOT EXISTS files (
id TEXT PRIMARY KEY,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
local_path TEXT NOT NULL UNIQUE,
file_missing INTEGER NOT NULL DEFAULT 0,
file_format TEXT NOT NULL DEFAULT '',
file_modified_at TEXT NOT NULL DEFAULT '',
extra_tags TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_files_song_id ON files(song_id);
CREATE INDEX IF NOT EXISTS idx_files_missing ON files(file_missing);
-- Dans-niveauer
CREATE TABLE IF NOT EXISTS dance_levels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sort_order INTEGER NOT NULL,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT ''
);
-- Danse
-- Dans + niveau + koreograf er unik kombination
CREATE TABLE IF NOT EXISTS dances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
level_id INTEGER REFERENCES dance_levels(id),
choreographer TEXT NOT NULL DEFAULT '',
video_url TEXT NOT NULL DEFAULT '',
stepsheet_url TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
use_count INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'local',
UNIQUE(name, level_id, choreographer)
);
-- Sang-dans tags
CREATE TABLE IF NOT EXISTS song_dances (
id TEXT PRIMARY KEY,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
dance_id INTEGER NOT NULL REFERENCES dances(id),
dance_order INTEGER NOT NULL DEFAULT 1,
UNIQUE(song_id, dance_id)
);
-- 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 '',
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_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 (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '',
api_project_id TEXT UNIQUE,
is_linked INTEGER NOT NULL DEFAULT 0,
server_permission TEXT NOT NULL DEFAULT 'edit',
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- 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 '',
alt_dance_override TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_playlist_songs_playlist ON playlist_songs(playlist_id);
CREATE INDEX IF NOT EXISTS idx_playlist_songs_song ON playlist_songs(song_id);
-- Event-state (gemmes løbende)
CREATE TABLE IF NOT EXISTS event_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Dans-navne til autoudfyld
CREATE TABLE IF NOT EXISTS dance_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
use_count INTEGER NOT NULL DEFAULT 1
);
"""
DEFAULT_DANCE_LEVELS = [
(10, "Absolute Beginner", "Ingen tidligere danse-erfaring kræves"),
(20, "Beginner", "Lidt tidligere erfaring"),
(30, "High Beginner", "God begynder, klar til mere"),
(40, "Low Improver", "Begyndende øvet"),
(50, "Improver", "Grundlæggende færdigheder på plads"),
(60, "High Improver", "Stærk øvet, næsten intermediate"),
(70, "Low Intermediate", "Begyndende intermediate"),
(80, "Intermediate", "Erfaren danser"),
(90, "High Intermediate", "Stærk intermediate"),
(99, "Advanced", "Fuld beherskelse af trin og teknik"),
]
def init_db():
"""Opret tabeller og seed dance_levels hvis de mangler."""
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with get_db() as conn:
conn.executescript(SCHEMA)
# Seed dans-niveauer
for sort_order, name, desc in DEFAULT_DANCE_LEVELS:
conn.execute(
"INSERT OR IGNORE INTO dance_levels (sort_order, name, description) VALUES (?,?,?)",
(sort_order, name, desc)
)
logger.info("Database initialiseret")
# ── Biblioteker ───────────────────────────────────────────────────────────────
def add_library(path: str) -> int:
with get_db() as conn:
cur = conn.execute(
"INSERT OR IGNORE INTO libraries (path) VALUES (?)", (path,)
)
return cur.lastrowid
def get_libraries() -> list:
with get_db() as conn:
return conn.execute(
"SELECT * FROM libraries WHERE is_active=1 ORDER BY path"
).fetchall()
def remove_library(library_id: int):
with get_db() as conn:
row = conn.execute(
"SELECT path FROM libraries WHERE id=?", (library_id,)
).fetchone()
if row:
# Marker filer fra denne mappe som missing
conn.execute(
"UPDATE files SET file_missing=1 WHERE local_path LIKE ?",
(row["path"] + "%",)
)
conn.execute("DELETE FROM libraries WHERE id=?", (library_id,))
# ── Sange ─────────────────────────────────────────────────────────────────────
def find_or_create_song(title: str, artist: str = "", album: str = "",
bpm: int = 0, duration_sec: int = 0,
mbid: str = "", acoustid: str = "",
song_id: str = None) -> str:
"""
Find eksisterende sang eller opret ny. Returnerer song_id.
Match-hierarki: server_id → mbid → acoustid → titel+artist → opret ny
"""
import uuid as _uuid
with get_db() as conn:
# Match på server-ID
if song_id:
row = conn.execute(
"SELECT id FROM songs WHERE id=?", (song_id,)
).fetchone()
if row:
# Opdater data hvis bedre info tilgængeligt
conn.execute("""
UPDATE songs SET
title = CASE WHEN title='' THEN ? ELSE title END,
artist = CASE WHEN artist='' THEN ? ELSE artist END,
bpm = CASE WHEN bpm=0 THEN ? ELSE bpm END,
mbid = CASE WHEN mbid IS NULL AND ? != '' THEN ? ELSE mbid END,
acoustid = CASE WHEN acoustid IS NULL AND ? != '' THEN ? ELSE acoustid END
WHERE id=?
""", (title, artist, bpm, mbid, mbid, acoustid, acoustid, song_id))
return song_id
# Match på MBID
if mbid:
row = conn.execute(
"SELECT id FROM songs WHERE mbid=?", (mbid,)
).fetchone()
if row:
return row["id"]
# Match på AcoustID
if acoustid:
row = conn.execute(
"SELECT id FROM songs WHERE acoustid=?", (acoustid,)
).fetchone()
if row:
if mbid:
conn.execute(
"UPDATE songs SET mbid=? WHERE id=? AND mbid IS NULL",
(mbid, row["id"])
)
return row["id"]
# Match på titel + artist
if title:
row = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=?",
(title, artist)
).fetchone()
if row:
if mbid:
conn.execute(
"UPDATE songs SET mbid=? WHERE id=? AND mbid IS NULL",
(mbid, row["id"])
)
return row["id"]
# Opret ny sang
new_id = song_id or str(_uuid.uuid4())
conn.execute(
"INSERT INTO songs (id, title, artist, album, bpm, duration_sec, mbid, acoustid) "
"VALUES (?,?,?,?,?,?,?,?)",
(new_id, title, artist, album, bpm, duration_sec,
mbid or None, acoustid or None)
)
return new_id
def get_song(song_id: str) -> sqlite3.Row | None:
with get_db() as conn:
return conn.execute(
"SELECT * FROM songs WHERE id=?", (song_id,)
).fetchone()
def update_song_bpm(song_id: str, bpm: int):
with get_db() as conn:
conn.execute(
"UPDATE songs SET bpm=? WHERE id=? AND (bpm=0 OR bpm IS NULL)",
(bpm, song_id)
)
def update_song_mbid(song_id: str, mbid: str, acoustid: str = ""):
with get_db() as conn:
conn.execute(
"UPDATE songs SET mbid=?, acoustid=? WHERE id=?",
(mbid or None, acoustid or None, song_id)
)
# ── Filer ─────────────────────────────────────────────────────────────────────
def upsert_file(song_id: str, local_path: str, file_format: str = "",
file_modified_at: str = "", extra_tags: str = "{}") -> str:
"""Opret eller opdater en fil-post. Returnerer file_id."""
import uuid as _uuid
with get_db() as conn:
existing = conn.execute(
"SELECT id FROM files WHERE local_path=?", (local_path,)
).fetchone()
if existing:
conn.execute("""
UPDATE files SET
song_id=?, file_missing=0,
file_format=?, file_modified_at=?, extra_tags=?
WHERE id=?
""", (song_id, file_format, file_modified_at, extra_tags, existing["id"]))
return existing["id"]
else:
file_id = str(_uuid.uuid4())
conn.execute(
"INSERT INTO files (id, song_id, local_path, file_format, file_modified_at, extra_tags) "
"VALUES (?,?,?,?,?,?)",
(file_id, song_id, local_path, file_format, file_modified_at, extra_tags)
)
return file_id
def get_file_for_song(song_id: str) -> sqlite3.Row | None:
"""Find bedste tilgængelige fil for en sang."""
with get_db() as conn:
return conn.execute(
"SELECT * FROM files WHERE song_id=? AND file_missing=0 LIMIT 1",
(song_id,)
).fetchone()
def get_file(file_id: str) -> sqlite3.Row | None:
with get_db() as conn:
return conn.execute(
"SELECT * FROM files WHERE id=?", (file_id,)
).fetchone()
def mark_file_missing(local_path: str):
with get_db() as conn:
conn.execute(
"UPDATE files SET file_missing=1 WHERE local_path=?", (local_path,)
)
def get_all_known_paths() -> set[str]:
with get_db() as conn:
rows = conn.execute("SELECT local_path FROM files").fetchall()
return {r["local_path"] for r in rows}
# ── Søgning i bibliotek ───────────────────────────────────────────────────────
def search_songs(query: str, limit: int = 200) -> list:
"""Søg i sange der har en tilgængelig fil."""
with get_db() as conn:
pattern = f"%{query}%"
return conn.execute("""
SELECT s.*, f.id as file_id, f.local_path, f.file_format, f.file_missing,
GROUP_CONCAT(d.name, ', ') as dance_names
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
LEFT JOIN song_dances sd ON sd.song_id = s.id
LEFT JOIN dances d ON d.id = sd.dance_id
WHERE s.title LIKE ? OR s.artist LIKE ? OR s.album LIKE ?
GROUP BY s.id
ORDER BY s.title
LIMIT ?
""", (pattern, pattern, pattern, limit)).fetchall()
def get_all_songs_with_files(limit: int = 5000) -> list:
"""Hent alle sange med tilgængelige filer — til biblioteksvisning."""
with get_db() as conn:
return conn.execute("""
SELECT s.*, f.id as file_id, f.local_path, f.file_format, f.file_missing,
GROUP_CONCAT(d.name ORDER BY sd.dance_order, ', ') as dance_names
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
LEFT JOIN song_dances sd ON sd.song_id = s.id
LEFT JOIN dances d ON d.id = sd.dance_id
GROUP BY s.id
ORDER BY s.title
LIMIT ?
""", (limit,)).fetchall()
# ── Playlister ────────────────────────────────────────────────────────────────
def create_playlist(name: str, description: str = "", tags: str = "") -> str:
import uuid as _uuid
pl_id = str(_uuid.uuid4())
with get_db() as conn:
conn.execute(
"INSERT INTO playlists (id, name, description, tags) VALUES (?,?,?,?)",
(pl_id, name, description, tags)
)
return pl_id
def get_playlists(tag_filter: str | None = None) -> list:
with get_db() as conn:
if tag_filter:
return conn.execute("""
SELECT p.id, p.name, p.description, p.tags, p.api_project_id,
p.is_linked, p.server_permission, p.is_deleted, p.created_at,
COUNT(ps.position) as song_count
FROM playlists p
LEFT JOIN playlist_songs ps ON ps.playlist_id = p.id
WHERE p.name != '__aktiv__' AND p.is_deleted = 0
AND (p.tags LIKE ? OR p.tags LIKE ? OR p.tags LIKE ? OR p.tags = ?)
GROUP BY p.id ORDER BY p.created_at DESC
""", (f"{tag_filter},%", f"%, {tag_filter},%",
f"%, {tag_filter}", tag_filter)).fetchall()
else:
return conn.execute("""
SELECT p.id, p.name, p.description, p.tags, p.api_project_id,
p.is_linked, p.server_permission, p.is_deleted, p.created_at,
COUNT(ps.position) as song_count
FROM playlists p
LEFT JOIN playlist_songs ps ON ps.playlist_id = p.id
WHERE p.name != '__aktiv__' AND p.is_deleted = 0
GROUP BY p.id ORDER BY p.created_at DESC
""").fetchall()
def delete_playlist(playlist_id: str):
"""Soft-slet — sæt is_deleted=1."""
with get_db() as conn:
conn.execute(
"UPDATE playlists SET is_deleted=1 WHERE id=?", (playlist_id,)
)
def get_playlist_with_songs(playlist_id: str) -> dict:
with get_db() as conn:
playlist = conn.execute(
"SELECT * FROM playlists WHERE id=?", (playlist_id,)
).fetchone()
if not playlist:
return {}
songs = conn.execute("""
SELECT ps.id as ps_id, ps.position, ps.status,
ps.is_workshop, ps.dance_override,
ps.song_id, ps.file_id,
s.title, s.artist, s.album, s.bpm, s.duration_sec,
f.local_path, f.file_format, f.file_missing
FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
LEFT JOIN files f ON f.id = ps.file_id
WHERE ps.playlist_id = ?
ORDER BY ps.position
""", (playlist_id,)).fetchall()
return {"playlist": dict(playlist), "songs": [dict(s) for s in songs]}
def add_song_to_playlist(playlist_id: str, song_id: str,
file_id: str | None = None,
position: int | None = None) -> str:
import uuid as _uuid
with get_db() as conn:
if position is None:
row = conn.execute(
"SELECT MAX(position) as max_pos FROM playlist_songs WHERE playlist_id=?",
(playlist_id,)
).fetchone()
position = (row["max_pos"] or 0) + 1
ps_id = str(_uuid.uuid4())
conn.execute(
"INSERT INTO playlist_songs (id, playlist_id, song_id, file_id, position) "
"VALUES (?,?,?,?,?)",
(ps_id, playlist_id, song_id, file_id, position)
)
return ps_id
# ── Dans-tags ─────────────────────────────────────────────────────────────────
def get_all_playlist_tags() -> list[str]:
return get_playlist_tags()
def get_playlist_tags() -> list[str]:
with get_db() as conn:
rows = conn.execute(
"SELECT tags FROM playlists WHERE tags != '' AND name != '__aktiv__' AND is_deleted=0"
).fetchall()
tags = set()
for row in rows:
for tag in row["tags"].split(","):
t = tag.strip().lower()
if t:
tags.add(t)
return sorted(tags)
def update_playlist_tags(playlist_id: str, tags: str):
with get_db() as conn:
conn.execute(
"UPDATE playlists SET tags=? WHERE id=?", (tags, playlist_id)
)
# ── Event-state ───────────────────────────────────────────────────────────────
def save_event_state(current_idx: int, statuses: list[str]):
import json
with get_db() as conn:
conn.execute(
"INSERT OR REPLACE INTO event_state (key,value) VALUES ('current_idx',?)",
(str(current_idx),)
)
conn.execute(
"INSERT OR REPLACE INTO event_state (key,value) VALUES ('statuses',?)",
(json.dumps(statuses),)
)
def load_event_state() -> tuple | None:
import json
with get_db() as conn:
idx_row = conn.execute(
"SELECT value FROM event_state WHERE key='current_idx'"
).fetchone()
stat_row = conn.execute(
"SELECT value FROM event_state WHERE key='statuses'"
).fetchone()
if not idx_row or not stat_row:
return None
return int(idx_row["value"]), json.loads(stat_row["value"])
# ── Dans-niveauer ─────────────────────────────────────────────────────────────
def get_dance_levels() -> list:
with get_db() as conn:
return conn.execute(
"SELECT * FROM dance_levels ORDER BY sort_order"
).fetchall()
def upsert_dance_levels(levels: list[dict]):
with get_db() as conn:
for lvl in levels:
conn.execute("""
INSERT INTO dance_levels (id, sort_order, name, description)
VALUES (:id, :sort_order, :name, :description)
ON CONFLICT(name) DO UPDATE SET
sort_order=excluded.sort_order,
description=excluded.description
""", 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

@@ -0,0 +1,201 @@
"""
scanner.py — Scanning af musikbiblioteker i baggrunden. v0.9
Skriver til files-tabellen og finder/opretter sange i songs-tabellen.
"""
import os
import logging
import time
from pathlib import Path
logger = logging.getLogger(__name__)
SUPPORTED = {'.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aiff', '.wma'}
import uuid as _uuid_module
def _find_or_create_song_conn(conn, title, artist, album, bpm,
duration_sec, mbid, acoustid) -> str:
"""Find eller opret sang via eksisterende forbindelse."""
if mbid:
row = conn.execute("SELECT id FROM songs WHERE mbid=?", (mbid,)).fetchone()
if row:
return row["id"]
if acoustid:
row = conn.execute("SELECT id FROM songs WHERE acoustid=?", (acoustid,)).fetchone()
if row:
if mbid:
conn.execute("UPDATE songs SET mbid=? WHERE id=? AND mbid IS NULL", (mbid, row["id"]))
return row["id"]
if title:
row = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=?", (title, artist)
).fetchone()
if row:
if mbid:
conn.execute("UPDATE songs SET mbid=? WHERE id=? AND mbid IS NULL", (mbid, row["id"]))
return row["id"]
new_id = str(_uuid_module.uuid4())
conn.execute(
"INSERT INTO songs (id, title, artist, album, bpm, duration_sec, mbid, acoustid) "
"VALUES (?,?,?,?,?,?,?,?)",
(new_id, title, artist, album, bpm, duration_sec, mbid or None, acoustid or None)
)
return new_id
def _upsert_file_conn(conn, song_id, local_path, file_format,
file_modified_at, extra_tags) -> str:
"""Opret eller opdater fil-post via eksisterende forbindelse."""
existing = conn.execute(
"SELECT id FROM files WHERE local_path=?", (local_path,)
).fetchone()
if existing:
conn.execute("""
UPDATE files SET song_id=?, file_missing=0,
file_format=?, file_modified_at=?, extra_tags=?
WHERE id=?
""", (song_id, file_format, file_modified_at, extra_tags, existing["id"]))
return existing["id"]
else:
file_id = str(_uuid_module.uuid4())
conn.execute(
"INSERT INTO files (id, song_id, local_path, file_format, file_modified_at, extra_tags) "
"VALUES (?,?,?,?,?,?)",
(file_id, song_id, local_path, file_format, file_modified_at, extra_tags)
)
return file_id
def is_supported(path) -> bool:
return Path(path).suffix.lower() in SUPPORTED
def get_file_mtime(path: Path) -> str:
try:
return str(os.path.getmtime(str(path)))
except Exception:
return ""
def scan_library(library_id: int, library_path: str, db_path: str,
overwrite_bpm: bool = False,
progress_callback=None) -> int:
"""
Scan ét bibliotek og upsert til files + songs tabellerne.
Returnerer antal scannede filer.
"""
import sqlite3
from local.tag_reader import read_tags
base = Path(library_path)
if not base.exists():
return 0
# Byg indeks over kendte filer (path → mtime)
conn = sqlite3.connect(db_path, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
known = {}
for row in conn.execute(
"SELECT local_path, file_modified_at FROM files WHERE file_missing=0"
).fetchall():
known[row["local_path"]] = row["file_modified_at"]
# Find alle musikfiler
all_files = []
for dirpath, _, filenames in os.walk(str(base), followlinks=False):
for fn in filenames:
fp = Path(dirpath) / fn
if is_supported(fp):
all_files.append(fp)
total = len(all_files)
done = 0
for fp in all_files:
path_str = str(fp)
mtime = get_file_mtime(fp)
if progress_callback:
progress_callback(done, total, fp.name)
# Spring over uændrede filer
if path_str in known and known[path_str] == mtime:
done += 1
time.sleep(0.005)
continue
try:
tags = read_tags(str(fp))
title = tags.get("title", "") or fp.stem
artist = tags.get("artist", "")
album = tags.get("album", "")
bpm = tags.get("bpm", 0)
mbid = tags.get("mbid", "")
acoustid = tags.get("acoustid", "")
duration_sec = tags.get("duration_sec", 0)
file_format = tags.get("file_format", fp.suffix.lstrip(".").lower())
import json as _json
_extra = tags.get("extra_tags", {})
extra_tags = _json.dumps(_extra) if isinstance(_extra, dict) else (_extra or "{}")
# Find eller opret sang — alt via samme conn
song_id = _find_or_create_song_conn(
conn, title, artist, album, bpm, duration_sec, mbid, acoustid
)
# Opdater BPM
if bpm and bpm > 0:
conn.execute(
"UPDATE songs SET bpm=? WHERE id=? AND (bpm=0 OR bpm IS NULL)",
(bpm, song_id)
)
# Opret eller opdater fil-post
_upsert_file_conn(conn, song_id, path_str, file_format, mtime, extra_tags)
# Dans-tags fra fil — synkroniser altid fra filen
file_dances = tags.get("dances", [])
if file_dances:
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:
logger.warning(f"Scan fejl {fp.name}: {e}")
done += 1
time.sleep(0.02)
# Marker manglende filer
for path_str in known:
if not Path(path_str).exists():
conn.execute(
"UPDATE files SET file_missing=1 WHERE local_path=?", (path_str,)
)
conn.commit()
conn.close()
logger.info(f"Scan færdig: {done} filer i {library_path}")
return done

View File

@@ -0,0 +1,630 @@
"""
sync_manager.py — Synkronisering mellem lokal database og server. v0.9
"""
import json
import logging
import sqlite3
import threading
import urllib.request
import urllib.error
from pathlib import Path
logger = logging.getLogger(__name__)
class SyncManager:
def __init__(self, api_url: str = "", db_path: str = "",
server_url: str = "", token: str | None = None):
# Støt både api_url og server_url som parameter-navn
self._api_url = (api_url or server_url).rstrip("/")
self._db_path = db_path
self._token: str | None = token
def set_token(self, token: str):
self._token = token
# ── HTTP ──────────────────────────────────────────────────────────────────
def _post(self, path: str, data: dict) -> dict:
body = json.dumps(data).encode()
req = urllib.request.Request(
f"{self._api_url}{path}",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._token}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
detail = e.read().decode()
raise Exception(f"HTTP {e.code}: {detail}")
def _get(self, path: str) -> dict:
req = urllib.request.Request(
f"{self._api_url}{path}",
headers={"Authorization": f"Bearer {self._token}"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
detail = e.read().decode()
raise Exception(f"HTTP {e.code}: {detail}")
# ── Push ──────────────────────────────────────────────────────────────────
def push(self, on_done=None, on_error=None):
def _run():
try:
payload = self._build_push_payload()
logger.info(f"Push: {len(payload['songs'])} sange, "
f"{len(payload['playlists'])} playlister")
result = self._post("/sync/push", payload)
self._save_server_ids(
result.get("song_id_map", {}),
result.get("playlist_id_map", {}),
)
logger.info(f"Push OK: {result.get('songs_synced','?')} sange synkroniseret")
if on_done:
on_done(result)
except Exception as e:
logger.error(f"Push fejl: {e}", exc_info=True)
if on_error:
on_error(str(e))
threading.Thread(target=_run, daemon=True).start()
# ── Push + Pull ───────────────────────────────────────────────────────────
def push_and_pull(self, on_done=None, on_error=None):
def _run():
try:
# 1. Push
payload = self._build_push_payload()
deleted = payload.get("deleted_playlists", [])
logger.info(f"Sync push — {len(payload['songs'])} sange, "
f"{len(payload['playlists'])} playlister, "
f"sletter {len(deleted)}: {deleted}")
push_result = self._post("/sync/push", payload)
self._save_server_ids(
push_result.get("song_id_map", {}),
push_result.get("playlist_id_map", {}),
)
logger.info(f"Push svar: status={push_result.get('status')}, "
f"sange={push_result.get('songs_synced', 0)}, "
f"playlister={push_result.get('playlists_synced', 0)}")
# 2. Pull
pull_result = self._get("/sync/pull")
pl_names = [p.get("name") for p in pull_result.get("my_playlists", [])]
logger.info(f"Pull modtog {len(pl_names)} playlister: {pl_names}")
self._apply_pull(pull_result)
# 3. Fjern soft-slettede permanent efter succesfuld sync
if deleted:
conn = sqlite3.connect(self._db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute(
"DELETE FROM playlists WHERE is_deleted=1 AND api_project_id IS NOT NULL"
)
conn.commit()
conn.close()
logger.info("Soft-slettede playlister fjernet lokalt efter sync")
pl_count = len(pull_result.get("my_playlists", []))
logger.info(f"Sync OK — {len(payload['songs'])} sange, "
f"{len(payload['playlists'])} playlister, "
f"{pl_count} server-playlister")
if on_done:
on_done({"push": push_result, "pull": pull_result})
except Exception as e:
logger.error(f"push_and_pull fejl: {e}", exc_info=True)
if on_error:
on_error(str(e))
threading.Thread(target=_run, daemon=True).start()
# ── Byg payload ───────────────────────────────────────────────────────────
def _build_push_payload(self) -> dict:
conn = sqlite3.connect(self._db_path, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
# Sange (dem der har filer — altså kendes lokalt)
songs = []
for row in conn.execute("""
SELECT DISTINCT s.id, s.title, s.artist, s.album,
s.bpm, s.duration_sec, s.mbid, s.acoustid, s.server_synced
FROM songs s
JOIN files f ON f.song_id = s.id AND f.file_missing = 0
""").fetchall():
songs.append({
"local_id": row["id"],
"title": row["title"] or "",
"artist": row["artist"] or "",
"album": row["album"] or "",
"bpm": row["bpm"] or 0,
"duration_sec": row["duration_sec"] or 0,
"mbid": row["mbid"] or "",
"acoustid": row["acoustid"] or "",
})
# Danse
dances = []
for row in conn.execute("""
SELECT d.name, dl.name as level_name, d.choreographer,
d.video_url, d.stepsheet_url, d.notes
FROM dances d LEFT JOIN dance_levels dl ON dl.id = d.level_id
""").fetchall():
dances.append({
"name": row["name"] or "",
"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 "",
})
# Dans-tags
song_dances = []
for row in conn.execute("""
SELECT sd.song_id, d.name as dance_name,
dl.name as level_name, 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
""").fetchall():
song_dances.append({
"song_local_id": row["song_id"],
"dance_name": row["dance_name"],
"level_name": row["level_name"] or "",
"dance_order": row["dance_order"],
})
# Alternativ-danse
song_alts = []
for row in conn.execute("""
SELECT sad.song_id, d.name as dance_name,
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
""").fetchall():
song_alts.append({
"song_local_id": row["song_id"],
"dance_name": row["dance_name"],
"level_name": row["level_name"] or "",
"note": row["note"] or "",
"user_rating": row["user_rating"],
})
# Playlister — alle ikke-slettede
playlists = []
for pl in conn.execute("""
SELECT id, name, description, tags, api_project_id
FROM playlists
WHERE name != '__aktiv__' AND is_deleted = 0
""").fetchall():
pl_songs = []
for ps in conn.execute("""
SELECT s.id as song_id, s.title, s.artist,
ps.position, ps.status, ps.is_workshop, ps.dance_override
FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id=? ORDER BY ps.position
""", (pl["id"],)).fetchall():
pl_songs.append({
"song_local_id": ps["song_id"],
"song_title": ps["title"] or "",
"song_artist": ps["artist"] or "",
"position": int(ps["position"] or 1),
"status": ps["status"] or "pending",
"is_workshop": bool(ps["is_workshop"]),
"dance_override": ps["dance_override"] or "",
})
# Brug api_project_id som local_id hvis kendt
local_id = pl["api_project_id"] or pl["id"]
playlists.append({
"local_id": local_id,
"name": pl["name"],
"description": pl["description"] or "",
"tags": pl["tags"] or "",
"visibility": "private",
"songs": pl_songs,
})
# Slettede playlister
deleted = [
row["api_project_id"]
for row in conn.execute(
"SELECT api_project_id FROM playlists "
"WHERE is_deleted=1 AND api_project_id IS NOT NULL AND api_project_id != ''"
).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_with_dances_synced": all_song_ids,
}
# ── Gem server-IDs ────────────────────────────────────────────────────────
def _save_server_ids(self, song_id_map: dict, playlist_id_map: dict):
"""
Gem server-IDs lokalt.
song_id_map: lokal_song_id → server_song_id
playlist_id_map: lokal_pl_id → server_pl_id
"""
if not song_id_map and not playlist_id_map:
return
conn = sqlite3.connect(self._db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
# Sange: hvis server gav et andet ID end det lokale, opdater
for local_id, server_id in song_id_map.items():
if local_id != server_id:
# Tjek om server-ID allerede eksisterer
existing = conn.execute(
"SELECT id FROM songs WHERE id=?", (server_id,)
).fetchone()
if not existing:
# Opdater lokal sang til server-ID
conn.execute(
"UPDATE songs SET id=?, server_synced=1 WHERE id=?",
(server_id, local_id)
)
# Opdater referencer
conn.execute(
"UPDATE files SET song_id=? WHERE song_id=?",
(server_id, local_id)
)
conn.execute(
"UPDATE playlist_songs SET song_id=? WHERE song_id=?",
(server_id, local_id)
)
conn.execute(
"UPDATE song_dances SET song_id=? WHERE song_id=?",
(server_id, local_id)
)
conn.execute(
"UPDATE song_alt_dances SET song_id=? WHERE song_id=?",
(server_id, local_id)
)
else:
conn.execute(
"UPDATE songs SET server_synced=1 WHERE id=?", (local_id,)
)
# Playlister
for local_id, server_id in playlist_id_map.items():
conn.execute(
"UPDATE playlists SET api_project_id=? WHERE id=? OR api_project_id=?",
(server_id, local_id, local_id)
)
conn.commit()
conn.close()
# ── Anvend pull ───────────────────────────────────────────────────────────
def _apply_pull(self, data: dict):
"""Gem server-data lokalt."""
import uuid
conn = sqlite3.connect(self._db_path, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
try:
# 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 "
"AND choreographer=? LIMIT 1",
(d["name"], choreo)
).fetchone()
if existing:
conn.execute("""
UPDATE dances SET
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("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 = {
row["api_project_id"]
for row in conn.execute(
"SELECT api_project_id FROM playlists "
"WHERE is_deleted=1 AND api_project_id IS NOT NULL"
).fetchall()
}
# Importer egne playlister
for pl in data.get("my_playlists", []):
server_id = pl.get("server_id")
name = pl.get("name", "")
if not server_id or not name:
continue
if server_id in deleted_server_ids:
continue
existing = conn.execute(
"SELECT id FROM playlists WHERE api_project_id=?", (server_id,)
).fetchone()
if existing:
pl_id = existing["id"]
conn.execute(
"UPDATE playlists SET name=? WHERE id=?", (name, pl_id)
)
else:
pl_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO playlists (id, name, description, api_project_id, is_linked, server_permission) "
"VALUES (?,?,?,?,1,'edit')",
(pl_id, name, pl.get("description",""), server_id)
)
# Genindlæs sange
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
position = 1
songs_from_server = pl.get("songs", [])
logger.info(f"Pull: liste '{name}' har {len(songs_from_server)} sange")
for song_data in songs_from_server:
server_song_id = song_data.get("song_id", "")
title = song_data.get("title", "")
artist = song_data.get("artist", "")
mbid = song_data.get("mbid", "")
acoustid = song_data.get("acoustid", "")
if not title and not server_song_id:
continue
# Find eller opret sang lokalt
local_song_id = self._find_or_create_song_local(
conn, server_song_id, title, artist,
mbid=mbid, acoustid=acoustid,
bpm=song_data.get("bpm", 0),
duration_sec=song_data.get("duration_sec", 0),
)
# Find tilgængelig fil til denne sang
file_row = conn.execute(
"SELECT id FROM files WHERE song_id=? AND file_missing=0 LIMIT 1",
(local_song_id,)
).fetchone()
file_id = file_row["id"] if file_row else None
conn.execute("""
INSERT INTO playlist_songs
(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override)
VALUES (?,?,?,?,?,?,?,?)
""", (str(uuid.uuid4()), pl_id, local_song_id, file_id, position,
song_data.get("status","pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override","") or ""))
position += 1
# Importer delte playlister
for pl in data.get("shared", []):
server_id = pl.get("server_id")
name = pl.get("name", "")
owner = pl.get("owner", "?")
if not server_id or not name:
continue
existing = conn.execute(
"SELECT id FROM playlists WHERE api_project_id=?", (server_id,)
).fetchone()
if existing:
pl_id = existing["id"]
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
else:
pl_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO playlists (id, name, description, api_project_id, is_linked, server_permission) "
"VALUES (?,?,?,?,1,'view')",
(pl_id, f"{name} ({owner})", "", server_id)
)
position = 1
for song_data in pl.get("songs", []):
server_song_id = song_data.get("song_id", "")
title = song_data.get("title", "")
artist = song_data.get("artist", "")
if not title and not server_song_id:
continue
local_song_id = self._find_or_create_song_local(
conn, server_song_id, title, artist,
mbid=song_data.get("mbid", ""),
acoustid=song_data.get("acoustid", ""),
)
file_row = conn.execute(
"SELECT id FROM files WHERE song_id=? AND file_missing=0 LIMIT 1",
(local_song_id,)
).fetchone()
file_id = file_row["id"] if file_row else None
conn.execute("""
INSERT INTO playlist_songs
(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override)
VALUES (?,?,?,?,?,?,?,?)
""", (str(uuid.uuid4()), pl_id, local_song_id, file_id, position,
song_data.get("status","pending"),
1 if song_data.get("is_workshop") else 0,
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:
conn.rollback()
raise
finally:
conn.close()
def _find_or_create_song_local(self, conn, server_song_id: str, title: str,
artist: str = "", mbid: str = "",
acoustid: str = "", bpm: int = 0,
duration_sec: int = 0) -> str:
"""Find eller opret sang lokalt. Returnerer lokal song_id."""
import uuid
# Match på server-ID
if server_song_id:
row = conn.execute(
"SELECT id FROM songs WHERE id=?", (server_song_id,)
).fetchone()
if row:
return row["id"]
# Match på MBID
if mbid:
row = conn.execute(
"SELECT id FROM songs WHERE mbid=?", (mbid,)
).fetchone()
if row:
return row["id"]
# Match på AcoustID
if acoustid:
row = conn.execute(
"SELECT id FROM songs WHERE acoustid=?", (acoustid,)
).fetchone()
if row:
return row["id"]
# Match på titel + artist
if title:
row = conn.execute(
"SELECT id FROM songs WHERE title=? AND artist=?", (title, artist)
).fetchone()
if row:
return row["id"]
# Opret ny — brug server-ID hvis tilgængeligt
new_id = server_song_id or str(uuid.uuid4())
conn.execute(
"INSERT INTO songs (id, title, artist, bpm, duration_sec, mbid, acoustid, server_synced) "
"VALUES (?,?,?,?,?,?,?,1)",
(new_id, title, artist, bpm, duration_sec, mbid or None, acoustid or None)
)
logger.info(f"Pull: oprettet sang '{title}' ({new_id})")
return new_id

View File

@@ -0,0 +1,523 @@
"""
tag_reader.py — Læser og skriver metadata fra lydfiler.
Understøttede formater og danse-tag support:
MP3 — læs + skriv danse (ID3 TXXX-felter)
FLAC — læs + skriv danse (Vorbis Comments)
OGG — læs + skriv danse (Vorbis Comments)
OPUS — læs + skriv danse (Vorbis Comments)
M4A — læs + skriv danse (MP4 custom felt ----:LINEDANCE:DANCE)
WAV — læs metadata, ingen danse-tag support
WMA — læs metadata, ingen danse-tag support
AIFF — læs metadata, ingen danse-tag support
Danse gemmes ALTID i SQLite uanset format.
Fil-skrivning er kun muligt for de formater der understøtter custom tags.
"""
import logging
logger = logging.getLogger(__name__)
import os
from datetime import datetime, timezone
from pathlib import Path
try:
from mutagen import File as MutagenFile
from mutagen.id3 import ID3, TXXX
from mutagen.flac import FLAC
from mutagen.mp4 import MP4, MP4FreeForm
MUTAGEN_AVAILABLE = True
except ImportError:
MUTAGEN_AVAILABLE = False
logger.warning("mutagen ikke installeret — tag-læsning deaktiveret")
# Filtyper vi høster metadata fra
SUPPORTED_EXTENSIONS = {
".mp3", ".flac", ".wav", ".m4a", ".aac",
".ogg", ".opus", ".wma", ".aiff", ".aif",
}
# Formater der understøtter skrivning af danse-tags til fil
WRITABLE_DANCE_FORMATS = {".mp3", ".flac", ".ogg", ".opus", ".m4a"}
# Tag-nøgler brugt på tværs af formater
TXXX_DANCE_PREFIX = "LINEDANCE_DANCE_" # MP3: TXXX:LINEDANCE_DANCE_1
VORBIS_DANCE_KEY = "linedance_dance" # FLAC/OGG: linedance_dance.1
M4A_DANCE_FREEFORM = "----:LINEDANCE:DANCE" # M4A: ----:LINEDANCE:DANCE (liste)
def is_supported(path: str | Path) -> bool:
return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS
def can_write_dances(path: str | Path) -> bool:
"""Returnerer True hvis formatet understøtter skrivning af danse-tags til fil."""
return Path(path).suffix.lower() in WRITABLE_DANCE_FORMATS
def get_file_modified_at(path: str | Path) -> str:
ts = os.path.getmtime(str(path))
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
# ── Læsning ───────────────────────────────────────────────────────────────────
def read_tags(path: str | Path) -> dict:
"""
Læser metadata og danse fra en lydfil.
Returnerer dict med: title, artist, album, bpm, duration_sec,
file_format, file_modified_at, dances, can_write_dances,
extra_tags (dict med alle øvrige tags som {navn: værdi}).
"""
path = Path(path)
result = {
"local_path": str(path),
"title": path.stem,
"artist": "",
"album": "",
"bpm": 0,
"duration_sec": 0,
"file_format": path.suffix.lower().lstrip("."),
"file_modified_at": get_file_modified_at(path),
"dances": [],
"can_write_dances": can_write_dances(path),
"extra_tags": {},
}
if not MUTAGEN_AVAILABLE:
return result
try:
audio = MutagenFile(str(path), easy=False)
if audio is None:
return result
if hasattr(audio, "info") and audio.info:
result["duration_sec"] = int(getattr(audio.info, "length", 0))
ext = path.suffix.lower()
if ext == ".mp3":
_read_mp3(audio, result)
elif ext == ".flac":
_read_vorbis(audio, result)
elif ext in (".ogg", ".opus"):
_read_vorbis(audio, result)
elif ext in (".m4a", ".aac", ".mp4"):
_read_m4a(audio, result)
else:
_read_generic(audio, result)
except Exception as e:
print(f"Fejl ved læsning af {path}: {e}")
return result
def _read_mp3(audio, result: dict):
tags = audio.tags
if not tags:
return
if "TIT2" in tags:
result["title"] = str(tags["TIT2"].text[0])
if "TPE1" in tags:
result["artist"] = str(tags["TPE1"].text[0])
if "TALB" in tags:
result["album"] = str(tags["TALB"].text[0])
if "TBPM" in tags:
try:
result["bpm"] = int(float(str(tags["TBPM"].text[0])))
except (ValueError, TypeError):
pass
dances = {}
extra = {}
# Kendte ID3-felt-navne til menneskelige navne
ID3_NAMES = {
"TIT2": "titel", "TPE1": "artist", "TALB": "album", "TBPM": "bpm",
"TYER": "år", "TDRC": "dato", "TCON": "genre", "TPE2": "albumartist",
"TPOS": "disknummer", "TRCK": "spornummer", "TCOM": "komponist",
"TLYR": "sangtekst", "TCOP": "copyright", "TPUB": "udgiver",
"TENC": "kodet_af", "TLAN": "sprog", "TMOO": "stemning",
"TPE3": "dirigent", "TPE4": "fortolket_af", "TOAL": "original_album",
"TOPE": "original_artist", "TORY": "original_år",
}
for key, frame in tags.items():
if key.startswith("TXXX:") and TXXX_DANCE_PREFIX in key:
try:
num = int(key.replace(f"TXXX:{TXXX_DANCE_PREFIX}", ""))
dances[num] = str(frame.text[0])
except (ValueError, IndexError):
pass
elif key.startswith("TXXX:"):
# Custom TXXX-felt — gem under dets beskrivelse
desc = key[5:] # fjern "TXXX:"
try:
extra[desc] = str(frame.text[0])
except Exception:
pass
elif key in ID3_NAMES and key not in ("TIT2","TPE1","TALB","TBPM"):
# Standardfelt vi ikke allerede har gemt
try:
val = str(frame.text[0]) if hasattr(frame, "text") else str(frame)
if val:
extra[ID3_NAMES[key]] = val
except Exception:
pass
elif hasattr(frame, "text") and key not in ("TIT2","TPE1","TALB","TBPM"):
# Alle andre tekstfelter
try:
val = str(frame.text[0])
if val and not key.startswith("APIC"): # spring albumcover over
extra[key] = val
except Exception:
pass
result["dances"] = [dances[k] for k in sorted(dances.keys())]
result["extra_tags"] = extra
# MusicBrainz Recording ID (MBID)
mbid = None
# Metode 1: UFID frame
for key, frame in tags.items():
if key.startswith("UFID") and "musicbrainz.org" in key.lower():
try:
mbid = frame.data.decode("utf-8", errors="replace").strip()
except Exception:
pass
break
# Metode 2: TXXX:MusicBrainz Recording Id
if not mbid:
for key, frame in tags.items():
if key.lower() in ("txxx:musicbrainz recording id",
"txxx:musicbrainz_trackid"):
try:
mbid = str(frame.text[0]).strip()
except Exception:
pass
break
if mbid:
result["mbid"] = mbid
def _read_vorbis(audio, result: dict):
"""FLAC og OGG/Opus bruger begge Vorbis Comments."""
tags = audio.tags
if not tags:
return
result["title"] = tags.get("title", [result["title"]])[0]
result["artist"] = tags.get("artist", [""])[0]
result["album"] = tags.get("album", [""])[0]
try:
result["bpm"] = int(tags.get("bpm", [0])[0])
except (ValueError, TypeError):
pass
# Danse
dances = {}
for key, values in tags.items():
if key.lower().startswith(f"{VORBIS_DANCE_KEY}."):
try:
num = int(key.split(".")[-1])
dances[num] = values[0]
except (ValueError, IndexError):
pass
if not dances and VORBIS_DANCE_KEY in tags:
result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()]
else:
result["dances"] = [dances[k] for k in sorted(dances.keys())]
# Øvrige tags
skip = {"title", "artist", "album", "bpm", VORBIS_DANCE_KEY}
extra = {}
for key, values in tags.items():
k = key.lower()
if k not in skip and not k.startswith(VORBIS_DANCE_KEY):
try:
extra[k] = str(values[0])
except Exception:
pass
result["extra_tags"] = extra
# MBID
for key in ("musicbrainz_trackid", "musicbrainz recording id"):
if key in tags:
try:
result["mbid"] = str(tags[key][0]).strip()
except Exception:
pass
break
def _read_m4a(audio, result: dict):
"""M4A/AAC/MP4 — iTunes atoms."""
tags = audio.tags
if not tags:
return
if "\xa9nam" in tags:
result["title"] = str(tags["\xa9nam"][0])
if "\xa9ART" in tags:
result["artist"] = str(tags["\xa9ART"][0])
if "\xa9alb" in tags:
result["album"] = str(tags["\xa9alb"][0])
if "tmpo" in tags:
try:
result["bpm"] = int(tags["tmpo"][0])
except (ValueError, TypeError):
pass
if M4A_DANCE_FREEFORM in tags:
result["dances"] = [
v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v)
for v in tags[M4A_DANCE_FREEFORM]
]
M4A_NAMES = {
"\xa9nam": "titel", "\xa9ART": "artist", "\xa9alb": "album",
"\xa9day": "år", "\xa9gen": "genre", "\xa9wrt": "komponist",
"\xa9cmt": "kommentar", "aART": "albumartist", "trkn": "spornummer",
"disk": "disknummer", "cprt": "copyright", "\xa9lyr": "sangtekst",
"tmpo": "bpm",
}
skip_keys = {"\xa9nam", "\xa9ART", "\xa9alb", "tmpo", M4A_DANCE_FREEFORM, "covr"}
extra = {}
for key, values in tags.items():
if key in skip_keys:
continue
label = M4A_NAMES.get(key, key)
try:
val = values[0]
if isinstance(val, (bytes, MP4FreeForm)):
val = val.decode("utf-8", errors="replace")
extra[label] = str(val)
except Exception:
pass
result["extra_tags"] = extra
# MBID
for key in tags:
if "musicbrainz" in key.lower() and "recording" in key.lower():
try:
val = tags[key][0]
if isinstance(val, (bytes, MP4FreeForm)):
val = val.decode("utf-8", errors="replace")
result["mbid"] = str(val).strip()
except Exception:
pass
break
def _read_generic(audio, result: dict):
"""Generisk læsning for WMA, AIFF og andre formater via easy tags."""
try:
from mutagen import File as MutagenFileEasy
local_path = result.get("local_path", "")
if local_path:
easy = MutagenFileEasy(local_path, easy=True)
if easy and easy.tags:
result["title"] = easy.tags.get("title", [result["title"]])[0]
result["artist"] = easy.tags.get("artist", [""])[0]
result["album"] = easy.tags.get("album", [""])[0]
except Exception:
# Fallback: læs direkte fra audio-objektet
try:
tags = audio.tags
if hasattr(tags, "items"):
for key, val in tags.items():
k = str(key).lower()
v = str(val[0]) if hasattr(val, "__iter__") and not isinstance(val, str) else str(val)
if "title" in k and not result["title"]:
result["title"] = v
elif "artist" in k and not result["artist"]:
result["artist"] = v
elif "album" in k and not result["album"]:
result["album"] = v
except Exception:
pass
# ── Skrivning ─────────────────────────────────────────────────────────────────
def write_dances(path: str | Path, dances: list[str]) -> bool:
"""
Skriver danse til filen hvis formatet understøtter det.
Returnerer True ved succes, False hvis formatet ikke understøtter det.
Kaster Exception ved fejl under skrivning.
"""
if not MUTAGEN_AVAILABLE:
return False
path = Path(path)
ext = path.suffix.lower()
if ext not in WRITABLE_DANCE_FORMATS:
return False
if ext == ".mp3":
return _write_mp3_dances(path, dances)
elif ext in (".flac", ".ogg", ".opus"):
return _write_vorbis_dances(path, dances)
elif ext in (".m4a", ".aac"):
return _write_m4a_dances(path, dances)
return False
def _write_mp3_dances(path: Path, dances: list[str]) -> bool:
try:
tags = ID3(str(path))
for key in [k for k in tags.keys() if TXXX_DANCE_PREFIX in k]:
del tags[key]
for i, name in enumerate(dances, start=1):
tags.add(TXXX(encoding=3, desc=f"{TXXX_DANCE_PREFIX}{i}", text=name))
tags.save(str(path))
return True
except Exception as e:
print(f"MP3 skrivefejl {path}: {e}")
return False
def _write_vorbis_dances(path: Path, dances: list[str]) -> bool:
try:
audio = MutagenFile(str(path), easy=False)
if audio is None or audio.tags is None:
return False
# Slet eksisterende danse-felter
keys_to_delete = [k for k in audio.tags.keys() if k.lower().startswith(f"{VORBIS_DANCE_KEY}.")]
for key in keys_to_delete:
del audio.tags[key]
# Skriv nye — ét felt per dans
for i, name in enumerate(dances, start=1):
audio.tags[f"{VORBIS_DANCE_KEY}.{i}"] = name
audio.save()
return True
except Exception as e:
logger.warning(f"Vorbis skrivefejl {path}: {e}")
return False
def _write_m4a_dances(path: Path, dances: list[str]) -> bool:
try:
audio = MP4(str(path))
audio.tags[M4A_DANCE_FREEFORM] = [
MP4FreeForm(name.encode("utf-8")) for name in dances
]
audio.save()
return True
except Exception as e:
logger.warning(f"M4A skrivefejl {path}: {e}")
return False
# ── Hurtig læsning af kun danse (uden fuld tag-scan) ─────────────────────────
def read_dances_from_file(path: str | Path) -> list[str]:
"""Læser kun danse fra en fil — hurtigere end fuld read_tags()."""
tags = read_tags(path)
return tags.get("dances", [])
# ── 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
y, sr = librosa.load(str(path), duration=60.0, mono=True)
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
# librosa returnerer array i nyere versioner
if hasattr(tempo, "__len__"):
bpm = float(tempo[0]) if len(tempo) > 0 else 0.0
else:
bpm = float(tempo)
return round(bpm, 1) if bpm > 0 else None
except ImportError:
logger.warning("librosa ikke installeret — installer med: pip install librosa")
return None
except Exception as e:
logger.warning(f"BPM-analyse fejl for {path}: {e}")
return None
def analyze_and_save_bpm(path: str | Path, song_id: str) -> float | None:
"""Analysér BPM og gem i SQLite. Returnerer målt BPM."""
bpm = analyze_bpm(path)
if bpm and bpm > 0:
try:
from local.local_db import get_db
with get_db() as conn:
conn.execute(
"UPDATE songs SET bpm=? WHERE id=? AND (bpm IS NULL OR bpm=0)",
(int(round(bpm)), song_id)
)
except Exception as e:
print(f"BPM gem fejl: {e}")
return bpm
# ── MBID skrivning ────────────────────────────────────────────────────────────
def write_mbid_to_file(path: str | Path, mbid: str) -> bool:
"""Skriv MusicBrainz Recording ID til filens tags."""
path = Path(path)
ext = path.suffix.lower()
try:
if ext == ".mp3":
return _write_mbid_mp3(path, mbid)
elif ext in (".flac", ".ogg", ".opus"):
return _write_mbid_vorbis(path, mbid)
elif ext in (".m4a", ".aac"):
return _write_mbid_m4a(path, mbid)
return False
except Exception as e:
logger.warning(f"MBID skrivefejl {path}: {e}")
return False
def _write_mbid_mp3(path: Path, mbid: str) -> bool:
try:
from mutagen.id3 import ID3, TXXX, UFID
tags = ID3(str(path))
# Skriv som TXXX:MusicBrainz Recording Id
tags.delall("TXXX:MusicBrainz Recording Id")
tags.add(TXXX(encoding=3, desc="MusicBrainz Recording Id", text=mbid))
# Skriv også som UFID
tags.delall("UFID:http://musicbrainz.org")
tags.add(UFID(owner="http://musicbrainz.org", data=mbid.encode("utf-8")))
tags.save(str(path))
return True
except Exception as e:
logger.warning(f"MBID MP3 skrivefejl {path}: {e}")
return False
def _write_mbid_vorbis(path: Path, mbid: str) -> bool:
try:
from mutagen import File as MutagenFile
audio = MutagenFile(str(path), easy=False)
if audio is None or audio.tags is None:
return False
audio.tags["musicbrainz_trackid"] = mbid
audio.save()
return True
except Exception as e:
logger.warning(f"MBID Vorbis skrivefejl {path}: {e}")
return False
def _write_mbid_m4a(path: Path, mbid: str) -> bool:
try:
from mutagen.mp4 import MP4, MP4FreeForm
audio = MP4(str(path))
key = "----:com.apple.iTunes:MusicBrainz Recording Id"
audio[key] = [MP4FreeForm(mbid.encode("utf-8"))]
audio.save()
return True
except Exception as e:
logger.warning(f"MBID M4A skrivefejl {path}: {e}")
return False

View File

@@ -0,0 +1,183 @@
"""
watchdog_process.py — Kører som selvstændig subprocess.
Overvåger musikmapper og opdaterer SQLite ved fil-ændringer.
Start: python watchdog_process.py <db_path>
"""
import sys
import os
import time
import json
import sqlite3
import logging
from pathlib import Path
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [watchdog] %(message)s",
stream=sys.stderr
)
logger = logging.getLogger(__name__)
SUPPORTED = {'.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aiff', '.wma'}
def is_supported(path: Path) -> bool:
return path.suffix.lower() in SUPPORTED
def get_libraries(db_path: str) -> list[dict]:
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
libs = conn.execute(
"SELECT id, path FROM libraries WHERE is_active=1"
).fetchall()
conn.close()
return [dict(l) for l in libs]
except Exception:
return []
def process_file(db_path: str, library_id: int, file_path: str,
deleted: bool = False):
"""Opdater SQLite for én fil."""
try:
# Tilføj app-mappen til sys.path så tag_reader kan importeres
app_dir = str(Path(__file__).parent.parent)
if app_dir not in sys.path:
sys.path.insert(0, app_dir)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
if deleted:
conn.execute(
"UPDATE songs SET file_missing=1 WHERE local_path=?",
(file_path,)
)
conn.commit()
conn.close()
return
from local.tag_reader import read_tags
import uuid
mtime = str(os.path.getmtime(file_path))
tags = read_tags(Path(file_path))
extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False)
existing = conn.execute(
"SELECT id, bpm FROM songs WHERE local_path=?", (file_path,)
).fetchone()
if existing:
bpm = tags.get("bpm", 0) or existing["bpm"] or 0
conn.execute("""
UPDATE songs SET
library_id=?, title=?, artist=?, album=?,
bpm=?, duration_sec=?, file_format=?,
file_modified_at=?, file_missing=0, extra_tags=?
WHERE id=?
""", (library_id, tags.get("title",""), tags.get("artist",""),
tags.get("album",""), bpm, tags.get("duration_sec",0),
tags.get("file_format",""), mtime, extra, existing["id"]))
else:
conn.execute("""
INSERT OR IGNORE INTO songs
(id, library_id, local_path, title, artist, album,
bpm, duration_sec, file_format, file_modified_at, extra_tags)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
""", (str(uuid.uuid4()), library_id, file_path,
tags.get("title",""), tags.get("artist",""),
tags.get("album",""), tags.get("bpm",0),
tags.get("duration_sec",0), tags.get("file_format",""),
mtime, extra))
conn.commit()
conn.close()
logger.info(f"Opdateret: {Path(file_path).name}")
except Exception as e:
logger.error(f"Fejl ved {file_path}: {e}")
def run(db_path: str):
try:
from watchdog.observers.polling import PollingObserver
from watchdog.events import FileSystemEventHandler
except ImportError:
logger.error("watchdog ikke installeret")
sys.exit(1)
class Handler(FileSystemEventHandler):
def __init__(self, library_id: int):
self.library_id = library_id
def on_created(self, event):
if not event.is_directory and is_supported(Path(event.src_path)):
time.sleep(0.5) # Vent til filen er skrevet færdig
process_file(db_path, self.library_id, event.src_path)
def on_modified(self, event):
if not event.is_directory and is_supported(Path(event.src_path)):
process_file(db_path, self.library_id, event.src_path)
def on_deleted(self, event):
if not event.is_directory and is_supported(Path(event.src_path)):
process_file(db_path, self.library_id, event.src_path,
deleted=True)
def on_moved(self, event):
if not event.is_directory:
if is_supported(Path(event.src_path)):
process_file(db_path, self.library_id, event.src_path,
deleted=True)
if is_supported(Path(event.dest_path)):
process_file(db_path, self.library_id, event.dest_path)
# Brug 60 sekunders poll-interval — opdager ændringer inden for 1 minut
observer = PollingObserver(timeout=60)
libraries = get_libraries(db_path)
if not libraries:
logger.info("Ingen biblioteker — venter...")
for lib in libraries:
path = Path(lib["path"])
if path.exists():
observer.schedule(Handler(lib["id"]), str(path), recursive=True)
logger.info(f"Overvåger: {path}")
else:
logger.warning(f"Mappe ikke fundet: {path}")
observer.start()
logger.info("Watchdog kører")
try:
while True:
time.sleep(30)
# Tjek om der er kommet nye biblioteker siden start
current = get_libraries(db_path)
current_paths = {lib["path"] for lib in current}
watched_paths = {str(w.path) for w in observer.emitters}
for lib in current:
if lib["path"] not in watched_paths:
path = Path(lib["path"])
if path.exists():
observer.schedule(
Handler(lib["id"]), str(path), recursive=True
)
logger.info(f"Tilføjet overvågning: {path}")
except KeyboardInterrupt:
pass
finally:
observer.stop()
observer.join()
logger.info("Watchdog stoppet")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Brug: python watchdog_process.py <db_path>")
sys.exit(1)
run(sys.argv[1])

69
linedance-app/main.py Normal file
View File

@@ -0,0 +1,69 @@
"""
main.py — Linedance afspiller.
Start:
python main.py
"""
import sys
import os
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__))
from app_logger import setup_logging
setup_logging()
from PyQt6.QtWidgets import QApplication
from ui.main_window import MainWindow
from ui.themes import apply_theme
def main():
app = QApplication(sys.argv)
app.setApplicationName("LineDance Player")
app.setOrganizationName("LineDance")
# Undertrryk ufarlige Qt Windows-advarsler i konsollen
import sys as _sys
from PyQt6.QtCore import qInstallMessageHandler, QtMsgType
def _qt_msg_handler(msg_type, context, message):
# Undertrykk kendte harmløse advarsler
if any(x in message for x in [
"registerTimer", "Unhandled scheme",
"Point size <= 0", "setPointSize",
"QFont::", "Timers cannot be stopped",
"Timers cannot be started",
"Cannot create children for a parent",
"event dispatcher has already been destroyed",
]):
return
if _sys.platform == "win32":
print(message)
qInstallMessageHandler(_qt_msg_handler)
# Indlæs sprog fra indstillinger
from ui.settings_dialog import load_settings
from translations import load_language
settings = load_settings()
load_language(settings.get("language", "da"))
apply_theme(app, dark=settings.get("dark_theme", True))
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1,332 @@
"""
player.py — VLC-baseret afspiller med PyQt6 signals.
Sender signals til GUI:
position_changed(float) — 0.01.0 progress
time_changed(int, int) — (current_sec, total_sec)
levels_changed(float, float) — VU-meter L/R 0.01.0
song_ended() — sang færdig
state_changed(str) — 'playing'|'paused'|'stopped'
"""
from PyQt6.QtCore import QObject, pyqtSignal, QTimer
import random
import math
try:
import vlc
VLC_AVAILABLE = True
except ImportError:
VLC_AVAILABLE = False
print("Advarsel: python-vlc ikke installeret — afspilning deaktiveret")
class Player(QObject):
position_changed = pyqtSignal(float)
time_changed = pyqtSignal(int, int)
levels_changed = pyqtSignal(float, float)
song_ended = pyqtSignal()
state_changed = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._path: str | None = None
self._duration: int = 0
self._demo_mode = False
self._demo_stop_sec = 10
self._demo_fade_sec = 5
self._demo_fading = False
self._volume = 78
if VLC_AVAILABLE:
self._instance = vlc.Instance("--no-video", "--quiet")
self._media_player = self._instance.media_player_new()
self._events = self._media_player.event_manager()
self._events.event_attach(
vlc.EventType.MediaPlayerEndReached,
self._on_end_reached,
)
else:
self._media_player = None
# Timer til polling af position + VU-simulation
self._poll_timer = QTimer(self)
self._poll_timer.setInterval(80)
self._poll_timer.timeout.connect(self._poll)
# ── Indlæsning ────────────────────────────────────────────────────────────
def load(self, path: str, duration_sec: int = 0):
"""Indlæs en lydfil uden at starte afspilning."""
self._path = path
self._duration = duration_sec
self._demo_mode = False
if VLC_AVAILABLE and self._media_player:
# Konverter GVFS SMB-sti til SMB URL som VLC kan tilgå direkte
vlc_path = self._resolve_path(path)
media = self._instance.media_new(vlc_path)
self._media_player.set_media(media)
self._media_player.audio_set_volume(self._volume)
self.position_changed.emit(0.0)
self.time_changed.emit(0, self._duration)
self.state_changed.emit("stopped")
def _resolve_path(self, path: str) -> str:
"""Konverter platform-specifikke netværksstier til URL'er VLC kan bruge."""
import re, sys
# Linux GVFS SMB: /run/user/1000/gvfs/smb-share:server=X,share=Y/sti/fil.mp3
m = re.match(r".*/gvfs/smb-share:server=([^,]+),share=([^/]+)(/.+)$", path)
if m:
server, share, rest = m.group(1), m.group(2), m.group(3)
return f"smb://{server}/{share}{rest}"
# Windows UNC: \\server\share\sti\fil.mp3
if path.startswith("\\\\"):
# \\server\share\rest → smb://server/share/rest
parts = path.replace("\\", "/").lstrip("/").split("/", 2)
if len(parts) >= 2:
server = parts[0]
share = parts[1]
rest = "/" + parts[2] if len(parts) > 2 else ""
return f"smb://{server}/{share}{rest}"
# Lokale stier og drevbogstaver (C:\...) — VLC håndterer dem fint
return path
self.position_changed.emit(0.0)
self.time_changed.emit(0, self._duration)
self.state_changed.emit("stopped")
# ── Transport ─────────────────────────────────────────────────────────────
def play(self):
self._demo_mode = False
if VLC_AVAILABLE and self._media_player:
self._media_player.play()
self._poll_timer.start()
self.state_changed.emit("playing")
def play_demo(self, stop_at_sec: int = 10, fade_sec: int = 5):
"""
Afspil fra start, fade ud over fade_sec sekunder og stop.
Total afspilningstid = stop_at_sec + fade_sec.
fade_sec=0 giver ingen fade.
"""
self._demo_mode = True
self._demo_stop_sec = stop_at_sec + fade_sec # total tid inkl. fade
self._demo_fade_sec = fade_sec
self._demo_fading = False
if VLC_AVAILABLE and self._media_player:
self._media_player.set_time(0)
self._media_player.audio_set_volume(self._volume)
self._media_player.play()
self._poll_timer.start()
self.state_changed.emit("playing")
def pause(self):
if VLC_AVAILABLE and self._media_player:
self._media_player.pause()
self.state_changed.emit("paused")
def stop(self):
self._demo_mode = False
self._demo_fading = False
if VLC_AVAILABLE and self._media_player:
self._media_player.audio_set_volume(self._volume)
self._media_player.stop()
self._poll_timer.stop()
self.position_changed.emit(0.0)
self.time_changed.emit(0, self._duration)
self.state_changed.emit("stopped")
def is_playing(self) -> bool:
if VLC_AVAILABLE and self._media_player:
return self._media_player.is_playing()
return False
def set_volume(self, volume: int):
"""0100"""
self._volume = volume
if VLC_AVAILABLE and self._media_player:
self._media_player.audio_set_volume(volume)
def set_position(self, fraction: float):
"""Søg til position 0.01.0"""
if VLC_AVAILABLE and self._media_player:
self._media_player.set_position(fraction)
# ── Intern polling ────────────────────────────────────────────────────────
def _poll(self):
"""Køres ~12 gange per sekund — opdaterer position og VU-meter."""
if VLC_AVAILABLE and self._media_player:
pos = self._media_player.get_position()
ms = self._media_player.get_time()
cur = max(0, ms // 1000)
else:
# Simuleret tilstand (til UI-test uden VLC)
pos = getattr(self, "_sim_pos", 0.0)
self._sim_pos = min(1.0, pos + 0.001)
cur = int(self._sim_pos * self._duration)
pos = self._sim_pos
if self._sim_pos >= 1.0:
self._on_end_reached(None)
return
self.position_changed.emit(pos)
self.time_changed.emit(cur, self._duration)
# Demo fade-out og stop
if self._demo_mode and cur >= self._demo_stop_sec:
# Færdig — gendan volumen og stop
if VLC_AVAILABLE and self._media_player:
self._media_player.audio_set_volume(self._volume)
self.stop()
self._demo_mode = False
self._demo_fading = False
self.position_changed.emit(0.0)
self.time_changed.emit(0, self._duration)
self.state_changed.emit("demo_ended")
return
# Demo fade-out — de sidste _demo_fade_sec sekunder (0 = ingen fade)
if self._demo_mode and VLC_AVAILABLE and self._media_player and self._demo_fade_sec > 0:
secs_left = self._demo_stop_sec - cur
if secs_left <= self._demo_fade_sec and secs_left > 0:
fade_fraction = secs_left / self._demo_fade_sec # 1.0 → 0.0
log_fraction = math.log10(1 + fade_fraction * 9) / math.log10(10)
faded_vol = int(self._volume * log_fraction)
self._media_player.audio_set_volume(max(0, faded_vol))
self._demo_fading = True
elif not self._demo_fading:
self._media_player.audio_set_volume(self._volume)
# VU-meter: brug VLC's audio-amplitude hvis tilgængelig, ellers simulér
if VLC_AVAILABLE and self._media_player and self._media_player.is_playing():
# VLC eksponerer ikke amplitude direkte — vi bruger en blød simulation
# der er baseret på position så det ser organisk ud
base = 0.55 + 0.3 * abs(pos - 0.5)
l = min(1.0, base + random.gauss(0, 0.12))
r = min(1.0, base + random.gauss(0, 0.12))
else:
l = r = 0.0
self.levels_changed.emit(max(0.0, l), max(0.0, r))
def set_audio_device(self, device_id: str):
"""Sæt lydoutput-enhed. device_id fra get_audio_devices()."""
if VLC_AVAILABLE and self._media_player:
self._media_player.audio_output_device_set(None, device_id)
@staticmethod
def get_audio_devices() -> list[dict]:
"""Returner liste af tilgængelige lydenheder."""
if not VLC_AVAILABLE:
return []
try:
instance = vlc.Instance("--no-video", "--quiet")
mp = instance.media_player_new()
devices = []
d = mp.audio_output_device_enum()
if d:
node = d
while node:
devices.append({
"id": node.contents.device.decode("utf-8", errors="replace"),
"name": node.contents.description.decode("utf-8", errors="replace"),
})
node = node.contents.next
vlc.libvlc_audio_output_device_list_release(d)
mp.release()
instance.release()
return devices
except Exception:
return []
def _on_end_reached(self, event):
"""Kaldes fra VLC's event-tråd — må IKKE røre Qt-objekter direkte."""
from PyQt6.QtCore import QTimer as _QTimer
_QTimer.singleShot(0, self._handle_end_in_main_thread)
def _handle_end_in_main_thread(self):
"""Kaldes i main thread — her er det sikkert at røre Qt."""
self._poll_timer.stop()
self.song_ended.emit()
self.state_changed.emit("stopped")
class PreviewPlayer(QObject):
"""Simpel preview-afspiller til bibliotek — ingen signals, bare play/stop."""
def __init__(self, parent=None):
super().__init__(parent)
self._volume = 78
self._device_id = ""
if VLC_AVAILABLE:
self._instance = vlc.Instance("--no-video", "--quiet")
self._mp = self._instance.media_player_new()
else:
self._mp = None
def play(self, path: str):
if not VLC_AVAILABLE or not self._mp:
return
from player.player import Player
vlc_path = Player._resolve_path(self, path)
media = self._instance.media_new(vlc_path)
self._mp.set_media(media)
self._mp.audio_set_volume(self._volume)
self._mp.play()
# Sæt lydenhed efter play — VLC nulstiller den ved ny media
if self._device_id:
self._mp.audio_output_device_set(None, self._device_id)
def pause(self):
if VLC_AVAILABLE and self._mp:
self._mp.pause()
def resume(self):
if VLC_AVAILABLE and self._mp:
self._mp.play()
def stop(self):
if VLC_AVAILABLE and self._mp:
self._mp.stop()
def seek(self, fraction: float):
if VLC_AVAILABLE and self._mp:
self._mp.set_position(fraction)
def is_playing(self) -> bool:
if VLC_AVAILABLE and self._mp:
return bool(self._mp.is_playing())
return False
def get_position(self) -> float:
if VLC_AVAILABLE and self._mp:
return max(0.0, self._mp.get_position())
return 0.0
def get_time(self) -> int:
if VLC_AVAILABLE and self._mp:
return max(0, self._mp.get_time() // 1000)
return 0
def get_duration(self) -> int:
if VLC_AVAILABLE and self._mp:
ms = self._mp.get_length()
return max(0, ms // 1000)
return 0
def set_volume(self, volume: int):
self._volume = volume
if VLC_AVAILABLE and self._mp:
self._mp.audio_set_volume(volume)
def set_audio_device(self, device_id: str):
self._device_id = device_id
if VLC_AVAILABLE and self._mp:
self._mp.audio_output_device_set(None, device_id)

View File

@@ -0,0 +1,7 @@
PyQt6>=6.6.0
python-vlc>=3.0.18
mutagen>=1.47.0
watchdog>=4.0.0
# BPM-analyse
librosa>=0.10.0

24
linedance-app/setup.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Kør fra LinedanceAfspiller/linedance-app/ mappen
echo "=== LineDance Player Setup ==="
# Opret venv hvis den ikke eksisterer
if [ ! -d "venv" ]; then
echo "Opretter virtual environment..."
python3 -m venv venv
fi
# Aktiver venv
source venv/bin/activate
# Installer afhængigheder
echo "Installerer afhængigheder..."
pip install --upgrade pip
pip install -r requirements.txt
echo ""
echo "=== Færdig! ==="
echo "Start programmet med:"
echo " source venv/bin/activate"
echo " python main.py"

View File

@@ -0,0 +1,57 @@
"""
translations/__init__.py — Central oversættelsesfunktion.
Brug:
from translations import _
btn = QPushButton(_("btn.start_event"))
lbl = QLabel(_("level.beginner"))
Fallback: hvis nøglen ikke findes returneres nøglen selv.
"""
_current: dict[str, str] = {}
_current_lang: str = "da"
def load_language(lang: str = "da"):
"""Indlæs sprogfil. Kaldes ved opstart og ved sprogskift i indstillinger."""
global _current, _current_lang
_current_lang = lang
try:
if lang == "en":
from translations.en import STRINGS
else:
from translations.da import STRINGS
_current = STRINGS
except ImportError:
from translations.da import STRINGS
_current = STRINGS
def _(key: str, **kwargs) -> str:
"""Oversæt en nøgle. Fallback = nøglen selv hvis ikke fundet."""
text = _current.get(key, key)
return text.format(**kwargs) if kwargs else text
def current_lang() -> str:
return _current_lang
def translate_level(level_name: str | None) -> str:
"""Oversæt et niveau-navn fra API/DB canonical navn til valgt sprog."""
if not level_name:
return _("level.none")
key = f"level.{level_name.lower().replace(' ', '_').replace('ø', 'o').replace('æ', 'ae').replace('å', 'aa')}"
result = _current.get(key)
if result:
return result
# Fallback: prøv direkte match
for k, v in _current.items():
if k.startswith("level.") and v.lower() == level_name.lower():
return v
return level_name # helt rå fallback
# Indlæs dansk som standard ved import
load_language("da")

View File

@@ -0,0 +1,201 @@
"""Dansk oversættelse — standardsprog."""
STRINGS = {
# App
"app.title": "LineDance Player",
"app.ready": "Klar",
"app.no_song": "— Ingen sang indlæst —",
"app.playlist_done": "— Danseliste afsluttet —",
"app.no_dance_tagged": "ingen dans tagget",
# Menu
"menu.file": "Filer",
"menu.go_online": "Gå online...",
"menu.go_offline": "Gå offline",
"menu.settings": "Indstillinger...",
"menu.quit": "Afslut",
# Bibliotek
"library.title": "BIBLIOTEK",
"library.search": "Søg i titel, artist, album, dans...",
"library.songs": "{count} sange",
"library.song": "{count} sang",
"library.results": "{count} resultater for \"{query}\"",
"library.result": "{count} resultat for \"{query}\"",
"library.btn_bpm": "♩ BPM alle",
"library.btn_manage": "⚙ Mapper",
"library.bpm_scanning": "{done}/{total}...",
"library.bpm_all_done": "♩ Alle har BPM",
"library.missing": "",
"library.no_dance": "ingen dans tagget",
# Mapper dialog
"folders.title": "Administrer musikbiblioteker",
"folders.active": "Aktive musikbiblioteker:",
"folders.note": "Når du fjerner et bibliotek, slettes det fra overvågningen.\nSangene forbliver i databasen men markeres som manglende (⚠).",
"folders.btn_add": "+ Tilføj mappe",
"folders.btn_remove": "✕ Fjern valgt",
"folders.btn_scan": "⟳ Scan alle",
"folders.btn_close": "Luk",
"folders.never_scanned": "aldrig",
"folders.not_found": " ⚠ mappe ikke fundet",
"folders.songs_count": "{count} sange · senest scannet: {date}",
"folders.confirm_remove": "Fjern overvågningen af:\n{path}\n\nSange i biblioteket forbliver i databasen men markeres som manglende.",
# Danseliste
"playlist.title": "DANSELISTE",
"playlist.new_title": "DANSELISTE — NY",
"playlist.btn_new": "✚ Ny",
"playlist.btn_save": "💾 Gem som...",
"playlist.btn_load": "📂 Hent...",
"playlist.btn_start": "▶ START EVENT",
"playlist.progress": "{played} / {total} afspillet",
"playlist.not_saved": "● ikke gemt",
"playlist.saved": "✓ gemt",
"playlist.save_error": "⚠ gemfejl",
"playlist.restored": "✓ gendannet",
"playlist.saved_as": "✓ gemt som \"{name}\"",
"playlist.name_prompt": "Navn på danselisten:",
"playlist.name_dialog": "Gem danseliste",
"playlist.load_dialog": "Hent danseliste",
"playlist.load_choose": "Vælg en liste:",
"playlist.empty": "Danselisten er tom.",
"playlist.no_lists": "Ingen gemte danselister fundet.",
"playlist.confirm_new": "Ryd den aktuelle liste og start forfra?",
"playlist.confirm_event": "Dette nulstiller alle statusser i danselisten.\nFortsæt?",
"playlist.ready": "Klar: {title} — tryk ▶ for at starte",
"playlist.done": "Danselisten er afsluttet",
"playlist.event_ready": "Event klar: {title} — tryk ▶ for at starte",
"playlist.added": "Tilføjet til danseliste: {title}",
# Kontekstmenu danseliste
"playlist.ctx_play": "▶ Afspil denne",
"playlist.ctx_skip": "— Spring over",
"playlist.ctx_unplay": "↺ Sæt til ikke afspillet",
"playlist.ctx_played": "✓ Sæt til afspillet",
"playlist.ctx_remove": "✕ Fjern fra liste",
# Kontekstmenu bibliotek
"library.ctx_add": "Tilføj til danseliste",
"library.ctx_play": "Afspil",
"library.ctx_tags": "✎ Rediger dans-tags...",
"library.ctx_bpm": "♩ Analysér BPM",
"library.ctx_send": "Send til",
"library.ctx_mail": "✉ Send som mail",
"library.btn_danse": "Danse",
# Afspiller
"player.no_song": "Ingen sang indlæst",
"player.loaded": "Indlæst: {title}",
"player.vol": "VOL",
"player.demo_btn": "\n{sec} SEK",
"player.event_resumed": "Event genoptaget ved: {title} — tryk ▶ for at fortsætte",
# Transport-knapper (tooltips)
"player.btn_prev": "Forrige sang",
"player.btn_play": "Afspil / Pause",
"player.btn_stop": "Stop",
"player.btn_next": "Næste sang",
"player.btn_demo": "Afspil forspil",
# Indstillinger
"settings.title": "Indstillinger",
"settings.tab_appearance": "🎨 Udseende",
"settings.tab_playback": "▶ Afspilning",
"settings.tab_mail": "✉ Mail",
"settings.tab_online": "🌐 Online",
"settings.tab_language": "🌍 Sprog",
"settings.btn_save": "💾 Gem indstillinger",
"settings.btn_cancel": "Annuller",
"settings.dark_theme": "Start med mørkt tema",
"settings.theme_note": "Du kan altid skifte tema mens programmet kører via topbar-knappen.",
"settings.demo_group": "Forspil (▶ N SEK knappen)",
"settings.demo_length": "Forspil-længde:",
"settings.demo_fade": "Fade-ud:",
"settings.demo_suffix": " sekunder",
"settings.fade_suffix": " sekunder (0 = ingen fade)",
"settings.demo_note": "Forspillet afspiller begyndelsen af sangen.\nFade-ud tilføjes oven i forspillets længde.",
"settings.mail_group": "Mailklient",
"settings.mail_label": "Klient:",
"settings.mail_path": "Sti:",
"settings.mail_auto": "Auto-detekter (Thunderbird → Outlook → mailto:)",
"settings.mail_tb": "Thunderbird",
"settings.mail_ol": "Outlook (Windows)",
"settings.mail_custom": "Brugerdefineret sti",
"settings.mail_mailto": "Kun mailto: (ingen vedhæftning)",
"settings.mail_note": "Med Thunderbird og Outlook åbnes et nyt compose-vindue med filen vedhæftet.",
"settings.online_group": "Automatisk login ved opstart",
"settings.auto_login": "Log automatisk ind når programmet starter",
"settings.username": "Brugernavn:",
"settings.password": "Kodeord:",
"settings.password_warn": "⚠ Kodeordet gemmes lokalt på denne computer.\nBrug kun dette på en personlig maskine.",
"settings.lang_group": "Sprog",
"settings.lang_label": "Programsprog:",
"settings.lang_note": "Sproget anvendes næste gang programmet startes.",
"settings.saved": "Indstillinger gemt",
# Tag-editor
"tags.title": "Rediger tags — {title}",
"tags.can_write": "✓ Danse skrives til filen",
"tags.cant_write": "⚠ Dette format understøtter ikke fil-skrivning",
"tags.hint": "Skriv dansenavn — forslag vises som 'Navn / Niveau'. Vælg fra listen for at få niveau automatisk.",
"tags.dances": "Danse",
"tags.alts": "Alternativ-danse",
"tags.btn_add": "+ Tilføj",
"tags.btn_save": "💾 Gem tags",
"tags.btn_cancel": "Annuller",
"tags.new_dance": "Ny dans (f.eks. Cowboy Cha Cha)...",
"tags.new_alt": "Alternativ dans...",
"tags.note": "note...",
"tags.warn_file": "Gemt i database, men kunne ikke skrive til filen.",
"tags.error": "Kunne ikke gemme: {error}",
"tags.no_level": "— intet niveau —",
# Niveauer
"level.none": "— intet niveau —",
"level.beginner": "Begynder",
"level.let_ovet": "Let øvet",
"level.easy": "Let øvet",
"level.ovet": "Øvet",
"level.intermediate": "Øvet",
"level.erfaren": "Erfaren",
"level.experienced": "Erfaren",
"level.ekspert": "Ekspert",
"level.expert": "Ekspert",
# Online / login
"online.logging_in": "Logger ind som {username}...",
"online.logged_in": "Online som {username}",
"online.auto_login_fail": "Auto-login fejlede — kør Filer → Gå online manuelt",
"online.logged_out": "Offline",
"online.syncing": "Synkroniserer dans-data...",
"online.synced": "Synkroniseret {levels} niveauer og {names} dans-navne",
# Scanning
"scan.preparing": "Starter scanning af biblioteker...",
"scan.scanning": "Scanner: {name}...",
"scan.scanning_count": "Scanner: {name} ({count} filer)...",
"scan.done": "Scan færdig — {count} filer gennemgået",
"scan.error": "Scan fejl: {error}",
"scan.folder_missing": "⚠ Mappe ikke fundet: {path}",
# Fejl
"error.title": "Fejl",
"error.db_init": "Database fejl: {error}",
"error.folder_remove": "Kunne ikke fjerne: {error}",
"error.save_tags": "Kunne ikke gemme tags: {error}",
# Mail
"mail.thunderbird_ok": "Thunderbird åbnet med {filename} vedh.",
"mail.outlook_ok": "Outlook åbnet med {filename} vedh.",
"mail.fallback": "Ingen kendt mailklient fundet — åbnet mailto: (uden vedhæftning)",
"mail.file_missing": "Filen blev ikke fundet — kan ikke sende mail",
# Generelt
"btn.ok": "OK",
"btn.cancel": "Annuller",
"btn.close": "Luk",
"btn.yes": "Ja",
"btn.no": "Nej",
"dialog.confirm": "Bekræft",
}

View File

@@ -0,0 +1,201 @@
"""English translation."""
STRINGS = {
# App
"app.title": "LineDance Player",
"app.ready": "Ready",
"app.no_song": "— No song loaded —",
"app.playlist_done": "— Playlist finished —",
"app.no_dance_tagged": "no dance tagged",
# Menu
"menu.file": "File",
"menu.go_online": "Go online...",
"menu.go_offline": "Go offline",
"menu.settings": "Settings...",
"menu.quit": "Quit",
# Library
"library.title": "LIBRARY",
"library.search": "Search title, artist, album, dance...",
"library.songs": "{count} songs",
"library.song": "{count} song",
"library.results": "{count} results for \"{query}\"",
"library.result": "{count} result for \"{query}\"",
"library.btn_bpm": "♩ BPM all",
"library.btn_manage": "⚙ Folders",
"library.bpm_scanning": "{done}/{total}...",
"library.bpm_all_done": "♩ All have BPM",
"library.missing": "",
"library.no_dance": "no dance tagged",
# Folders dialog
"folders.title": "Manage music libraries",
"folders.active": "Active music libraries:",
"folders.note": "When you remove a library, it is removed from monitoring.\nSongs remain in the database but are marked as missing (⚠).",
"folders.btn_add": "+ Add folder",
"folders.btn_remove": "✕ Remove selected",
"folders.btn_scan": "⟳ Scan all",
"folders.btn_close": "Close",
"folders.never_scanned": "never",
"folders.not_found": " ⚠ folder not found",
"folders.songs_count": "{count} songs · last scanned: {date}",
"folders.confirm_remove": "Remove monitoring of:\n{path}\n\nSongs remain in the database but will be marked as missing.",
# Playlist
"playlist.title": "PLAYLIST",
"playlist.new_title": "PLAYLIST — NEW",
"playlist.btn_new": "✚ New",
"playlist.btn_save": "💾 Save as...",
"playlist.btn_load": "📂 Load...",
"playlist.btn_start": "▶ START EVENT",
"playlist.progress": "{played} / {total} played",
"playlist.not_saved": "● unsaved",
"playlist.saved": "✓ saved",
"playlist.save_error": "⚠ save error",
"playlist.restored": "✓ restored",
"playlist.saved_as": "✓ saved as \"{name}\"",
"playlist.name_prompt": "Playlist name:",
"playlist.name_dialog": "Save playlist",
"playlist.load_dialog": "Load playlist",
"playlist.load_choose": "Choose a playlist:",
"playlist.empty": "The playlist is empty.",
"playlist.no_lists": "No saved playlists found.",
"playlist.confirm_new": "Clear the current playlist and start over?",
"playlist.confirm_event": "This will reset all statuses in the playlist.\nContinue?",
"playlist.ready": "Ready: {title} — press ▶ to start",
"playlist.done": "Playlist finished",
"playlist.event_ready": "Event ready: {title} — press ▶ to start",
"playlist.added": "Added to playlist: {title}",
# Playlist context menu
"playlist.ctx_play": "▶ Play this",
"playlist.ctx_skip": "— Skip",
"playlist.ctx_unplay": "↺ Mark as not played",
"playlist.ctx_played": "✓ Mark as played",
"playlist.ctx_remove": "✕ Remove from playlist",
# Library context menu
"library.ctx_add": "Add to playlist",
"library.ctx_play": "Play",
"library.ctx_tags": "✎ Edit dance tags...",
"library.ctx_bpm": "♩ Analyse BPM",
"library.ctx_send": "Send to",
"library.ctx_mail": "✉ Send by email",
"library.btn_danse": "Dances",
# Player
"player.no_song": "No song loaded",
"player.loaded": "Loaded: {title}",
"player.vol": "VOL",
"player.demo_btn": "\n{sec} SEC",
"player.event_resumed": "Event resumed at: {title} — press ▶ to continue",
# Transport tooltips
"player.btn_prev": "Previous song",
"player.btn_play": "Play / Pause",
"player.btn_stop": "Stop",
"player.btn_next": "Next song",
"player.btn_demo": "Play preview",
# Settings
"settings.title": "Settings",
"settings.tab_appearance": "🎨 Appearance",
"settings.tab_playback": "▶ Playback",
"settings.tab_mail": "✉ Mail",
"settings.tab_online": "🌐 Online",
"settings.tab_language": "🌍 Language",
"settings.btn_save": "💾 Save settings",
"settings.btn_cancel": "Cancel",
"settings.dark_theme": "Start with dark theme",
"settings.theme_note": "You can always switch theme while the program is running.",
"settings.demo_group": "Preview (▶ N SEC button)",
"settings.demo_length": "Preview length:",
"settings.demo_fade": "Fade-out:",
"settings.demo_suffix": " seconds",
"settings.fade_suffix": " seconds (0 = no fade)",
"settings.demo_note": "The preview plays the beginning of the song.\nFade-out is added on top of the preview length.",
"settings.mail_group": "Mail client",
"settings.mail_label": "Client:",
"settings.mail_path": "Path:",
"settings.mail_auto": "Auto-detect (Thunderbird → Outlook → mailto:)",
"settings.mail_tb": "Thunderbird",
"settings.mail_ol": "Outlook (Windows)",
"settings.mail_custom": "Custom path",
"settings.mail_mailto": "mailto: only (no attachment)",
"settings.mail_note": "With Thunderbird and Outlook a new compose window opens with the file attached.",
"settings.online_group": "Automatic login at startup",
"settings.auto_login": "Log in automatically when the program starts",
"settings.username": "Username:",
"settings.password": "Password:",
"settings.password_warn": "⚠ The password is stored locally on this computer.\nOnly use this on a personal machine.",
"settings.lang_group": "Language",
"settings.lang_label": "Interface language:",
"settings.lang_note": "The language will be applied next time the program starts.",
"settings.saved": "Settings saved",
# Tag editor
"tags.title": "Edit tags — {title}",
"tags.can_write": "✓ Dances are written to the file",
"tags.cant_write": "⚠ This format does not support file writing",
"tags.hint": "Type a dance name — suggestions show as 'Name / Level'. Select from list to set level automatically.",
"tags.dances": "Dances",
"tags.alts": "Alternative dances",
"tags.btn_add": "+ Add",
"tags.btn_save": "💾 Save tags",
"tags.btn_cancel": "Cancel",
"tags.new_dance": "New dance (e.g. Cowboy Cha Cha)...",
"tags.new_alt": "Alternative dance...",
"tags.note": "note...",
"tags.warn_file": "Saved to database, but could not write to file.",
"tags.error": "Could not save: {error}",
"tags.no_level": "— no level —",
# Levels
"level.none": "— no level —",
"level.beginner": "Beginner",
"level.let_ovet": "Easy",
"level.easy": "Easy",
"level.ovet": "Intermediate",
"level.intermediate": "Intermediate",
"level.erfaren": "Experienced",
"level.experienced": "Experienced",
"level.ekspert": "Expert",
"level.expert": "Expert",
# Online / login
"online.logging_in": "Logging in as {username}...",
"online.logged_in": "Online as {username}",
"online.auto_login_fail": "Auto-login failed — use File → Go online manually",
"online.logged_out": "Offline",
"online.syncing": "Syncing dance data...",
"online.synced": "Synced {levels} levels and {names} dance names",
# Scanning
"scan.preparing": "Starting library scan...",
"scan.scanning": "Scanning: {name}...",
"scan.scanning_count": "Scanning: {name} ({count} files)...",
"scan.done": "Scan complete — {count} files processed",
"scan.error": "Scan error: {error}",
"scan.folder_missing": "⚠ Folder not found: {path}",
# Errors
"error.title": "Error",
"error.db_init": "Database error: {error}",
"error.folder_remove": "Could not remove: {error}",
"error.save_tags": "Could not save tags: {error}",
# Mail
"mail.thunderbird_ok": "Thunderbird opened with {filename} attached.",
"mail.outlook_ok": "Outlook opened with {filename} attached.",
"mail.fallback": "No known mail client found — opened mailto: (no attachment)",
"mail.file_missing": "File not found — cannot send mail",
# General
"btn.ok": "OK",
"btn.cancel": "Cancel",
"btn.close": "Close",
"btn.yes": "Yes",
"btn.no": "No",
"dialog.confirm": "Confirm",
}

View File

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

@@ -0,0 +1,84 @@
"""
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
class BpmScanWorker(QThread):
progress = pyqtSignal(int, int) # done, total
finished = pyqtSignal(int) # antal analyseret
def __init__(self, library_id: int, db_path: str,
scan_all: bool = False):
super().__init__()
self._library_id = library_id
self._db_path = db_path
self._scan_all = scan_all
self._cancelled = False
def cancel(self):
self.requestInterruption()
self._cancelled = True
def run(self):
import time
self._cancelled = False
try:
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 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 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
for song in songs:
if self._cancelled or self.isInterruptionRequested():
break
try:
bpm = analyze_bpm(song["local_path"])
if bpm and bpm > 0:
conn.execute(
"UPDATE songs SET bpm=? WHERE id=?",
(int(round(bpm)), song["id"])
)
conn.commit()
except Exception:
pass
done += 1
self.progress.emit(done, total)
time.sleep(0.01)
conn.close()
self.finished.emit(done)
except Exception:
self.finished.emit(0)

View File

@@ -0,0 +1,222 @@
"""
dance_info_dialog.py — Rediger info om en dans: koreograf, video, step sheet, noter.
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QTextEdit, QComboBox, QFrame, QMessageBox,
QTabWidget, QWidget,
)
from PyQt6.QtCore import Qt, QUrl
from PyQt6.QtGui import QDesktopServices
class DanceInfoDialog(QDialog):
"""Vis og rediger info om danse tilknyttet en sang."""
def __init__(self, song: dict, parent=None):
super().__init__(parent)
self._song = song
self._dances = [] # [{dance_id, name, level_name, ...}]
self._current_idx = 0
self.setWindowTitle(f"Dans-info — {song.get('title', '')}")
self.setMinimumSize(560, 420)
self.resize(620, 460)
self._load_dances()
self._build_ui()
if self._dances:
self._show_dance(0)
def _load_dances(self):
try:
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()
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()
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}")
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# Sang-info
info = QFrame()
info.setObjectName("track_display")
il = QHBoxLayout(info)
il.setContentsMargins(10, 8, 10, 8)
lbl = QLabel(self._song.get("title", ""))
lbl.setObjectName("track_title")
il.addWidget(lbl, stretch=1)
layout.addWidget(info)
if not self._dances:
layout.addWidget(QLabel("Ingen danse tagget på denne sang."))
btn_close = QPushButton("Luk")
btn_close.clicked.connect(self.reject)
layout.addWidget(btn_close)
return
# Dans-vælger
top = QHBoxLayout()
top.addWidget(QLabel("Dans:"))
self._dance_combo = QComboBox()
for d in self._dances:
prefix = "" if d["is_alt"] else ""
lvl = f" / {d['level_name']}" if d["level_name"] else ""
self._dance_combo.addItem(f"{prefix}{d['name']}{lvl}")
self._dance_combo.currentIndexChanged.connect(self._on_dance_changed)
top.addWidget(self._dance_combo, stretch=1)
layout.addLayout(top)
# Formular
form_frame = QFrame()
form_frame.setObjectName("track_display")
form = QVBoxLayout(form_frame)
form.setContentsMargins(12, 10, 12, 10)
form.setSpacing(8)
# Koreograf
row1 = QHBoxLayout()
row1.addWidget(QLabel("Koreograf:"))
self._choreo = QLineEdit()
self._choreo.setPlaceholderText("Koreografens navn...")
row1.addWidget(self._choreo)
form.addLayout(row1)
# Step sheet URL
row2 = QHBoxLayout()
row2.addWidget(QLabel("Step sheet:"))
self._stepsheet = QLineEdit()
self._stepsheet.setPlaceholderText("https://www.copperknob.co.uk/...")
row2.addWidget(self._stepsheet)
btn_ss = QPushButton("")
btn_ss.setFixedWidth(28)
btn_ss.setToolTip("Åbn i browser")
btn_ss.clicked.connect(lambda: self._open_url(self._stepsheet.text()))
row2.addWidget(btn_ss)
form.addLayout(row2)
# Video URL
row3 = QHBoxLayout()
row3.addWidget(QLabel("Video:"))
self._video = QLineEdit()
self._video.setPlaceholderText("https://www.youtube.com/...")
row3.addWidget(self._video)
btn_v = QPushButton("")
btn_v.setFixedWidth(28)
btn_v.setToolTip("Åbn i browser")
btn_v.clicked.connect(lambda: self._open_url(self._video.text()))
row3.addWidget(btn_v)
form.addLayout(row3)
# Noter
form.addWidget(QLabel("Noter:"))
self._notes = QTextEdit()
self._notes.setPlaceholderText("Egne noter om dansen...")
self._notes.setMaximumHeight(80)
form.addWidget(self._notes)
layout.addWidget(form_frame, stretch=1)
# Knapper
btn_row = QHBoxLayout()
btn_row.addStretch()
btn_cancel = QPushButton("Luk")
btn_cancel.clicked.connect(self.reject)
btn_row.addWidget(btn_cancel)
btn_save = QPushButton("💾 Gem")
btn_save.setObjectName("btn_play")
btn_save.clicked.connect(self._save)
btn_row.addWidget(btn_save)
layout.addLayout(btn_row)
def _on_dance_changed(self, idx: int):
self._save_to_cache(self._current_idx)
self._current_idx = idx
self._show_dance(idx)
def _show_dance(self, idx: int):
if not 0 <= idx < len(self._dances):
return
d = self._dances[idx]
self._choreo.setText(d["choreographer"])
self._stepsheet.setText(d["stepsheet_url"])
self._video.setText(d["video_url"])
self._notes.setPlainText(d["notes"])
def _save_to_cache(self, idx: int):
"""Gem UI-værdier til cache så de ikke mistes ved dans-skift."""
if not 0 <= idx < len(self._dances):
return
self._dances[idx]["choreographer"] = self._choreo.text().strip()
self._dances[idx]["stepsheet_url"] = self._stepsheet.text().strip()
self._dances[idx]["video_url"] = self._video.text().strip()
self._dances[idx]["notes"] = self._notes.toPlainText().strip()
def _save(self):
self._save_to_cache(self._current_idx)
try:
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}")
def _open_url(self, url: str):
url = url.strip()
if not url:
return
if not url.startswith("http"):
url = "https://" + url
QDesktopServices.openUrl(QUrl(url))

View File

@@ -0,0 +1,178 @@
"""
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 (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QListWidget, QListWidgetItem,
)
from PyQt6.QtCore import Qt, QTimer
class DancePickerDialog(QDialog):
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._existing_dances = existing_dances or []
self.setWindowTitle("Vælg dans")
self.setMinimumWidth(420)
self.setFixedWidth(460)
self._build_ui(current_dance, song_title)
self._load_suggestions("")
def _build_ui(self, current_dance: str, song_title: str):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
if song_title:
lbl = QLabel(song_title)
lbl.setObjectName("track_title")
lbl.setWordWrap(True)
layout.addWidget(lbl)
layout.addWidget(QLabel("Dans:"))
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)
# 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)
# 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)
# 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_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 _load_suggestions(self, prefix: str):
try:
from local.local_db import get_dance_suggestions
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:
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, {
"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 _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_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.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 ""

View File

@@ -0,0 +1,300 @@
"""
library_manager.py — Håndter musikmapper.
Tilføj/fjern mapper. BPM-scanning per bibliotek.
Fil-scanning starter automatisk når vinduet lukkes.
"""
import sqlite3
from pathlib import Path
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFrame, QMessageBox, QScrollArea, QWidget,
)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QThread
class LibraryManagerDialog(QDialog):
libraries_changed = pyqtSignal()
def __init__(self, db_path: str, parent=None):
super().__init__(parent)
self._db_path = db_path
self._workers = {} # library_id → ScanWorker
self.setWindowTitle("Musikmapper")
self.setMinimumWidth(600)
self.setMinimumHeight(300)
self._build_ui()
self._load()
# ── UI ────────────────────────────────────────────────────────────────────
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
lbl = QLabel(
"Tilføj eller fjern musikmapper. "
"Fil-scanning starter automatisk når vinduet lukkes."
)
lbl.setObjectName("result_count")
lbl.setWordWrap(True)
layout.addWidget(lbl)
# Scrollbart område til biblioteksliste
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
self._scroll_content = QWidget()
self._libs_layout = QVBoxLayout(self._scroll_content)
self._libs_layout.setSpacing(6)
self._libs_layout.addStretch()
scroll.setWidget(self._scroll_content)
layout.addWidget(scroll, stretch=1)
# Knap-række
btn_row = QHBoxLayout()
btn_add = QPushButton(" Tilføj mappe")
btn_add.clicked.connect(self._add_folder)
btn_row.addWidget(btn_add)
btn_row.addStretch()
btn_close = QPushButton("Luk")
btn_close.clicked.connect(self.accept)
btn_row.addWidget(btn_close)
layout.addLayout(btn_row)
def _load(self):
"""Indlæs biblioteker fra DB og vis dem."""
from PyQt6.QtWidgets import QApplication
# Ryd eksisterende rækker (ikke stretch)
while self._libs_layout.count() > 1:
item = self._libs_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
QApplication.processEvents() # Lad Qt rydde op før vi bygger nyt
try:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
libs = conn.execute(
"SELECT id, path FROM libraries "
"WHERE is_active=1 ORDER BY path"
).fetchall()
counts = {}
bpm_missing = {}
for lib in libs:
counts[lib["id"]] = conn.execute(
"SELECT COUNT(*) FROM files "
"WHERE file_missing=0 AND local_path LIKE ?",
(lib["path"] + "%",)
).fetchone()[0]
bpm_missing[lib["id"]] = conn.execute(
"SELECT COUNT(*) FROM files f "
"JOIN songs s ON s.id = f.song_id "
"WHERE f.file_missing=0 AND f.local_path LIKE ? "
"AND (s.bpm IS NULL OR s.bpm=0)",
(lib["path"] + "%",)
).fetchone()[0]
conn.close()
if not libs:
lbl = QLabel("Ingen musikmapper tilføjet endnu.")
lbl.setObjectName("result_count")
self._libs_layout.insertWidget(0, lbl)
return
for i, lib in enumerate(libs):
row = self._make_lib_row(
dict(lib),
counts[lib["id"]],
bpm_missing[lib["id"]]
)
self._libs_layout.insertWidget(i, row)
except Exception as e:
lbl = QLabel(f"Fejl: {e}")
self._libs_layout.insertWidget(0, lbl)
def _make_lib_row(self, lib: dict, song_count: int,
bpm_missing: int) -> QFrame:
lib_id = lib["id"]
path = lib["path"]
exists = Path(path).exists()
last = ""
frame = QFrame()
frame.setObjectName("track_display")
vbox = QVBoxLayout(frame)
vbox.setContentsMargins(10, 8, 10, 8)
vbox.setSpacing(4)
# Sti
lbl_path = QLabel(("" if not exists else "") + path)
lbl_path.setObjectName("track_title" if exists else "result_count")
vbox.addWidget(lbl_path)
# Info
lbl_info = QLabel(
f" {song_count} sange · "
f"senest scannet: {last} · "
f"{bpm_missing} uden BPM"
+ (" · ⚠ mappe ikke fundet" if not exists else "")
)
lbl_info.setObjectName("result_count")
vbox.addWidget(lbl_info)
# Status-label til scanning
lbl_status = QLabel("")
lbl_status.setObjectName("result_count")
lbl_status.hide()
vbox.addWidget(lbl_status)
# Knapper
btn_row = QHBoxLayout()
btn_row.setSpacing(6)
btn_bpm = QPushButton(f"♩ BPM manglende ({bpm_missing})")
btn_bpm.setFixedHeight(30)
btn_bpm.setEnabled(exists and bpm_missing > 0
and lib_id not in self._workers)
btn_bpm.clicked.connect(
lambda _, lid=lib_id, p=path, b=btn_bpm, s=lbl_status:
self._start_bpm(lid, p, False, b, s)
)
btn_row.addWidget(btn_bpm)
btn_bpm_all = QPushButton("♩ BPM alle")
btn_bpm_all.setFixedHeight(30)
btn_bpm_all.setEnabled(exists and lib_id not in self._workers)
btn_bpm_all.clicked.connect(
lambda _, lid=lib_id, p=path, b=btn_bpm_all, s=lbl_status:
self._start_bpm(lid, p, True, b, s)
)
btn_row.addWidget(btn_bpm_all)
btn_row.addStretch()
btn_remove = QPushButton("✕ Fjern")
btn_remove.setFixedHeight(30)
btn_remove.clicked.connect(lambda _, l=lib: self._remove_library(l))
btn_row.addWidget(btn_remove)
vbox.addLayout(btn_row)
return frame
# ── Tilføj / fjern ────────────────────────────────────────────────────────
def _add_folder(self):
from PyQt6.QtWidgets import QFileDialog
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
if not folder:
return
try:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
# Tjek om mappen allerede er aktiv
existing = conn.execute(
"SELECT id, is_active FROM libraries WHERE path=?", (folder,)
).fetchone()
if existing:
if existing["is_active"]:
QMessageBox.information(
self, "Allerede tilføjet",
"Denne mappe er allerede i listen."
)
conn.close()
return
else:
# Reaktiver en tidligere fjernet mappe
conn.execute(
"UPDATE libraries SET is_active=1 WHERE path=?",
(folder,)
)
else:
conn.execute(
"INSERT INTO libraries (path, is_active) VALUES (?, 1)",
(folder,)
)
conn.commit()
conn.close()
self._load()
self.libraries_changed.emit()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke tilføje: {e}")
def _remove_library(self, lib: dict):
reply = QMessageBox.question(
self, "Fjern mappe",
f"Fjern:\n{lib['path']}\n\n"
"Alle sange fra denne mappe slettes fra databasen.\n"
"Dans-tags og playlister bevares.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
# Stop evt. BPM-scanning på dette bibliotek
if lib["id"] in self._workers:
self._workers[lib["id"]].cancel()
self._workers.pop(lib["id"], None)
try:
conn = sqlite3.connect(self._db_path)
# Marker filer fra denne mappe som missing
conn.execute(
"UPDATE files SET file_missing=1 WHERE local_path LIKE ?",
(lib["path"] + "%",)
)
# Slet selve biblioteks-rækken
conn.execute(
"DELETE FROM libraries WHERE id=?", (lib["id"],)
)
conn.commit()
conn.close()
self._load()
QTimer.singleShot(300, self.libraries_changed.emit)
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}")
# ── BPM-scanning ──────────────────────────────────────────────────────────
def _start_bpm(self, library_id: int, path: str,
scan_all: bool, btn: QPushButton, lbl: QLabel):
if library_id in self._workers:
return
from local.local_db import DB_PATH
from ui.bpm_worker import BpmScanWorker
worker = BpmScanWorker(library_id, str(DB_PATH),
scan_all=scan_all)
def on_progress(done, total):
lbl.setText(f"{done}/{total} analyseret...")
lbl.show()
btn.setEnabled(False)
def on_finished(count):
lbl.setText(f"{count} analyseret")
btn.setEnabled(True)
self._workers.pop(library_id, None)
QTimer.singleShot(300, self._load)
worker.progress.connect(on_progress)
worker.finished.connect(on_finished)
self._workers[library_id] = worker
worker.start()
worker.setPriority(QThread.Priority.LowestPriority)
# ── Luk ───────────────────────────────────────────────────────────────────
def closeEvent(self, event):
for w in list(self._workers.values()):
w.cancel()
w.wait(2000) # Vent max 2 sek på at tråden stopper
event.accept()

View File

@@ -0,0 +1,585 @@
"""
library_panel.py — Musikbibliotek med søgning og drag-and-drop til danseliste.
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
QLineEdit, QLabel, QHBoxLayout, QPushButton,
QFrame, QSlider, QCheckBox,
QAbstractItemView, QStyledItemDelegate,
)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QMimeData, QByteArray, QRect
from PyQt6.QtGui import QColor, QDrag, QPainter, QBrush, QPen, QFont
class DanseButtonDelegate(QStyledItemDelegate):
"""Tegner en orange 'Danse' label i højre side af hvert list-item."""
BTN_W = 54
BTN_H = 22
BTN_MARGIN = 8
def paint(self, painter: QPainter, option, index):
super().paint(painter, option, index)
rect = option.rect
btn_rect = QRect(
rect.right() - self.BTN_W - self.BTN_MARGIN,
rect.top() + (rect.height() - self.BTN_H) // 2,
self.BTN_W,
self.BTN_H,
)
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setBrush(QBrush(QColor("#e8a020")))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(btn_rect, 4, 4)
painter.setPen(QPen(QColor("#111111")))
font = QFont()
font.setPointSize(8)
font.setBold(True)
painter.setFont(font)
painter.drawText(btn_rect, Qt.AlignmentFlag.AlignCenter, "Danse")
painter.restore()
class DraggableLibraryList(QListWidget):
"""QListWidget med drag, dobbeltklik og klik på højre side for dans-tags."""
danse_clicked = __import__('PyQt6.QtCore', fromlist=['pyqtSignal']).pyqtSignal(dict)
def __init__(self, parent=None):
super().__init__(parent)
self.setDragEnabled(True)
self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
self.setDefaultDropAction(Qt.DropAction.CopyAction)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
item = self.itemAt(event.pos())
if item and event.pos().x() > self.viewport().width() - 75:
song = item.data(Qt.ItemDataRole.UserRole)
if song:
self.danse_clicked.emit(song)
return
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
item = self.itemAt(event.pos())
if item:
# Dobbeltklik i højre 75px = Danse, ellers song_selected
if event.pos().x() > self.viewport().width() - 75:
song = item.data(Qt.ItemDataRole.UserRole)
if song:
self.danse_clicked.emit(song)
return
super().mouseDoubleClickEvent(event)
def startDrag(self, supported_actions):
item = self.currentItem()
if not item:
return
song = item.data(Qt.ItemDataRole.UserRole)
if not song:
return
import json
data = json.dumps(song).encode("utf-8")
mime = QMimeData()
mime.setData("application/x-linedance-song", QByteArray(data))
mime.setText(song.get("title", ""))
drag = QDrag(self)
drag.setMimeData(mime)
drag.exec(Qt.DropAction.CopyAction)
class LibraryPanel(QWidget):
song_selected = pyqtSignal(dict)
add_to_playlist = pyqtSignal(dict)
scan_requested = pyqtSignal()
sync_requested = pyqtSignal()
edit_tags_requested = pyqtSignal(dict)
send_mail_requested = pyqtSignal(dict)
def __init__(self, parent=None):
super().__init__(parent)
self._all_songs: list[dict] = []
self._filtered: list[dict] = []
self._bpm_scan_running = False
self._search_timer = QTimer(self)
self._search_timer.setSingleShot(True)
self._search_timer.setInterval(150)
self._search_timer.timeout.connect(self._do_search)
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Header
header = QHBoxLayout()
header.setContentsMargins(10, 6, 10, 6)
lbl = QLabel("BIBLIOTEK")
lbl.setObjectName("section_title")
header.addWidget(lbl)
header.addStretch()
btn_refresh = QPushButton("↻ Opdater")
btn_refresh.setFixedHeight(28)
btn_refresh.setToolTip("Opdater bibliotek fra database")
btn_refresh.clicked.connect(self._refresh_library)
header.addWidget(btn_refresh)
btn_manage = QPushButton("⚙ Mapper")
btn_manage.setFixedHeight(28)
btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker")
btn_manage.clicked.connect(self._manage_libraries)
header.addWidget(btn_manage)
layout.addLayout(header)
# Søgefelt + checkbox
search_row = QHBoxLayout()
search_row.setSpacing(6)
self._search = QLineEdit()
self._search.setPlaceholderText("Søg i titel, artist, album, dans...")
self._search.textChanged.connect(self._on_search_changed)
search_row.addWidget(self._search)
from PyQt6.QtWidgets import QCheckBox
self._chk_alt = QCheckBox("Inkl. alt.")
self._chk_alt.setToolTip("Søg også i alternativ-danse")
self._chk_alt.setChecked(False)
self._chk_alt.toggled.connect(self._on_search_changed)
search_row.addWidget(self._chk_alt)
layout.addLayout(search_row)
# Resultat-tæller + drag-hint
hint_row = QHBoxLayout()
hint_row.setContentsMargins(8, 2, 8, 2)
self._count_label = QLabel("0 sange")
self._count_label.setObjectName("result_count")
hint_row.addWidget(self._count_label)
hint_row.addStretch()
drag_hint = QLabel("træk til danseliste →")
drag_hint.setObjectName("result_count")
hint_row.addWidget(drag_hint)
layout.addLayout(hint_row)
# Liste — draggable
self._list = DraggableLibraryList()
self._list.setObjectName("library_list")
self._list.itemDoubleClicked.connect(self._on_double_click)
self._list.danse_clicked.connect(self.edit_tags_requested)
self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self._list.customContextMenuRequested.connect(self._show_context_menu)
self._list.setItemDelegate(DanseButtonDelegate(self._list))
layout.addWidget(self._list)
# Preview-afspiller bar
self._preview_bar = self._build_preview_bar()
self._preview_bar.setVisible(False)
layout.addWidget(self._preview_bar)
# Timer til preview progress opdatering
self._preview_timer = QTimer(self)
self._preview_timer.setInterval(200)
self._preview_timer.timeout.connect(self._update_preview_progress)
def _build_preview_bar(self) -> QWidget:
bar = QFrame()
bar.setObjectName("track_display")
bar.setFixedHeight(62)
row = QHBoxLayout(bar)
row.setContentsMargins(10, 8, 10, 8)
row.setSpacing(10)
# Play/pause knap — orange som hoved-afspiller
self._btn_preview_play = QPushButton("")
self._btn_preview_play.setFixedSize(36, 36)
self._btn_preview_play.setObjectName("btn_play_small")
self._btn_preview_play.setToolTip("Afspil / Pause")
self._btn_preview_play.clicked.connect(self._toggle_preview_playback)
row.addWidget(self._btn_preview_play)
# Stop knap
self._btn_preview_stop = QPushButton("")
self._btn_preview_stop.setFixedSize(30, 30)
self._btn_preview_stop.setObjectName("btn_stop_small")
self._btn_preview_stop.setToolTip("Stop preview")
self._btn_preview_stop.clicked.connect(self._stop_preview)
row.addWidget(self._btn_preview_stop)
# Titel + progress i midten
info = QVBoxLayout()
info.setSpacing(4)
info.setContentsMargins(0, 0, 0, 0)
self._lbl_preview_title = QLabel("")
self._lbl_preview_title.setObjectName("track_meta")
info.addWidget(self._lbl_preview_title)
self._preview_progress = QSlider(Qt.Orientation.Horizontal)
self._preview_progress.setRange(0, 1000)
self._preview_progress.sliderMoved.connect(self._seek_preview)
info.addWidget(self._preview_progress)
row.addLayout(info, stretch=1)
# Tid
self._lbl_preview_time = QLabel("0:00")
self._lbl_preview_time.setObjectName("track_meta")
self._lbl_preview_time.setFixedWidth(70)
self._lbl_preview_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
row.addWidget(self._lbl_preview_time)
# Volumen
self._preview_vol = QSlider(Qt.Orientation.Horizontal)
self._preview_vol.setRange(0, 100)
self._preview_vol.setValue(78)
self._preview_vol.setFixedWidth(70)
self._preview_vol.setToolTip("Volumen preview")
self._preview_vol.valueChanged.connect(self._on_preview_volume)
row.addWidget(self._preview_vol)
return bar
# ── Scanning ──────────────────────────────────────────────────────────────
def _on_scan_clicked(self):
self.scan_requested.emit()
def _on_sync_clicked(self):
self._btn_sync.setText("⇅ ...")
self._btn_sync.setEnabled(False)
self.sync_requested.emit()
QTimer.singleShot(3000, lambda: (
self._btn_sync.setText("⇅ Sync"),
self._btn_sync.setEnabled(True),
))
def set_scanning(self, scanning: bool, status_text: str = ""):
pass # Status vises i statuslinjen
def update_scan_status(self, text: str):
pass # Status vises i statuslinjen
# ── Sange ─────────────────────────────────────────────────────────────────
def load_songs(self, songs: list[dict]):
self._all_songs = songs
self._do_search()
# ── Søgning ───────────────────────────────────────────────────────────────
def _on_search_changed(self):
self._search_timer.start()
def _do_search(self):
q = self._search.text().strip().lower()
incl_alt = self._chk_alt.isChecked()
self._filtered = [
s for s in self._all_songs if self._matches(s, q, incl_alt)
] if q else list(self._all_songs)
total = len(self._all_songs)
found = len(self._filtered)
q_text = self._search.text().strip()
self._count_label.setText(
f"{found} resultat{'er' if found != 1 else ''} for \"{q_text}\"" if q_text
else f"{total} sang{'e' if total != 1 else ''}"
)
self._render()
def _matches(self, song: dict, q: str, incl_alt: bool = False) -> bool:
fields = [
song.get("title", ""),
song.get("artist", ""),
song.get("album", ""),
song.get("file_format", ""),
] + song.get("dances", []) \
+ song.get("dance_choreographers", []) \
+ song.get("dance_levels", [])
if incl_alt:
fields += song.get("alt_dances", [])
return any(q in f.lower() for f in fields if f)
def _render(self):
self._list.clear()
q = self._search.text().strip().lower()
for song in self._filtered:
dances = song.get("dances", [])
dance_levels = song.get("dance_levels", [])
missing = song.get("file_missing", False)
dance_parts = []
choreos = song.get("dance_choreographers", [])
for i, d in enumerate(dances):
lvl = dance_levels[i] if i < len(dance_levels) else ""
choreo = choreos[i] if i < len(choreos) else ""
part = f"{d} / {lvl}" if lvl else d
if choreo:
part += f" · {choreo}"
dance_parts.append(part)
dance_str = " · " + " | ".join(dance_parts) if dance_parts else ""
prefix = "" if missing else ""
bpm = song.get("bpm", 0)
bpm_str = f"{bpm} BPM" if bpm else "? BPM"
line1 = prefix + song.get("title", "")
line2 = f" {song.get('artist','')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}"
item = QListWidgetItem(f"{line1}\n{line2}")
item.setData(Qt.ItemDataRole.UserRole, song)
item.setSizeHint(__import__('PyQt6.QtCore', fromlist=['QSize']).QSize(0, 52))
if missing:
item.setForeground(QColor("#5a6070"))
elif q and any(q in d.lower() for d in dances):
item.setForeground(QColor("#e8a020"))
self._list.addItem(item)
def _start_bulk_bpm_scan(self):
"""Start BPM-analyse på alle sange uden BPM i baggrundstråd med lav prioritet."""
if self._bpm_scan_running:
return
songs_without_bpm = [s for s in self._all_songs
if not s.get("bpm") and not s.get("file_missing")]
if not songs_without_bpm:
self._btn_bpm_scan.setText("♩ Alle har BPM")
return
self._bpm_scan_running = True
self._btn_bpm_scan.setText(f"♩ Scanner 0/{len(songs_without_bpm)}...")
self._btn_bpm_scan.setEnabled(False)
from PyQt6.QtCore import QThread, pyqtSignal as _sig
class BulkBpmWorker(QThread):
progress = _sig(int, int, str) # done, total, title
finished = _sig()
def __init__(self, songs):
super().__init__()
self._songs = songs
def run(self):
from local.tag_reader import analyze_and_save_bpm
total = len(self._songs)
for i, song in enumerate(self._songs, start=1):
if self.isInterruptionRequested():
break
try:
bpm = analyze_and_save_bpm(song["local_path"], song["id"])
if bpm:
song["bpm"] = int(round(bpm))
except Exception:
pass
self.progress.emit(i, total, song.get("title", ""))
self.finished.emit()
self._bulk_bpm_worker = BulkBpmWorker(songs_without_bpm)
def on_progress(done, total, title):
self._btn_bpm_scan.setText(f"{done}/{total}...")
# Opdater sangen i listen
for s in self._all_songs:
if s.get("title") == title and s.get("bpm"):
break
self._do_search()
def on_finished():
self._bpm_scan_running = False
self._btn_bpm_scan.setEnabled(True)
self._btn_bpm_scan.setText("♩ BPM alle")
self._do_search()
self._bulk_bpm_worker.progress.connect(on_progress)
self._bulk_bpm_worker.finished.connect(on_finished)
self._bulk_bpm_worker.start()
self._bulk_bpm_worker.setPriority(QThread.Priority.LowestPriority)
# ── Handlinger ────────────────────────────────────────────────────────────
def _show_context_menu(self, pos):
from PyQt6.QtWidgets import QMenu
item = self._list.itemAt(pos)
if not item:
return
song = item.data(Qt.ItemDataRole.UserRole)
if not song:
return
menu = QMenu(self)
act_add = menu.addAction("Tilføj til danseliste")
act_play = menu.addAction("Afspil")
# Preview kun hvis preview player er sat op
is_previewing = (
hasattr(self, "_preview_player") and
self._preview_player and
self._preview_player.is_playing()
)
act_preview = menu.addAction(
"⏹ Stop preview" if is_previewing else "▶ Preview (høretelefoner)"
)
menu.addSeparator()
act_tags = menu.addAction("✎ Rediger dans-tags...")
act_info = menu.addAction(" Dans-info...")
act_bpm = menu.addAction("♩ Analysér BPM")
menu.addSeparator()
send_menu = menu.addMenu("Send til")
act_mail = send_menu.addAction("✉ Send som mail")
action = menu.exec(self._list.mapToGlobal(pos))
if action == act_add:
self.add_to_playlist.emit(song)
elif action == act_play:
self.song_selected.emit(song)
elif action == act_preview:
self._toggle_preview(song)
elif action == act_tags:
self.edit_tags_requested.emit(song)
elif action == act_info:
from ui.dance_info_dialog import DanceInfoDialog
dlg = DanceInfoDialog(song, parent=self.window())
dlg.exec()
elif action == act_bpm:
self._analyze_bpm(song)
elif action == act_mail:
self.send_mail_requested.emit(song)
def set_preview_player(self, preview_player):
"""Sæt preview-afspilleren fra main_window."""
self._preview_player = preview_player
def _on_double_click(self, item: QListWidgetItem):
song = item.data(Qt.ItemDataRole.UserRole)
if song:
self._start_preview(song)
def _start_preview(self, song: dict):
if not hasattr(self, "_preview_player") or not self._preview_player:
self.song_selected.emit(song)
return
path = song.get("local_path", "")
if not path:
return
self._preview_song = song
self._preview_player.play(path)
title = song.get("title", "")
artist = song.get("artist", "")
self._lbl_preview_title.setText(f"{title} · {artist}")
self._btn_preview_play.setText("")
self._preview_bar.setVisible(True)
self._preview_timer.start()
def _toggle_preview_playback(self):
if not hasattr(self, "_preview_player") or not self._preview_player:
return
if self._preview_player.is_playing():
self._preview_player.pause()
self._btn_preview_play.setText("")
else:
self._preview_player.resume()
self._btn_preview_play.setText("")
def _stop_preview(self):
if hasattr(self, "_preview_player") and self._preview_player:
self._preview_player.stop()
self._preview_timer.stop()
self._preview_bar.setVisible(False)
self._btn_preview_play.setText("")
def _seek_preview(self, value: int):
if hasattr(self, "_preview_player") and self._preview_player:
self._preview_player.seek(value / 1000.0)
def _on_preview_volume(self, value: int):
if hasattr(self, "_preview_player") and self._preview_player:
self._preview_player.set_volume(value)
def _update_preview_progress(self):
if not hasattr(self, "_preview_player") or not self._preview_player:
return
if not self._preview_player.is_playing():
self._btn_preview_play.setText("")
return
pos = self._preview_player.get_position()
cur = self._preview_player.get_time()
dur = self._preview_player.get_duration()
self._preview_progress.setValue(int(pos * 1000))
def fmt(s): return f"{s//60}:{s%60:02d}"
self._lbl_preview_time.setText(f"{fmt(cur)} / {fmt(dur)}")
def _toggle_preview(self, song: dict):
"""Start/stop preview af en sang."""
if not hasattr(self, "_preview_player") or not self._preview_player:
return
if self._preview_player.is_playing():
self._stop_preview()
else:
self._start_preview(song)
def _analyze_bpm(self, song: dict):
"""Analysér BPM i baggrundstråd og opdater biblioteket."""
path = song.get("local_path", "")
song_id = song.get("id", "")
if not path or not song_id:
return
from PyQt6.QtCore import QThread, pyqtSignal as _sig
class BpmWorker(QThread):
done = _sig(float)
def __init__(self, p, sid):
super().__init__()
self._p, self._sid = p, sid
def run(self):
from local.tag_reader import analyze_and_save_bpm
bpm = analyze_and_save_bpm(self._p, self._sid)
if bpm:
self.done.emit(bpm)
self._bpm_worker = BpmWorker(path, song_id)
def on_bpm_done(bpm):
# Opdater sangen i _all_songs listen direkte
for s in self._all_songs:
if s.get("id") == song_id:
s["bpm"] = int(round(bpm))
break
self._do_search()
self._bpm_worker.done.connect(on_bpm_done)
self._bpm_worker.start()
def _refresh_library(self):
"""Opdater fil-tilgængelighed og genindlæs bibliotek."""
mw = self.window()
if hasattr(mw, "_run_availability_check"):
mw._run_availability_check()
elif hasattr(mw, "_reload_library"):
mw._reload_library()
def _manage_libraries(self):
from ui.library_manager import LibraryManagerDialog
from local.local_db import DB_PATH
dialog = LibraryManagerDialog(db_path=str(DB_PATH), parent=self.window())
dialog.libraries_changed.connect(self._on_libraries_changed)
dialog.exec()
# Reload øjeblikkeligt når dialog lukkes
mw = self.window()
if hasattr(mw, "_reload_library"):
mw._reload_library()
# Start scanning
if hasattr(mw, "start_background_scan"):
QTimer.singleShot(1000, mw.start_background_scan)
def _on_libraries_changed(self):
"""Kaldes ved tilføj/fjern — reload øjeblikkeligt."""
mw = self.window()
if hasattr(mw, "_reload_library"):
mw._reload_library()
def _add_folder(self):
from PyQt6.QtWidgets import QFileDialog
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
if folder:
mw = self.window()
if hasattr(mw, "add_library_path"):
mw.add_library_path(folder)

View File

@@ -0,0 +1,139 @@
"""
login_dialog.py — Login-dialog til at gå online.
Server-URL er hardcodet i config.
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QLineEdit, QPushButton, QFrame, QCheckBox,
)
from PyQt6.QtCore import Qt, QSettings
# ── Hardcodet server-URL ──────────────────────────────────────────────────────
API_URL = "https://api.linedanceplayer.dk"
# ─────────────────────────────────────────────────────────────────────────────
class LoginDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Gå online")
self.setFixedWidth(340)
self.setModal(True)
self._token: str | None = None
self._username: str | None = None
self._api_url = API_URL
self._build_ui()
self._load_saved_settings()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(10)
layout.setContentsMargins(20, 20, 20, 20)
title = QLabel("Log ind på LineDance")
title.setObjectName("track_title")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title)
sub = QLabel("Synkroniser projekter og alternativ-danse med andre brugere")
sub.setObjectName("track_meta")
sub.setWordWrap(True)
sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(sub)
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
layout.addWidget(line)
layout.addWidget(QLabel("Brugernavn:"))
self._user_input = QLineEdit()
self._user_input.setPlaceholderText("dit-brugernavn")
layout.addWidget(self._user_input)
layout.addWidget(QLabel("Kodeord:"))
self._pass_input = QLineEdit()
self._pass_input.setEchoMode(QLineEdit.EchoMode.Password)
self._pass_input.setPlaceholderText("••••••••")
self._pass_input.returnPressed.connect(self._on_login)
layout.addWidget(self._pass_input)
self._remember = QCheckBox("Husk brugernavn")
self._remember.setChecked(True)
layout.addWidget(self._remember)
self._status_label = QLabel("")
self._status_label.setObjectName("track_meta")
self._status_label.setWordWrap(True)
layout.addWidget(self._status_label)
btn_row = QHBoxLayout()
btn_cancel = QPushButton("Annuller")
btn_cancel.clicked.connect(self.reject)
btn_row.addWidget(btn_cancel)
self._btn_login = QPushButton("Log ind")
self._btn_login.setObjectName("btn_play")
self._btn_login.setDefault(True)
self._btn_login.clicked.connect(self._on_login)
btn_row.addWidget(self._btn_login)
layout.addLayout(btn_row)
def _load_saved_settings(self):
settings = QSettings("LineDance", "Player")
self._user_input.setText(settings.value("username", ""))
def _save_settings(self):
if self._remember.isChecked():
settings = QSettings("LineDance", "Player")
settings.setValue("username", self._user_input.text().strip())
def _on_login(self):
username = self._user_input.text().strip()
password = self._pass_input.text()
if not username or not password:
self._set_status("Udfyld brugernavn og kodeord", error=True)
return
self._btn_login.setEnabled(False)
self._set_status("Forbinder...")
try:
import urllib.request, urllib.parse, json
data = urllib.parse.urlencode({
"username": username,
"password": password,
}).encode()
req = urllib.request.Request(
f"{API_URL}/auth/login",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=8) as resp:
body = json.loads(resp.read())
self._token = body.get("access_token")
self._username = username
self._save_settings()
self._set_status("Logget ind!", error=False)
self.accept()
except Exception as e:
self._set_status(f"Fejl: {e}", error=True)
self._btn_login.setEnabled(True)
def _set_status(self, text: str, error: bool = False):
self._status_label.setText(text)
color = "#e74c3c" if error else "#2ecc71"
self._status_label.setStyleSheet(f"color: {color};")
def get_credentials(self) -> tuple[str, str, str]:
"""Returnerer (api_url, username, token) efter succesfuldt login."""
return self._api_url, self._username, self._token

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
"""
next_up_bar.py — Banner der vises når en sang er færdig.
"""
from PyQt6.QtWidgets import (
QFrame, QHBoxLayout, QVBoxLayout, QLabel, QPushButton,
)
from PyQt6.QtCore import pyqtSignal
class NextUpBar(QFrame):
play_next_clicked = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("next_up_frame")
self.hide()
self._build_ui()
def _build_ui(self):
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 10, 16, 10)
# Tekst
text_layout = QVBoxLayout()
text_layout.setSpacing(2)
self._label = QLabel("NÆSTE SANG KLAR")
self._label.setObjectName("next_up_label")
text_layout.addWidget(self._label)
self._title = QLabel("")
self._title.setObjectName("next_up_title")
text_layout.addWidget(self._title)
self._sub = QLabel("")
self._sub.setObjectName("next_up_sub")
text_layout.addWidget(self._sub)
layout.addLayout(text_layout)
layout.addStretch()
# Knap
self._btn = QPushButton("▶ AFSPIL NÆSTE")
self._btn.setObjectName("btn_play_next")
self._btn.setFixedHeight(44)
self._btn.setMinimumWidth(160)
self._btn.clicked.connect(self.play_next_clicked.emit)
layout.addWidget(self._btn)
def show_next(self, title: str, artist: str, dances: list[str]):
dance_str = "Dans: " + ", ".join(dances) if dances else ""
sub = f"{artist}{' · ' + dance_str if dance_str else ''}"
self._title.setText(title)
self._sub.setText(sub)
self.show()
def hide_bar(self):
self.hide()

View File

@@ -0,0 +1,522 @@
"""
playlist_browser.py — Dialog til at hente og gemme danselister med tag-organisering.
Viser en liste over alle gemte danselister med:
- Navn, dato, antal sange
- Tag-filtrering i venstre side
- Gem ny liste med tags
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QListWidget, QListWidgetItem, QWidget,
QSplitter, QFrame, QMessageBox, QInputDialog,
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QColor
class PlaylistBrowserDialog(QDialog):
"""Kombineret gem/hent dialog til danselister."""
playlist_selected = pyqtSignal(str, str) # playlist_id, name
sync_requested = pyqtSignal() # bed main_window om at køre sync
def __init__(self, mode: str = "load", current_songs: list = None,
current_name: str = "", parent=None):
super().__init__(parent)
self._mode = mode # "load" eller "save"
self._current_songs = current_songs or []
self._current_name = current_name
self._all_playlists = []
self._active_tag = None
title = "Gem danseliste" if mode == "save" else "Hent danseliste"
self.setWindowTitle(title)
self.setMinimumSize(700, 480)
self.resize(780, 520)
self._build_ui()
self._load_data()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# Gem-felter (kun i save-mode)
if self._mode == "save":
save_frame = QFrame()
save_frame.setObjectName("track_display")
save_layout = QVBoxLayout(save_frame)
save_layout.setContentsMargins(10, 8, 10, 8)
row1 = QHBoxLayout()
row1.addWidget(QLabel("Navn:"))
self._name_input = QLineEdit()
self._name_input.setText(self._current_name)
self._name_input.setPlaceholderText("Navn på danselisten...")
row1.addWidget(self._name_input)
save_layout.addLayout(row1)
row2 = QHBoxLayout()
row2.addWidget(QLabel("Tags:"))
self._tags_input = QLineEdit()
self._tags_input.setPlaceholderText("stævne, øvning, workshop (komma-separeret)")
self._tags_input.textChanged.connect(self._suggest_tags)
row2.addWidget(self._tags_input)
save_layout.addLayout(row2)
# Tag-forslag
self._tag_suggestions = QListWidget()
self._tag_suggestions.setMaximumHeight(80)
self._tag_suggestions.hide()
self._tag_suggestions.itemClicked.connect(self._add_tag_suggestion)
save_layout.addWidget(self._tag_suggestions)
lbl = QLabel(f"{len(self._current_songs)} sange vil blive gemt")
lbl.setObjectName("result_count")
save_layout.addWidget(lbl)
layout.addWidget(save_frame)
# Splitter: tags til venstre, lister til højre
splitter = QSplitter(Qt.Orientation.Horizontal)
# ── Venstre: tag-filtrering ──
tag_panel = QWidget()
tag_layout = QVBoxLayout(tag_panel)
tag_layout.setContentsMargins(0, 0, 0, 0)
tag_layout.setSpacing(4)
lbl_tags = QLabel("FILTRÉR PÅ TAG")
lbl_tags.setObjectName("section_title")
tag_layout.addWidget(lbl_tags)
self._tag_list = QListWidget()
self._tag_list.currentItemChanged.connect(self._on_tag_selected)
tag_layout.addWidget(self._tag_list)
tag_panel.setMaximumWidth(180)
splitter.addWidget(tag_panel)
# ── Højre: danseliste-oversigt ──
list_panel = QWidget()
list_layout = QVBoxLayout(list_panel)
list_layout.setContentsMargins(0, 0, 0, 0)
list_layout.setSpacing(4)
# Søgefelt
self._search = QLineEdit()
self._search.setPlaceholderText("Søg i navn...")
self._search.textChanged.connect(self._filter)
list_layout.addWidget(self._search)
self._count_label = QLabel("")
self._count_label.setObjectName("result_count")
list_layout.addWidget(self._count_label)
self._list = QListWidget()
self._list.itemDoubleClicked.connect(self._on_double_click)
list_layout.addWidget(self._list)
splitter.addWidget(list_panel)
splitter.setSizes([160, 580])
layout.addWidget(splitter, stretch=1)
# Knapper
btn_row = QHBoxLayout()
if self._mode == "load":
btn_delete = QPushButton("🗑 Slet valgte")
btn_delete.clicked.connect(self._delete_selected)
btn_row.addWidget(btn_delete)
btn_tags = QPushButton("🏷 Rediger tags")
btn_tags.clicked.connect(self._edit_tags)
btn_row.addWidget(btn_tags)
btn_share = QPushButton("↗ Del...")
btn_share.clicked.connect(self._share_selected)
btn_row.addWidget(btn_share)
btn_shared = QPushButton("🌐 Hent delte")
btn_shared.clicked.connect(self._fetch_shared)
btn_row.addWidget(btn_shared)
btn_row.addStretch()
btn_cancel = QPushButton("Annuller")
btn_cancel.clicked.connect(self.reject)
btn_row.addWidget(btn_cancel)
if self._mode == "save":
btn_ok = QPushButton("💾 Gem")
btn_ok.setObjectName("btn_play")
btn_ok.clicked.connect(self._save)
else:
btn_ok = QPushButton("📂 Hent valgte")
btn_ok.setObjectName("btn_play")
btn_ok.clicked.connect(self._load_selected)
btn_row.addWidget(btn_ok)
layout.addLayout(btn_row)
def _load_data(self):
try:
from local.local_db import get_playlists, get_all_playlist_tags
self._all_playlists = [dict(r) for r in get_playlists()]
# Udfyld tag-liste
self._tag_list.clear()
all_item = QListWidgetItem("Alle lister")
all_item.setData(Qt.ItemDataRole.UserRole, None)
self._tag_list.addItem(all_item)
for tag in get_all_playlist_tags():
item = QListWidgetItem(f"# {tag}")
item.setData(Qt.ItemDataRole.UserRole, tag)
self._tag_list.addItem(item)
self._tag_list.setCurrentRow(0)
self._render(self._all_playlists)
except Exception as e:
print(f"Playlist browser load fejl: {e}")
def _on_tag_selected(self, current, previous):
if not current:
return
self._active_tag = current.data(Qt.ItemDataRole.UserRole)
self._filter()
def _suggest_tags(self, text: str):
"""Vis forslag til det sidst indtastede tag."""
if not hasattr(self, '_tag_suggestions'):
return
parts = text.split(",")
prefix = parts[-1].strip().lower()
if not prefix:
self._tag_suggestions.hide()
return
try:
from local.local_db import get_all_playlist_tags
all_tags = get_all_playlist_tags()
matches = [t for t in all_tags
if t.startswith(prefix) and t not in
[p.strip().lower() for p in parts[:-1]]]
if matches:
self._tag_suggestions.clear()
for t in matches[:5]:
self._tag_suggestions.addItem(t)
self._tag_suggestions.show()
else:
self._tag_suggestions.hide()
except Exception:
self._tag_suggestions.hide()
def _add_tag_suggestion(self, item):
"""Tilføj et foreslået tag til tekstfeltet."""
parts = self._tags_input.text().split(",")
parts[-1] = " " + item.text()
self._tags_input.setText(",".join(parts) + ", ")
self._tag_suggestions.hide()
self._tags_input.setFocus()
def _edit_tags(self):
"""Rediger tags på den valgte liste."""
item = self._list.currentItem()
if not item:
return
pl = item.data(Qt.ItemDataRole.UserRole)
if not pl or not isinstance(pl, dict):
return
from PyQt6.QtWidgets import QInputDialog
current = pl.get("tags", "")
new_tags, ok = QInputDialog.getText(
self, "Rediger tags", "Tags (komma-separeret):", text=current
)
if ok:
try:
from local.local_db import update_playlist_tags
update_playlist_tags(pl["id"], new_tags.strip())
self._load_data()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke opdatere tags: {e}")
def _filter(self):
query = self._search.text().strip().lower()
tag = self._active_tag
filtered = self._all_playlists
if tag:
filtered = [
p for p in filtered
if tag in [t.strip().lower() for t in p.get("tags", "").split(",")]
]
if query:
filtered = [p for p in filtered if query in p["name"].lower()]
self._render(filtered)
def _render(self, playlists: list):
self._list.clear()
self._count_label.setText(f"{len(playlists)} liste{'r' if len(playlists) != 1 else ''}")
for pl in playlists:
date = pl.get("created_at", "")[:10]
count = pl.get("song_count", 0)
tags = pl.get("tags", "")
tag_str = f" [{tags}]" if tags else ""
item = QListWidgetItem(
f"{pl['name']}\n"
f" {date} · {count} sange{tag_str}"
)
item.setData(Qt.ItemDataRole.UserRole, pl)
self._list.addItem(item)
def _on_double_click(self, item: QListWidgetItem):
if self._mode == "load":
self._load_selected()
else:
self._save()
def _load_selected(self):
item = self._list.currentItem()
if not item:
QMessageBox.information(self, "Vælg", "Vælg en liste først.")
return
pl = item.data(Qt.ItemDataRole.UserRole)
self.playlist_selected.emit(pl["id"], pl["name"])
self.accept()
def _save(self):
name = self._name_input.text().strip()
if not name:
QMessageBox.warning(self, "Navn mangler", "Angiv et navn til danselisten.")
self._name_input.setFocus()
return
tags = self._tags_input.text().strip()
# Tjek om navn allerede eksisterer
existing = [p for p in self._all_playlists
if p["name"].lower() == name.lower()]
if existing:
reply = QMessageBox.question(
self, "Navn eksisterer allerede",
f"Der findes allerede en liste med navnet '{name}'.\n"
f"Vil du overskrive den?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
try:
from local.local_db import get_db, add_song_to_playlist
pl_id = existing[0]["id"]
with get_db() as conn:
conn.execute(
"DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,)
)
if tags:
conn.execute(
"UPDATE playlists SET tags=? WHERE id=?", (tags, pl_id)
)
for i, song in enumerate(self._current_songs, start=1):
if song.get("id"):
add_song_to_playlist(pl_id, song["id"],
file_id=song.get("file_id"),
position=i)
self.playlist_selected.emit(pl_id, name)
self.accept()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke overskrive: {e}")
return
try:
from local.local_db import create_playlist, add_song_to_playlist
pl_id = create_playlist(name, tags=tags)
for i, song in enumerate(self._current_songs, start=1):
if song.get("id"):
add_song_to_playlist(pl_id, song["id"],
file_id=song.get("file_id"),
position=i)
self.playlist_selected.emit(pl_id, name)
self.accept()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
def _delete_selected(self):
item = self._list.currentItem()
if not item:
return
pl = item.data(Qt.ItemDataRole.UserRole)
reply = QMessageBox.question(
self, "Slet danseliste",
f"Slet '{pl['name']}'?\n\nDette kan ikke fortrydes.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
try:
from local.local_db import delete_playlist
delete_playlist(pl["id"])
self._load_data()
# Signal til main_window om at køre sync
self.sync_requested.emit()
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke slette: {e}")
def _share_selected(self):
"""Åbn del-dialog for den valgte playliste."""
item = self._list.currentItem()
if not item:
QMessageBox.information(self, "Del", "Vælg en playliste først.")
return
pl = item.data(Qt.ItemDataRole.UserRole)
if not isinstance(pl, dict):
return
# Hent server-info fra settings
try:
from ui.settings_dialog import load_settings
s = load_settings()
server_url = s.get("server_url", "")
token = self._get_token()
if not token:
QMessageBox.warning(self, "Ikke logget ind",
"Du skal være logget ind for at dele.")
return
# Find server-ID for playlisten
server_id = pl.get("api_project_id")
if not server_id:
QMessageBox.warning(self, "Ikke synkroniseret",
"Synkroniser playlisten til serveren først\n"
"(Filer → Synkroniser nu).")
return
from ui.share_dialog import ShareDialog
dlg = ShareDialog(server_id, pl["name"], server_url, token,
parent=self)
dlg.exec()
except Exception as e:
QMessageBox.warning(self, "Fejl", str(e))
def _get_token(self) -> str | None:
"""Hent JWT token fra main_window."""
mw = self.parent()
while mw and not hasattr(mw, "_api_token"):
mw = mw.parent()
return getattr(mw, "_api_token", None) if mw else None
def _fetch_shared(self):
"""Hent playlister der er delt med mig fra serveren."""
try:
from ui.settings_dialog import load_settings
s = load_settings()
server_url = s.get("server_url", "").rstrip("/")
token = self._get_token()
if not token:
QMessageBox.warning(self, "Ikke logget ind",
"Du skal være logget ind for at hente delte lister.")
return
import urllib.request, json
req = urllib.request.Request(
f"{server_url}/sharing/playlists/shared-with-me",
headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
shared = json.loads(resp.read())
if not shared:
QMessageBox.information(self, "Ingen delte lister",
"Ingen playlister er delt med dig.")
return
# Vis valgdialog
from PyQt6.QtWidgets import QInputDialog
options = [
f"{p['name']} (af {p['owner']}, {p['song_count']} sange, {p['permission']})"
for p in shared
]
choice, ok = QInputDialog.getItem(
self, "Hent delt playliste",
"Vælg en playliste at hente:",
options, 0, False
)
if not ok:
return
idx = options.index(choice)
chosen = shared[idx]
# Hent indholdet
req2 = urllib.request.Request(
f"{server_url}/sharing/playlists/{chosen['project_id']}",
headers={"Authorization": f"Bearer {token}"}
)
with urllib.request.urlopen(req2, timeout=10) as resp:
pl_data = json.loads(resp.read())
self._import_shared_playlist(pl_data, server_url, token,
permission=chosen.get("permission", "view"))
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke hente: {e}")
def _import_shared_playlist(self, pl_data: dict, server_url: str, token: str,
permission: str = "view"):
"""Importer en delt playliste som en linket liste."""
import sqlite3
from local.local_db import DB_PATH, get_db, add_song_to_playlist
name = pl_data["name"]
server_id = pl_data["id"]
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
# Tjek om listen allerede er linket
existing = conn.execute(
"SELECT id FROM playlists WHERE api_project_id=?", (server_id,)
).fetchone()
conn.close()
if existing:
# Opdater eksisterende
pl_id = existing["id"]
with get_db() as c:
c.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
else:
# Opret ny linket playliste
with get_db() as c:
c.execute(
"INSERT INTO playlists (name, api_project_id, is_linked, server_permission) "
"VALUES (?, ?, 1, ?)",
(name, server_id, permission)
)
pl_id = c.execute("SELECT last_insert_rowid()").fetchone()[0]
# Indsæt sange med sang-matching
matched = 0
with get_db() as c:
for song_data in pl_data.get("songs", []):
local = c.execute(
"SELECT id FROM songs WHERE title=? AND artist=? AND file_missing=0",
(song_data["title"], song_data["artist"])
).fetchone()
if local:
c.execute(
"INSERT INTO playlist_songs "
"(playlist_id, song_id, position, status, is_workshop, dance_override) "
"VALUES (?,?,?,?,?,?)",
(pl_id, local["id"], song_data["position"],
song_data.get("status", "pending"),
1 if song_data.get("is_workshop") else 0,
song_data.get("dance_override") or "")
)
matched += 1
self._load_data()
self.playlist_selected.emit(pl_id, name)
perm_text = {"view": "se", "copy": "kopiere", "edit": "redigere"}.get(
permission, permission
)
QMessageBox.information(
self, "Linket",
f"'{name}' er nu linket til server-listen.\n"
f"Du har rettighed til at {perm_text} listen.\n\n"
f"{matched} af {len(pl_data.get('songs', []))} sange fundet lokalt."
)

View File

@@ -0,0 +1,166 @@
"""
playlist_info_dialog.py — Flydende danseliste-info vindue med dynamisk opdatering.
"""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QFrame, QGridLayout,
)
from PyQt6.QtCore import Qt, pyqtSignal
from datetime import datetime, timedelta
def fmt_time(seconds: int) -> str:
if seconds < 0:
seconds = 0
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
if h > 0:
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
class PlaylistInfoWindow(QWidget):
def __init__(self, playlist_panel, parent=None):
super().__init__(parent,
Qt.WindowType.Tool |
Qt.WindowType.WindowStaysOnTopHint
)
self._panel = playlist_panel
self._pause_seconds = getattr(playlist_panel, "_pause_seconds", 60)
self._workshop_seconds = getattr(playlist_panel, "_workshop_seconds", 600)
self.setWindowTitle("Danseliste-info")
self.setMinimumWidth(380)
self.setFixedWidth(440)
self._build_ui()
self._update()
playlist_panel.playlist_changed.connect(self._update)
playlist_panel.status_changed.connect(lambda *_: self._update())
def moveEvent(self, event):
from PyQt6.QtCore import QSettings
QSettings("LineDance", "Player").setValue("window/info_pos", self.pos())
super().moveEvent(event)
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# Stats
stats = QFrame()
stats.setObjectName("track_display")
grid = QGridLayout(stats)
grid.setContentsMargins(12, 10, 12, 10)
grid.setSpacing(5)
grid.setColumnStretch(1, 1)
def row(r, label, attr):
l = QLabel(label)
l.setObjectName("track_meta")
grid.addWidget(l, r, 0)
v = QLabel("")
v.setAlignment(Qt.AlignmentFlag.AlignRight)
grid.addWidget(v, r, 1)
setattr(self, attr, v)
row(0, "Antal sange:", "_lbl_count")
row(1, "Afspillet:", "_lbl_played")
row(2, "Workshop:", "_lbl_ws")
row(3, "Sprunget over:", "_lbl_skipped")
row(4, "Tilbage:", "_lbl_remaining")
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
grid.addWidget(sep, 5, 0, 1, 2)
row(6, "Musik-tid total:", "_lbl_music_total")
row(7, "Musik-tid tilbage:", "_lbl_music_remain")
row(8, "Pause-tid total:", "_lbl_pause_total")
row(9, "Workshop-tid total:", "_lbl_ws_total")
row(10, "Samlet tid total:", "_lbl_total")
row(11, "Samlet tid tilbage:", "_lbl_total_remain")
layout.addWidget(stats)
# Fremgang og ETA
eta_frame = QFrame()
eta_frame.setObjectName("track_display")
eta_layout = QVBoxLayout(eta_frame)
eta_layout.setContentsMargins(12, 8, 12, 8)
eta_layout.setSpacing(4)
self._lbl_eta = QLabel("")
self._lbl_eta.setWordWrap(True)
self._lbl_eta.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._lbl_eta.setObjectName("track_title")
eta_layout.addWidget(self._lbl_eta)
self._lbl_finish = QLabel("")
self._lbl_finish.setWordWrap(True)
self._lbl_finish.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._lbl_finish.setObjectName("track_title")
eta_layout.addWidget(self._lbl_finish)
layout.addWidget(eta_frame)
def _update(self):
songs = self._panel.get_songs()
statuses = self._panel.get_statuses()
total = len(songs)
played = statuses.count("played")
skipped = statuses.count("skipped")
done = played + skipped # samlet "overstået"
remaining = total - done
ws_total = sum(1 for s in songs if s.get("is_workshop"))
ws_remain = sum(1 for s, st in zip(songs, statuses)
if s.get("is_workshop") and st == "pending")
music_total = sum(s.get("duration_sec", 0) for s in songs)
music_remain = sum(
s.get("duration_sec", 0)
for s, st in zip(songs, statuses) if st == "pending"
)
p = self._pause_seconds
w = self._workshop_seconds
pause_total = max(0, total - 1) * p
pause_remain = max(0, remaining - 1) * p
ws_time_total = ws_total * w
ws_time_remain = ws_remain * w
total_time = music_total + pause_total + ws_time_total
remain_time = music_remain + pause_remain + ws_time_remain
self._lbl_count.setText(str(total))
self._lbl_played.setText(str(played))
self._lbl_ws.setText(f"{ws_total} ({fmt_time(ws_time_total)})")
self._lbl_skipped.setText(str(skipped))
self._lbl_remaining.setText(str(remaining))
self._lbl_music_total.setText(fmt_time(music_total))
self._lbl_music_remain.setText(fmt_time(music_remain))
self._lbl_pause_total.setText(f"{fmt_time(pause_total)} ({max(0,total-1)} × {p}s)")
self._lbl_ws_total.setText(f"{fmt_time(ws_time_total)} ({ws_total} × {w//60}min)")
self._lbl_total.setText(fmt_time(total_time))
self._lbl_total_remain.setText(fmt_time(remain_time))
# ETA
if remaining == 0 and total > 0:
self._lbl_eta.setText("✓ Danselisten er afsluttet!")
self._lbl_finish.setText("")
elif total > 0:
pct = int(done / total * 100) if total > 0 else 0
self._lbl_eta.setText(
f"{pct}% færdig · {fmt_time(remain_time)} tilbage"
if done > 0 else f"Samlet varighed: {fmt_time(total_time)}"
)
finish = datetime.now() + timedelta(seconds=remain_time)
self._lbl_finish.setText(f"Estimeret sluttid: {finish.strftime('%H:%M')}")
else:
self._lbl_eta.setText("Ingen sange i listen")
self._lbl_finish.setText("")

View File

@@ -0,0 +1,327 @@
"""
playlist_manager.py — Dialog til danseliste-administration.
Ny liste, gem, load og importer M3U/M3U8/tekst.
"""
import os
from pathlib import Path
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QListWidget, QListWidgetItem, QFileDialog,
QMessageBox, QTabWidget, QWidget, QTextEdit,
)
from PyQt6.QtCore import Qt, pyqtSignal
class PlaylistManagerDialog(QDialog):
"""
Fanebaseret dialog med tre faner:
1. Gem aktuel liste
2. Indlæs gemt liste
3. Importer fra fil (M3U / M3U8 / tekst)
"""
playlist_loaded = pyqtSignal(str, list, int) # (navn, sange, pl_id)
def __init__(self, current_songs: list[dict], parent=None):
super().__init__(parent)
self.setWindowTitle("Danseliste-administration")
self.setMinimumWidth(500)
self.setMinimumHeight(460)
self._current_songs = current_songs
self._build_ui()
self._load_saved_playlists()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
tabs = QTabWidget()
tabs.addTab(self._build_save_tab(), "💾 Gem liste")
tabs.addTab(self._build_load_tab(), "📂 Indlæs liste")
tabs.addTab(self._build_import_tab(), "📥 Importer")
layout.addWidget(tabs)
btn_close = QPushButton("Luk")
btn_close.clicked.connect(self.accept)
row = QHBoxLayout()
row.addStretch()
row.addWidget(btn_close)
layout.addLayout(row)
# ── Fane 1: Gem ───────────────────────────────────────────────────────────
def _build_save_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setSpacing(10)
layout.addWidget(QLabel(f"Aktuel liste har {len(self._current_songs)} sange."))
layout.addWidget(QLabel("Navn på danselisten:"))
self._save_name = QLineEdit()
self._save_name.setPlaceholderText("f.eks. Sommer Event 2025")
layout.addWidget(self._save_name)
btn_save = QPushButton("💾 Gem")
btn_save.clicked.connect(self._save_playlist)
layout.addWidget(btn_save)
self._save_status = QLabel("")
self._save_status.setObjectName("result_count")
layout.addWidget(self._save_status)
layout.addStretch()
return tab
def _save_playlist(self):
name = self._save_name.text().strip()
if not name:
self._save_status.setText("Angiv et navn")
return
if not self._current_songs:
self._save_status.setText("Danselisten er tom")
return
try:
from local.local_db import create_playlist, add_song_to_playlist, get_db
pl_id = create_playlist(name)
for i, song in enumerate(self._current_songs, start=1):
add_song_to_playlist(pl_id, song["id"], position=i)
self._save_status.setText(f"✓ Gemt som \"{name}\"")
self._load_saved_playlists()
except Exception as e:
self._save_status.setText(f"Fejl: {e}")
# ── Fane 2: Indlæs ────────────────────────────────────────────────────────
def _build_load_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.addWidget(QLabel("Gemte danselister:"))
self._pl_list = QListWidget()
self._pl_list.itemDoubleClicked.connect(self._load_selected)
layout.addWidget(self._pl_list)
btn_row = QHBoxLayout()
btn_load = QPushButton("📂 Indlæs valgte")
btn_load.clicked.connect(self._load_selected_btn)
btn_delete = QPushButton("🗑 Slet valgte")
btn_delete.clicked.connect(self._delete_selected)
btn_row.addWidget(btn_load)
btn_row.addWidget(btn_delete)
layout.addLayout(btn_row)
self._load_status = QLabel("")
self._load_status.setObjectName("result_count")
layout.addWidget(self._load_status)
return tab
def _load_saved_playlists(self):
if not hasattr(self, "_pl_list"):
return
self._pl_list.clear()
try:
from local.local_db import get_playlists
for pl in get_playlists():
item = QListWidgetItem(pl["name"])
item.setData(Qt.ItemDataRole.UserRole, dict(pl))
self._pl_list.addItem(item)
except Exception:
pass
def _load_selected_btn(self):
item = self._pl_list.currentItem()
if item:
self._load_selected(item)
def _load_selected(self, item: QListWidgetItem):
pl = item.data(Qt.ItemDataRole.UserRole)
if not pl:
return
try:
from local.local_db import get_playlist_with_songs, get_db
data = get_playlist_with_songs(pl["id"])
songs = []
for row in data.get("songs", []):
with get_db() as conn:
dances = conn.execute(
"""SELECT d.name FROM song_dances sd
JOIN dances d ON d.id = sd.dance_id
WHERE sd.song_id=? ORDER BY sd.dance_order""",
(row["id"],)
).fetchall()
songs.append({
"id": row["id"],
"title": row.get("title", ""),
"artist": row.get("artist", ""),
"album": row.get("album", ""),
"bpm": row.get("bpm", 0),
"duration_sec": row.get("duration_sec", 0),
"local_path": row.get("local_path", ""),
"file_format": row.get("file_format", ""),
"file_missing": bool(row.get("file_missing", False)),
"dances": [d["name"] for d in dances],
})
self.playlist_loaded.emit(pl["name"], songs, pl["id"])
self._load_status.setText(f"✓ Indlæst: {pl['name']} ({len(songs)} sange)")
except Exception as e:
self._load_status.setText(f"Fejl: {e}")
def _delete_selected(self):
item = self._pl_list.currentItem()
if not item:
return
pl = item.data(Qt.ItemDataRole.UserRole)
reply = QMessageBox.question(
self, "Slet liste",
f"Slet danselisten \"{pl['name']}\"?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
try:
from local.local_db import delete_playlist
delete_playlist(pl["id"])
self._load_saved_playlists()
except Exception as e:
self._load_status.setText(f"Fejl: {e}")
# ── Fane 3: Importer ──────────────────────────────────────────────────────
def _build_import_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setSpacing(8)
lbl = QLabel(
"Importer fra M3U, M3U8 eller en tekstfil med én filsti per linje.\n"
"Sange der ikke er i biblioteket forsøges tilføjet automatisk."
)
lbl.setWordWrap(True)
lbl.setObjectName("result_count")
layout.addWidget(lbl)
btn_browse = QPushButton("📂 Vælg fil...")
btn_browse.clicked.connect(self._browse_import)
layout.addWidget(btn_browse)
layout.addWidget(QLabel("Eller indsæt filstier direkte (én per linje):"))
self._import_text = QTextEdit()
self._import_text.setPlaceholderText(
"/sti/til/sang1.mp3\n/sti/til/sang2.flac\n..."
)
self._import_text.setMaximumHeight(120)
layout.addWidget(self._import_text)
layout.addWidget(QLabel("Navn på den importerede liste:"))
self._import_name = QLineEdit()
self._import_name.setPlaceholderText("Importeret liste")
layout.addWidget(self._import_name)
btn_import = QPushButton("📥 Importer")
btn_import.clicked.connect(self._do_import)
layout.addWidget(btn_import)
self._import_status = QLabel("")
self._import_status.setObjectName("result_count")
self._import_status.setWordWrap(True)
layout.addWidget(self._import_status)
layout.addStretch()
return tab
def _browse_import(self):
path, _ = QFileDialog.getOpenFileName(
self, "Vælg afspilningsliste",
filter="Afspilningslister (*.m3u *.m3u8 *.txt);;Alle filer (*)"
)
if path:
self._import_name.setText(Path(path).stem)
paths = self._parse_playlist_file(path)
self._import_text.setPlainText("\n".join(paths))
def _parse_playlist_file(self, path: str) -> list[str]:
"""Parser M3U, M3U8 og tekst — returnerer liste af filstier."""
paths = []
base_dir = str(Path(path).parent)
try:
enc = "utf-8-sig" if path.lower().endswith(".m3u8") else "latin-1"
with open(path, encoding=enc, errors="replace") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
# Gør relativ sti absolut
if not os.path.isabs(line):
line = os.path.join(base_dir, line)
paths.append(line)
except Exception as e:
self._import_status.setText(f"Læsefejl: {e}")
return paths
def _do_import(self):
raw = self._import_text.toPlainText().strip()
if not raw:
self._import_status.setText("Ingen filstier angivet")
return
name = self._import_name.text().strip() or "Importeret liste"
paths = [line.strip() for line in raw.splitlines() if line.strip()]
found = []
missing = []
try:
from local.local_db import get_song_by_path, upsert_song, get_db
from local.tag_reader import read_tags, is_supported
for p in paths:
row = get_song_by_path(p)
if row:
# Hent danse
with get_db() as conn:
dances = conn.execute(
"""SELECT d.name FROM song_dances sd
JOIN dances d ON d.id = sd.dance_id
WHERE sd.song_id=? ORDER BY sd.dance_order""",
(row["id"],)
).fetchall()
found.append({
"id": row["id"],
"title": row["title"],
"artist": row["artist"],
"album": row["album"],
"bpm": row["bpm"],
"duration_sec": row["duration_sec"],
"local_path": row["local_path"],
"file_format": row["file_format"],
"file_missing": bool(row["file_missing"]),
"dances": [d["name"] for d in dances],
})
elif os.path.exists(p) and is_supported(p):
# Filen er ikke scannet endnu — høst tags og tilføj
tags = read_tags(p)
song_id = upsert_song(tags)
found.append({
"id": song_id,
"title": tags.get("title", Path(p).stem),
"artist": tags.get("artist", ""),
"album": tags.get("album", ""),
"bpm": tags.get("bpm", 0),
"duration_sec": tags.get("duration_sec", 0),
"local_path": p,
"file_format": tags.get("file_format", ""),
"file_missing": False,
"dances": tags.get("dances", []),
})
else:
missing.append(p)
if found:
self.playlist_loaded.emit(name, found, 0)
status = f"✓ Importeret {len(found)} sange som \"{name}\""
if missing:
status += f"\n{len(missing)} filer ikke fundet"
self._import_status.setText(status)
else:
self._import_status.setText("Ingen filer fundet — tjek stierne")
except Exception as e:
self._import_status.setText(f"Importfejl: {e}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
"""
register_dialog.py — Opret ny konto på LineDance API.
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QFormLayout, QLabel, QLineEdit,
QPushButton, QGroupBox, QMessageBox,
)
from PyQt6.QtCore import Qt
class RegisterDialog(QDialog):
def __init__(self, server_url: str = "http://localhost:8000", parent=None):
super().__init__(parent)
self._server_url = server_url.rstrip("/")
self.setWindowTitle("Opret konto")
self.setMinimumWidth(400)
self._build_ui()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
lbl = QLabel(f"Opret konto på:\n{self._server_url}")
lbl.setObjectName("result_count")
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(lbl)
grp = QGroupBox("Kontooplysninger")
form = QFormLayout(grp)
self._name = QLineEdit()
self._name.setPlaceholderText("Dit fulde navn")
form.addRow("Navn:", self._name)
self._username = QLineEdit()
self._username.setPlaceholderText("brugernavn (ingen mellemrum)")
form.addRow("Brugernavn:", self._username)
self._email = QLineEdit()
self._email.setPlaceholderText("din@email.dk")
form.addRow("E-mail:", self._email)
self._password = QLineEdit()
self._password.setEchoMode(QLineEdit.EchoMode.Password)
self._password.setPlaceholderText("mindst 8 tegn")
form.addRow("Kodeord:", self._password)
self._password2 = QLineEdit()
self._password2.setEchoMode(QLineEdit.EchoMode.Password)
self._password2.setPlaceholderText("gentag kodeord")
form.addRow("Gentag kodeord:", self._password2)
layout.addWidget(grp)
self._status = QLabel("")
self._status.setObjectName("result_count")
self._status.setWordWrap(True)
self._status.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(self._status)
btn_row = QVBoxLayout()
self._btn_register = QPushButton("✚ Opret konto")
self._btn_register.setObjectName("btn_play")
self._btn_register.clicked.connect(self._register)
btn_row.addWidget(self._btn_register)
btn_cancel = QPushButton("Annuller")
btn_cancel.clicked.connect(self.reject)
btn_row.addWidget(btn_cancel)
layout.addLayout(btn_row)
def _register(self):
name = self._name.text().strip()
username = self._username.text().strip()
email = self._email.text().strip()
password = self._password.text()
password2 = self._password2.text()
# Validering
if not all([username, email, password]):
self._status.setText("⚠ Udfyld alle felter.")
return
if " " in username:
self._status.setText("⚠ Brugernavnet må ikke indeholde mellemrum.")
return
if "@" not in email:
self._status.setText("⚠ Ugyldig e-mailadresse.")
return
if len(password) < 8:
self._status.setText("⚠ Kodeordet skal være mindst 8 tegn.")
return
if password != password2:
self._status.setText("⚠ Kodeordene er ikke ens.")
return
self._btn_register.setEnabled(False)
self._status.setText("Opretter konto...")
try:
import urllib.request, json
data = json.dumps({
"username": username,
"email": email,
"full_name": name,
"password": password,
}).encode("utf-8")
req = urllib.request.Request(
f"{self._server_url}/auth/register",
data=data,
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read())
self._status.setText(
f"✓ Konto oprettet!\n\n"
f"Tjek din e-mail ({email}) for at bekræfte kontoen.\n"
f"Herefter kan du logge ind."
)
self._btn_register.setEnabled(False)
except urllib.error.HTTPError as e:
body = json.loads(e.read())
msg = body.get("detail", str(e))
self._status.setText(f"{msg}")
self._btn_register.setEnabled(True)
except Exception as e:
self._status.setText(f"⚠ Kunne ikke forbinde til server:\n{e}")
self._btn_register.setEnabled(True)

View File

@@ -0,0 +1,52 @@
"""
scan_worker.py — QThread der scanner biblioteker i baggrunden.
Rapporterer fremgang via signals uden at blokere GUI.
"""
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)
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):
super().__init__()
self._library_id = library_id
self._library_path = library_path
self._db_path = db_path
self._overwrite_bpm = overwrite_bpm
self._cancelled = False
def cancel(self):
self._cancelled = True
self.requestInterruption()
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,
self._library_path,
self._db_path,
overwrite_bpm=self._overwrite_bpm,
progress_callback=on_progress,
)
if not self._cancelled:
self.finished.emit(count, self._library_path)
except InterruptedError:
pass
except Exception as e:
self.error.emit(str(e))

View File

@@ -0,0 +1,529 @@
"""
settings_dialog.py — Indstillinger for LineDance Player.
Gemmes via QSettings og læses ved opstart.
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QComboBox, QSpinBox, QCheckBox, QFrame,
QTabWidget, QWidget, QFileDialog, QGroupBox, QFormLayout,
)
from PyQt6.QtCore import Qt, QSettings
SETTINGS_KEY_THEME = "appearance/dark_theme"
SETTINGS_KEY_DEMO_SEC = "playback/demo_seconds"
SETTINGS_KEY_DEMO_FADE = "playback/demo_fade_seconds"
SETTINGS_KEY_VOLUME = "playback/volume"
SETTINGS_KEY_MAIL_CLIENT = "mail/client"
SETTINGS_KEY_MAIL_PATH = "mail/custom_path"
SETTINGS_KEY_AUTO_LOGIN = "online/auto_login"
SETTINGS_KEY_USERNAME = "online/username"
SETTINGS_KEY_PASSWORD = "online/password"
SETTINGS_KEY_SERVER_URL = "online/server_url"
SETTINGS_KEY_LANGUAGE = "appearance/language"
SETTINGS_KEY_BETWEEN_SEC = "playback/between_seconds"
SETTINGS_KEY_WORKSHOP_MIN = "playback/workshop_minutes"
SETTINGS_KEY_MAIN_DEVICE = "playback/audio_device_main"
SETTINGS_KEY_PREV_DEVICE = "playback/audio_device_preview"
SETTINGS_KEY_AFTER_SONG = "playback/after_song_mode"
SETTINGS_KEY_AFTER_DELAY = "playback/after_song_delay"
SETTINGS_KEY_ACOUSTID = "playback/acoustid_enabled"
SETTINGS_KEY_ACOUSTID_KEY = "playback/acoustid_api_key"
def load_settings() -> dict:
s = QSettings("LineDance", "Player")
return {
"dark_theme": s.value(SETTINGS_KEY_THEME, True, type=bool),
"demo_seconds": s.value(SETTINGS_KEY_DEMO_SEC, 10, type=int),
"demo_fade_seconds": s.value(SETTINGS_KEY_DEMO_FADE, 5, type=int),
"volume": s.value(SETTINGS_KEY_VOLUME, 78, type=int),
"mail_client": s.value(SETTINGS_KEY_MAIL_CLIENT, "auto"),
"mail_path": s.value(SETTINGS_KEY_MAIL_PATH, ""),
"auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool),
"username": s.value(SETTINGS_KEY_USERNAME, ""),
"password": s.value(SETTINGS_KEY_PASSWORD, ""),
"server_url": s.value(SETTINGS_KEY_SERVER_URL, "http://localhost:8000"),
"language": s.value(SETTINGS_KEY_LANGUAGE, "da"),
"between_seconds": s.value(SETTINGS_KEY_BETWEEN_SEC, 60, type=int),
"workshop_minutes": s.value(SETTINGS_KEY_WORKSHOP_MIN, 10, type=int),
"audio_device_main": s.value(SETTINGS_KEY_MAIN_DEVICE, ""),
"audio_device_preview":s.value(SETTINGS_KEY_PREV_DEVICE, ""),
"after_song_mode": s.value(SETTINGS_KEY_AFTER_SONG, "manual"),
"after_song_delay": s.value(SETTINGS_KEY_AFTER_DELAY, 2, type=int),
"acoustid_enabled": s.value(SETTINGS_KEY_ACOUSTID, False, type=bool),
"acoustid_api_key": s.value(SETTINGS_KEY_ACOUSTID_KEY, ""),
}
def save_settings(values: dict):
s = QSettings("LineDance", "Player")
s.setValue(SETTINGS_KEY_THEME, values.get("dark_theme", True))
s.setValue(SETTINGS_KEY_DEMO_SEC, values.get("demo_seconds", 10))
s.setValue(SETTINGS_KEY_DEMO_FADE, values.get("demo_fade_seconds", 5))
s.setValue(SETTINGS_KEY_VOLUME, values.get("volume", 78))
s.setValue(SETTINGS_KEY_MAIL_CLIENT, values.get("mail_client", "auto"))
s.setValue(SETTINGS_KEY_MAIL_PATH, values.get("mail_path", ""))
s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False))
s.setValue(SETTINGS_KEY_USERNAME, values.get("username", ""))
s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", ""))
s.setValue(SETTINGS_KEY_SERVER_URL, values.get("server_url", "http://localhost:8000"))
s.setValue(SETTINGS_KEY_LANGUAGE, values.get("language", "da"))
s.setValue(SETTINGS_KEY_BETWEEN_SEC, values.get("between_seconds", 60))
s.setValue(SETTINGS_KEY_WORKSHOP_MIN,values.get("workshop_minutes", 10))
s.setValue(SETTINGS_KEY_MAIN_DEVICE, values.get("audio_device_main", ""))
s.setValue(SETTINGS_KEY_PREV_DEVICE, values.get("audio_device_preview", ""))
s.setValue(SETTINGS_KEY_AFTER_SONG, values.get("after_song_mode", "manual"))
s.setValue(SETTINGS_KEY_AFTER_DELAY, values.get("after_song_delay", 2))
s.setValue(SETTINGS_KEY_ACOUSTID, values.get("acoustid_enabled", False))
s.setValue(SETTINGS_KEY_ACOUSTID_KEY, values.get("acoustid_api_key", ""))
class SettingsDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Indstillinger")
self.setMinimumWidth(480)
self.setModal(True)
self._values = load_settings()
self._build_ui()
self._populate()
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
tabs = QTabWidget()
tabs.setStyleSheet("""
QTabBar::tab {
padding: 6px 14px;
font-size: 13px;
color: #9aa0b0;
background: #1e2128;
border: none;
min-width: 80px;
}
QTabBar::tab:selected {
color: #e0e4f0;
background: #2a2d36;
border-bottom: 2px solid #e8a020;
}
QTabBar::tab:hover {
color: #e0e4f0;
background: #252830;
}
""")
tabs.addTab(self._build_appearance_tab(), "Udseende")
tabs.addTab(self._build_playback_tab(), "Afspilning")
tabs.addTab(self._build_mail_tab(), "Mail")
tabs.addTab(self._build_online_tab(), "Online")
tabs.addTab(self._build_language_tab(), "Sprog")
layout.addWidget(tabs)
# Knapper
btn_row = QHBoxLayout()
btn_row.addStretch()
btn_cancel = QPushButton("Annuller")
btn_cancel.clicked.connect(self.reject)
btn_row.addWidget(btn_cancel)
btn_save = QPushButton("💾 Gem indstillinger")
btn_save.setObjectName("btn_play")
btn_save.setDefault(True)
btn_save.clicked.connect(self._save_and_close)
btn_row.addWidget(btn_save)
layout.addLayout(btn_row)
# ── Fane: Udseende ────────────────────────────────────────────────────────
def _build_appearance_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setSpacing(12)
grp = QGroupBox("Standard tema")
grp_layout = QVBoxLayout(grp)
self._chk_dark = QCheckBox("Start med mørkt tema")
grp_layout.addWidget(self._chk_dark)
note = QLabel("Du kan altid skifte tema mens programmet kører via topbar-knappen.")
note.setObjectName("result_count")
note.setWordWrap(True)
grp_layout.addWidget(note)
layout.addWidget(grp)
layout.addStretch()
return tab
# ── Fane: Afspilning ──────────────────────────────────────────────────────
def _build_playback_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setSpacing(12)
grp = QGroupBox("Forspil (▶ N SEK knappen)")
grp_layout = QFormLayout(grp)
self._spin_demo = QSpinBox()
self._spin_demo.setRange(3, 60)
self._spin_demo.setSuffix(" sekunder")
self._spin_demo.setFixedWidth(140)
grp_layout.addRow("Forspil-længde:", self._spin_demo)
self._spin_fade = QSpinBox()
self._spin_fade.setRange(0, 15)
self._spin_fade.setSuffix(" sekunder (0 = ingen fade)")
self._spin_fade.setFixedWidth(220)
self._spin_fade.setToolTip(
"Fade-out tilføjes til forspillets længde.\n"
"F.eks. 10 sek forspil + 5 sek fade = 15 sek total.\n"
"Sæt til 0 for ingen fade."
)
grp_layout.addRow("Fade-ud:", self._spin_fade)
note = QLabel(
"Forspillet afspiller begyndelsen af sangen så arrangøren kan bekræfte\n"
"at det er den rigtige sang og dans inden eventet starter.\n"
"Fade-ud tilføjes oven i forspillets længde og fades logaritmisk."
)
note.setObjectName("result_count")
note.setWordWrap(True)
grp_layout.addRow(note)
layout.addWidget(grp)
grp2 = QGroupBox("Danseliste-tider ( info-vinduet)")
grp2_layout = QFormLayout(grp2)
self._spin_between = QSpinBox()
self._spin_between.setRange(0, 600)
self._spin_between.setSuffix(" sekunder")
self._spin_between.setFixedWidth(140)
grp2_layout.addRow("Tid mellem musikstykker:", self._spin_between)
self._spin_workshop = QSpinBox()
self._spin_workshop.setRange(0, 120)
self._spin_workshop.setSuffix(" minutter")
self._spin_workshop.setFixedWidth(140)
grp2_layout.addRow("Tid per workshop:", self._spin_workshop)
layout.addWidget(grp2)
# Reaktion når sang slutter
from PyQt6.QtWidgets import QRadioButton, QButtonGroup
grp3 = QGroupBox("Når en sang slutter")
grp3_layout = QVBoxLayout(grp3)
grp3_layout.setSpacing(8)
self._radio_manual = QRadioButton("Manuel — marker næste klar, vent på ▶")
self._radio_auto_demo = QRadioButton("Auto-demo — afspil demo af næste sang automatisk")
self._radio_auto_play = QRadioButton("Auto-play — start næste sang automatisk")
self._radio_demo_then_play = QRadioButton("Auto-demo → auto-play — demo, pause, så spiller sangen automatisk")
self._after_song_group = QButtonGroup(self)
self._after_song_group.addButton(self._radio_manual, 0)
self._after_song_group.addButton(self._radio_auto_demo, 1)
self._after_song_group.addButton(self._radio_auto_play, 2)
self._after_song_group.addButton(self._radio_demo_then_play, 3)
grp3_layout.addWidget(self._radio_manual)
grp3_layout.addWidget(self._radio_auto_demo)
grp3_layout.addWidget(self._radio_auto_play)
grp3_layout.addWidget(self._radio_demo_then_play)
delay_row = QHBoxLayout()
delay_row.addWidget(QLabel(" Pause før næste starter:"))
self._spin_after_delay = QSpinBox()
self._spin_after_delay.setRange(0, 30)
self._spin_after_delay.setSuffix(" sekunder")
self._spin_after_delay.setFixedWidth(160)
self._spin_after_delay.setToolTip(
"Bruges til auto-demo og auto-play.\n"
"Antal sekunder der ventes inden næste sang starter."
)
delay_row.addWidget(self._spin_after_delay)
delay_row.addStretch()
grp3_layout.addLayout(delay_row)
layout.addWidget(grp3)
grp3 = QGroupBox("Lydenheder")
grp3_layout = QFormLayout(grp3)
from player.player import Player as _Player
devices = _Player.get_audio_devices()
device_items = [("Standard", "")] + [(d["name"], d["id"]) for d in devices]
self._combo_main_device = QComboBox()
self._combo_preview_device = QComboBox()
for name, did in device_items:
self._combo_main_device.addItem(name, did)
self._combo_preview_device.addItem(name, did)
grp3_layout.addRow("Hoved-afspiller (sal):", self._combo_main_device)
grp3_layout.addRow("Preview (høretelefoner):", self._combo_preview_device)
note3 = QLabel("Preview-afspilleren bruges til at lytte til sange i biblioteket\nudenom at afbryde den sang der spiller i salen.")
note3.setObjectName("result_count")
note3.setWordWrap(True)
grp3_layout.addRow(note3)
layout.addWidget(grp3)
# AcoustID fingerprinting
grp4 = QGroupBox("AcoustID fingerprinting (valgfri)")
grp4_layout = QVBoxLayout(grp4)
grp4_layout.setSpacing(6)
self._chk_acoustid = QCheckBox("Kør AcoustID fingerprinting i baggrunden")
self._chk_acoustid.setToolTip(
"Analyserer sange uden MBID og slår dem op i AcoustID-databasen.\n"
"Kræver fpcalc (Chromaprint) installeret.\n"
"Startes automatisk 10 sekunder efter opstart."
)
grp4_layout.addWidget(self._chk_acoustid)
key_row = QHBoxLayout()
key_row.addWidget(QLabel("API-nøgle:"))
self._acoustid_key = QLineEdit()
self._acoustid_key.setPlaceholderText("Hent gratis på acoustid.org/api-key")
key_row.addWidget(self._acoustid_key)
grp4_layout.addLayout(key_row)
note4 = QLabel(
"fpcalc skal installeres separat:\n"
" Linux: sudo apt install libchromaprint-tools\n"
" Windows: download fra acoustid.org/chromaprint"
)
note4.setObjectName("result_count")
note4.setWordWrap(True)
grp4_layout.addWidget(note4)
layout.addWidget(grp4)
layout.addStretch()
return tab
# ── Fane: Mail ────────────────────────────────────────────────────────────
def _build_mail_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setSpacing(12)
grp = QGroupBox("Mailklient")
grp_layout = QFormLayout(grp)
self._mail_combo = QComboBox()
self._mail_combo.addItem("Auto-detekter (Thunderbird → Outlook → mailto:)", "auto")
self._mail_combo.addItem("Thunderbird", "thunderbird")
self._mail_combo.addItem("Outlook (Windows)", "outlook")
self._mail_combo.addItem("Brugerdefineret sti", "custom")
self._mail_combo.addItem("Kun mailto: (ingen vedhæftning)", "mailto")
self._mail_combo.currentIndexChanged.connect(self._on_mail_combo_changed)
grp_layout.addRow("Klient:", self._mail_combo)
path_row = QHBoxLayout()
self._mail_path = QLineEdit()
self._mail_path.setPlaceholderText("/usr/bin/thunderbird eller C:\\...\\thunderbird.exe")
path_row.addWidget(self._mail_path)
btn_browse = QPushButton("...")
btn_browse.setFixedWidth(32)
btn_browse.clicked.connect(self._browse_mail_path)
path_row.addWidget(btn_browse)
self._mail_path_row_widget = QWidget()
self._mail_path_row_widget.setLayout(path_row)
grp_layout.addRow("Sti:", self._mail_path_row_widget)
note = QLabel(
"Med Thunderbird og Outlook åbnes et nyt compose-vindue med filen vedhæftet.\n"
"mailto: åbner standard-mailprogrammet men uden automatisk vedhæftning."
)
note.setObjectName("result_count")
note.setWordWrap(True)
grp_layout.addRow(note)
layout.addWidget(grp)
layout.addStretch()
return tab
def _on_mail_combo_changed(self, idx: int):
is_custom = self._mail_combo.currentData() == "custom"
self._mail_path_row_widget.setVisible(is_custom)
def _browse_mail_path(self):
path, _ = QFileDialog.getOpenFileName(self, "Vælg mailklient")
if path:
self._mail_path.setText(path)
# ── Fane: Online ──────────────────────────────────────────────────────────
def _build_online_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setSpacing(12)
# Server URL
grp_server = QGroupBox("Server")
grp_server_layout = QFormLayout(grp_server)
self._server_url = QLineEdit()
self._server_url.setPlaceholderText("http://localhost:8000")
grp_server_layout.addRow("API-adresse:", self._server_url)
note_server = QLabel("Adressen på LineDance API-serveren.")
note_server.setObjectName("result_count")
grp_server_layout.addRow(note_server)
layout.addWidget(grp_server)
# Login
grp = QGroupBox("Konto")
grp_layout = QFormLayout(grp)
btn_register = QPushButton("✚ Opret ny konto...")
btn_register.clicked.connect(self._open_register)
grp_layout.addRow(btn_register)
self._chk_auto_login = QCheckBox("Log automatisk ind når programmet starter")
self._chk_auto_login.stateChanged.connect(self._on_auto_login_changed)
grp_layout.addRow(self._chk_auto_login)
self._user_input = QLineEdit()
self._user_input.setPlaceholderText("brugernavn eller e-mail")
grp_layout.addRow("Brugernavn:", self._user_input)
self._pass_input = QLineEdit()
self._pass_input.setEchoMode(QLineEdit.EchoMode.Password)
self._pass_input.setPlaceholderText("••••••••")
grp_layout.addRow("Kodeord:", self._pass_input)
note = QLabel(
"⚠ Kodeordet gemmes lokalt på denne computer.\n"
"Brug kun dette på en personlig maskine."
)
note.setObjectName("result_count")
note.setWordWrap(True)
grp_layout.addRow(note)
layout.addWidget(grp)
layout.addStretch()
return tab
def _open_register(self):
from ui.register_dialog import RegisterDialog
server_url = self._server_url.text().strip() or "http://localhost:8000"
dlg = RegisterDialog(server_url=server_url, parent=self)
dlg.exec()
def _build_language_tab(self) -> QWidget:
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setSpacing(12)
grp = QGroupBox("Sprog")
grp_layout = QFormLayout(grp)
self._lang_combo = QComboBox()
self._lang_combo.addItem("Dansk", "da")
self._lang_combo.addItem("English", "en")
grp_layout.addRow("Programsprog:", self._lang_combo)
note = QLabel("Sproget anvendes næste gang programmet startes.")
note.setObjectName("result_count")
note.setWordWrap(True)
grp_layout.addRow(note)
layout.addWidget(grp)
layout.addStretch()
return tab
def _on_auto_login_changed(self, state: int):
enabled = state == Qt.CheckState.Checked.value
self._user_input.setEnabled(enabled)
self._pass_input.setEnabled(enabled)
# ── Populer fra gemte værdier ─────────────────────────────────────────────
def _populate(self):
v = self._values
self._chk_dark.setChecked(v.get("dark_theme", True))
self._spin_demo.setValue(v.get("demo_seconds", 10))
self._spin_fade.setValue(v.get("demo_fade_seconds", 5))
self._spin_between.setValue(v.get("between_seconds", 60))
self._spin_workshop.setValue(v.get("workshop_minutes", 10))
# Sprog
lang = v.get("language", "da")
for i in range(self._lang_combo.count()):
if self._lang_combo.itemData(i) == lang:
self._lang_combo.setCurrentIndex(i)
break
# Mail
client = v.get("mail_client", "auto")
for i in range(self._mail_combo.count()):
if self._mail_combo.itemData(i) == client:
self._mail_combo.setCurrentIndex(i)
break
self._mail_path.setText(v.get("mail_path", ""))
self._on_mail_combo_changed(self._mail_combo.currentIndex())
# Online
auto = v.get("auto_login", False)
self._chk_auto_login.setChecked(auto)
self._user_input.setText(v.get("username", ""))
self._pass_input.setText(v.get("password", ""))
self._server_url.setText(v.get("server_url", "http://localhost:8000"))
self._user_input.setEnabled(auto)
self._pass_input.setEnabled(auto)
# Lydenheder
main_dev = v.get("audio_device_main", "")
preview_dev = v.get("audio_device_preview", "")
for i in range(self._combo_main_device.count()):
if self._combo_main_device.itemData(i) == main_dev:
self._combo_main_device.setCurrentIndex(i)
break
for i in range(self._combo_preview_device.count()):
if self._combo_preview_device.itemData(i) == preview_dev:
self._combo_preview_device.setCurrentIndex(i)
break
# Reaktion når sang slutter
mode = v.get("after_song_mode", "manual")
if mode == "auto_demo":
self._radio_auto_demo.setChecked(True)
elif mode == "auto_play":
self._radio_auto_play.setChecked(True)
elif mode == "demo_then_play":
self._radio_demo_then_play.setChecked(True)
else:
self._radio_manual.setChecked(True)
self._spin_after_delay.setValue(v.get("after_song_delay", 2))
self._chk_acoustid.setChecked(v.get("acoustid_enabled", False))
self._acoustid_key.setText(v.get("acoustid_api_key", ""))
# ── Gem ───────────────────────────────────────────────────────────────────
def _save_and_close(self):
values = {
"dark_theme": self._chk_dark.isChecked(),
"demo_seconds": self._spin_demo.value(),
"demo_fade_seconds": self._spin_fade.value(),
"between_seconds": self._spin_between.value(),
"workshop_minutes": self._spin_workshop.value(),
"mail_client": self._mail_combo.currentData(),
"mail_path": self._mail_path.text().strip(),
"auto_login": self._chk_auto_login.isChecked(),
"username": self._user_input.text().strip(),
"password": self._pass_input.text(),
"server_url": self._server_url.text().strip() or "http://localhost:8000",
"language": self._lang_combo.currentData(),
"audio_device_main": self._combo_main_device.currentData() or "",
"audio_device_preview":self._combo_preview_device.currentData() or "",
"after_song_mode": (
"auto_demo" if self._radio_auto_demo.isChecked() else
"auto_play" if self._radio_auto_play.isChecked() else
"demo_then_play" if self._radio_demo_then_play.isChecked() else
"manual"
),
"after_song_delay": self._spin_after_delay.value(),
"acoustid_enabled": self._chk_acoustid.isChecked(),
"acoustid_api_key": self._acoustid_key.text().strip(),
}
save_settings(values)
self._values = values
self.accept()
def get_values(self) -> dict:
return self._values

View File

@@ -0,0 +1,180 @@
"""
share_dialog.py — Forenklet del-dialog.
Kun ejer kan dele. Delte brugere får listen ved næste sync.
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QComboBox, QFrame, QListWidget, QListWidgetItem,
QMessageBox,
)
from PyQt6.QtCore import Qt
class ShareDialog(QDialog):
def __init__(self, playlist_id: str, playlist_name: str,
server_url: str, token: str, parent=None):
super().__init__(parent)
self._playlist_id = playlist_id
self._playlist_name = playlist_name
self._server_url = server_url.rstrip("/")
self._token = token
self.setWindowTitle(f"Del — {playlist_name}")
self.setMinimumWidth(440)
self._build_ui()
self._load_shares()
self._load_visibility()
def _headers(self):
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {self._token}",
}
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(10)
# Synlighed
vis_frame = QFrame()
vis_frame.setObjectName("track_display")
vis_layout = QHBoxLayout(vis_frame)
vis_layout.setContentsMargins(10, 8, 10, 8)
vis_layout.addWidget(QLabel("Synlighed:"))
self._vis_combo = QComboBox()
self._vis_combo.addItem("Privat — kun dig", "private")
self._vis_combo.addItem("Delt — kun inviterede", "shared")
self._vis_combo.addItem("Public — hjemmesiden", "public")
vis_layout.addWidget(self._vis_combo, stretch=1)
btn_vis = QPushButton("Gem")
btn_vis.setFixedHeight(28)
btn_vis.clicked.connect(self._set_visibility)
vis_layout.addWidget(btn_vis)
layout.addWidget(vis_frame)
# Del med bruger
inv_frame = QFrame()
inv_frame.setObjectName("track_display")
inv_layout = QVBoxLayout(inv_frame)
inv_layout.setContentsMargins(10, 8, 10, 8)
inv_layout.setSpacing(6)
inv_layout.addWidget(QLabel("Del med (e-mail):"))
row = QHBoxLayout()
self._email_input = QLineEdit()
self._email_input.setPlaceholderText("bruger@eksempel.dk")
row.addWidget(self._email_input)
btn_inv = QPushButton("Del")
btn_inv.setFixedHeight(28)
btn_inv.clicked.connect(self._invite)
row.addWidget(btn_inv)
inv_layout.addLayout(row)
note = QLabel("Brugeren får listen ved næste synkronisering.\nKun du kan redigere listen.")
note.setObjectName("result_count")
note.setWordWrap(True)
inv_layout.addWidget(note)
layout.addWidget(inv_frame)
# Liste
lbl = QLabel("Delt med:")
lbl.setObjectName("track_meta")
layout.addWidget(lbl)
self._shares_list = QListWidget()
self._shares_list.setMaximumHeight(120)
layout.addWidget(self._shares_list)
btn_remove = QPushButton("✕ Fjern valgt deling")
btn_remove.clicked.connect(self._remove_share)
layout.addWidget(btn_remove)
self._status = QLabel("")
self._status.setObjectName("result_count")
self._status.setWordWrap(True)
layout.addWidget(self._status)
btn_close = QPushButton("Luk")
btn_close.clicked.connect(self.accept)
layout.addWidget(btn_close)
def _load_visibility(self):
try:
import urllib.request, json
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}",
headers=self._headers()
)
with urllib.request.urlopen(req, timeout=8) as resp:
data = json.loads(resp.read())
vis = data.get("visibility", "private")
for i in range(self._vis_combo.count()):
if self._vis_combo.itemData(i) == vis:
self._vis_combo.setCurrentIndex(i)
break
except Exception:
pass
def _set_visibility(self):
vis = self._vis_combo.currentData()
try:
import urllib.request, json
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}/visibility?visibility={vis}",
data=b"", headers=self._headers(), method="PATCH"
)
urllib.request.urlopen(req, timeout=8)
self._status.setText(f"✓ Synlighed gemt")
except Exception as e:
self._status.setText(f"{e}")
def _load_shares(self):
try:
import urllib.request, json
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}/shares",
headers=self._headers()
)
with urllib.request.urlopen(req, timeout=8) as resp:
shares = json.loads(resp.read())
self._shares_list.clear()
for s in shares:
item = QListWidgetItem(s["email"])
item.setData(Qt.ItemDataRole.UserRole, s["id"])
self._shares_list.addItem(item)
except Exception as e:
self._status.setText(f"Kunne ikke hente delinger: {e}")
def _invite(self):
email = self._email_input.text().strip()
if not email or "@" not in email:
self._status.setText("⚠ Ugyldig e-mailadresse")
return
try:
import urllib.request, json
data = json.dumps({"email": email}).encode()
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}/share",
data=data, headers=self._headers(), method="POST"
)
urllib.request.urlopen(req, timeout=8)
self._email_input.clear()
self._status.setText(f"✓ Delt med {email}")
self._load_shares()
except Exception as e:
self._status.setText(f"{e}")
def _remove_share(self):
item = self._shares_list.currentItem()
if not item:
return
share_id = item.data(Qt.ItemDataRole.UserRole)
try:
import urllib.request
req = urllib.request.Request(
f"{self._server_url}/sharing/playlists/{self._playlist_id}/share/{share_id}",
headers=self._headers(), method="DELETE"
)
urllib.request.urlopen(req, timeout=8)
self._status.setText("✓ Deling fjernet")
self._load_shares()
except Exception as e:
self._status.setText(f"{e}")

View File

@@ -0,0 +1,618 @@
"""
tag_editor.py — Rediger danse og alternativ-danse.
Dans = navn + niveau kombination. Autoudfyld viser "Navn / Niveau".
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QComboBox, QWidget, QMessageBox, QGroupBox,
QScrollArea, QFrame, QListWidget, QListWidgetItem,
)
from PyQt6.QtCore import Qt, QTimer
class DanceLineEdit(QLineEdit):
"""Simpelt tekstfelt til dans-navn i eksisterende rækker."""
from PyQt6.QtCore import pyqtSignal
dance_selected = pyqtSignal(dict)
def __init__(self, placeholder="", parent=None):
super().__init__(parent)
self.setPlaceholderText(placeholder)
def get_dance_info(self) -> dict:
text = self.text().strip()
if " / " in text:
parts = text.split(" / ", 1)
return {"name": parts[0].strip()}
return {"name": text}
class TagEditorDialog(QDialog):
def keyPressEvent(self, event):
"""Forhindre Enter i at lukke dialogen — bruges til at tilføje danse."""
from PyQt6.QtCore import Qt as _Qt
if event.key() in (_Qt.Key.Key_Return, _Qt.Key.Key_Enter):
# Send Enter videre til det fokuserede widget i stedet for at lukke
focused = self.focusWidget()
if focused and focused is not self:
focused.event(event)
return
super().keyPressEvent(event)
def __init__(self, song: dict, parent=None):
super().__init__(parent)
self._song = song
self._levels = []
self._dances = [] # fra DB: {dance_id, name, level_id, level_name, dance_order}
self._alts = [] # fra DB: {dance_id, name, level_id, level_name, note}
self.setWindowTitle(f"Rediger tags — {song.get('title', '')}")
self.setMinimumSize(860, 520)
self.resize(980, 600)
self._load_levels()
self._load_existing()
self._build_ui()
def _load_levels(self):
try:
from local.local_db import get_dance_levels
self._levels = [dict(r) for r in get_dance_levels()]
except Exception:
self._levels = []
def _load_existing(self):
try:
from local.local_db import get_dances_for_song, get_alt_dances_for_song
self._dances = get_dances_for_song(self._song.get("id"))
self._alts = get_alt_dances_for_song(self._song.get("id"))
except Exception as e:
print(f"load fejl: {e}")
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8)
# Sang-info
info = QFrame()
info.setObjectName("track_display")
il = QHBoxLayout(info)
il.setContentsMargins(10, 8, 10, 8)
lbl_t = QLabel(self._song.get("title", ""))
lbl_t.setObjectName("track_title")
il.addWidget(lbl_t, stretch=1)
fmt = self._song.get("file_format", "").lower()
can_write = fmt in ("mp3", "flac", "ogg", "opus", "m4a")
lbl_w = QLabel("✓ Danse skrives til filen" if can_write
else "⚠ Dette format understøtter ikke fil-skrivning")
lbl_w.setObjectName("result_count")
il.addWidget(lbl_w)
layout.addWidget(info)
# Hint om autoudfyld
hint = QLabel("Skriv dansenavn — forslag vises som 'Navn / Niveau'. "
"Vælg fra listen for at få niveau automatisk.")
hint.setObjectName("result_count")
hint.setWordWrap(True)
layout.addWidget(hint)
# To kolonner — hoveddanse får mere plads
cols = QHBoxLayout()
cols.setSpacing(12)
cols.addWidget(self._build_dances_panel(), stretch=3)
cols.addWidget(self._build_alts_panel(), stretch=2)
layout.addLayout(cols, stretch=1)
btn_row = QHBoxLayout()
btn_row.addStretch()
btn_cancel = QPushButton("Annuller")
btn_cancel.clicked.connect(self.reject)
btn_row.addWidget(btn_cancel)
btn_save = QPushButton("💾 Gem tags")
btn_save.setObjectName("btn_play")
btn_save.clicked.connect(self._save)
btn_row.addWidget(btn_save)
layout.addLayout(btn_row)
def _build_dances_panel(self) -> QGroupBox:
from translations import _
grp = QGroupBox(_("tags.dances"))
layout = QVBoxLayout(grp)
# Kolonneoverskrifter
hdr = QWidget()
hdr_layout = QHBoxLayout(hdr)
hdr_layout.setContentsMargins(0, 0, 0, 0)
hdr_layout.setSpacing(4)
lbl_dans = QLabel("Dans")
lbl_dans.setObjectName("result_count")
lbl_niveau = QLabel("Niveau")
lbl_niveau.setObjectName("result_count")
lbl_niveau.setFixedWidth(130)
lbl_choreo = QLabel("Koreograf")
lbl_choreo.setObjectName("result_count")
lbl_choreo.setFixedWidth(140)
lbl_btn = QLabel("") # plads til knapper
lbl_btn.setFixedWidth(72)
hdr_layout.addWidget(lbl_dans, stretch=2)
hdr_layout.addWidget(lbl_niveau)
hdr_layout.addWidget(lbl_choreo)
hdr_layout.addWidget(lbl_btn)
layout.addWidget(hdr)
# Eksisterende danse
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
container = QWidget()
self._dance_layout = QVBoxLayout(container)
self._dance_layout.setSpacing(4)
self._dance_layout.addStretch()
scroll.setWidget(container)
layout.addWidget(scroll, stretch=1)
self._dance_rows = []
for d in self._dances:
self._add_dance_row(d["name"], d["level_id"], d.get("choreographer", ""))
# Søgefelt
self._new_dance = QLineEdit()
self._new_dance.setPlaceholderText("Søg dans eller koreograf...")
self._new_dance.textChanged.connect(self._on_dance_search)
self._new_dance.returnPressed.connect(self._on_add_dance)
layout.addWidget(self._new_dance)
# Forslags-liste
self._dance_suggestions = QListWidget()
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")
)
layout.addWidget(self._dance_suggestions)
# Timer til debounce
self._dance_search_timer = QTimer(self)
self._dance_search_timer.setSingleShot(True)
self._dance_search_timer.setInterval(150)
self._dance_search_timer.timeout.connect(
lambda: self._load_dance_suggestions(
self._new_dance.text().strip(), self._dance_suggestions
)
)
self._load_dance_suggestions("", self._dance_suggestions)
return grp
def _add_dance_row(self, name="", level_id=None, choreographer=""):
try:
from translations import _, translate_level
except Exception:
def _(key, **kw): return key.split(".")[-1]
def translate_level(n): return n
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(4)
edit = DanceLineEdit("Dans...", self)
edit.setText(name)
edit.setToolTip(name)
edit.textChanged.connect(lambda txt, e=edit: e.setToolTip(txt))
row_layout.addWidget(edit, stretch=2)
# Niveau-dropdown
level_cb = QComboBox()
level_cb.addItem(_("tags.no_level"), None)
for lvl in self._levels:
level_cb.addItem(translate_level(lvl["name"]), lvl["id"])
if level_id is not None:
for i in range(level_cb.count()):
if level_cb.itemData(i) == level_id:
level_cb.setCurrentIndex(i)
break
level_cb.setFixedWidth(130)
row_layout.addWidget(level_cb)
# Koreograf-felt med autocomplete
choreo_edit = QLineEdit()
choreo_edit.setText(choreographer)
choreo_edit.setPlaceholderText("Koreograf...")
choreo_edit.setFixedWidth(140)
choreo_edit.setToolTip(choreographer)
choreo_edit.textChanged.connect(
lambda txt, ce=choreo_edit: (
ce.setToolTip(txt),
self._show_choreo_suggestions(txt, ce)
)
)
row_layout.addWidget(choreo_edit)
# Når autoudfyld vælger — opdater dropdown og koreograf
def on_dance_selected(dance_info, cb=level_cb, ce=choreo_edit):
if dance_info.get("level_id") is not None:
for i in range(cb.count()):
if cb.itemData(i) == dance_info["level_id"]:
cb.setCurrentIndex(i)
break
if dance_info.get("choreographer") and not ce.text().strip():
ce.setText(dance_info["choreographer"])
edit.dance_selected.connect(on_dance_selected)
btn_info = QPushButton("•••")
btn_info.setFixedSize(36, 24)
btn_info.setToolTip("Åbn dans-info (link, video, noter)")
btn_info.setObjectName("btn_info_row")
btn_info.style().unpolish(btn_info)
btn_info.style().polish(btn_info)
row_layout.addWidget(btn_info)
btn_rm = QPushButton("")
btn_rm.setFixedSize(32, 24)
btn_rm.setToolTip("Fjern dans")
btn_rm.setObjectName("btn_rm_row")
btn_rm.style().unpolish(btn_rm)
btn_rm.style().polish(btn_rm)
row_layout.addWidget(btn_rm)
idx = self._dance_layout.count() - 1
self._dance_layout.insertWidget(idx, row_widget)
entry = {"widget": row_widget, "edit": edit, "level": level_cb, "choreo": choreo_edit}
self._dance_rows.append(entry)
btn_rm.clicked.connect(lambda: self._remove_dance_row(entry))
btn_info.clicked.connect(lambda: self._open_dance_info(entry))
def _create_dance(self):
"""Opret en ny dans i databasen uden at knytte den til musik."""
from PyQt6.QtWidgets import QDialog, QFormLayout, QDialogButtonBox
from PyQt6.QtCore import Qt
dlg = QDialog(self)
dlg.setWindowTitle("Opret dans")
dlg.setFixedWidth(360)
layout = QVBoxLayout(dlg)
layout.setSpacing(8)
form = QFormLayout()
name_edit = QLineEdit()
name_edit.setPlaceholderText("f.eks. Cowboy Cha Cha")
form.addRow("Dans-navn:", name_edit)
from PyQt6.QtWidgets import QComboBox
level_cb = QComboBox()
level_cb.addItem("— intet niveau —", None)
for lvl in self._levels:
level_cb.addItem(lvl["name"], lvl["id"])
form.addRow("Niveau:", level_cb)
choreo_edit = QLineEdit()
choreo_edit.setPlaceholderText("Koreografens navn (valgfri)")
form.addRow("Koreograf:", choreo_edit)
layout.addLayout(form)
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
btns.accepted.connect(dlg.accept)
btns.rejected.connect(dlg.reject)
layout.addWidget(btns)
name_edit.setFocus()
if dlg.exec():
name = name_edit.text().strip()
choreo = choreo_edit.text().strip()
level = level_cb.currentData()
if name:
try:
from local.local_db import get_or_create_dance
get_or_create_dance(name, level, choreographer=choreo)
# Opdater forslagslisten
self._load_dance_suggestions("", self._dance_suggestions)
self._load_existing_dance_suggestions("", self._alt_suggestions)
self._new_dance.setPlaceholderText(f'"{name}" oprettet ✓')
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke oprette dans:\n{e}")
def _remove_dance_row(self, entry):
self._dance_rows.remove(entry)
entry["widget"].deleteLater()
def _on_dance_search(self):
self._dance_search_timer.start()
def _load_existing_dance_suggestions(self, prefix: str, list_widget):
"""Kun eksisterende danse fra DB — ingen nye kan oprettes herfra."""
try:
from local.local_db import get_dance_suggestions
suggestions = get_dance_suggestions(prefix, limit=20)
list_widget.clear()
for s in suggestions:
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"])
item.setData(Qt.ItemDataRole.UserRole + 2, s.get("choreographer", ""))
list_widget.addItem(item)
except Exception:
pass
def _on_alt_search(self):
self._alt_search_timer.start()
def _open_dance_info(self, entry: dict):
"""Åbn dans-info dialog for den dans der er i denne række."""
name = entry["edit"].text().strip()
if not name:
entry["edit"].setFocus()
entry["edit"].setPlaceholderText("Skriv dans-navn først...")
return
from ui.dance_info_dialog import DanceInfoDialog
dlg = DanceInfoDialog(song=self._song, parent=self)
dlg.exec()
# Opdater koreograf-feltet fra DB bagefter
try:
from local.local_db import get_db
with get_db() as conn:
row = conn.execute(
"SELECT choreographer FROM dances "
"WHERE name=? COLLATE NOCASE LIMIT 1", (name,)
).fetchone()
if row and row["choreographer"]:
entry["choreo"].setText(row["choreographer"])
except Exception:
pass
def _show_choreo_suggestions(self, prefix: str, source_edit: 'QLineEdit'):
"""Vis autocomplete popup for koreograf direkte under feltet."""
pass # Simpel løsning: autocomplete via QCompleter nedenfor
def _load_dance_suggestions(self, prefix: str, list_widget):
try:
from local.local_db import get_dance_suggestions
suggestions = get_dance_suggestions(prefix, limit=15)
list_widget.clear()
for s in suggestions:
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"])
item.setData(Qt.ItemDataRole.UserRole + 2, s.get("choreographer", ""))
list_widget.addItem(item)
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."""
name = item.data(Qt.ItemDataRole.UserRole + 1) or item.text().split(" / ")[0]
level_id = item.data(Qt.ItemDataRole.UserRole)
choreographer = item.data(Qt.ItemDataRole.UserRole + 2) or ""
if panel == "dance":
self._add_dance_row(name, level_id, choreographer)
self._new_dance.clear()
self._new_dance.setFocus()
self._load_dance_suggestions("", self._dance_suggestions)
else:
self._add_alt_row(name, level_id)
self._new_alt.clear()
self._new_alt.setFocus()
self._load_dance_suggestions("", self._alt_suggestions)
def _on_add_dance(self):
text = self._new_dance.text().strip()
if text:
name, level_id = self._parse_name_level(text)
self._add_dance_row(name, level_id)
self._new_dance.clear()
self._load_dance_suggestions("", self._dance_suggestions)
def _parse_name_level(self, text: str) -> tuple:
"""Parse 'Navn / Niveau' og returnér (name, level_id)."""
if " / " in text:
parts = text.split(" / ", 1)
name = parts[0].strip()
level_name = parts[1].strip()
for lvl in self._levels:
if lvl["name"].lower() == level_name.lower():
return name, lvl["id"]
return name, None
return text, None
def _build_alts_panel(self) -> QGroupBox:
from translations import _
grp = QGroupBox(_("tags.alts"))
layout = QVBoxLayout(grp)
# Eksisterende alternativ-rækker
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
container = QWidget()
self._alt_layout = QVBoxLayout(container)
self._alt_layout.setSpacing(4)
self._alt_layout.addStretch()
scroll.setWidget(container)
layout.addWidget(scroll, stretch=1)
self._alt_rows = []
for a in self._alts:
self._add_alt_row(a["name"], a["level_id"], a.get("note", ""))
# Søgefelt — kun eksisterende danse
hint = QLabel("Søg blandt eksisterende danse:")
hint.setObjectName("result_count")
layout.addWidget(hint)
self._new_alt = QLineEdit()
self._new_alt.setPlaceholderText("Søg dans...")
self._new_alt.textChanged.connect(self._on_alt_search)
self._new_alt.returnPressed.connect(self._on_add_alt)
layout.addWidget(self._new_alt)
self._alt_suggestions = QListWidget()
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")
)
layout.addWidget(self._alt_suggestions)
self._alt_search_timer = QTimer(self)
self._alt_search_timer.setSingleShot(True)
self._alt_search_timer.setInterval(150)
self._alt_search_timer.timeout.connect(
lambda: self._load_existing_dance_suggestions(
self._new_alt.text().strip(), self._alt_suggestions
)
)
self._load_existing_dance_suggestions("", self._alt_suggestions)
return grp
def _add_alt_row(self, name="", level_id=None, note=""):
try:
from translations import _, translate_level
except Exception:
def _(key, **kw): return key.split(".")[-1]
def translate_level(n): return n
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(4)
lbl = QLabel("")
lbl.setObjectName("track_meta")
row_layout.addWidget(lbl)
# Vis dans-navn — ikke redigerbart, kun valgt fra listen
lbl_name = QLabel(name)
lbl_name.setObjectName("track_title")
row_layout.addWidget(lbl_name, stretch=1)
# Niveau (read-only label)
level_name = ""
for lvl in self._levels:
if lvl["id"] == level_id:
level_name = lvl["name"]
break
if level_name:
lbl_level = QLabel(level_name)
lbl_level.setObjectName("result_count")
lbl_level.setFixedWidth(110)
row_layout.addWidget(lbl_level)
btn_rm = QPushButton("")
btn_rm.setFixedSize(32, 24)
btn_rm.setToolTip("Fjern alternativ-dans")
btn_rm.setObjectName("btn_rm_row")
btn_rm.style().unpolish(btn_rm)
btn_rm.style().polish(btn_rm)
row_layout.addWidget(btn_rm)
idx = self._alt_layout.count() - 1
self._alt_layout.insertWidget(idx, row_widget)
entry = {"widget": row_widget, "name": name, "level_id": level_id}
self._alt_rows.append(entry)
btn_rm.clicked.connect(lambda: self._remove_alt_row(entry))
def _remove_alt_row(self, entry):
self._alt_rows.remove(entry)
entry["widget"].deleteLater()
def _on_add_alt(self):
"""Alternativ-danse kan kun tilføjes fra forslagslisten, ikke som fri tekst."""
if self._alt_suggestions.count() > 0:
self._alt_suggestions.setCurrentRow(0)
self._add_from_suggestion(self._alt_suggestions.currentItem(), "alt")
def _save(self):
song_id = self._song.get("id")
local_path = self._song.get("local_path", "")
try:
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
dances = []
for row in self._dance_rows:
name = row["edit"].text().strip()
if name:
dances.append({
"name": name,
"level_id": row["level"].currentData(),
"choreographer": row["choreo"].text().strip(),
})
alts = []
for row in self._alt_rows:
name = row.get("name", "")
if name:
alts.append({
"name": name,
"level_id": row.get("level_id"),
"note": "",
})
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,))
# 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 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):
try:
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}")

414
linedance-app/ui/themes.py Normal file
View File

@@ -0,0 +1,414 @@
"""
themes.py — Lyst og mørkt tema til PyQt6.
"""
DARK = """
QWidget {
background-color: #1a1c1f;
color: #e8eaf0;
font-family: 'Barlow', 'Segoe UI', sans-serif;
font-size: 13px;
}
QMainWindow, #root {
background-color: #111214;
}
/* Knapper */
QPushButton {
background-color: #30343c;
color: #9aa0b0;
border: 1px solid #4a5060;
border-radius: 4px;
padding: 6px 14px;
}
QPushButton:hover {
background-color: #454a56;
color: #e8eaf0;
border-color: #e8a020;
}
QPushButton:pressed {
background-color: #22252a;
}
QPushButton:checked {
background-color: #e8a020;
color: #111214;
border-color: #c47a10;
}
QPushButton#btn_play {
background-color: #e8a020;
color: #111214;
border-color: #c47a10;
font-size: 22px;
font-weight: bold;
}
QPushButton#btn_play:hover {
background-color: #c47a10;
}
QPushButton#btn_play_small {
background-color: #e8a020;
color: #111214;
border-color: #c47a10;
font-size: 14px;
font-weight: bold;
padding: 0px 0px;
}
QPushButton#btn_play_small:hover {
background-color: #c47a10;
}
QPushButton#btn_stop_small {
color: #e74c3c;
font-size: 12px;
padding: 0px 0px;
}
QPushButton#btn_stop_small:hover {
border-color: #e74c3c;
}
QPushButton#btn_stop {
color: #e74c3c;
}
QPushButton#btn_stop:hover {
border-color: #e74c3c;
}
QPushButton#btn_demo {
color: #3b8fd4;
border-color: #3b8fd4;
font-size: 11px;
}
QPushButton#btn_demo:hover, QPushButton#btn_demo:checked {
background-color: #3b8fd4;
color: #111214;
border-color: #3b8fd4;
}
QPushButton#btn_info_row {
color: #3b8fd4;
border-color: #3b8fd4;
padding: 2px 6px;
}
QPushButton#btn_info_row:hover {
background-color: rgba(59,143,212,0.15);
border-color: #3b8fd4;
}
QPushButton#btn_rm_row {
color: #e74c3c;
border-color: #e74c3c;
padding: 2px 6px;
}
QPushButton#btn_rm_row:hover {
background-color: rgba(231,76,60,0.15);
border-color: #e74c3c;
}
/* Slider */
QSlider::groove:horizontal {
height: 4px;
background: #2c3038;
border-radius: 2px;
}
QSlider::sub-page:horizontal {
background: #e8a020;
border-radius: 2px;
}
QSlider::handle:horizontal {
background: #e8a020;
width: 12px;
height: 12px;
margin: -4px 0;
border-radius: 6px;
}
/* Volume slider — stor og tydelig */
QSlider#vol_slider::groove:horizontal {
height: 6px;
background: #2c3038;
border-radius: 3px;
}
QSlider#vol_slider::sub-page:horizontal {
background: #e8a020;
border-radius: 3px;
}
QSlider#vol_slider::handle:horizontal {
background: #e8a020;
border: 3px solid #f0c060;
width: 22px;
height: 22px;
margin: -9px 0;
border-radius: 12px;
}
/* Lister */
QListWidget {
background-color: #1a1c1f;
border: none;
outline: none;
}
QListWidget::item {
padding: 6px 10px;
border-bottom: 1px solid #22252a;
}
QListWidget::item:selected {
background-color: #2c3038;
color: #e8eaf0;
border-left: 2px solid #e8a020;
}
QListWidget::item:hover {
background-color: #22252a;
}
/* Søgefelt */
QLineEdit {
background-color: #111214;
border: 1px solid #3a3e46;
border-radius: 3px;
padding: 5px 8px;
color: #e8eaf0;
}
QLineEdit:focus {
border-color: #e8a020;
}
/* Labels */
QLabel#track_title {
font-size: 20px;
font-weight: bold;
color: #e8eaf0;
font-family: 'Rajdhani', 'Segoe UI', sans-serif;
}
QLabel#track_meta {
font-size: 11px;
color: #9aa0b0;
font-family: 'Courier New', monospace;
}
QLabel#section_title {
font-size: 11px;
font-weight: bold;
color: #5a6070;
letter-spacing: 2px;
font-family: 'Courier New', monospace;
padding: 6px 10px;
background-color: #22252a;
border-bottom: 1px solid #3a3e46;
}
QLabel#next_up_label {
color: #e8a020;
font-family: 'Courier New', monospace;
font-size: 11px;
letter-spacing: 2px;
}
QLabel#next_up_title {
font-size: 17px;
font-weight: bold;
color: #e8eaf0;
}
QLabel#next_up_sub {
font-size: 11px;
color: #9aa0b0;
font-family: 'Courier New', monospace;
}
QLabel#vol_label {
font-size: 10px;
color: #5a6070;
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
QLabel#vol_val {
font-size: 11px;
color: #9aa0b0;
font-family: 'Courier New', monospace;
min-width: 28px;
}
QLabel#result_count {
font-size: 10px;
color: #5a6070;
font-family: 'Courier New', monospace;
padding: 3px 10px;
}
/* Frames / paneler */
QFrame#panel {
background-color: #1a1c1f;
border: 1px solid #3a3e46;
border-radius: 4px;
}
QFrame#now_playing_frame {
background-color: #1a1c1f;
border: 1px solid #3a3e46;
border-radius: 4px 4px 0 0;
}
QFrame#track_display {
background-color: #111214;
border: 1px solid #3a3e46;
border-radius: 3px;
padding: 4px;
}
QFrame#transport_frame {
background-color: #1a1c1f;
border: 1px solid #3a3e46;
border-top: none;
border-radius: 0 0 4px 4px;
}
QFrame#next_up_frame {
background-color: #22252a;
border: 1px solid #e8a020;
border-top: none;
border-bottom: none;
}
QFrame#progress_frame {
background-color: #1a1c1f;
border: 1px solid #3a3e46;
border-top: none;
border-bottom: none;
}
/* Scrollbar */
QScrollBar:vertical {
background: #1a1c1f;
width: 6px;
border-radius: 3px;
}
QScrollBar::handle:vertical {
background: #4a5060;
border-radius: 3px;
min-height: 20px;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
/* Højreklik-menu */
QMenu {
background-color: #22252a;
color: #e8eaf0;
border: 1px solid #4a5060;
padding: 4px 0;
font-size: 14px;
}
QMenu::item {
padding: 8px 24px;
border-radius: 0;
}
QMenu::item:selected {
background-color: #e8a020;
color: #111214;
}
QMenu::separator {
height: 1px;
background: #3a3e46;
margin: 4px 8px;
}
/* Topbar */
QFrame#topbar {
background-color: #1a1c1f;
border: 1px solid #3a3e46;
border-radius: 4px;
}
QLabel#logo {
font-size: 16px;
font-weight: bold;
letter-spacing: 3px;
color: #e8a020;
font-family: 'Rajdhani', 'Segoe UI', sans-serif;
}
QLabel#conn_label {
font-size: 11px;
color: #5a6070;
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
"""
LIGHT = DARK + """
QWidget {
background-color: #d8dae0;
color: #1a1c22;
}
QMainWindow, #root {
background-color: #c8cad0;
}
QPushButton {
background-color: #b0b4bc;
color: #1a1c22;
border-color: #8890a0;
}
QPushButton:hover {
background-color: #c8ccd4;
color: #1a1c22;
border-color: #c07010;
}
QPushButton#btn_play {
background-color: #c07010;
color: #fff;
border-color: #a05808;
}
QPushButton#btn_play_small {
background-color: #c07010;
color: #fff;
border-color: #a05808;
}
QPushButton#btn_play_small:hover {
background-color: #a05808;
}
QPushButton#btn_info_row {
color: #1a6fb0;
border-color: #1a6fb0;
padding: 2px 6px;
}
QPushButton#btn_info_row:hover {
background-color: rgba(26,111,176,0.12);
}
QPushButton#btn_rm_row {
color: #c0392b;
border-color: #c0392b;
padding: 2px 6px;
}
QPushButton#btn_rm_row:hover {
background-color: rgba(192,57,43,0.12);
}
QListWidget {
background-color: #d8dae0;
color: #1a1c22;
}
QListWidget::item {
color: #1a1c22;
}
QListWidget::item:selected {
background-color: #c07010;
color: #ffffff;
border-left: 2px solid #a05808;
}
QListWidget::item:hover {
background-color: #c8ccd4;
color: #1a1c22;
}
QLineEdit {
background-color: #c8cad0;
border-color: #aab0bc;
color: #1a1c22;
}
QLineEdit:focus { border-color: #c07010; }
QFrame#panel, QFrame#now_playing_frame,
QFrame#transport_frame, QFrame#progress_frame {
background-color: #d8dae0;
border-color: #aab0bc;
}
QFrame#track_display { background-color: #c8cad0; border-color: #aab0bc; }
QFrame#topbar { background-color: #d8dae0; border-color: #aab0bc; }
QLabel#section_title { background-color: #e4e6ec; color: #1a1c22; border-color: #aab0bc; }
QLabel#track_title { color: #1a1c22; }
QLabel#track_meta { color: #4a5060; }
QLabel#result_count { color: #5a6070; }
QSlider::groove:horizontal { background: #b0b4bc; }
QScrollBar:vertical { background: #d8dae0; }
QScrollBar::handle:vertical { background: #8890a0; }
QMenu {
background-color: #e4e6ec;
color: #1a1c22;
border: 1px solid #aab0bc;
}
QMenu::item:selected {
background-color: #c07010;
color: #ffffff;
}
"""
def apply_theme(app, dark: bool = True):
app.setStyleSheet(DARK if dark else LIGHT)

View File

@@ -0,0 +1,96 @@
"""
vu_meter.py — VU-meter widget der tegner L og R kanaler.
Opdateres via set_levels(left, right) med værdier 0.01.0.
"""
from PyQt6.QtWidgets import QWidget
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QPainter, QColor
import random
NUM_BARS = 14
BAR_W = 14
BAR_H = 4
BAR_GAP = 2
CHAN_GAP = 6
PADDING = 4
COLOR_OFF = QColor("#1a2218")
COLOR_GREEN = QColor("#28a050")
COLOR_YELLOW = QColor("#c8a020")
COLOR_RED = QColor("#c83020")
# Grænser for farver (bar-indeks fra bunden)
YELLOW_FROM = NUM_BARS - 4
RED_FROM = NUM_BARS - 2
class VUMeter(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self._left = 0.0
self._right = 0.0
self._peak_l = 0.0
self._peak_r = 0.0
self._dark = True
total_h = NUM_BARS * (BAR_H + BAR_GAP) + PADDING * 2 + 16 # +16 til label
total_w = (BAR_W + CHAN_GAP) * 2 + PADDING * 2
self.setFixedSize(total_w, total_h)
def set_dark(self, dark: bool):
self._dark = dark
self.update()
def set_levels(self, left: float, right: float):
"""Sæt niveauer 0.01.0. Kaldes fra afspiller-tråden via signal."""
self._left = max(0.0, min(1.0, left))
self._right = max(0.0, min(1.0, right))
self._peak_l = max(self._peak_l * 0.92, self._left)
self._peak_r = max(self._peak_r * 0.92, self._right)
self.update()
def reset(self):
self._left = self._right = self._peak_l = self._peak_r = 0.0
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
off_color = QColor("#d0d8cc") if not self._dark else COLOR_OFF
for ch_idx, level in enumerate([self._left, self._right]):
x = PADDING + ch_idx * (BAR_W + CHAN_GAP)
active_bars = int(level * NUM_BARS)
for bar_idx in range(NUM_BARS):
y = PADDING + (NUM_BARS - 1 - bar_idx) * (BAR_H + BAR_GAP)
if bar_idx < active_bars:
if bar_idx >= RED_FROM:
color = COLOR_RED
elif bar_idx >= YELLOW_FROM:
color = COLOR_YELLOW
else:
color = COLOR_GREEN
else:
color = off_color
painter.fillRect(x, y, BAR_W, BAR_H,
QColor(color.red(), color.green(), color.blue(), 220))
# Kanal-labels
label_y = PADDING + NUM_BARS * (BAR_H + BAR_GAP) + 4
painter.setPen(QColor("#5a6070"))
font = painter.font()
font.setPointSize(8)
font.setFamily("Courier New")
painter.setFont(font)
for ch_idx, label in enumerate(["L", "R"]):
x = PADDING + ch_idx * (BAR_W + CHAN_GAP) + BAR_W // 2
painter.drawText(x - 4, label_y + 10, label)
painter.end()

BIN
linedance-app/upx.exe Normal file

Binary file not shown.

44
reset_local.sh Normal file
View File

@@ -0,0 +1,44 @@
#!/bin/bash
# ================================================================
# reset_local.sh — Nulstil SQLite på desktop-maskinen
# Kør på den PC hvor LineDance-appen kører
# ================================================================
set -e
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo ""
echo -e "${YELLOW}▶ Finder SQLite-database...${NC}"
SQLITE_DB=""
for candidate in \
"$HOME/.local/share/LineDanceAfspiller/library.db" \
"$HOME/.linedance/library.db" \
"$HOME/AppData/Local/LineDanceAfspiller/library.db"; do
if [ -f "$candidate" ]; then
SQLITE_DB="$candidate"
break
fi
done
if [ -z "$SQLITE_DB" ]; then
FOUND=$(find "$HOME" -name "library.db" 2>/dev/null | head -1)
[ -n "$FOUND" ] && SQLITE_DB="$FOUND"
fi
if [ -n "$SQLITE_DB" ]; then
BACKUP="${SQLITE_DB}.backup_$(date +%Y%m%d_%H%M%S)"
cp "$SQLITE_DB" "$BACKUP"
rm "$SQLITE_DB"
echo -e "${GREEN} ✓ Slettet: $SQLITE_DB${NC}"
echo " Backup gemt: $BACKUP"
else
echo -e "${GREEN} ✓ Ingen SQLite-fil fundet — appen starter allerede frisk${NC}"
fi
echo ""
echo -e "${GREEN}✓ Lokal database nulstillet${NC}"
echo "Start nu LineDance-appen — den genskaber databasen automatisk."
echo ""