Compare commits
81 Commits
e5fbf54302
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d7ad55a0f | |||
| 324c94fde2 | |||
| 8d4c4a81c1 | |||
| 09412073cd | |||
| 25c2dd9e78 | |||
| 115cc92d6a | |||
| c3453d8d55 | |||
| d28aafb2c6 | |||
| b695a4858b | |||
| 37b49c1fed | |||
| 545cdc6866 | |||
| ec3989e6a4 | |||
| 6ed349277c | |||
| 2deb0260f0 | |||
| 8a4c879213 | |||
| f92af40dd7 | |||
| efc30cdbb2 | |||
| a9aa451d63 | |||
| 4df87cf48e | |||
| f943c5ffba | |||
| 31dcd9fcfa | |||
| 34040af464 | |||
| 9052dd8b0f | |||
| b226795731 | |||
| 9e5ddec184 | |||
| fb7622549c | |||
| c966d38f11 | |||
| 0a3a6d44da | |||
| e149fb3ce2 | |||
| bf26ff6377 | |||
| 3f67f7dc3a | |||
| 602f7cc2d4 | |||
| 80407e98fb | |||
| f0a4b4dfa7 | |||
| 24bb71cdd7 | |||
| e4ab9caab6 | |||
| efe3739626 | |||
| c5e35f0889 | |||
| 920cd8222d | |||
| 4a206f2f19 | |||
| cd3ed811f6 | |||
| 4ad8241c0e | |||
| 55642ecb1b | |||
| d9a321d570 | |||
| 58c4e85eaf | |||
| f7b0f16250 | |||
| edfde16c92 | |||
| 03f8061601 | |||
| ed4fe4e712 | |||
| 4aba2f02a2 | |||
| 460b41a8c5 | |||
| 287477753e | |||
| d4356e7337 | |||
| 66804681da | |||
| 9257f198eb | |||
| b805bd77e7 | |||
| d056f02f78 | |||
| 3cc2c975f3 | |||
| 69d1d484a2 | |||
| b066b6d92c | |||
| cb204baa50 | |||
| e86173f7ec | |||
| c3623962c5 | |||
| 6d3ed85679 | |||
| 30125aa77b | |||
| bbd5690d72 | |||
| 45dcedaed4 | |||
| 1ea5cad01f | |||
| 2812e3182c | |||
| a9915c0cc9 | |||
| bdb1f5915a | |||
| d6cc22dc9a | |||
| 754d82a183 | |||
| 358aeca7c8 | |||
| 564122df0a | |||
| 88a3d2f67b | |||
| 99cab7be86 | |||
| 57f3c913b4 | |||
| b678787236 | |||
| 99f6a265c0 | |||
| b90bdd851d |
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal 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/
|
||||
772
Henriks Musik/linedance_tag_analyse.py
Normal file
772
Henriks Musik/linedance_tag_analyse.py
Normal 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å = r.get("niveau_rå", "").strip()
|
||||
if rå:
|
||||
niveau_værdier[rå] = niveau_værdier.get(rå, 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
2036
Henriks Musik/rapport.csv
Normal file
File diff suppressed because it is too large
Load Diff
51
full_reset.sh
Normal file
51
full_reset.sh
Normal 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 ""
|
||||
17
linedance-api/.env.example
Normal file
17
linedance-api/.env.example
Normal 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
18
linedance-api/.gitignore
vendored
Normal 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
1
linedance-api/=4.0.0
Normal file
@@ -0,0 +1 @@
|
||||
Requirement already satisfied: bcrypt in ./venv/lib/python3.12/site-packages (5.0.0)
|
||||
22
linedance-api/Dockerfile
Normal file
22
linedance-api/Dockerfile
Normal 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
39
linedance-api/README.md
Normal 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
|
||||
0
linedance-api/app/__init__.py
Normal file
0
linedance-api/app/__init__.py
Normal file
24
linedance-api/app/core/config.py
Normal file
24
linedance-api/app/core/config.py
Normal 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()
|
||||
21
linedance-api/app/core/database.py
Normal file
21
linedance-api/app/core/database.py
Normal 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()
|
||||
104
linedance-api/app/core/mail.py
Normal file
104
linedance-api/app/core/mail.py
Normal 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}")
|
||||
40
linedance-api/app/core/security.py
Normal file
40
linedance-api/app/core/security.py
Normal 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
67
linedance-api/app/main.py
Normal 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"}
|
||||
222
linedance-api/app/models/__init__.py
Normal file
222
linedance-api/app/models/__init__.py
Normal 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")
|
||||
121
linedance-api/app/routers/alt_dance_ratings.py
Normal file
121
linedance-api/app/routers/alt_dance_ratings.py
Normal 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
|
||||
3
linedance-api/app/routers/alternatives.py
Normal file
3
linedance-api/app/routers/alternatives.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""alternatives.py — Placeholder (håndteres via /sync)."""
|
||||
from fastapi import APIRouter
|
||||
router = APIRouter(prefix="/alternatives", tags=["alternatives"])
|
||||
151
linedance-api/app/routers/auth.py
Normal file
151
linedance-api/app/routers/auth.py
Normal 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."}
|
||||
108
linedance-api/app/routers/dances.py
Normal file
108
linedance-api/app/routers/dances.py
Normal 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"}
|
||||
109
linedance-api/app/routers/live.py
Normal file
109
linedance-api/app/routers/live.py
Normal 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
|
||||
205
linedance-api/app/routers/projects.py
Normal file
205
linedance-api/app/routers/projects.py
Normal 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()
|
||||
220
linedance-api/app/routers/sharing.py
Normal file
220
linedance-api/app/routers/sharing.py
Normal 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"]),
|
||||
}
|
||||
29
linedance-api/app/routers/songs.py
Normal file
29
linedance-api/app/routers/songs.py
Normal 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()
|
||||
473
linedance-api/app/routers/sync.py
Normal file
473
linedance-api/app/routers/sync.py
Normal 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,
|
||||
}
|
||||
115
linedance-api/app/schemas/__init__.py
Normal file
115
linedance-api/app/schemas/__init__.py
Normal 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()
|
||||
78
linedance-api/app/websocket/manager.py
Normal file
78
linedance-api/app/websocket/manager.py
Normal 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,
|
||||
})
|
||||
33
linedance-api/docker-compose.yml
Normal file
33
linedance-api/docker-compose.yml
Normal 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
|
||||
29
linedance-api/local/__init__.py
Normal file
29
linedance-api/local/__init__.py
Normal 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()
|
||||
"""
|
||||
258
linedance-api/local/file_watcher.py
Normal file
258
linedance-api/local/file_watcher.py
Normal 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
|
||||
330
linedance-api/local/local_db.py
Normal file
330
linedance-api/local/local_db.py
Normal 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]}
|
||||
280
linedance-api/local/tag_reader.py
Normal file
280
linedance-api/local/tag_reader.py
Normal 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", [])
|
||||
14
linedance-api/requirements.txt
Normal file
14
linedance-api/requirements.txt
Normal 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
24
linedance-api/start.sh
Executable 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
|
||||
14
linedance-api/start_local.bat
Normal file
14
linedance-api/start_local.bat
Normal 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
|
||||
3
linedance-api/web/Dockerfile
Normal file
3
linedance-api/web/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM nginx:alpine
|
||||
COPY public /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
25
linedance-api/web/nginx.conf
Normal file
25
linedance-api/web/nginx.conf
Normal 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;
|
||||
}
|
||||
586
linedance-api/web/public/app.html
Normal file
586
linedance-api/web/public/app.html
Normal 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>
|
||||
1
linedance-api/web/public/download/.gitkeep
Normal file
1
linedance-api/web/public/download/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Placer LineDancePlayer-Setup.exe her
|
||||
BIN
linedance-api/web/public/download/LineDancePlayer-Setup.exe
Normal file
BIN
linedance-api/web/public/download/LineDancePlayer-Setup.exe
Normal file
Binary file not shown.
667
linedance-api/web/public/index.html
Normal file
667
linedance-api/web/public/index.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
updateAuthUI();
|
||||
loadPublicPlaylists();
|
||||
if (token) loadMyPlaylists();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
529
linedance-api/web/public/live.html
Normal file
529
linedance-api/web/public/live.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
|
||||
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
94
linedance-app/BUILD.md
Normal 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>
|
||||
```
|
||||
47
linedance-app/BUILD_VEJLEDNING.md
Normal file
47
linedance-app/BUILD_VEJLEDNING.md
Normal 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.
|
||||
161
linedance-app/LineDancePlayer.spec
Normal file
161
linedance-app/LineDancePlayer.spec
Normal 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
57
linedance-app/README.md
Normal 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.
|
||||
25
linedance-app/app_logger.py
Normal file
25
linedance-app/app_logger.py
Normal 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
142
linedance-app/build.bat
Normal 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
30
linedance-app/build_linux.sh
Executable 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
|
||||
119
linedance-app/build_windows.spec
Normal file
119
linedance-app/build_windows.spec
Normal 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
118
linedance-app/installer.nsi
Normal 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
|
||||
BIN
linedance-app/installer/header.bmp
Normal file
BIN
linedance-app/installer/header.bmp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
linedance-app/installer/icon.ico
Normal file
BIN
linedance-app/installer/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 166 B |
BIN
linedance-app/installer/welcome.bmp
Normal file
BIN
linedance-app/installer/welcome.bmp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
29
linedance-app/local/__init__.py
Normal file
29
linedance-app/local/__init__.py
Normal 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()
|
||||
"""
|
||||
299
linedance-app/local/acoustid_worker.py
Normal file
299
linedance-app/local/acoustid_worker.py
Normal 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
|
||||
34
linedance-app/local/file_watcher.py
Normal file
34
linedance-app/local/file_watcher.py
Normal 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
|
||||
173
linedance-app/local/linked_playlist.py
Normal file
173
linedance-app/local/linked_playlist.py
Normal 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
|
||||
771
linedance-app/local/local_db.py
Normal file
771
linedance-app/local/local_db.py
Normal 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}")
|
||||
201
linedance-app/local/scanner.py
Normal file
201
linedance-app/local/scanner.py
Normal 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
|
||||
630
linedance-app/local/sync_manager.py
Normal file
630
linedance-app/local/sync_manager.py
Normal 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
|
||||
523
linedance-app/local/tag_reader.py
Normal file
523
linedance-app/local/tag_reader.py
Normal 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
|
||||
183
linedance-app/local/watchdog_process.py
Normal file
183
linedance-app/local/watchdog_process.py
Normal 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
69
linedance-app/main.py
Normal 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()
|
||||
0
linedance-app/player/__init__.py
Normal file
0
linedance-app/player/__init__.py
Normal file
332
linedance-app/player/player.py
Normal file
332
linedance-app/player/player.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
player.py — VLC-baseret afspiller med PyQt6 signals.
|
||||
|
||||
Sender signals til GUI:
|
||||
position_changed(float) — 0.0–1.0 progress
|
||||
time_changed(int, int) — (current_sec, total_sec)
|
||||
levels_changed(float, float) — VU-meter L/R 0.0–1.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):
|
||||
"""0–100"""
|
||||
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.0–1.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)
|
||||
7
linedance-app/requirements.txt
Normal file
7
linedance-app/requirements.txt
Normal 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
24
linedance-app/setup.sh
Executable 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"
|
||||
57
linedance-app/translations/__init__.py
Normal file
57
linedance-app/translations/__init__.py
Normal 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")
|
||||
201
linedance-app/translations/da.py
Normal file
201
linedance-app/translations/da.py
Normal 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",
|
||||
}
|
||||
201
linedance-app/translations/en.py
Normal file
201
linedance-app/translations/en.py
Normal 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",
|
||||
}
|
||||
0
linedance-app/ui/__init__.py
Normal file
0
linedance-app/ui/__init__.py
Normal file
345
linedance-app/ui/alt_dance_picker_dialog.py
Normal file
345
linedance-app/ui/alt_dance_picker_dialog.py
Normal 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
|
||||
84
linedance-app/ui/bpm_worker.py
Normal file
84
linedance-app/ui/bpm_worker.py
Normal 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)
|
||||
222
linedance-app/ui/dance_info_dialog.py
Normal file
222
linedance-app/ui/dance_info_dialog.py
Normal 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))
|
||||
178
linedance-app/ui/dance_picker_dialog.py
Normal file
178
linedance-app/ui/dance_picker_dialog.py
Normal 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 ""
|
||||
300
linedance-app/ui/library_manager.py
Normal file
300
linedance-app/ui/library_manager.py
Normal 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()
|
||||
585
linedance-app/ui/library_panel.py
Normal file
585
linedance-app/ui/library_panel.py
Normal 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)
|
||||
139
linedance-app/ui/login_dialog.py
Normal file
139
linedance-app/ui/login_dialog.py
Normal 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
|
||||
1514
linedance-app/ui/main_window.py
Normal file
1514
linedance-app/ui/main_window.py
Normal file
File diff suppressed because it is too large
Load Diff
59
linedance-app/ui/next_up_bar.py
Normal file
59
linedance-app/ui/next_up_bar.py
Normal 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()
|
||||
522
linedance-app/ui/playlist_browser.py
Normal file
522
linedance-app/ui/playlist_browser.py
Normal 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."
|
||||
)
|
||||
166
linedance-app/ui/playlist_info_dialog.py
Normal file
166
linedance-app/ui/playlist_info_dialog.py
Normal 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("")
|
||||
327
linedance-app/ui/playlist_manager.py
Normal file
327
linedance-app/ui/playlist_manager.py
Normal 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}")
|
||||
1509
linedance-app/ui/playlist_panel.py
Normal file
1509
linedance-app/ui/playlist_panel.py
Normal file
File diff suppressed because it is too large
Load Diff
131
linedance-app/ui/register_dialog.py
Normal file
131
linedance-app/ui/register_dialog.py
Normal 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)
|
||||
52
linedance-app/ui/scan_worker.py
Normal file
52
linedance-app/ui/scan_worker.py
Normal 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))
|
||||
529
linedance-app/ui/settings_dialog.py
Normal file
529
linedance-app/ui/settings_dialog.py
Normal 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
|
||||
180
linedance-app/ui/share_dialog.py
Normal file
180
linedance-app/ui/share_dialog.py
Normal 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}")
|
||||
618
linedance-app/ui/tag_editor.py
Normal file
618
linedance-app/ui/tag_editor.py
Normal 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
414
linedance-app/ui/themes.py
Normal 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)
|
||||
96
linedance-app/ui/vu_meter.py
Normal file
96
linedance-app/ui/vu_meter.py
Normal 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.0–1.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.0–1.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
BIN
linedance-app/upx.exe
Normal file
Binary file not shown.
44
reset_local.sh
Normal file
44
reset_local.sh
Normal 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 ""
|
||||
Reference in New Issue
Block a user