Compare commits

...

86 Commits

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

3
.env
View File

@@ -1,3 +0,0 @@
DATABASE_URL=mysql+pymysql://linedance:20gorm66@mysql.ckvist.lan:3306/linedance
SECRET_KEY=e0a15d5a35d1091261cbdf0fd6310492ebd23d66a6d4a8c4253ab33e2594c67a
ACCESS_TOKEN_EXPIRE_MINUTES=10080

View File

@@ -1,3 +0,0 @@
DATABASE_URL=mysql+pymysql://bruger:kodeord@localhost:3306/linedance
SECRET_KEY=skift-denne-til-en-lang-tilfaeldig-streng
ACCESS_TOKEN_EXPIRE_MINUTES=10080

53
.gitignore vendored Normal file
View File

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

View File

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

2036
Henriks Musik/rapport.csv Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,161 +0,0 @@
# -*- 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",
)

View File

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

View File

@@ -1,33 +0,0 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.database import engine, Base
from app.routers import auth, projects, songs, alternatives
from app.websocket.manager import router as ws_router
# Opret tabeller hvis de ikke findes (til udvikling — brug Alembic i produktion)
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(ws_router)
@app.get("/")
def root():
return {"status": "ok", "service": "Linedance API"}

View File

@@ -1,68 +0,0 @@
@echo off
echo ================================================
echo LineDance Player - Byg EXE
echo ================================================
echo.
REM Tjek at vi er i det rigtige bibliotek
if not exist main.py (
echo FEJL: Kør build.bat fra LinedanceAfspiller\linedance-app mappen
pause
exit /b 1
)
REM Aktiver venv
if not exist venv\Scripts\activate.bat (
echo Opretter virtuelt miljø...
python -m venv venv
)
call venv\Scripts\activate.bat
REM Installer/opdater pakker
echo Installerer pakker...
pip install -r requirements.txt --quiet
pip install pyinstaller --quiet
REM Tjek VLC
if not exist "C:\Program Files\VideoLAN\VLC\libvlc.dll" (
if not exist "C:\Program Files (x86)\VideoLAN\VLC\libvlc.dll" (
echo.
echo ADVARSEL: VLC ser ikke ud til at vaere installeret!
echo Download VLC fra: https://www.videolan.org/vlc/
echo Vaelg 64-bit versionen.
echo.
pause
exit /b 1
)
)
REM Ryd gamle build-filer
echo Rydder gamle build-filer...
if exist build rmdir /s /q build
if exist dist rmdir /s /q dist
REM Byg EXE
echo.
echo Bygger LineDancePlayer.exe ...
echo (Dette tager typisk 1-3 minutter)
echo.
pyinstaller LineDancePlayer.spec
if %ERRORLEVEL% neq 0 (
echo.
echo FEJL under build! Se fejlbesked ovenfor.
pause
exit /b 1
)
echo.
echo ================================================
echo BUILD FAERDIG!
echo Filen ligger i: dist\LineDancePlayer.exe
echo ================================================
echo.
REM Vis filstoerrelse
for %%A in (dist\LineDancePlayer.exe) do echo Filstoerrelse: %%~zA bytes
pause

51
full_reset.sh Normal file
View File

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

View File

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

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

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

1
linedance-api/=4.0.0 Normal file
View File

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

22
linedance-api/Dockerfile Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,473 @@
"""
sync.py — Push/pull synkronisering mellem lokal app og server.
POST /sync/push — send lokal data op til server
GET /sync/pull — hent server-data ned til app
"""
import uuid
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.models import (
User, Song, Dance, DanceLevel, Project, ProjectSong,
PlaylistShare, CommunityDance, SongDance, SongAltDance,
)
router = APIRouter(prefix="/sync", tags=["sync"])
logger = logging.getLogger(__name__)
# ── Schemas ───────────────────────────────────────────────────────────────────
class SongData(BaseModel):
local_id: str
title: str
artist: str = ""
album: str = ""
bpm: int = 0
duration_sec: int = 0
mbid: str = ""
acoustid: str = ""
class DanceData(BaseModel):
name: str
level_name: str = ""
choreographer: str = ""
video_url: str = ""
stepsheet_url: str = ""
notes: str = ""
class SongDanceData(BaseModel):
song_local_id: str
dance_name: str
level_name: str = ""
dance_order: int = 1
class SongAltDanceData(BaseModel):
song_local_id: str
dance_name: str
level_name: str = ""
note: str = ""
user_rating: Optional[int] = None
class PlaylistSongData(BaseModel):
song_local_id: str
song_title: str = ""
song_artist: str = ""
position: int
status: str = "pending"
is_workshop: bool = False
dance_override: str = ""
class PlaylistData(BaseModel):
local_id: str
name: str
description: str = ""
tags: str = ""
visibility: str = "private"
songs: list[PlaylistSongData] = []
class PushPayload(BaseModel):
songs: list[SongData] = []
dances: list[DanceData] = []
song_dances: list[SongDanceData] = []
song_alts: list[SongAltDanceData] = []
playlists: list[PlaylistData] = []
deleted_playlists: list[str] = [] # server-IDs (Project.id)
songs_with_dances_synced: list[str] = [] # sang-IDs der er fuldt synkroniseret
# ── Hjælpefunktion: find eller opret sang globalt ─────────────────────────────
def _find_or_create_song(db: Session, title: str, artist: str = "",
mbid: str = "", acoustid: str = "",
album: str = "", bpm: int = 0,
duration_sec: int = 0) -> Song:
"""
Match-hierarki:
1. MBID — sikreste
2. AcoustID
3. Titel + artist
4. Opret ny
"""
if mbid:
song = db.query(Song).filter_by(mbid=mbid).first()
if song:
return song
if acoustid:
song = db.query(Song).filter_by(acoustid=acoustid).first()
if song:
# Tilføj mbid hvis den mangler
if mbid and not song.mbid:
song.mbid = mbid
return song
if title:
song = db.query(Song).filter(
Song.title == title,
Song.artist == artist,
).first()
if song:
# Opdater med bedre data hvis tilgængeligt
if mbid and not song.mbid:
song.mbid = mbid
if acoustid and not song.acoustid:
song.acoustid = acoustid
if bpm and not song.bpm:
song.bpm = bpm
return song
# Opret ny global sang
song = Song(
title=title, artist=artist, album=album,
bpm=bpm, duration_sec=duration_sec,
mbid=mbid or None,
acoustid=acoustid or None,
)
db.add(song)
db.flush()
return song
# ── Push ──────────────────────────────────────────────────────────────────────
@router.post("/push")
def push(
payload: PushPayload,
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Upload lokal data til server. Returnerer server-IDs."""
import sqlalchemy as _sa
song_id_map = {} # local_id → server Song.id
dance_id_map = {} # "name|level_id" → Dance.id
level_map = {} # level_name.lower() → DanceLevel.id
# ── Dans-niveauer ─────────────────────────────────────────────────────────
for lvl in db.query(DanceLevel).all():
level_map[lvl.name.lower()] = lvl.id
# ── Sange (globale) ───────────────────────────────────────────────────────
for s in payload.songs:
if not s.title:
continue
song = _find_or_create_song(
db, s.title, s.artist,
mbid=s.mbid, acoustid=s.acoustid,
album=s.album, bpm=s.bpm, duration_sec=s.duration_sec,
)
song_id_map[s.local_id] = song.id
# ── Danse ─────────────────────────────────────────────────────────────────
for d in payload.dances:
level_id = level_map.get(d.level_name.lower()) if d.level_name else None
key = f"{d.name.lower()}|{level_id}"
existing = db.query(Dance).filter_by(name=d.name, level_id=level_id).first()
if existing:
if d.choreographer: existing.choreographer = d.choreographer
if d.video_url: existing.video_url = d.video_url
if d.stepsheet_url: existing.stepsheet_url = d.stepsheet_url
dance_id_map[key] = existing.id
else:
dance = Dance(
name=d.name, level_id=level_id,
choreographer=d.choreographer,
video_url=d.video_url,
stepsheet_url=d.stepsheet_url,
notes=d.notes,
)
db.add(dance)
db.flush()
dance_id_map[key] = dance.id
# ── Sang-dans tags — synkroniser fuldt per sang ──────────────────────────
# Slet eksisterende tags for sange der er med i push, genindsæt fra klient
synced_song_ids = set()
for sd in payload.song_dances:
song_id = song_id_map.get(sd.song_local_id)
if not song_id:
continue
if song_id not in synced_song_ids:
db.execute(_sa.text("DELETE FROM song_dances WHERE song_id=:sid"),
{"sid": song_id})
synced_song_ids.add(song_id)
level_id = level_map.get(sd.level_name.lower()) if sd.level_name else None
key = f"{sd.dance_name.lower()}|{level_id}"
dance_id = dance_id_map.get(key)
if not dance_id:
continue
db.execute(_sa.text(
"INSERT IGNORE INTO song_dances (id, song_id, dance_id, dance_order) "
"VALUES (:id, :song_id, :dance_id, :dance_order)"
), {"id": str(uuid.uuid4()), "song_id": song_id,
"dance_id": dance_id, "dance_order": sd.dance_order})
# Sange der er fuldt synkroniseret men har ingen dans-tags — slet på server
for local_id in payload.songs_with_dances_synced:
song_id = song_id_map.get(local_id)
if song_id and song_id not in synced_song_ids:
db.execute(_sa.text("DELETE FROM song_dances WHERE song_id=:sid"),
{"sid": song_id})
for sa in payload.song_alts:
song_id = song_id_map.get(sa.song_local_id)
if not song_id:
continue
level_id = level_map.get(sa.level_name.lower()) if sa.level_name else None
key = f"{sa.dance_name.lower()}|{level_id}"
dance_id = dance_id_map.get(key)
if not dance_id:
continue
# Opdater community rating hvis bruger har givet en vurdering
if sa.user_rating and 1 <= sa.user_rating <= 5:
from app.models import CommunityDanceAlt, DanceAltRating
song_obj = db.query(Song).filter_by(id=song_id).first()
if song_obj:
alt = db.query(CommunityDanceAlt).filter_by(
song_title=song_obj.title,
song_artist=song_obj.artist,
alt_dance_id=dance_id,
).first()
if not alt:
alt = CommunityDanceAlt(
id=str(uuid.uuid4()),
song_mbid=song_obj.mbid or None,
song_title=song_obj.title,
song_artist=song_obj.artist,
alt_dance_id=dance_id,
submitted_by=me.id,
avg_rating=float(sa.user_rating),
rating_count=1,
)
db.add(alt)
db.flush()
existing_r = db.query(DanceAltRating).filter_by(
alternative_id=alt.id, user_id=me.id
).first()
if existing_r:
old_score = existing_r.score
existing_r.score = sa.user_rating
total = alt.avg_rating * alt.rating_count - old_score + sa.user_rating
alt.avg_rating = total / alt.rating_count
else:
db.add(DanceAltRating(
id=str(uuid.uuid4()),
alternative_id=alt.id,
user_id=me.id,
score=sa.user_rating,
))
total = alt.avg_rating * alt.rating_count + sa.user_rating
alt.rating_count += 1
alt.avg_rating = total / alt.rating_count
db.execute(_sa.text(
"INSERT IGNORE INTO song_alt_dances (id, song_id, dance_id, note) "
"VALUES (:id, :song_id, :dance_id, :note)"
), {"id": str(uuid.uuid4()), "song_id": song_id,
"dance_id": dance_id, "note": sa.note or ""})
# ── Playlister ────────────────────────────────────────────────────────────
playlist_id_map = {}
for pl in payload.playlists:
# Find eksisterende via server-ID (local_id er api_project_id på klienten)
existing = None
if pl.local_id:
existing = db.query(Project).filter_by(
id=pl.local_id, owner_id=me.id
).first()
if not existing:
existing = db.query(Project).filter_by(
owner_id=me.id, name=pl.name
).first()
if existing:
existing.name = pl.name
existing.description = pl.description
existing.visibility = pl.visibility
if pl.songs:
db.query(ProjectSong).filter_by(project_id=existing.id).delete()
project = existing
else:
project = Project(
owner_id=me.id, name=pl.name,
description=pl.description, visibility=pl.visibility,
)
db.add(project)
db.flush()
playlist_id_map[pl.local_id] = project.id
for ps in pl.songs:
# Find sang via song_id_map eller titel+artist
song_id = song_id_map.get(ps.song_local_id)
if not song_id and ps.song_title:
song = _find_or_create_song(db, ps.song_title, ps.song_artist)
song_id = song.id
if not song_id:
continue
db.add(ProjectSong(
project_id=project.id, song_id=song_id,
position=ps.position, status=ps.status,
is_workshop=ps.is_workshop,
dance_override=ps.dance_override,
))
# ── Slet playlister ───────────────────────────────────────────────────────
for project_id in payload.deleted_playlists:
proj = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
if proj:
db.query(ProjectSong).filter_by(project_id=proj.id).delete()
db.delete(proj)
db.commit()
return {
"status": "ok",
"songs_synced": len(song_id_map),
"playlists_synced": len(playlist_id_map),
"song_id_map": {k: str(v) for k, v in song_id_map.items()},
"playlist_id_map": {k: str(v) for k, v in playlist_id_map.items()},
}
# ── Pull ──────────────────────────────────────────────────────────────────────
@router.get("/pull")
def pull(
db: Session = Depends(get_db),
me: User = Depends(get_current_user),
):
"""Hent server-data til lokal app."""
# Dans-niveauer
levels = [
{"id": l.id, "name": l.name, "sort_order": l.sort_order}
for l in db.query(DanceLevel).order_by(DanceLevel.sort_order).all()
]
# Danse
dances = [
{
"name": d.name,
"level_id": d.level_id,
"choreographer": d.choreographer,
"video_url": d.video_url,
"stepsheet_url": d.stepsheet_url,
"notes": d.notes,
"use_count": d.use_count,
}
for d in db.query(Dance).order_by(Dance.use_count.desc()).limit(500).all()
]
# Delte playlister
shared_ids = {
s.project_id for s in db.query(PlaylistShare).filter(
(PlaylistShare.shared_with_id == me.id) |
(PlaylistShare.invited_email == me.email)
).all()
}
shared = []
for p in db.query(Project).filter(Project.id.in_(shared_ids)).all():
if p.owner_id == me.id:
continue
owner = db.query(User).filter_by(id=p.owner_id).first()
shared.append({
"server_id": p.id,
"name": p.name,
"owner": owner.username if owner else "?",
"songs": [
{
"song_id": str(ps.song_id),
"title": ps.song.title,
"artist": ps.song.artist,
"mbid": ps.song.mbid or "",
"acoustid": ps.song.acoustid or "",
"bpm": ps.song.bpm,
"duration_sec": ps.song.duration_sec,
"position": ps.position,
"status": ps.status,
"is_workshop": ps.is_workshop,
"dance_override": ps.dance_override or "",
}
for ps in sorted(p.project_songs, key=lambda x: x.position)
if ps.song
],
})
# Egne playlister
my_playlists = []
for p in db.query(Project).filter_by(owner_id=me.id).all():
my_playlists.append({
"server_id": p.id,
"name": p.name,
"description": p.description or "",
"songs": [
{
"song_id": str(ps.song_id),
"title": ps.song.title,
"artist": ps.song.artist,
"mbid": ps.song.mbid or "",
"acoustid": ps.song.acoustid or "",
"bpm": ps.song.bpm,
"duration_sec": ps.song.duration_sec,
"position": ps.position,
"status": ps.status,
"is_workshop": ps.is_workshop,
"dance_override": ps.dance_override or "",
}
for ps in sorted(p.project_songs, key=lambda x: x.position)
if ps.song
],
})
logger.info(f"Pull: {len(my_playlists)} playlister for {me.username}")
# Dans-tags (brugerens egne)
song_tags = []
for sd in db.query(SongDance).all():
dance = db.query(Dance).filter_by(id=sd.dance_id).first()
if not dance:
continue
level = db.query(DanceLevel).filter_by(id=dance.level_id).first() if dance.level_id else None
song_tags.append({
"song_id": sd.song_id,
"dance_name": dance.name,
"choreographer": dance.choreographer or "",
"level_name": level.name if level else "",
"dance_order": sd.dance_order,
})
# Community alternativ-danse (top 500 mest ratede)
from app.models import CommunityDanceAlt, DanceAltRating
community_alts = []
for alt in db.query(CommunityDanceAlt).order_by(
CommunityDanceAlt.avg_rating.desc()
).limit(500).all():
my_rating = db.query(DanceAltRating).filter_by(
alternative_id=alt.id, user_id=me.id
).first()
community_alts.append({
"song_mbid": alt.song_mbid or "",
"song_title": alt.song_title,
"song_artist": alt.song_artist,
"dance_name": alt.alt_dance.name if alt.alt_dance else "",
"avg_rating": round(alt.avg_rating, 1),
"rating_count": alt.rating_count,
"my_rating": my_rating.score if my_rating else None,
})
return {
"levels": levels,
"dances": dances,
"shared": shared,
"my_playlists": my_playlists,
"song_tags": song_tags,
"community_alts": community_alts,
}

View File

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

View File

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

View File

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

View File

@@ -191,36 +191,34 @@ class LibraryWatcher:
def _full_scan_library(self, library_id: int, library_path: str): def _full_scan_library(self, library_id: int, library_path: str):
""" """
Sammenligner filer disk med SQLite og synkroniserer forskelle. Sammenligner filer disk med SQLite og synkroniserer forskelle.
Håndterer utilgængelige mapper og symlinks sikkert.
Tre operationer:
1. Nye filer indsæt i SQLite
2. Ændrede filer opdater SQLite (baseret fil-timestamp)
3. Forsvundne marker som missing i SQLite
""" """
logger.info(f"Fuld scan starter: {library_path}") logger.info(f"Fuld scan starter: {library_path}")
base = Path(library_path) base = Path(library_path)
# Tjek at mappen faktisk er tilgængelig — med timeout # Hvad SQLite kender til
if not self._path_accessible(base):
logger.warning(f"Bibliotek ikke tilgængeligt (timeout eller ingen adgang): {library_path}")
return
known = get_all_song_paths_for_library(library_id) known = get_all_song_paths_for_library(library_id)
# Hvad der faktisk er på disk
found_paths = set() found_paths = set()
processed = 0 processed = 0
errors = 0 errors = 0
import os for file_path in base.rglob("*"):
for dirpath, dirnames, filenames in os.walk( if not file_path.is_file() or not is_supported(file_path):
str(base), followlinks=False,
onerror=lambda e: logger.warning(f"Adgang nægtet: {e}")
):
for filename in filenames:
file_path = Path(dirpath) / filename
try:
if not is_supported(file_path):
continue continue
path_str = str(file_path) path_str = str(file_path)
found_paths.add(path_str) found_paths.add(path_str)
disk_modified = get_file_modified_at(file_path) 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: if path_str not in known or known[path_str] != disk_modified:
try:
tags = read_tags(file_path) tags = read_tags(file_path)
tags["library_id"] = library_id tags["library_id"] = library_id
upsert_song(tags) upsert_song(tags)
@@ -246,20 +244,6 @@ class LibraryWatcher:
f"{processed} opdateret, {missing_count} mangler, {errors} fejl" f"{processed} opdateret, {missing_count} mangler, {errors} fejl"
) )
def _path_accessible(self, path: Path, timeout_sec: float = 5.0) -> bool:
"""Tjek om en sti er tilgængelig inden for timeout."""
import threading
result = [False]
def check():
try:
result[0] = path.exists() and path.is_dir()
except Exception:
result[0] = False
t = threading.Thread(target=check, daemon=True)
t.start()
t.join(timeout=timeout_sec)
return result[0]
# ── Singleton til brug i appen ──────────────────────────────────────────────── # ── Singleton til brug i appen ────────────────────────────────────────────────

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,667 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LineDance Player — Danselister</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0e0f11;
--surface: #16181c;
--border: #2a2d35;
--accent: #e8a020;
--accent2: #c47a10;
--text: #e8eaf0;
--muted: #6b7080;
--green: #2ecc71;
--red: #e74c3c;
--mono: 'DM Mono', monospace;
--sans: 'DM Sans', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: var(--sans); min-height: 100vh; }
header {
border-bottom: 1px solid var(--border);
padding: 0 2rem; display: flex; align-items: center; gap: 1.5rem;
height: 60px; position: sticky; top: 0; background: var(--bg); z-index: 100;
}
.logo { font-family: var(--mono); font-size: 1rem; letter-spacing: .05em; }
.logo span { color: var(--accent); }
nav { margin-left: auto; display: flex; gap: .75rem; align-items: center; }
#user-label { font-size: .85rem; color: var(--muted); }
.btn {
font-family: var(--sans); font-size: .85rem; font-weight: 500;
padding: .4rem 1rem; border-radius: 6px; border: 1px solid var(--border);
background: transparent; color: var(--text); cursor: pointer;
transition: all .15s; text-decoration: none;
display: inline-flex; align-items: center; gap: .4rem;
}
.btn:hover { background: var(--surface); border-color: var(--accent); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #111; font-weight: 700; }
.btn.primary:hover { background: var(--accent2); }
.btn.danger { border-color: var(--red); color: var(--red); }
.btn.danger:hover { background: rgba(231,76,60,.1); }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn.sm { font-size: .75rem; padding: .25rem .6rem; }
.tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 2rem; max-width: 900px; margin: 0 auto; }
.tab {
font-size: .85rem; font-weight: 500; padding: .75rem 1.25rem;
color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent;
margin-bottom: -1px; transition: all .15s;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.hero { padding: 3rem 2rem 2rem; max-width: 900px; margin: 0 auto; }
.hero h1 { font-size: clamp(1.8rem,4vw,3rem); font-weight: 700; line-height: 1.1; margin-bottom: .75rem; }
.hero h1 em { color: var(--accent); font-style: normal; }
.hero p { color: var(--muted); font-size: 1rem; }
/* Tag-søgning */
.search-row {
display: flex; gap: .6rem; flex-wrap: wrap;
margin-bottom: 1.25rem; align-items: center;
}
.search-input {
background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; padding: .4rem .8rem; color: var(--text);
font-family: var(--sans); font-size: .85rem; outline: none;
transition: border-color .15s; flex: 1; min-width: 180px; max-width: 320px;
}
.search-input:focus { border-color: var(--accent); }
.tag-btn {
font-family: var(--mono); font-size: .72rem; padding: .2rem .6rem;
border-radius: 4px; border: 1px solid var(--border);
background: transparent; color: var(--muted); cursor: pointer; transition: all .15s;
}
.tag-btn:hover, .tag-btn.active { background: rgba(232,160,32,.12); color: var(--accent); border-color: rgba(232,160,32,.3); }
.section { max-width: 900px; margin: 0 auto; padding: 0 2rem 4rem; }
.section-title {
font-family: var(--mono); font-size: .72rem; letter-spacing: .15em;
color: var(--muted); text-transform: uppercase;
margin-bottom: 1.25rem; padding-bottom: .6rem; border-bottom: 1px solid var(--border);
}
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1rem; }
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 1.25rem; transition: all .2s; position: relative; overflow: hidden;
}
.card.clickable { cursor: pointer; }
.card.clickable::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: var(--accent); transform: scaleX(0); transition: transform .2s; transform-origin: left;
}
.card.clickable:hover { border-color: var(--accent); transform: translateY(-2px); }
.card.clickable:hover::before { transform: scaleX(1); }
.card.locked { border-color: rgba(107,112,128,.4); opacity: .75; }
.card-title { font-weight: 600; font-size: .95rem; margin-bottom: .3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-owner { font-size: .78rem; color: var(--muted); font-family: var(--mono); margin-bottom: .75rem; }
.card-meta { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
.card-tags { display: flex; gap: .35rem; flex-wrap: wrap; margin-top: .5rem; }
.badge {
font-family: var(--mono); font-size: .68rem; padding: .18rem .45rem;
border-radius: 4px; border: 1px solid;
}
.badge.orange { background: rgba(232,160,32,.12); color: var(--accent); border-color: rgba(232,160,32,.3); }
.badge.green { background: rgba(46,204,113,.12); color: var(--green); border-color: rgba(46,204,113,.3); }
.badge.muted { background: rgba(107,112,128,.12); color: var(--muted); border-color: rgba(107,112,128,.3); }
.badge.red { background: rgba(231,76,60,.12); color: var(--red); border-color: rgba(231,76,60,.3); }
.card-actions { display: flex; gap: .5rem; margin-top: .75rem; padding-top: .75rem; border-top: 1px solid var(--border); flex-wrap: wrap; }
#detail {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
z-index: 200; align-items: center; justify-content: center; padding: 2rem;
}
#detail.open { display: flex; }
.detail-box {
background: var(--surface); border: 1px solid var(--border);
border-radius: 14px; width: 100%; max-width: 540px; max-height: 80vh;
display: flex; flex-direction: column; overflow: hidden; animation: fadeUp .25s ease;
}
.detail-header { padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border); display: flex; align-items: flex-start; gap: 1rem; }
.detail-header-text { flex: 1; min-width: 0; }
.detail-header h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: .2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.detail-header p { font-size: .82rem; color: var(--muted); }
.detail-songs { flex: 1; overflow-y: auto; padding: .4rem 0; }
.detail-songs::-webkit-scrollbar { width: 4px; }
.detail-songs::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.song-row { display: flex; align-items: center; gap: .75rem; padding: .55rem 1.5rem; transition: background .1s; }
.song-row:hover { background: rgba(255,255,255,.03); }
.song-num { font-family: var(--mono); font-size: .72rem; color: var(--muted); width: 1.5rem; text-align: right; flex-shrink: 0; }
.song-info { flex: 1; min-width: 0; }
.song-title { font-size: .88rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.song-artist { font-size: .76rem; color: var(--muted); }
.song-dance { font-family: var(--mono); font-size: .7rem; color: var(--accent); flex-shrink: 0; }
.detail-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; gap: .6rem; justify-content: flex-end; }
#login-modal {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
z-index: 300; align-items: center; justify-content: center;
}
#login-modal.open { display: flex; }
.login-box { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 2rem; width: 100%; max-width: 360px; animation: fadeUp .25s ease; }
.login-box h3 { font-size: 1.05rem; margin-bottom: 1.25rem; }
.form-row { margin-bottom: .9rem; }
.form-row label { display: block; font-size: .78rem; color: var(--muted); margin-bottom: .35rem; }
.form-row input {
width: 100%; background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; padding: .45rem .7rem; color: var(--text);
font-family: var(--sans); font-size: .88rem; outline: none; transition: border-color .15s;
}
.form-row input:focus { border-color: var(--accent); }
.msg { font-size: .8rem; padding: .5rem .7rem; border-radius: 6px; margin-bottom: .9rem; }
.msg.error { background: rgba(231,76,60,.12); color: var(--red); border: 1px solid rgba(231,76,60,.3); }
.msg.success { background: rgba(46,204,113,.12); color: var(--green); border: 1px solid rgba(46,204,113,.3); }
/* Mine danselister — sidebar layout */
.mine-layout { display: flex; gap: 1.5rem; align-items: flex-start; }
.mine-sidebar {
width: 180px; flex-shrink: 0;
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: .75rem; position: sticky; top: 80px;
}
.mine-sidebar-title {
font-family: var(--mono); font-size: .65rem; letter-spacing: .15em;
text-transform: uppercase; color: var(--muted);
padding-bottom: .5rem; margin-bottom: .5rem;
border-bottom: 1px solid var(--border);
}
.mine-tag-btn {
display: block; width: 100%; text-align: left;
font-size: .8rem; padding: .3rem .5rem; border-radius: 5px;
border: none; background: none; color: var(--muted);
cursor: pointer; transition: all .15s;
}
.mine-tag-btn:hover { color: var(--text); background: rgba(255,255,255,.04); }
.mine-tag-btn.active { color: var(--accent); background: rgba(232,160,32,.1); font-weight: 500; }
.mine-tag-btn .mine-tag-count {
float: right; font-family: var(--mono); font-size: .68rem; color: var(--muted);
}
.mine-grid-wrap { flex: 1; min-width: 0; }
.mine-search { margin-bottom: .75rem; }
.mine-search input {
width: 100%; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; padding: .4rem .8rem; color: var(--text);
font-family: var(--sans); font-size: .85rem; outline: none; transition: border-color .15s;
}
.mine-search input:focus { border-color: var(--accent); }
.empty { text-align: center; padding: 3rem 1rem; color: var(--muted); font-size: .9rem; grid-column: 1/-1; }
.spinner { width: 28px; height: 28px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto .75rem; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.fade-in { animation: fadeUp .25s ease; }
</style>
</head>
<body>
<header>
<div class="logo">LINE<span>DANCE</span> PLAYER</div>
<nav>
<span id="user-label"></span>
<button class="btn" id="btn-login">Log ind</button>
<button class="btn" id="btn-logout" style="display:none">Log ud</button>
</nav>
</header>
<div class="tabs">
<div class="tab active" data-tab="public">Offentlige danselister</div>
<div class="tab" id="tab-mine" data-tab="mine" style="display:none">Mine danselister</div>
</div>
<div class="hero">
<h1 id="hero-title">Offentlige<br><em>danselister</em></h1>
<p id="hero-sub">Browse og kopiér danselister delt af LineDance Player-brugere.</p>
</div>
<div class="section">
<div id="pane-public">
<div class="search-row">
<input class="search-input" id="search-public" placeholder="Søg på navn eller tag..." oninput="filterPublic()">
<div id="tag-btns" style="display:flex;gap:.4rem;flex-wrap:wrap;"></div>
</div>
<div class="section-title" id="public-title">Alle offentlige danselister</div>
<div id="grid-public" class="grid">
<div class="empty"><div class="spinner"></div>Henter danselister...</div>
</div>
</div>
<div id="pane-mine" style="display:none">
<div class="section-title" id="mine-title">Mine danselister</div>
<div class="mine-layout">
<div class="mine-sidebar">
<div class="mine-sidebar-title">Tags</div>
<button class="mine-tag-btn active" data-tag="" onclick="setMineTag('')">
Alle <span class="mine-tag-count" id="mine-all-count"></span>
</button>
<div id="mine-tag-list"></div>
</div>
<div class="mine-grid-wrap">
<div class="mine-search">
<input id="search-mine" placeholder="Søg danseliste..." oninput="filterMine()">
</div>
<div id="grid-mine" class="grid">
<div class="empty"><div class="spinner"></div></div>
</div>
</div>
</div>
</div>
</div>
<div id="detail">
<div class="detail-box">
<div class="detail-header">
<div class="detail-header-text">
<h2 id="d-title"></h2>
<p id="d-meta"></p>
</div>
<button class="btn sm" id="btn-close-detail"></button>
</div>
<div class="detail-songs" id="d-songs"></div>
<div class="detail-footer">
<button class="btn" id="btn-close-detail2">Luk</button>
<button class="btn primary" id="btn-copy">Kopiér til min konto</button>
</div>
</div>
</div>
<div id="qr-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);backdrop-filter:blur(4px);z-index:300;align-items:center;justify-content:center;">
<div style="background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:2rem;width:100%;max-width:340px;text-align:center;animation:fadeUp .25s ease;">
<h3 id="qr-title" style="font-size:1rem;margin-bottom:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></h3>
<canvas id="qr-canvas" style="margin:0 auto 1rem;display:block;border-radius:8px;"></canvas>
<div id="qr-url" style="font-size:.72rem;color:var(--muted);word-break:break-all;margin-bottom:1.25rem;"></div>
<div style="display:flex;gap:.6rem;justify-content:center;">
<button class="btn sm" onclick="copyLiveUrl()">Kopiér link</button>
<button class="btn sm" onclick="document.getElementById('qr-modal').style.display='none'">Luk</button>
</div>
<div id="copy-msg" style="font-size:.75rem;color:var(--green);margin-top:.6rem;height:1rem;"></div>
</div>
</div>
<div id="login-modal">
<div class="login-box">
<h3>Log ind</h3>
<div id="login-msg"></div>
<div class="form-row"><label>Brugernavn eller e-mail</label><input type="text" id="inp-user" placeholder="dit@email.dk"></div>
<div class="form-row"><label>Adgangskode</label><input type="password" id="inp-pass" placeholder="••••••••"></div>
<div style="display:flex;gap:.6rem;justify-content:flex-end;margin-top:1rem">
<button class="btn" id="btn-cancel-login">Annuller</button>
<button class="btn primary" id="btn-do-login">Log ind</button>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrious/4.0.2/qrious.min.js"></script>
<script>
const API = '/api';
let token = localStorage.getItem('ld_token') || '';
let username = localStorage.getItem('ld_user') || '';
let currentPlaylistId = null;
let currentTab = 'public';
let allPublicLists = [];
let activeTag = '';
let allMineLists = [];
let activeMineTag = '';
function updateAuthUI() {
document.getElementById('btn-login').style.display = token ? 'none' : '';
document.getElementById('btn-logout').style.display = token ? '' : 'none';
document.getElementById('tab-mine').style.display = token ? '' : 'none';
document.getElementById('user-label').textContent = token ? username : '';
if (!token && currentTab === 'mine') switchTab('public');
}
document.getElementById('btn-login').onclick = () =>
document.getElementById('login-modal').classList.add('open');
document.getElementById('btn-cancel-login').onclick = () =>
document.getElementById('login-modal').classList.remove('open');
document.getElementById('btn-logout').onclick = () => {
token = ''; username = '';
localStorage.removeItem('ld_token'); localStorage.removeItem('ld_user');
updateAuthUI();
};
document.getElementById('inp-pass').onkeydown = e => {
if (e.key === 'Enter') document.getElementById('btn-do-login').click();
};
document.getElementById('btn-do-login').onclick = async () => {
const user = document.getElementById('inp-user').value.trim();
const pass = document.getElementById('inp-pass').value;
const msg = document.getElementById('login-msg');
msg.innerHTML = '';
try {
const fd = new FormData();
fd.append('username', user); fd.append('password', pass);
const r = await fetch(`${API}/auth/login`, { method: 'POST', body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Login fejlede');
token = d.access_token; username = user;
localStorage.setItem('ld_token', token); localStorage.setItem('ld_user', username);
document.getElementById('login-modal').classList.remove('open');
updateAuthUI();
// Skift til mine danselister ved login
switchTab('mine');
loadMyPlaylists();
} catch(e) {
msg.innerHTML = `<div class="msg error">${e.message}</div>`;
}
};
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(t =>
t.classList.toggle('active', t.dataset.tab === tab));
document.getElementById('pane-public').style.display = tab === 'public' ? '' : 'none';
document.getElementById('pane-mine').style.display = tab === 'mine' ? '' : 'none';
if (tab === 'public') {
document.getElementById('hero-title').innerHTML = 'Offentlige<br><em>danselister</em>';
document.getElementById('hero-sub').textContent = 'Browse og kopiér danselister delt af LineDance Player-brugere.';
} else {
document.getElementById('hero-title').innerHTML = 'Mine<br><em>danselister</em>';
document.getElementById('hero-sub').textContent = 'Administrér dine danselister.';
}
}
document.querySelectorAll('.tab').forEach(t => t.onclick = () => switchTab(t.dataset.tab));
// ── Tag-søgning ───────────────────────────────────────────────────────────────
function filterPublic() {
const q = document.getElementById('search-public').value.trim().toLowerCase();
const filtered = allPublicLists.filter(p => {
const matchText = !q ||
p.name.toLowerCase().includes(q) ||
(p.tags || '').toLowerCase().includes(q) ||
(p.owner || '').toLowerCase().includes(q);
const matchTag = !activeTag ||
(p.tags || '').toLowerCase().split(',').map(t => t.trim()).includes(activeTag);
return matchText && matchTag;
});
renderPublicGrid(filtered);
const n = filtered.length;
document.getElementById('public-title').textContent =
(q || activeTag) ? `${n} danseliste${n !== 1 ? 'r' : ''} fundet` : 'Alle offentlige danselister';
}
function setTag(tag) {
activeTag = (activeTag === tag) ? '' : tag;
document.querySelectorAll('.tag-btn').forEach(b =>
b.classList.toggle('active', b.dataset.tag === activeTag));
filterPublic();
}
function renderPublicGrid(lists) {
const grid = document.getElementById('grid-public');
if (!lists.length) {
grid.innerHTML = '<div class="empty">Ingen danselister matcher søgningen.</div>';
return;
}
grid.innerHTML = lists.map(p => {
const tags = (p.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const tagHtml = tags.map(t =>
`<span class="badge muted" style="cursor:pointer" onclick="setTag('${esc(t)}')">${esc(t)}</span>`
).join('');
return `
<div class="card clickable fade-in" data-id="${p.id}">
<div class="card-title">${esc(p.name)}</div>
<div class="card-owner">@ ${esc(p.owner)}</div>
<div class="card-meta">
<span class="badge orange">${p.song_count} sange</span>
<span class="badge green">offentlig</span>
</div>
${tagHtml ? `<div class="card-tags">${tagHtml}</div>` : ''}
</div>`;
}).join('');
grid.querySelectorAll('.card').forEach(c =>
c.onclick = () => openDetail(c.dataset.id, false));
}
function buildTagButtons(lists) {
const tagSet = new Set();
lists.forEach(p => (p.tags || '').split(',').forEach(t => {
const tt = t.trim().toLowerCase();
if (tt) tagSet.add(tt);
}));
const tags = [...tagSet].sort();
const container = document.getElementById('tag-btns');
container.innerHTML = tags.map(t =>
`<button class="tag-btn" data-tag="${esc(t)}" onclick="setTag('${esc(t)}')">${esc(t)}</button>`
).join('');
}
async function loadPublicPlaylists() {
const grid = document.getElementById('grid-public');
try {
const r = await fetch(`${API}/sharing/public`);
allPublicLists = await r.json();
buildTagButtons(allPublicLists);
renderPublicGrid(allPublicLists);
} catch(e) {
grid.innerHTML = `<div class="empty">Kunne ikke hente danselister.<br>${e.message}</div>`;
}
}
// ── Mine danselister ──────────────────────────────────────────────────────────
function buildMineSidebar(lists) {
const tagCounts = {};
lists.forEach(p => (p.tags || '').split(',').forEach(t => {
const tt = t.trim().toLowerCase();
if (tt) tagCounts[tt] = (tagCounts[tt] || 0) + 1;
}));
document.getElementById('mine-all-count').textContent = lists.length;
const container = document.getElementById('mine-tag-list');
container.innerHTML = Object.entries(tagCounts)
.sort((a,b) => a[0].localeCompare(b[0]))
.map(([tag, count]) => `
<button class="mine-tag-btn${activeMineTag === tag ? ' active' : ''}"
data-tag="${esc(tag)}" onclick="setMineTag('${esc(tag)}')">
${esc(tag)} <span class="mine-tag-count">${count}</span>
</button>`).join('');
}
function setMineTag(tag) {
activeMineTag = tag;
document.querySelectorAll('.mine-tag-btn').forEach(b =>
b.classList.toggle('active', b.dataset.tag === tag));
filterMine();
}
function filterMine() {
const q = (document.getElementById('search-mine')?.value || '').trim().toLowerCase();
const filtered = allMineLists.filter(p => {
const matchText = !q ||
p.name.toLowerCase().includes(q) ||
(p.tags || '').toLowerCase().includes(q);
const matchTag = !activeMineTag ||
(p.tags || '').toLowerCase().split(',').map(t => t.trim()).includes(activeMineTag);
return matchText && matchTag;
});
renderMineGrid(filtered);
const n = filtered.length;
document.getElementById('mine-title').textContent =
(q || activeMineTag) ? `${n} danseliste${n !== 1 ? 'r' : ''} fundet` : 'Mine danselister';
}
function renderMineGrid(lists) {
const grid = document.getElementById('grid-mine');
if (!lists.length) {
grid.innerHTML = '<div class="empty">Ingen danselister matcher søgningen.</div>';
return;
}
grid.innerHTML = lists.map(p => {
const vis = p.visibility || 'private';
const locked = p.locked || false;
const bc = locked ? 'muted' : vis === 'public' ? 'green' : vis === 'shared' ? 'orange' : 'muted';
const bl = locked ? '🔒 låst' : vis === 'public' ? 'offentlig' : vis === 'shared' ? 'delt' : 'privat';
const sc = p.song_count || (p.songs || []).length || 0;
const tags = (p.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const tagHtml = tags.map(t =>
`<span class="badge muted" style="cursor:pointer" onclick="setMineTag('${esc(t)}')">${esc(t)}</span>`
).join('');
return `
<div class="card fade-in${locked ? ' locked' : ''}">
<div class="card-title">${locked ? '🔒 ' : ''}${esc(p.name)}</div>
<div class="card-meta">
<span class="badge orange">${sc} sange</span>
<span class="badge ${bc}" id="vis-badge-${p.id}">${bl}</span>
</div>
${tagHtml ? `<div class="card-tags">${tagHtml}</div>` : ''}
<div class="card-actions">
<button class="btn sm" onclick="openDetail('${p.id}',true)">Se sange</button>
${!locked ? `
<button class="btn sm${vis==='public'?' danger':''}" id="vis-btn-${p.id}"
onclick="toggleVis('${p.id}','${vis}')">
${vis === 'public' ? 'Gør privat' : 'Gør offentlig'}
</button>
<button class="btn sm danger" onclick="confirmLock('${p.id}','${esc(p.name)}')" title="Lås permanent">🔒</button>
` : ''}
<a class="btn sm" href="/live.html?id=${p.id}" target="_blank" title="Storskærm">📺</a>
<button class="btn sm" onclick="showQR('${p.id}','${esc(p.name)}')" title="QR-kode">QR</button>
</div>
</div>`;
}).join('');
}
async function loadMyPlaylists() {
const grid = document.getElementById('grid-mine');
grid.innerHTML = '<div class="empty"><div class="spinner"></div></div>';
try {
const r = await fetch(`${API}/projects/my`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!r.ok) throw new Error('Ikke autoriseret');
allMineLists = await r.json();
if (!allMineLists.length) {
document.getElementById('grid-mine').innerHTML = '<div class="empty">Ingen danselister endnu.</div>';
return;
}
buildMineSidebar(allMineLists);
filterMine();
} catch(e) {
grid.innerHTML = `<div class="empty">Kunne ikke hente danselister.<br>${e.message}</div>`;
}
}
async function toggleVis(id, current) {
const newVis = current === 'public' ? 'private' : 'public';
try {
const r = await fetch(`${API}/sharing/playlists/${id}/visibility?visibility=${newVis}`, {
method: 'PATCH', headers: { 'Authorization': `Bearer ${token}` }
});
if (!r.ok) throw new Error('Fejl');
loadMyPlaylists();
loadPublicPlaylists();
} catch(e) { alert('Fejl: ' + e.message); }
}
function confirmLock(id, name) {
if (!confirm(`Lås "${name}" permanent?\n\nEn låst danseliste kan ikke længere redigeres eller opdateres fra appen. Dette kan ikke fortrydes.`)) return;
lockPlaylist(id);
}
async function lockPlaylist(id) {
try {
const r = await fetch(`${API}/projects/${id}/lock`, {
method: 'POST', headers: { 'Authorization': `Bearer ${token}` }
});
if (!r.ok) throw new Error((await r.json()).detail || 'Fejl');
loadMyPlaylists();
} catch(e) { alert('Fejl: ' + e.message); }
}
// ── Detail-visning ────────────────────────────────────────────────────────────
async function openDetail(id, isOwn) {
currentPlaylistId = id;
document.getElementById('btn-copy').style.display = isOwn ? 'none' : '';
document.getElementById('detail').classList.add('open');
document.getElementById('d-songs').innerHTML = '<div class="empty"><div class="spinner"></div></div>';
try {
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const r = await fetch(`${API}/sharing/playlists/${id}`, { headers });
const p = await r.json();
document.getElementById('d-title').textContent = p.name;
document.getElementById('d-meta').textContent = `${p.songs.length} sange${p.owner ? ' · @ '+p.owner : ''}`;
document.getElementById('d-songs').innerHTML = p.songs.length
? p.songs.map((s,i) => `
<div class="song-row">
<span class="song-num">${i+1}</span>
<div class="song-info">
<div class="song-title">${esc(s.title)}</div>
<div class="song-artist">${esc(s.artist)}</div>
</div>
${s.dance_override ? `<span class="song-dance">${esc(s.dance_override)}</span>` : ''}
</div>`).join('')
: '<div class="empty">Ingen sange.</div>';
} catch(e) {
document.getElementById('d-songs').innerHTML = '<div class="empty">Kunne ikke hente detaljer.</div>';
}
}
function closeDetail() {
document.getElementById('detail').classList.remove('open');
currentPlaylistId = null;
}
document.getElementById('btn-close-detail').onclick = closeDetail;
document.getElementById('btn-close-detail2').onclick = closeDetail;
document.getElementById('detail').onclick = e => {
if (e.target === document.getElementById('detail')) closeDetail();
};
document.getElementById('btn-copy').onclick = async () => {
if (!token) { document.getElementById('login-modal').classList.add('open'); return; }
const btn = document.getElementById('btn-copy');
btn.disabled = true; btn.textContent = 'Kopierer...';
try {
const r = await fetch(`${API}/sharing/playlists/${currentPlaylistId}/copy`, {
method: 'POST', headers: { 'Authorization': `Bearer ${token}` }
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Fejl');
btn.textContent = '✓ Kopieret!'; btn.style.background = 'var(--green)';
setTimeout(() => { btn.textContent = 'Kopiér til min konto'; btn.style.background = ''; btn.disabled = false; }, 2500);
loadMyPlaylists();
} catch(e) { btn.textContent = '⚠ ' + e.message; btn.disabled = false; }
};
// ── QR ───────────────────────────────────────────────────────────────────────
let currentQRUrl = '';
function showQR(id, name) {
const url = `${location.protocol}//${location.host}/live.html?id=${id}`;
currentQRUrl = url;
document.getElementById('qr-title').textContent = name;
document.getElementById('qr-url').textContent = url;
document.getElementById('copy-msg').textContent = '';
document.getElementById('qr-modal').style.display = 'flex';
const canvas = document.getElementById('qr-canvas');
if (window.QRious) {
new QRious({ element: canvas, value: url, size: 220, backgroundAlpha: 0, foreground: '#eceef4' });
} else {
canvas.style.display = 'none';
}
}
function copyLiveUrl() {
navigator.clipboard.writeText(currentQRUrl).then(() => {
const msg = document.getElementById('copy-msg');
msg.textContent = '✓ Kopieret!';
setTimeout(() => msg.textContent = '', 2000);
});
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
updateAuthUI();
loadPublicPlaylists();
if (token) loadMyPlaylists();
</script>
</body>
</html>

View File

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

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

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

View File

@@ -1,10 +1,9 @@
""" """
app_logger.py Central logging til fil i stedet for konsol. app_logger.py - Central logging til fil i stedet for konsol.
P<EFBFBD> Windows uden konsol skrives alt til ~/.linedance/app.log Paa Windows uden konsol skrives alt til ~/.linedance/app.log
""" """
import logging import logging
import sys
from pathlib import Path from pathlib import Path
LOG_PATH = Path.home() / ".linedance" / "app.log" LOG_PATH = Path.home() / ".linedance" / "app.log"
@@ -13,13 +12,6 @@ LOG_PATH = Path.home() / ".linedance" / "app.log"
def setup_logging(): def setup_logging():
LOG_PATH.parent.mkdir(parents=True, exist_ok=True) LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
handlers = [logging.FileHandler(LOG_PATH, encoding="utf-8")] handlers = [logging.FileHandler(LOG_PATH, encoding="utf-8")]
# Kun tilføj konsol-handler hvis vi kører med konsol (development)
if sys.stdout and hasattr(sys.stdout, 'write'):
try:
sys.stdout.write("") # test om konsol virker
handlers.append(logging.StreamHandler(sys.stdout))
except Exception:
pass
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,

View File

@@ -1,35 +1,142 @@
@echo off @echo off
echo === LineDance Player - Windows Build === setlocal enabledelayedexpansion
echo.
echo ================================================
echo LineDance Player - Windows Build + Installer
echo ================================================
echo. echo.
:: ── Aktiver venv ──────────────────────────────────────────────────────────────
if exist "venv\Scripts\activate.bat" ( if exist "venv\Scripts\activate.bat" (
call venv\Scripts\activate.bat call venv\Scripts\activate.bat
) else ( ) else (
echo ADVARSEL: venv ikke fundet echo ADVARSEL: venv ikke fundet - bruger system Python
) )
pip install pyinstaller >nul 2>&1 :: ── 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 "dist\LineDancePlayer" rmdir /s /q "dist\LineDancePlayer"
if exist "build\LineDancePlayer" rmdir /s /q "build\LineDancePlayer" if exist "build\LineDancePlayer" rmdir /s /q "build\LineDancePlayer"
echo Bygger... (1-3 minutter) :: ── PyInstaller build ─────────────────────────────────────────────────────────
echo [3/4] Bygger med PyInstaller (2-5 minutter)...
echo. echo.
pyinstaller build_windows.spec --clean --noconfirm pyinstaller build_windows.spec --clean --noconfirm
if errorlevel 1 ( if errorlevel 1 (
echo. echo.
echo FEJL: Se fejlbesked ovenfor echo FEJL: PyInstaller fejlede - se fejlbesked ovenfor
pause pause & exit /b 1
exit /b 1
) )
echo. echo.
echo === FAERDIG === echo OK: dist\LineDancePlayer\ er klar
echo Program: dist\LineDancePlayer\LineDancePlayer.exe
echo. echo.
echo HUSK: Kopieer hele dist\LineDancePlayer\ mappen - ikke kun .exe!
echo HUSK: VLC skal vaere installeret paa maskinen. :: ── 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. echo.
pause pause

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,77 +0,0 @@
('C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\LineDancePlayer.exe',
False,
False,
True,
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\bootloader\\images\\icon-windowed.ico',
None,
False,
False,
b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<assembly xmlns='
b'"urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">\n <trustInfo x'
b'mlns="urn:schemas-microsoft-com:asm.v3">\n <security>\n <requested'
b'Privileges>\n <requestedExecutionLevel level="asInvoker" uiAccess='
b'"false"/>\n </requestedPrivileges>\n </security>\n </trustInfo>\n '
b'<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">\n <'
b'application>\n <supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f'
b'0}"/>\n <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>\n '
b' <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>\n <s'
b'upportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>\n <supporte'
b'dOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>\n </application>\n <'
b'/compatibility>\n <application xmlns="urn:schemas-microsoft-com:asm.v3">'
b'\n <windowsSettings>\n <longPathAware xmlns="http://schemas.micros'
b'oft.com/SMI/2016/WindowsSettings">true</longPathAware>\n </windowsSett'
b'ings>\n </application>\n <dependency>\n <dependentAssembly>\n <ass'
b'emblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version='
b'"6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" langua'
b'ge="*"/>\n </dependentAssembly>\n </dependency>\n</assembly>',
True,
False,
None,
None,
None,
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\LineDancePlayer.pkg',
[('pyi-contents-directory _internal', '', 'OPTION'),
('PYZ-00.pyz',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\PYZ-00.pyz',
'PYZ'),
('struct',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\localpycs\\struct.pyc',
'PYMODULE'),
('pyimod01_archive',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\localpycs\\pyimod01_archive.pyc',
'PYMODULE'),
('pyimod02_importers',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\localpycs\\pyimod02_importers.pyc',
'PYMODULE'),
('pyimod03_ctypes',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\localpycs\\pyimod03_ctypes.pyc',
'PYMODULE'),
('pyimod04_pywin32',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\localpycs\\pyimod04_pywin32.pyc',
'PYMODULE'),
('pyiboot01_bootstrap',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\loader\\pyiboot01_bootstrap.py',
'PYSOURCE'),
('pyi_rth_inspect',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_inspect.py',
'PYSOURCE'),
('pyi_rth_pkgutil',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pkgutil.py',
'PYSOURCE'),
('pyi_rth_multiprocessing',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_multiprocessing.py',
'PYSOURCE'),
('pyi_rth_pyqt6',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pyqt6.py',
'PYSOURCE'),
('main',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\main.py',
'PYSOURCE')],
[],
False,
False,
1775858552,
[('runw.exe',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\bootloader\\Windows-64bit-intel\\runw.exe',
'EXECUTABLE')],
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\python313.dll')

View File

@@ -1,55 +0,0 @@
('C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\LineDancePlayer.pkg',
{'BINARY': True,
'DATA': True,
'EXECUTABLE': True,
'EXTENSION': True,
'PYMODULE': True,
'PYSOURCE': True,
'PYZ': False,
'SPLASH': True,
'SYMLINK': False},
[('pyi-contents-directory _internal', '', 'OPTION'),
('PYZ-00.pyz',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\PYZ-00.pyz',
'PYZ'),
('struct',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\localpycs\\struct.pyc',
'PYMODULE'),
('pyimod01_archive',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\localpycs\\pyimod01_archive.pyc',
'PYMODULE'),
('pyimod02_importers',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\localpycs\\pyimod02_importers.pyc',
'PYMODULE'),
('pyimod03_ctypes',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\localpycs\\pyimod03_ctypes.pyc',
'PYMODULE'),
('pyimod04_pywin32',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\localpycs\\pyimod04_pywin32.pyc',
'PYMODULE'),
('pyiboot01_bootstrap',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\loader\\pyiboot01_bootstrap.py',
'PYSOURCE'),
('pyi_rth_inspect',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_inspect.py',
'PYSOURCE'),
('pyi_rth_pkgutil',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pkgutil.py',
'PYSOURCE'),
('pyi_rth_multiprocessing',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_multiprocessing.py',
'PYSOURCE'),
('pyi_rth_pyqt6',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pyqt6.py',
'PYSOURCE'),
('main',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\main.py',
'PYSOURCE')],
'python313.dll',
True,
False,
False,
[],
None,
None,
None)

View File

@@ -1,946 +0,0 @@
('C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\build\\build_windows\\PYZ-00.pyz',
[('PyQt6',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\__init__.py',
'PYMODULE'),
('PyQt6.lupdate',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\lupdate\\__init__.py',
'PYMODULE'),
('PyQt6.lupdate.designer_source',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\lupdate\\designer_source.py',
'PYMODULE'),
('PyQt6.lupdate.lupdate',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\lupdate\\lupdate.py',
'PYMODULE'),
('PyQt6.lupdate.pylupdate',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\lupdate\\pylupdate.py',
'PYMODULE'),
('PyQt6.lupdate.python_source',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\lupdate\\python_source.py',
'PYMODULE'),
('PyQt6.lupdate.source_file',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\lupdate\\source_file.py',
'PYMODULE'),
('PyQt6.lupdate.translation_file',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\lupdate\\translation_file.py',
'PYMODULE'),
('PyQt6.lupdate.translations',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\lupdate\\translations.py',
'PYMODULE'),
('PyQt6.lupdate.user',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\lupdate\\user.py',
'PYMODULE'),
('PyQt6.uic',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\__init__.py',
'PYMODULE'),
('PyQt6.uic.Compiler',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\Compiler\\__init__.py',
'PYMODULE'),
('PyQt6.uic.Compiler.as_string',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\Compiler\\as_string.py',
'PYMODULE'),
('PyQt6.uic.Compiler.compiler',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\Compiler\\compiler.py',
'PYMODULE'),
('PyQt6.uic.Compiler.indenter',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\Compiler\\indenter.py',
'PYMODULE'),
('PyQt6.uic.Compiler.misc',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\Compiler\\misc.py',
'PYMODULE'),
('PyQt6.uic.Compiler.proxy_metaclass',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\Compiler\\proxy_metaclass.py',
'PYMODULE'),
('PyQt6.uic.Compiler.qobjectcreator',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\Compiler\\qobjectcreator.py',
'PYMODULE'),
('PyQt6.uic.Compiler.qtproxies',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\Compiler\\qtproxies.py',
'PYMODULE'),
('PyQt6.uic.Loader',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\Loader\\__init__.py',
'PYMODULE'),
('PyQt6.uic.Loader.loader',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\Loader\\loader.py',
'PYMODULE'),
('PyQt6.uic.Loader.qobjectcreator',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\Loader\\qobjectcreator.py',
'PYMODULE'),
('PyQt6.uic.compile_ui',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\compile_ui.py',
'PYMODULE'),
('PyQt6.uic.enum_map',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\enum_map.py',
'PYMODULE'),
('PyQt6.uic.exceptions',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\exceptions.py',
'PYMODULE'),
('PyQt6.uic.icon_cache',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\icon_cache.py',
'PYMODULE'),
('PyQt6.uic.load_ui',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\load_ui.py',
'PYMODULE'),
('PyQt6.uic.objcreator',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\objcreator.py',
'PYMODULE'),
('PyQt6.uic.properties',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\properties.py',
'PYMODULE'),
('PyQt6.uic.pyuic',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\pyuic.py',
'PYMODULE'),
('PyQt6.uic.ui_file',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\ui_file.py',
'PYMODULE'),
('PyQt6.uic.uiparser',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyQt6\\uic\\uiparser.py',
'PYMODULE'),
('__future__',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\__future__.py',
'PYMODULE'),
('_colorize',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\_colorize.py',
'PYMODULE'),
('_compat_pickle',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\_compat_pickle.py',
'PYMODULE'),
('_compression',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\_compression.py',
'PYMODULE'),
('_opcode_metadata',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\_opcode_metadata.py',
'PYMODULE'),
('_py_abc',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\_py_abc.py',
'PYMODULE'),
('_pydatetime',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\_pydatetime.py',
'PYMODULE'),
('_pydecimal',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\_pydecimal.py',
'PYMODULE'),
('_pyi_rth_utils',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\fake-modules\\_pyi_rth_utils\\__init__.py',
'PYMODULE'),
('_pyi_rth_utils.qt',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\PyInstaller\\fake-modules\\_pyi_rth_utils\\qt.py',
'PYMODULE'),
('_strptime',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\_strptime.py',
'PYMODULE'),
('_threading_local',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\_threading_local.py',
'PYMODULE'),
('argparse',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\argparse.py',
'PYMODULE'),
('ast',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ast.py',
'PYMODULE'),
('base64',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\base64.py',
'PYMODULE'),
('bisect',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\bisect.py',
'PYMODULE'),
('bz2',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\bz2.py',
'PYMODULE'),
('calendar',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\calendar.py',
'PYMODULE'),
('code',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\code.py',
'PYMODULE'),
('codeop',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\codeop.py',
'PYMODULE'),
('concurrent',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\concurrent\\__init__.py',
'PYMODULE'),
('concurrent.futures',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\concurrent\\futures\\__init__.py',
'PYMODULE'),
('concurrent.futures._base',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\concurrent\\futures\\_base.py',
'PYMODULE'),
('concurrent.futures.process',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\concurrent\\futures\\process.py',
'PYMODULE'),
('concurrent.futures.thread',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\concurrent\\futures\\thread.py',
'PYMODULE'),
('contextlib',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\contextlib.py',
'PYMODULE'),
('contextvars',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\contextvars.py',
'PYMODULE'),
('copy',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\copy.py',
'PYMODULE'),
('csv',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\csv.py',
'PYMODULE'),
('ctypes',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ctypes\\__init__.py',
'PYMODULE'),
('ctypes._aix',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ctypes\\_aix.py',
'PYMODULE'),
('ctypes._endian',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ctypes\\_endian.py',
'PYMODULE'),
('ctypes.macholib',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ctypes\\macholib\\__init__.py',
'PYMODULE'),
('ctypes.macholib.dyld',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ctypes\\macholib\\dyld.py',
'PYMODULE'),
('ctypes.macholib.dylib',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ctypes\\macholib\\dylib.py',
'PYMODULE'),
('ctypes.macholib.framework',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ctypes\\macholib\\framework.py',
'PYMODULE'),
('ctypes.util',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ctypes\\util.py',
'PYMODULE'),
('ctypes.wintypes',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ctypes\\wintypes.py',
'PYMODULE'),
('dataclasses',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\dataclasses.py',
'PYMODULE'),
('datetime',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\datetime.py',
'PYMODULE'),
('decimal',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\decimal.py',
'PYMODULE'),
('dis',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\dis.py',
'PYMODULE'),
('email',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\__init__.py',
'PYMODULE'),
('email._encoded_words',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\_encoded_words.py',
'PYMODULE'),
('email._header_value_parser',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\_header_value_parser.py',
'PYMODULE'),
('email._parseaddr',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\_parseaddr.py',
'PYMODULE'),
('email._policybase',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\_policybase.py',
'PYMODULE'),
('email.base64mime',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\base64mime.py',
'PYMODULE'),
('email.charset',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\charset.py',
'PYMODULE'),
('email.contentmanager',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\contentmanager.py',
'PYMODULE'),
('email.encoders',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\encoders.py',
'PYMODULE'),
('email.errors',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\errors.py',
'PYMODULE'),
('email.feedparser',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\feedparser.py',
'PYMODULE'),
('email.generator',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\generator.py',
'PYMODULE'),
('email.header',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\header.py',
'PYMODULE'),
('email.headerregistry',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\headerregistry.py',
'PYMODULE'),
('email.iterators',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\iterators.py',
'PYMODULE'),
('email.message',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\message.py',
'PYMODULE'),
('email.parser',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\parser.py',
'PYMODULE'),
('email.policy',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\policy.py',
'PYMODULE'),
('email.quoprimime',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\quoprimime.py',
'PYMODULE'),
('email.utils',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\email\\utils.py',
'PYMODULE'),
('fnmatch',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\fnmatch.py',
'PYMODULE'),
('fractions',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\fractions.py',
'PYMODULE'),
('ftplib',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ftplib.py',
'PYMODULE'),
('getopt',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\getopt.py',
'PYMODULE'),
('getpass',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\getpass.py',
'PYMODULE'),
('gettext',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\gettext.py',
'PYMODULE'),
('glob',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\glob.py',
'PYMODULE'),
('gzip',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\gzip.py',
'PYMODULE'),
('hashlib',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\hashlib.py',
'PYMODULE'),
('hmac',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\hmac.py',
'PYMODULE'),
('http',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\http\\__init__.py',
'PYMODULE'),
('http.client',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\http\\client.py',
'PYMODULE'),
('http.cookiejar',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\http\\cookiejar.py',
'PYMODULE'),
('importlib',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\__init__.py',
'PYMODULE'),
('importlib._abc',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\_abc.py',
'PYMODULE'),
('importlib._bootstrap',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\_bootstrap.py',
'PYMODULE'),
('importlib._bootstrap_external',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\_bootstrap_external.py',
'PYMODULE'),
('importlib.abc',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\abc.py',
'PYMODULE'),
('importlib.machinery',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\machinery.py',
'PYMODULE'),
('importlib.metadata',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\metadata\\__init__.py',
'PYMODULE'),
('importlib.metadata._adapters',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\metadata\\_adapters.py',
'PYMODULE'),
('importlib.metadata._collections',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\metadata\\_collections.py',
'PYMODULE'),
('importlib.metadata._functools',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\metadata\\_functools.py',
'PYMODULE'),
('importlib.metadata._itertools',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\metadata\\_itertools.py',
'PYMODULE'),
('importlib.metadata._meta',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\metadata\\_meta.py',
'PYMODULE'),
('importlib.metadata._text',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\metadata\\_text.py',
'PYMODULE'),
('importlib.readers',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\readers.py',
'PYMODULE'),
('importlib.resources',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\resources\\__init__.py',
'PYMODULE'),
('importlib.resources._adapters',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\resources\\_adapters.py',
'PYMODULE'),
('importlib.resources._common',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\resources\\_common.py',
'PYMODULE'),
('importlib.resources._functional',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\resources\\_functional.py',
'PYMODULE'),
('importlib.resources._itertools',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\resources\\_itertools.py',
'PYMODULE'),
('importlib.resources.abc',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\resources\\abc.py',
'PYMODULE'),
('importlib.resources.readers',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\resources\\readers.py',
'PYMODULE'),
('importlib.util',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\importlib\\util.py',
'PYMODULE'),
('inspect',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\inspect.py',
'PYMODULE'),
('ipaddress',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ipaddress.py',
'PYMODULE'),
('json',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\json\\__init__.py',
'PYMODULE'),
('json.decoder',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\json\\decoder.py',
'PYMODULE'),
('json.encoder',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\json\\encoder.py',
'PYMODULE'),
('json.scanner',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\json\\scanner.py',
'PYMODULE'),
('local',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\local\\__init__.py',
'PYMODULE'),
('local.file_watcher',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\local\\file_watcher.py',
'PYMODULE'),
('local.local_db',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\local\\local_db.py',
'PYMODULE'),
('local.tag_reader',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\local\\tag_reader.py',
'PYMODULE'),
('logging',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\logging\\__init__.py',
'PYMODULE'),
('lzma',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\lzma.py',
'PYMODULE'),
('mimetypes',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\mimetypes.py',
'PYMODULE'),
('multiprocessing',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\__init__.py',
'PYMODULE'),
('multiprocessing.connection',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\connection.py',
'PYMODULE'),
('multiprocessing.context',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\context.py',
'PYMODULE'),
('multiprocessing.dummy',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\dummy\\__init__.py',
'PYMODULE'),
('multiprocessing.dummy.connection',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\dummy\\connection.py',
'PYMODULE'),
('multiprocessing.forkserver',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\forkserver.py',
'PYMODULE'),
('multiprocessing.heap',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\heap.py',
'PYMODULE'),
('multiprocessing.managers',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\managers.py',
'PYMODULE'),
('multiprocessing.pool',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\pool.py',
'PYMODULE'),
('multiprocessing.popen_fork',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\popen_fork.py',
'PYMODULE'),
('multiprocessing.popen_forkserver',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\popen_forkserver.py',
'PYMODULE'),
('multiprocessing.popen_spawn_posix',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\popen_spawn_posix.py',
'PYMODULE'),
('multiprocessing.popen_spawn_win32',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\popen_spawn_win32.py',
'PYMODULE'),
('multiprocessing.process',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\process.py',
'PYMODULE'),
('multiprocessing.queues',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\queues.py',
'PYMODULE'),
('multiprocessing.reduction',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\reduction.py',
'PYMODULE'),
('multiprocessing.resource_sharer',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\resource_sharer.py',
'PYMODULE'),
('multiprocessing.resource_tracker',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\resource_tracker.py',
'PYMODULE'),
('multiprocessing.shared_memory',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\shared_memory.py',
'PYMODULE'),
('multiprocessing.sharedctypes',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\sharedctypes.py',
'PYMODULE'),
('multiprocessing.spawn',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\spawn.py',
'PYMODULE'),
('multiprocessing.synchronize',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\synchronize.py',
'PYMODULE'),
('multiprocessing.util',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\multiprocessing\\util.py',
'PYMODULE'),
('mutagen',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\__init__.py',
'PYMODULE'),
('mutagen._constants',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\_constants.py',
'PYMODULE'),
('mutagen._file',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\_file.py',
'PYMODULE'),
('mutagen._iff',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\_iff.py',
'PYMODULE'),
('mutagen._riff',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\_riff.py',
'PYMODULE'),
('mutagen._tags',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\_tags.py',
'PYMODULE'),
('mutagen._util',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\_util.py',
'PYMODULE'),
('mutagen._vorbis',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\_vorbis.py',
'PYMODULE'),
('mutagen.aac',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\aac.py',
'PYMODULE'),
('mutagen.ac3',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\ac3.py',
'PYMODULE'),
('mutagen.aiff',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\aiff.py',
'PYMODULE'),
('mutagen.apev2',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\apev2.py',
'PYMODULE'),
('mutagen.asf',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\asf\\__init__.py',
'PYMODULE'),
('mutagen.asf._attrs',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\asf\\_attrs.py',
'PYMODULE'),
('mutagen.asf._objects',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\asf\\_objects.py',
'PYMODULE'),
('mutagen.asf._util',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\asf\\_util.py',
'PYMODULE'),
('mutagen.dsdiff',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\dsdiff.py',
'PYMODULE'),
('mutagen.dsf',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\dsf.py',
'PYMODULE'),
('mutagen.easyid3',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\easyid3.py',
'PYMODULE'),
('mutagen.easymp4',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\easymp4.py',
'PYMODULE'),
('mutagen.flac',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\flac.py',
'PYMODULE'),
('mutagen.id3',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\id3\\__init__.py',
'PYMODULE'),
('mutagen.id3._file',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\id3\\_file.py',
'PYMODULE'),
('mutagen.id3._frames',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\id3\\_frames.py',
'PYMODULE'),
('mutagen.id3._id3v1',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\id3\\_id3v1.py',
'PYMODULE'),
('mutagen.id3._specs',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\id3\\_specs.py',
'PYMODULE'),
('mutagen.id3._tags',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\id3\\_tags.py',
'PYMODULE'),
('mutagen.id3._util',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\id3\\_util.py',
'PYMODULE'),
('mutagen.monkeysaudio',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\monkeysaudio.py',
'PYMODULE'),
('mutagen.mp3',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\mp3\\__init__.py',
'PYMODULE'),
('mutagen.mp3._util',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\mp3\\_util.py',
'PYMODULE'),
('mutagen.mp4',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\mp4\\__init__.py',
'PYMODULE'),
('mutagen.mp4._as_entry',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\mp4\\_as_entry.py',
'PYMODULE'),
('mutagen.mp4._atom',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\mp4\\_atom.py',
'PYMODULE'),
('mutagen.mp4._util',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\mp4\\_util.py',
'PYMODULE'),
('mutagen.musepack',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\musepack.py',
'PYMODULE'),
('mutagen.ogg',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\ogg.py',
'PYMODULE'),
('mutagen.oggflac',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\oggflac.py',
'PYMODULE'),
('mutagen.oggopus',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\oggopus.py',
'PYMODULE'),
('mutagen.oggspeex',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\oggspeex.py',
'PYMODULE'),
('mutagen.oggtheora',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\oggtheora.py',
'PYMODULE'),
('mutagen.oggvorbis',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\oggvorbis.py',
'PYMODULE'),
('mutagen.optimfrog',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\optimfrog.py',
'PYMODULE'),
('mutagen.smf',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\smf.py',
'PYMODULE'),
('mutagen.tak',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\tak.py',
'PYMODULE'),
('mutagen.trueaudio',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\trueaudio.py',
'PYMODULE'),
('mutagen.wave',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\wave.py',
'PYMODULE'),
('mutagen.wavpack',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\mutagen\\wavpack.py',
'PYMODULE'),
('netrc',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\netrc.py',
'PYMODULE'),
('nturl2path',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\nturl2path.py',
'PYMODULE'),
('numbers',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\numbers.py',
'PYMODULE'),
('opcode',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\opcode.py',
'PYMODULE'),
('pathlib',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\pathlib\\__init__.py',
'PYMODULE'),
('pathlib._abc',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\pathlib\\_abc.py',
'PYMODULE'),
('pathlib._local',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\pathlib\\_local.py',
'PYMODULE'),
('pickle',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\pickle.py',
'PYMODULE'),
('pkgutil',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\pkgutil.py',
'PYMODULE'),
('platform',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\platform.py',
'PYMODULE'),
('player',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\player\\__init__.py',
'PYMODULE'),
('player.player',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\player\\player.py',
'PYMODULE'),
('pprint',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\pprint.py',
'PYMODULE'),
('py_compile',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\py_compile.py',
'PYMODULE'),
('queue',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\queue.py',
'PYMODULE'),
('quopri',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\quopri.py',
'PYMODULE'),
('random',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\random.py',
'PYMODULE'),
('runpy',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\runpy.py',
'PYMODULE'),
('secrets',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\secrets.py',
'PYMODULE'),
('selectors',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\selectors.py',
'PYMODULE'),
('shutil',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\shutil.py',
'PYMODULE'),
('signal',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\signal.py',
'PYMODULE'),
('socket',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\socket.py',
'PYMODULE'),
('sqlite3',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\sqlite3\\__init__.py',
'PYMODULE'),
('sqlite3.__main__',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\sqlite3\\__main__.py',
'PYMODULE'),
('sqlite3.dbapi2',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\sqlite3\\dbapi2.py',
'PYMODULE'),
('sqlite3.dump',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\sqlite3\\dump.py',
'PYMODULE'),
('ssl',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\ssl.py',
'PYMODULE'),
('statistics',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\statistics.py',
'PYMODULE'),
('string',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\string.py',
'PYMODULE'),
('stringprep',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\stringprep.py',
'PYMODULE'),
('subprocess',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\subprocess.py',
'PYMODULE'),
('tarfile',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\tarfile.py',
'PYMODULE'),
('tempfile',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\tempfile.py',
'PYMODULE'),
('textwrap',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\textwrap.py',
'PYMODULE'),
('threading',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\threading.py',
'PYMODULE'),
('token',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\token.py',
'PYMODULE'),
('tokenize',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\tokenize.py',
'PYMODULE'),
('tracemalloc',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\tracemalloc.py',
'PYMODULE'),
('tty',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\tty.py',
'PYMODULE'),
('typing',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\typing.py',
'PYMODULE'),
('ui',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\ui\\__init__.py',
'PYMODULE'),
('ui.library_manager',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\ui\\library_manager.py',
'PYMODULE'),
('ui.library_panel',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\ui\\library_panel.py',
'PYMODULE'),
('ui.login_dialog',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\ui\\login_dialog.py',
'PYMODULE'),
('ui.main_window',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\ui\\main_window.py',
'PYMODULE'),
('ui.next_up_bar',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\ui\\next_up_bar.py',
'PYMODULE'),
('ui.playlist_manager',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\ui\\playlist_manager.py',
'PYMODULE'),
('ui.playlist_panel',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\ui\\playlist_panel.py',
'PYMODULE'),
('ui.scan_worker',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\ui\\scan_worker.py',
'PYMODULE'),
('ui.settings_dialog',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\ui\\settings_dialog.py',
'PYMODULE'),
('ui.themes',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\ui\\themes.py',
'PYMODULE'),
('ui.vu_meter',
'C:\\Users\\carsten\\Documents\\GitClone\\LinedanceAfspiller\\linedance-app\\ui\\vu_meter.py',
'PYMODULE'),
('urllib',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\urllib\\__init__.py',
'PYMODULE'),
('urllib.error',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\urllib\\error.py',
'PYMODULE'),
('urllib.parse',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\urllib\\parse.py',
'PYMODULE'),
('urllib.request',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\urllib\\request.py',
'PYMODULE'),
('urllib.response',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\urllib\\response.py',
'PYMODULE'),
('uuid',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\uuid.py',
'PYMODULE'),
('vlc',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\vlc.py',
'PYMODULE'),
('watchdog',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\__init__.py',
'PYMODULE'),
('watchdog.events',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\events.py',
'PYMODULE'),
('watchdog.observers',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\observers\\__init__.py',
'PYMODULE'),
('watchdog.observers.api',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\observers\\api.py',
'PYMODULE'),
('watchdog.observers.fsevents',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\observers\\fsevents.py',
'PYMODULE'),
('watchdog.observers.inotify',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\observers\\inotify.py',
'PYMODULE'),
('watchdog.observers.inotify_buffer',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\observers\\inotify_buffer.py',
'PYMODULE'),
('watchdog.observers.inotify_c',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\observers\\inotify_c.py',
'PYMODULE'),
('watchdog.observers.kqueue',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\observers\\kqueue.py',
'PYMODULE'),
('watchdog.observers.polling',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\observers\\polling.py',
'PYMODULE'),
('watchdog.observers.read_directory_changes',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\observers\\read_directory_changes.py',
'PYMODULE'),
('watchdog.observers.winapi',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\observers\\winapi.py',
'PYMODULE'),
('watchdog.tricks',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\tricks\\__init__.py',
'PYMODULE'),
('watchdog.utils',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\utils\\__init__.py',
'PYMODULE'),
('watchdog.utils.bricks',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\utils\\bricks.py',
'PYMODULE'),
('watchdog.utils.delayed_queue',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\utils\\delayed_queue.py',
'PYMODULE'),
('watchdog.utils.dirsnapshot',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\utils\\dirsnapshot.py',
'PYMODULE'),
('watchdog.utils.echo',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\utils\\echo.py',
'PYMODULE'),
('watchdog.utils.event_debouncer',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\utils\\event_debouncer.py',
'PYMODULE'),
('watchdog.utils.patterns',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\utils\\patterns.py',
'PYMODULE'),
('watchdog.utils.platform',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\utils\\platform.py',
'PYMODULE'),
('watchdog.utils.process_watcher',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\site-packages\\watchdog\\utils\\process_watcher.py',
'PYMODULE'),
('xml',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\__init__.py',
'PYMODULE'),
('xml.etree',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\etree\\__init__.py',
'PYMODULE'),
('xml.etree.ElementInclude',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\etree\\ElementInclude.py',
'PYMODULE'),
('xml.etree.ElementPath',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\etree\\ElementPath.py',
'PYMODULE'),
('xml.etree.ElementTree',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\etree\\ElementTree.py',
'PYMODULE'),
('xml.etree.cElementTree',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\etree\\cElementTree.py',
'PYMODULE'),
('xml.parsers',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\parsers\\__init__.py',
'PYMODULE'),
('xml.parsers.expat',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\parsers\\expat.py',
'PYMODULE'),
('xml.sax',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\sax\\__init__.py',
'PYMODULE'),
('xml.sax._exceptions',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\sax\\_exceptions.py',
'PYMODULE'),
('xml.sax.expatreader',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\sax\\expatreader.py',
'PYMODULE'),
('xml.sax.handler',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\sax\\handler.py',
'PYMODULE'),
('xml.sax.saxutils',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\sax\\saxutils.py',
'PYMODULE'),
('xml.sax.xmlreader',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xml\\sax\\xmlreader.py',
'PYMODULE'),
('xmlrpc',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xmlrpc\\__init__.py',
'PYMODULE'),
('xmlrpc.client',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\xmlrpc\\client.py',
'PYMODULE'),
('zipfile',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\zipfile\\__init__.py',
'PYMODULE'),
('zipfile._path',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\zipfile\\_path\\__init__.py',
'PYMODULE'),
('zipfile._path.glob',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\zipfile\\_path\\glob.py',
'PYMODULE'),
('zipimport',
'C:\\Users\\carsten\\AppData\\Local\\Programs\\Python\\Python313\\Lib\\zipimport.py',
'PYMODULE')])

View File

@@ -1,43 +0,0 @@
This file lists modules PyInstaller was not able to find. This does not
necessarily mean these modules are required for running your program. Both
Python's standard library and 3rd-party Python packages often conditionally
import optional modules, some of which may be available only on certain
platforms.
Types of import:
* top-level: imported at the top-level - look at these first
* conditional: imported within an if-statement
* delayed: imported within a function
* optional: imported within a try-except-statement
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
tracking down the missing module yourself. Thanks!
missing module named pwd - imported by posixpath (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib._local (optional), subprocess (delayed, conditional, optional), netrc (delayed, optional), getpass (delayed, optional)
missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib._local (optional), subprocess (delayed, conditional, optional)
missing module named 'collections.abc' - imported by tracemalloc (top-level), traceback (top-level), typing (top-level), inspect (top-level), logging (top-level), importlib.resources.readers (top-level), selectors (top-level), sqlite3.dbapi2 (top-level), mutagen.apev2 (top-level), mutagen.mp4 (top-level), http.client (top-level), watchdog.utils.patterns (conditional), watchdog.events (conditional), watchdog.observers.inotify_c (conditional), watchdog.utils.dirsnapshot (conditional), watchdog.observers.kqueue (conditional), watchdog.observers.polling (conditional), xml.etree.ElementTree (top-level)
missing module named posix - imported by posixpath (optional), shutil (conditional), importlib._bootstrap_external (conditional), os (conditional, optional)
missing module named resource - imported by posix (top-level)
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
missing module named _posixsubprocess - imported by subprocess (conditional), multiprocessing.util (delayed)
missing module named fcntl - imported by subprocess (optional)
missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
missing module named _scproxy - imported by urllib.request (conditional)
missing module named termios - imported by getpass (optional), vlc (conditional, optional), tty (top-level)
missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named pyimod02_importers - imported by C:\Users\carsten\AppData\Local\Programs\Python\Python313\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed)
missing module named readline - imported by code (delayed, conditional, optional), sqlite3.__main__ (delayed, conditional, optional)
missing module named distro - imported by vlc (delayed, conditional, optional)
missing module named vms_lib - imported by platform (delayed, optional)
missing module named 'java.lang' - imported by platform (delayed, optional)
missing module named java - imported by platform (delayed)
missing module named _watchdog_fsevents - imported by watchdog.observers.fsevents (top-level)
missing module named librosa - imported by local.tag_reader (delayed, optional)
invalid module named ui.tag_editor - imported by ui.main_window (delayed), C:\Users\carsten\Documents\GitClone\LinedanceAfspiller\linedance-app\main.py (top-level)

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +1,97 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all, collect_submodules
block_cipher = None block_cipher = None
# Saml ALT fra PyQt6 inkl. plugins og DLL-filer import os, glob
pyqt6_datas, pyqt6_binaries, pyqt6_hiddenimports = collect_all('PyQt6')
# Find VLC installation
VLC_PATH = None
for _p in [
r'C:\Program Files\VideoLAN\VLC',
r'C:\Program Files (x86)\VideoLAN\VLC',
]:
if os.path.exists(os.path.join(_p, 'libvlc.dll')):
VLC_PATH = _p
break
VLC_BINARIES = []
if VLC_PATH:
VLC_BINARIES = [
(os.path.join(VLC_PATH, 'libvlc.dll'), '.'),
(os.path.join(VLC_PATH, 'libvlccore.dll'), '.'),
]
for _dll in glob.glob(os.path.join(VLC_PATH, 'plugins', '**', '*.dll'), recursive=True):
_rel = os.path.relpath(os.path.dirname(_dll), VLC_PATH)
VLC_BINARIES.append((_dll, _rel))
a = Analysis( a = Analysis(
['main.py'], ['main.py'],
pathex=['.'], pathex=['.'],
binaries=pyqt6_binaries, binaries=VLC_BINARIES,
datas=pyqt6_datas, datas=[
hiddenimports=pyqt6_hiddenimports + [ ('translations', 'translations'),
],
hiddenimports=[
'PyQt6.sip', 'PyQt6.sip',
'PyQt6.QtCore', 'PyQt6.QtCore',
'PyQt6.QtGui', 'PyQt6.QtGui',
'PyQt6.QtWidgets', 'PyQt6.QtWidgets',
# UI moduler 'PyQt6.QtNetwork',
'ui.main_window', 'ui.main_window', 'ui.playlist_panel', 'ui.library_panel',
'ui.playlist_panel', 'ui.library_manager', 'ui.themes', 'ui.vu_meter',
'ui.library_panel', 'ui.scan_worker', 'ui.bpm_worker', 'ui.tag_editor',
'ui.library_manager', 'ui.settings_dialog', 'ui.playlist_browser',
'ui.themes', 'ui.playlist_info_dialog', 'ui.dance_info_dialog',
'ui.vu_meter', 'ui.dance_picker_dialog', 'ui.alt_dance_picker_dialog', 'ui.share_dialog',
'ui.scan_worker', 'ui.register_dialog',
'ui.tag_editor',
'ui.login_dialog',
'ui.settings_dialog',
'ui.playlist_manager',
'ui.next_up_bar',
# Player + local
'player.player', 'player.player',
'local.local_db', 'local.local_db', 'local.scanner', 'local.file_watcher',
'local.tag_reader', 'local.sync_manager', 'local.linked_playlist',
'local.file_watcher', 'translations', 'translations.da', 'translations.en',
# Biblioteker
'mutagen', 'mutagen.mp3', 'mutagen.id3', 'mutagen.flac', 'mutagen', 'mutagen.mp3', 'mutagen.id3', 'mutagen.flac',
'mutagen.mp4', 'mutagen.oggvorbis', 'mutagen.ogg', 'mutagen.mp4', 'mutagen.oggvorbis', 'mutagen.ogg',
'mutagen.wave', 'mutagen.aiff', 'mutagen.asf', 'mutagen.wave', 'mutagen.aiff', 'mutagen.asf',
'watchdog', 'watchdog.observers', 'watchdog.events',
'watchdog.observers.winapi',
'vlc', 'sqlite3', 'vlc', 'sqlite3',
], ],
hookspath=[], hookspath=[],
hooksconfig={},
runtime_hooks=[], runtime_hooks=[],
excludes=['tkinter', 'matplotlib', 'pandas', 'scipy', 'IPython'], excludes=[
win_no_prefer_redirects=False, 'tkinter', 'tk', 'tcl',
win_private_assemblies=False, '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, cipher=block_cipher,
noarchive=False, 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) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE( exe = EXE(
@@ -63,13 +103,9 @@ exe = EXE(
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
upx=False, # UPX kan give problemer med PyQt6 DLL-filer upx=False,
console=False, # Ingen konsol-vindue console=False,
disable_windowed_traceback=False, icon='installer\\icon.ico',
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=None,
) )
coll = COLLECT( coll = COLLECT(
@@ -79,6 +115,5 @@ coll = COLLECT(
a.datas, a.datas,
strip=False, strip=False,
upx=False, upx=False,
upx_exclude=[],
name='LineDancePlayer', name='LineDancePlayer',
) )

View File

@@ -1,149 +0,0 @@
# The PEP 484 type hints stub file for the QAxContainer module.
#
# Generated by SIP 6.15.3
#
# Copyright (c) 2026 Riverbank Computing Limited <info@riverbankcomputing.com>
#
# This file is part of PyQt6.
#
# This file may be used under the terms of the GNU General Public License
# version 3.0 as published by the Free Software Foundation and appearing in
# the file LICENSE included in the packaging of this file. Please review the
# following information to ensure the GNU General Public License version 3.0
# requirements will be met: http://www.gnu.org/copyleft/gpl.html.
#
# If you do not wish to use this file under the terms of the GPL version 3.0
# then you may purchase a commercial license. For more information contact
# info@riverbankcomputing.com.
#
# This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE
# WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.
import collections, re, typing
try:
from warnings import deprecated
except ImportError:
pass
import PyQt6.sip
from PyQt6 import QtCore
from PyQt6 import QtGui
from PyQt6 import QtWidgets
# Support for QDate, QDateTime and QTime.
import datetime
# Convenient type aliases.
PYQT_SIGNAL = typing.Union[QtCore.pyqtSignal, QtCore.pyqtBoundSignal]
PYQT_SLOT = typing.Union[collections.abc.Callable[..., Any], QtCore.pyqtBoundSignal]
class QAxBase(PyQt6.sip.simplewrapper):
@typing.overload
def __init__(self) -> None: ...
@typing.overload
def __init__(self, a0: 'QAxBase') -> None: ...
def setClassContext(self, classContext: int) -> None: ...
def classContext(self) -> int: ...
def disableEventSink(self) -> None: ...
def disableClassInfo(self) -> None: ...
def disableMetaObject(self) -> None: ...
def setControl(self, a0: str|None) -> bool: ...
def clear(self) -> None: ...
def asVariant(self) -> typing.Any: ...
def verbs(self) -> list[str]: ...
def isNull(self) -> bool: ...
def setPropertyWritable(self, a0: str, a1: bool) -> None: ...
def propertyWritable(self, a0: str) -> bool: ...
def generateDocumentation(self) -> str: ...
def setPropertyBag(self, a0: dict[str|None, typing.Any]) -> None: ...
def propertyBag(self) -> dict[str, typing.Any]: ...
@typing.overload
def querySubObject(self, a0: str, a1: collections.abc.Iterable[typing.Any]) -> 'QAxObject|None': ...
@typing.overload
def querySubObject(self, a0: str, value1: typing.Any = ..., value2: typing.Any = ..., value3: typing.Any = ..., value4: typing.Any = ..., value5: typing.Any = ..., value6: typing.Any = ..., value7: typing.Any = ..., value8: typing.Any = ...) -> 'QAxObject|None': ...
@typing.overload
def dynamicCall(self, a0: str, a1: collections.abc.Iterable[typing.Any]) -> typing.Any: ...
@typing.overload
def dynamicCall(self, a0: str, value1: typing.Any = ..., value2: typing.Any = ..., value3: typing.Any = ..., value4: typing.Any = ..., value5: typing.Any = ..., value6: typing.Any = ..., value7: typing.Any = ..., value8: typing.Any = ...) -> typing.Any: ...
def control(self) -> str: ...
class QAxObjectInterface(PyQt6.sip.simplewrapper):
@typing.overload
def __init__(self) -> None: ...
@typing.overload
def __init__(self, a0: 'QAxObjectInterface') -> None: ...
def resetControl(self) -> None: ...
def setControl(self, c: str|None) -> bool: ...
def control(self) -> str: ...
def setClassContext(self, classContext: int) -> None: ...
def classContext(self) -> int: ...
class QAxBaseObject(QtCore.QObject, QAxObjectInterface):
def __init__(self) -> None: ...
signal: typing.ClassVar[QtCore.pyqtSignal]
propertyChanged: typing.ClassVar[QtCore.pyqtSignal]
exception: typing.ClassVar[QtCore.pyqtSignal]
class QAxObject(QAxBaseObject, QAxBase):
@typing.overload
def __init__(self, parent: QtCore.QObject|None = ...) -> None: ...
@typing.overload
def __init__(self, a0: str|None, parent: QtCore.QObject|None = ...) -> None: ...
def connectNotify(self, a0: QtCore.QMetaMethod) -> None: ...
def doVerb(self, a0: str|None) -> bool: ...
def clear(self) -> None: ...
def resetControl(self) -> None: ...
def setControl(self, c: str|None) -> bool: ...
def control(self) -> str: ...
def setClassContext(self, classContext: int) -> None: ...
def classContext(self) -> int: ...
class QAxBaseWidget(QtWidgets.QWidget, QAxObjectInterface):
def __init__(self) -> None: ...
signal: typing.ClassVar[QtCore.pyqtSignal]
propertyChanged: typing.ClassVar[QtCore.pyqtSignal]
exception: typing.ClassVar[QtCore.pyqtSignal]
class QAxWidget(QAxBaseWidget, QAxBase):
@typing.overload
def __init__(self, parent: QtWidgets.QWidget|None = ..., flags: QtCore.Qt.WindowType = ...) -> None: ...
@typing.overload
def __init__(self, a0: str|None, parent: QtWidgets.QWidget|None = ..., flags: QtCore.Qt.WindowType = ...) -> None: ...
def connectNotify(self, a0: QtCore.QMetaMethod) -> None: ...
def translateKeyEvent(self, a0: int, a1: int) -> bool: ...
def resizeEvent(self, a0: QtGui.QResizeEvent|None) -> None: ...
def changeEvent(self, a0: QtCore.QEvent|None) -> None: ...
@typing.overload
def createHostWindow(self, a0: bool) -> bool: ...
@typing.overload
def createHostWindow(self, a0: bool, a1: QtCore.QByteArray|bytes|bytearray|memoryview) -> bool: ...
def minimumSizeHint(self) -> QtCore.QSize: ...
def sizeHint(self) -> QtCore.QSize: ...
def doVerb(self, a0: str|None) -> bool: ...
def clear(self) -> None: ...
def resetControl(self) -> None: ...
def setControl(self, c: str|None) -> bool: ...
def control(self) -> str: ...
def setClassContext(self, classContext: int) -> None: ...
def classContext(self) -> int: ...

Some files were not shown because too many files have changed in this diff Show More