Compare commits
88 Commits
18b0e4d8d9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d7ad55a0f | |||
| 324c94fde2 | |||
| 8d4c4a81c1 | |||
| 09412073cd | |||
| 25c2dd9e78 | |||
| 115cc92d6a | |||
| c3453d8d55 | |||
| d28aafb2c6 | |||
| b695a4858b | |||
| 37b49c1fed | |||
| 545cdc6866 | |||
| ec3989e6a4 | |||
| 6ed349277c | |||
| 2deb0260f0 | |||
| 8a4c879213 | |||
| f92af40dd7 | |||
| efc30cdbb2 | |||
| a9aa451d63 | |||
| 4df87cf48e | |||
| f943c5ffba | |||
| 31dcd9fcfa | |||
| 34040af464 | |||
| 9052dd8b0f | |||
| b226795731 | |||
| 9e5ddec184 | |||
| fb7622549c | |||
| c966d38f11 | |||
| 0a3a6d44da | |||
| e149fb3ce2 | |||
| bf26ff6377 | |||
| 3f67f7dc3a | |||
| 602f7cc2d4 | |||
| 80407e98fb | |||
| f0a4b4dfa7 | |||
| 24bb71cdd7 | |||
| e4ab9caab6 | |||
| efe3739626 | |||
| c5e35f0889 | |||
| 920cd8222d | |||
| 4a206f2f19 | |||
| cd3ed811f6 | |||
| 4ad8241c0e | |||
| 55642ecb1b | |||
| d9a321d570 | |||
| 58c4e85eaf | |||
| f7b0f16250 | |||
| edfde16c92 | |||
| 03f8061601 | |||
| ed4fe4e712 | |||
| 4aba2f02a2 | |||
| 460b41a8c5 | |||
| 287477753e | |||
| d4356e7337 | |||
| 66804681da | |||
| 9257f198eb | |||
| b805bd77e7 | |||
| d056f02f78 | |||
| 3cc2c975f3 | |||
| 69d1d484a2 | |||
| b066b6d92c | |||
| cb204baa50 | |||
| e86173f7ec | |||
| c3623962c5 | |||
| 6d3ed85679 | |||
| 30125aa77b | |||
| bbd5690d72 | |||
| 45dcedaed4 | |||
| 1ea5cad01f | |||
| 2812e3182c | |||
| a9915c0cc9 | |||
| bdb1f5915a | |||
| d6cc22dc9a | |||
| 754d82a183 | |||
| 358aeca7c8 | |||
| 564122df0a | |||
| 88a3d2f67b | |||
| 99cab7be86 | |||
| 57f3c913b4 | |||
| b678787236 | |||
| 99f6a265c0 | |||
| b90bdd851d | |||
| e5fbf54302 | |||
| 181cb28a86 | |||
| 78a2cf79cd | |||
| b500a43264 | |||
| b2a6b16290 | |||
| c90159d9da | |||
| 4771d99186 |
3
.env
3
.env
@@ -1,3 +0,0 @@
|
|||||||
DATABASE_URL=mysql+pymysql://linedance:20gorm66@mysql.ckvist.lan:3306/linedance
|
|
||||||
SECRET_KEY=e0a15d5a35d1091261cbdf0fd6310492ebd23d66a6d4a8c4253ab33e2594c67a
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
|
||||||
@@ -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
53
.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
.env/
|
||||||
|
|
||||||
|
# PyInstaller output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.spec.bak
|
||||||
|
|
||||||
|
# Miljøvariabler og hemmeligheder
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Database og brugerdata
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
~/.linedance/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Test
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Node (til fremtidig web-del)
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
772
Henriks Musik/linedance_tag_analyse.py
Normal file
772
Henriks Musik/linedance_tag_analyse.py
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
linedance_tag_analyse.py
|
||||||
|
|
||||||
|
Scanner en mappe med MP3-filer og analyserer om de følger mønsteret:
|
||||||
|
TIT2 = dansens navn
|
||||||
|
TALB = sangens rigtige titel
|
||||||
|
TCON = niveau
|
||||||
|
|
||||||
|
Verificerer via MusicBrainz API og gemmer resultat i CSV til gennemgang.
|
||||||
|
|
||||||
|
Brug:
|
||||||
|
python linedance_tag_analyse.py /sti/til/musik [--output rapport.csv] [--apply godkendt.csv]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import struct
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── Konfiguration ──────────────────────────────────────────────────────────────
|
||||||
|
ACOUSTID_API_KEY = "6fd9DGNDqG" # Erstat med din egen
|
||||||
|
ACOUSTID_API_URL = "https://api.acoustid.org/v2/lookup"
|
||||||
|
MUSICBRAINZ_URL = "https://musicbrainz.org/ws/2/recording"
|
||||||
|
MB_USER_AGENT = "LineDanceTagFixer/1.0 (dit@email.dk)" # Skal udfyldes
|
||||||
|
API_DELAY = 1.1 # Sek mellem MusicBrainz-kald (max 1/sek)
|
||||||
|
ACOUSTID_DELAY = 0.4
|
||||||
|
MATCH_THRESHOLD = 0.80 # Mindste lighed for "sikker" match
|
||||||
|
FPCALC_PATH = "fpcalc" # Eller fuld sti f.eks. C:\Tools\fpcalc.exe
|
||||||
|
|
||||||
|
# ── Hjælpefunktioner ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def normalize(s: str) -> str:
|
||||||
|
"""Normaliser streng til sammenligning — lowercase, fjern specialtegn."""
|
||||||
|
import unicodedata, re
|
||||||
|
s = unicodedata.normalize("NFKD", s or "")
|
||||||
|
s = s.encode("ascii", "ignore").decode()
|
||||||
|
s = re.sub(r"[^a-z0-9 ]", "", s.lower())
|
||||||
|
s = re.sub(r"\s+", " ", s).strip()
|
||||||
|
return s
|
||||||
|
|
||||||
|
def similarity(a: str, b: str) -> float:
|
||||||
|
"""Simpel tegnbaseret lighed 0-1."""
|
||||||
|
a, b = normalize(a), normalize(b)
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
if a == b:
|
||||||
|
return 1.0
|
||||||
|
# Longest common subsequence approximation via set overlap
|
||||||
|
set_a = set(a.split())
|
||||||
|
set_b = set(b.split())
|
||||||
|
if not set_a or not set_b:
|
||||||
|
return 0.0
|
||||||
|
overlap = len(set_a & set_b)
|
||||||
|
return overlap / max(len(set_a), len(set_b))
|
||||||
|
|
||||||
|
# ── Niveau-normalisering ──────────────────────────────────────────────────────
|
||||||
|
# Officielle niveauer fra Linedancer Guide to Level Definitions (2017):
|
||||||
|
# Absolute Beginner → Beginner → Improver → Intermediate → Advanced
|
||||||
|
#
|
||||||
|
# Vi gemmer dem på engelsk da det er den internationale standard.
|
||||||
|
# Mapper alle kendte varianter til officiel betegnelse.
|
||||||
|
|
||||||
|
NIVEAU_MAP = {
|
||||||
|
# ── Absolute Beginner ──
|
||||||
|
"absolute beginner": "Absolute Beginner",
|
||||||
|
"abs. beginner": "Absolute Beginner",
|
||||||
|
"abs beginner": "Absolute Beginner",
|
||||||
|
"absolute beg": "Absolute Beginner",
|
||||||
|
"ab": "Absolute Beginner",
|
||||||
|
"absolut begynder": "Absolute Beginner",
|
||||||
|
|
||||||
|
# ── Beginner ──
|
||||||
|
"beginner": "Beginner",
|
||||||
|
"beg": "Beginner",
|
||||||
|
"begin": "Beginner",
|
||||||
|
"begynder": "Beginner",
|
||||||
|
"newbie": "Beginner",
|
||||||
|
"basic": "Beginner",
|
||||||
|
|
||||||
|
# ── High Beginner ──
|
||||||
|
"high beginner": "High Beginner",
|
||||||
|
"high beg": "High Beginner",
|
||||||
|
|
||||||
|
# ── Low Improver ──
|
||||||
|
"low improver": "Low Improver",
|
||||||
|
"low imp": "Low Improver",
|
||||||
|
"beginner/improver": "Low Improver",
|
||||||
|
"beg/imp": "Low Improver",
|
||||||
|
"beg/improver": "Low Improver",
|
||||||
|
|
||||||
|
# ── Improver ──
|
||||||
|
"improver": "Improver",
|
||||||
|
"imp": "Improver",
|
||||||
|
"easy": "Improver",
|
||||||
|
"easy intermediate": "Improver",
|
||||||
|
"easy/intermediate": "Improver",
|
||||||
|
"easy inter": "Improver",
|
||||||
|
"let øvet": "Improver",
|
||||||
|
"let": "Improver",
|
||||||
|
|
||||||
|
# ── High Improver ──
|
||||||
|
"high improver": "High Improver",
|
||||||
|
"high imp": "High Improver",
|
||||||
|
"improver/intermediate": "High Improver",
|
||||||
|
"imp/int": "High Improver",
|
||||||
|
|
||||||
|
# ── Low Intermediate ──
|
||||||
|
"low intermediate": "Low Intermediate",
|
||||||
|
"low inter": "Low Intermediate",
|
||||||
|
"low int": "Low Intermediate",
|
||||||
|
|
||||||
|
# ── Intermediate ──
|
||||||
|
"intermediate": "Intermediate",
|
||||||
|
"inter": "Intermediate",
|
||||||
|
"int": "Intermediate",
|
||||||
|
"øvet": "Intermediate",
|
||||||
|
|
||||||
|
# ── High Intermediate ──
|
||||||
|
"high intermediate": "High Intermediate",
|
||||||
|
"high inter": "High Intermediate",
|
||||||
|
"high int": "High Intermediate",
|
||||||
|
"intermediate/advanced": "High Intermediate",
|
||||||
|
"int/adv": "High Intermediate",
|
||||||
|
|
||||||
|
# ── Advanced ──
|
||||||
|
"advanced": "Advanced",
|
||||||
|
"adv": "Advanced",
|
||||||
|
"videregående": "Advanced",
|
||||||
|
}
|
||||||
|
|
||||||
|
def normaliser_niveau(raw: str) -> tuple[str, bool]:
|
||||||
|
"""
|
||||||
|
Returner (normaliseret_niveau, fundet).
|
||||||
|
fundet=True hvis vi genkender niveauet.
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return "", False
|
||||||
|
key = raw.strip().lower()
|
||||||
|
if key in NIVEAU_MAP:
|
||||||
|
return NIVEAU_MAP[key], True
|
||||||
|
# Delvis match — find længste nøgle der er indeholdt
|
||||||
|
best_match = ""
|
||||||
|
best_len = 0
|
||||||
|
for k, v in NIVEAU_MAP.items():
|
||||||
|
if k in key and len(k) > best_len:
|
||||||
|
best_match = v
|
||||||
|
best_len = len(k)
|
||||||
|
if best_match:
|
||||||
|
return best_match, True
|
||||||
|
return raw.strip(), False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def read_id3(path: str) -> dict:
|
||||||
|
"""Læs relevante ID3v2 tags fra MP3 uden eksterne biblioteker."""
|
||||||
|
result = {
|
||||||
|
"tit2": "", "tpe1": "", "talb": "", "tcon": "",
|
||||||
|
"mbid": "", "linedance_dances": []
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = f.read(65536)
|
||||||
|
|
||||||
|
if data[:3] != b"ID3":
|
||||||
|
return result
|
||||||
|
|
||||||
|
major = data[3]
|
||||||
|
tag_size = ((data[6]&0x7f)<<21)|((data[7]&0x7f)<<14)|((data[8]&0x7f)<<7)|(data[9]&0x7f)
|
||||||
|
pos = 10
|
||||||
|
|
||||||
|
while pos < min(tag_size + 10, len(data) - 10):
|
||||||
|
if major == 3:
|
||||||
|
fid = data[pos:pos+4].decode("latin1", errors="replace")
|
||||||
|
fsize = struct.unpack(">I", data[pos+4:pos+8])[0]
|
||||||
|
pos += 10
|
||||||
|
else: # v2.4
|
||||||
|
fid = data[pos:pos+4].decode("latin1", errors="replace")
|
||||||
|
fsize = struct.unpack(">I", data[pos+4:pos+8])[0]
|
||||||
|
pos += 10
|
||||||
|
|
||||||
|
if fid == "\x00\x00\x00\x00" or fsize <= 0 or fsize > 100000:
|
||||||
|
break
|
||||||
|
|
||||||
|
content = data[pos:pos+fsize]
|
||||||
|
pos += fsize
|
||||||
|
|
||||||
|
try:
|
||||||
|
if fid.startswith("T") and len(content) > 1:
|
||||||
|
enc = content[0]
|
||||||
|
raw = content[1:]
|
||||||
|
if enc in (1, 2):
|
||||||
|
txt = raw.decode("utf-16", errors="replace")
|
||||||
|
elif enc == 3:
|
||||||
|
txt = raw.decode("utf-8", errors="replace")
|
||||||
|
else:
|
||||||
|
txt = raw.decode("latin1", errors="replace")
|
||||||
|
txt = txt.strip("\x00").strip()
|
||||||
|
|
||||||
|
if fid == "TIT2":
|
||||||
|
result["tit2"] = txt
|
||||||
|
elif fid == "TPE1":
|
||||||
|
result["tpe1"] = txt
|
||||||
|
elif fid == "TALB":
|
||||||
|
result["talb"] = txt
|
||||||
|
elif fid == "TCON":
|
||||||
|
result["tcon"] = txt
|
||||||
|
elif fid == "TXXX":
|
||||||
|
# Format: desc\x00value
|
||||||
|
parts = txt.split("\x00", 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
desc, val = parts
|
||||||
|
else:
|
||||||
|
desc, val = txt, ""
|
||||||
|
desc_clean = desc.strip()
|
||||||
|
val_clean = val.strip()
|
||||||
|
|
||||||
|
if desc_clean in ("MusicBrainz Recording Id", "MusicBrainz Track Id"):
|
||||||
|
result["mbid"] = val_clean
|
||||||
|
elif desc_clean.startswith("LINEDANCE_DANCE_"):
|
||||||
|
result["linedance_dances"].append(val_clean)
|
||||||
|
|
||||||
|
elif fid == "UFID" and len(content) > 4:
|
||||||
|
# UFID: owner\x00data
|
||||||
|
null = content.find(b"\x00")
|
||||||
|
if null >= 0:
|
||||||
|
owner = content[:null].decode("latin1", errors="replace")
|
||||||
|
if "musicbrainz" in owner.lower():
|
||||||
|
result["mbid"] = content[null+1:].decode("utf-8", errors="replace").strip("\x00")
|
||||||
|
|
||||||
|
elif fid == "COMM" and len(content) > 4:
|
||||||
|
enc = content[0]
|
||||||
|
raw = content[4:]
|
||||||
|
if enc in (1, 2):
|
||||||
|
txt = raw.decode("utf-16", errors="replace")
|
||||||
|
else:
|
||||||
|
txt = raw.decode("utf-8", errors="replace")
|
||||||
|
result["comm"] = txt.strip("\x00").strip()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = str(e)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── AcoustID fingerprinting ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def fingerprint_file(path: str) -> tuple[str, int] | None:
|
||||||
|
"""Kør fpcalc og returner (fingerprint, duration)."""
|
||||||
|
fpcalc = find_fpcalc()
|
||||||
|
if not fpcalc:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[fpcalc, "-json", path],
|
||||||
|
capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
return None
|
||||||
|
d = json.loads(r.stdout)
|
||||||
|
fp = d.get("fingerprint")
|
||||||
|
dur = int(d.get("duration", 0))
|
||||||
|
if fp and dur:
|
||||||
|
return fp, dur
|
||||||
|
return None
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_fpcalc() -> str | None:
|
||||||
|
"""Find fpcalc på systemet — returner sti eller None."""
|
||||||
|
import shutil
|
||||||
|
# Prøv konfigureret sti først
|
||||||
|
if shutil.which(FPCALC_PATH):
|
||||||
|
return FPCALC_PATH
|
||||||
|
# Prøv kendte Windows-stier
|
||||||
|
windows_paths = [
|
||||||
|
r"C:\Program Files\fpcalc\fpcalc.exe",
|
||||||
|
r"C:\Tools\fpcalc.exe",
|
||||||
|
r"C:\fpcalc.exe",
|
||||||
|
os.path.join(os.path.dirname(__file__), "fpcalc.exe"),
|
||||||
|
os.path.join(os.path.dirname(__file__), "fpcalc"),
|
||||||
|
]
|
||||||
|
for p in windows_paths:
|
||||||
|
if os.path.isfile(p):
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_acoustid(fingerprint: str, duration: int) -> str | None:
|
||||||
|
"""Slå fingerprint op i AcoustID og returner MBID."""
|
||||||
|
try:
|
||||||
|
params = urllib.parse.urlencode({
|
||||||
|
"client": ACOUSTID_API_KEY,
|
||||||
|
"fingerprint": fingerprint,
|
||||||
|
"duration": duration,
|
||||||
|
"meta": "recordings",
|
||||||
|
})
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{ACOUSTID_API_URL}?{params}",
|
||||||
|
headers={"User-Agent": MB_USER_AGENT}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
|
||||||
|
if data.get("status") != "ok":
|
||||||
|
return None
|
||||||
|
|
||||||
|
results = data.get("results", [])
|
||||||
|
if not results:
|
||||||
|
return None
|
||||||
|
|
||||||
|
best = max(results, key=lambda r: r.get("score", 0))
|
||||||
|
if best.get("score", 0) < 0.85:
|
||||||
|
return None
|
||||||
|
|
||||||
|
recordings = best.get("recordings", [])
|
||||||
|
return recordings[0].get("id") if recordings else None
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── MusicBrainz opslag ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def lookup_musicbrainz(mbid: str) -> dict | None:
|
||||||
|
"""Slå MBID op i MusicBrainz og returner titel + kunstner."""
|
||||||
|
try:
|
||||||
|
url = f"{MUSICBRAINZ_URL}/{mbid}?fmt=json&inc=artists"
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": MB_USER_AGENT})
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
|
||||||
|
title = data.get("title", "")
|
||||||
|
artists = data.get("artist-credit", [])
|
||||||
|
artist = ""
|
||||||
|
if artists:
|
||||||
|
parts = []
|
||||||
|
for a in artists:
|
||||||
|
if isinstance(a, dict) and "artist" in a:
|
||||||
|
parts.append(a["artist"].get("name", ""))
|
||||||
|
elif isinstance(a, str):
|
||||||
|
parts.append(a)
|
||||||
|
artist = "".join(parts)
|
||||||
|
|
||||||
|
return {"title": title, "artist": artist} if title else None
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Hoved-analyse ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def analyse_file(path: str, use_acoustid: bool = True) -> dict:
|
||||||
|
"""
|
||||||
|
Analyser én fil og returner en dict med resultat.
|
||||||
|
Status: SIKKER / SANDSYNLIG / USIKKER / UKENDT
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# ── Værdier der ligner niveau men er genre/andet ──────────────────────────
|
||||||
|
IKKE_NIVEAU = {
|
||||||
|
"country", "rock", "pop", "jazz", "blues", "latin", "swing",
|
||||||
|
"line dance", "linedance", "general country", "waltz", "cha cha",
|
||||||
|
"rumba", "tango", "samba", "foxtrot", "quickstep", "two step",
|
||||||
|
"west coast swing", "east coast swing",
|
||||||
|
}
|
||||||
|
|
||||||
|
def dans_fra_filnavn(filename):
|
||||||
|
"""Forsøg at udtrække dansenavn fra filnavn."""
|
||||||
|
navn = os.path.splitext(filename)[0]
|
||||||
|
# Mønster 1: starter med (dans) eller [dans]
|
||||||
|
m = re.match(r"^[\(\[](.*?)[\)\]]", navn)
|
||||||
|
if m:
|
||||||
|
kandidat = m.group(1).strip()
|
||||||
|
if len(kandidat) > 2 and not kandidat.isdigit():
|
||||||
|
return kandidat
|
||||||
|
# Mønster 2: slutter med (dans)
|
||||||
|
m = re.search(r"[\(\[](.*?)[\)\]]\s*$", navn)
|
||||||
|
if m:
|
||||||
|
kandidat = m.group(1).strip()
|
||||||
|
if len(kandidat) > 2 and not kandidat.isdigit():
|
||||||
|
return kandidat
|
||||||
|
return ""
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"fil": path,
|
||||||
|
"filename": os.path.basename(path),
|
||||||
|
"tit2": "",
|
||||||
|
"tpe1": "",
|
||||||
|
"talb": "",
|
||||||
|
"tcon": "",
|
||||||
|
"mbid": "",
|
||||||
|
"mb_title": "",
|
||||||
|
"mb_artist": "",
|
||||||
|
"dans_forslag": "",
|
||||||
|
"niveau_rå": "",
|
||||||
|
"niveau_forslag": "",
|
||||||
|
"niveau_genkendt": "",
|
||||||
|
"rigtig_titel": "",
|
||||||
|
"rigtig_artist": "",
|
||||||
|
"match_score": 0.0,
|
||||||
|
"status": "UKENDT",
|
||||||
|
"noter": "",
|
||||||
|
"handling": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Læs tags
|
||||||
|
tags = read_id3(path)
|
||||||
|
result["tit2"] = tags.get("tit2", "")
|
||||||
|
result["tpe1"] = tags.get("tpe1", "")
|
||||||
|
result["talb"] = tags.get("talb", "")
|
||||||
|
result["tcon"] = tags.get("tcon", "")
|
||||||
|
result["mbid"] = tags.get("mbid", "")
|
||||||
|
ld_dances = tags.get("linedance_dances", [])
|
||||||
|
|
||||||
|
noter = []
|
||||||
|
|
||||||
|
# Normaliser niveau fra TCON — filtrer genre-værdier fra
|
||||||
|
niveau_rå = result["tcon"]
|
||||||
|
if niveau_rå.strip().lower() in IKKE_NIVEAU:
|
||||||
|
niveau_normaliseret, niveau_genkendt = "", False
|
||||||
|
noter.append(f"TCON er genre, ikke niveau: '{niveau_rå}'")
|
||||||
|
else:
|
||||||
|
niveau_normaliseret, niveau_genkendt = normaliser_niveau(niveau_rå)
|
||||||
|
result["niveau_rå"] = niveau_rå
|
||||||
|
result["niveau_forslag"] = niveau_normaliseret
|
||||||
|
result["niveau_genkendt"] = "JA" if niveau_genkendt else ("NEJ" if niveau_rå else "")
|
||||||
|
|
||||||
|
if niveau_rå and not niveau_genkendt:
|
||||||
|
noter.append(f"Ukendt niveau-værdi: '{niveau_rå}'")
|
||||||
|
|
||||||
|
if tags.get("error"):
|
||||||
|
result["status"] = "FEJL"
|
||||||
|
result["noter"] = tags["error"]
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 2. Hent MBID hvis mangler
|
||||||
|
mbid = result["mbid"]
|
||||||
|
if not mbid and use_acoustid:
|
||||||
|
fp = fingerprint_file(path)
|
||||||
|
if fp:
|
||||||
|
fingerprint, duration = fp
|
||||||
|
time.sleep(ACOUSTID_DELAY)
|
||||||
|
mbid = lookup_acoustid(fingerprint, duration)
|
||||||
|
if mbid:
|
||||||
|
result["mbid"] = mbid
|
||||||
|
noter.append("MBID fundet via AcoustID")
|
||||||
|
else:
|
||||||
|
noter.append("AcoustID: ingen match")
|
||||||
|
else:
|
||||||
|
noter.append("fpcalc fejlede")
|
||||||
|
|
||||||
|
# 3. MusicBrainz opslag
|
||||||
|
mb = None
|
||||||
|
if mbid:
|
||||||
|
time.sleep(API_DELAY)
|
||||||
|
mb = lookup_musicbrainz(mbid)
|
||||||
|
if mb:
|
||||||
|
result["mb_title"] = mb["title"]
|
||||||
|
result["mb_artist"] = mb["artist"]
|
||||||
|
else:
|
||||||
|
noter.append("MusicBrainz: ingen data for MBID")
|
||||||
|
|
||||||
|
# 4. Vurder mønster
|
||||||
|
tit2 = result["tit2"]
|
||||||
|
talb = result["talb"]
|
||||||
|
tpe1 = result["tpe1"]
|
||||||
|
|
||||||
|
if mb:
|
||||||
|
# Sammenlign TALB med MusicBrainz titel
|
||||||
|
score_title = similarity(talb, mb["title"])
|
||||||
|
score_artist = similarity(tpe1, mb["artist"])
|
||||||
|
result["match_score"] = round((score_title + score_artist) / 2, 2)
|
||||||
|
|
||||||
|
if score_title >= MATCH_THRESHOLD:
|
||||||
|
# TALB matcher rigtig titel → TIT2 er sandsynligvis dansen
|
||||||
|
result["dans_forslag"] = tit2
|
||||||
|
result["rigtig_titel"] = mb["title"]
|
||||||
|
result["rigtig_artist"] = mb["artist"]
|
||||||
|
result["niveau_forslag"] = result["tcon"]
|
||||||
|
|
||||||
|
if ld_dances:
|
||||||
|
noter.append(f"LINEDANCE_DANCE_1 allerede sat: {ld_dances[0]}")
|
||||||
|
if similarity(tit2, ld_dances[0]) > 0.7:
|
||||||
|
result["status"] = "ALLEREDE_RETTET"
|
||||||
|
result["handling"] = ""
|
||||||
|
else:
|
||||||
|
noter.append(f"TIT2 og LINEDANCE_DANCE_1 stemmer ikke overens!")
|
||||||
|
result["status"] = "KONFLIKT"
|
||||||
|
result["handling"] = "MANUEL"
|
||||||
|
elif score_title >= 0.95 and score_artist >= MATCH_THRESHOLD:
|
||||||
|
result["status"] = "SIKKER"
|
||||||
|
result["handling"] = "RET_AUTOMATISK"
|
||||||
|
elif score_title >= MATCH_THRESHOLD:
|
||||||
|
result["status"] = "SANDSYNLIG"
|
||||||
|
result["handling"] = "GODKEND_MANUEL"
|
||||||
|
else:
|
||||||
|
result["status"] = "USIKKER"
|
||||||
|
result["handling"] = "MANUEL"
|
||||||
|
else:
|
||||||
|
noter.append(f"TALB matcher ikke MB titel (score={score_title:.2f}): '{talb}' vs '{mb['title']}'")
|
||||||
|
result["status"] = "USIKKER"
|
||||||
|
result["handling"] = "MANUEL"
|
||||||
|
# Vis alligevel hvad MB siger
|
||||||
|
result["rigtig_titel"] = mb["title"]
|
||||||
|
result["rigtig_artist"] = mb["artist"]
|
||||||
|
else:
|
||||||
|
# Ingen MB data — vurder ud fra tags alene
|
||||||
|
filename = os.path.basename(path)
|
||||||
|
|
||||||
|
if talb and tit2 and tit2 != talb:
|
||||||
|
# Klassisk mønster: TIT2=dans, TALB=sang
|
||||||
|
noter.append("Ingen MBID — mønster muligvis rigtigt men ukontrolleret")
|
||||||
|
result["dans_forslag"] = tit2
|
||||||
|
result["rigtig_titel"] = talb
|
||||||
|
result["status"] = "UKONTROLLERET"
|
||||||
|
result["handling"] = "GODKEND_MANUEL"
|
||||||
|
|
||||||
|
elif not tit2 and not talb:
|
||||||
|
# Helt tomme tags — prøv filnavn
|
||||||
|
dans_fn = dans_fra_filnavn(filename)
|
||||||
|
if dans_fn:
|
||||||
|
noter.append(f"Dans udtrukket fra filnavn: '{dans_fn}'")
|
||||||
|
result["dans_forslag"] = dans_fn
|
||||||
|
result["status"] = "FRA_FILNAVN"
|
||||||
|
result["handling"] = "GODKEND_MANUEL"
|
||||||
|
else:
|
||||||
|
result["status"] = "UKENDT"
|
||||||
|
result["handling"] = "MANUEL"
|
||||||
|
|
||||||
|
elif tit2 and tit2 == talb:
|
||||||
|
# Titel = album — dans kan være i filnavn
|
||||||
|
dans_fn = dans_fra_filnavn(filename)
|
||||||
|
if dans_fn:
|
||||||
|
noter.append(f"TIT2=TALB, dans fra filnavn: '{dans_fn}'")
|
||||||
|
result["dans_forslag"] = dans_fn
|
||||||
|
result["rigtig_titel"] = tit2
|
||||||
|
result["status"] = "FRA_FILNAVN"
|
||||||
|
result["handling"] = "GODKEND_MANUEL"
|
||||||
|
else:
|
||||||
|
result["status"] = "UKENDT"
|
||||||
|
result["handling"] = "MANUEL"
|
||||||
|
|
||||||
|
else:
|
||||||
|
result["status"] = "UKENDT"
|
||||||
|
result["handling"] = "MANUEL"
|
||||||
|
|
||||||
|
result["noter"] = "; ".join(noter)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── CSV output ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
FIELDNAMES = [
|
||||||
|
"status", "handling", "filename",
|
||||||
|
"dans_forslag", "niveau_forslag", "niveau_genkendt", "niveau_rå",
|
||||||
|
"rigtig_titel", "rigtig_artist",
|
||||||
|
"mb_title", "mb_artist", "match_score",
|
||||||
|
"tit2", "tpe1", "talb", "tcon", "mbid",
|
||||||
|
"noter", "fil"
|
||||||
|
]
|
||||||
|
|
||||||
|
def write_csv(rows: list[dict], path: str):
|
||||||
|
with open(path, "w", newline="", encoding="utf-8-sig") as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=FIELDNAMES, extrasaction="ignore")
|
||||||
|
w.writeheader()
|
||||||
|
w.writerows(rows)
|
||||||
|
print(f"\nGemt: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Apply: udfør rettelser fra godkendt CSV ───────────────────────────────────
|
||||||
|
|
||||||
|
def apply_corrections(csv_path: str, dry_run: bool = True):
|
||||||
|
"""Læs godkendt CSV og udfør rettelserne."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
with open(csv_path, newline="", encoding="utf-8-sig") as f:
|
||||||
|
rows = list(csv.DictReader(f))
|
||||||
|
|
||||||
|
to_fix = [r for r in rows if r.get("handling") in ("RET_AUTOMATISK", "GODKEND_MANUEL")]
|
||||||
|
print(f"\n{len(to_fix)} filer skal rettes")
|
||||||
|
if dry_run:
|
||||||
|
print("(DRY RUN — ingen filer ændres endnu)\n")
|
||||||
|
|
||||||
|
ok = fejl = spring = 0
|
||||||
|
|
||||||
|
for row in to_fix:
|
||||||
|
path = row["fil"]
|
||||||
|
dans = row["dans_forslag"].strip()
|
||||||
|
titel = row["rigtig_titel"].strip()
|
||||||
|
artist = row["rigtig_artist"].strip() or row["tpe1"].strip()
|
||||||
|
|
||||||
|
if not dans or not titel:
|
||||||
|
print(f" SPRING: {row['filename']} — mangler dans eller titel")
|
||||||
|
spring += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" {'[DRY]' if dry_run else '[RET]'} {row['filename']}")
|
||||||
|
print(f" TIT2: '{row['tit2']}' → '{titel}'")
|
||||||
|
print(f" TALB: '{row['talb']}' → beholdes")
|
||||||
|
print(f" LINEDANCE_DANCE_1 → '{dans}'")
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
try:
|
||||||
|
write_corrections(path, titel, artist, dans)
|
||||||
|
ok += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" FEJL: {e}")
|
||||||
|
fejl += 1
|
||||||
|
else:
|
||||||
|
ok += 1
|
||||||
|
|
||||||
|
print(f"\nResultat: {ok} ok, {fejl} fejl, {spring} sprunget over")
|
||||||
|
|
||||||
|
|
||||||
|
def write_corrections(path: str, title: str, artist: str, dance: str):
|
||||||
|
"""Skriv rettede tags til fil med mutagen."""
|
||||||
|
try:
|
||||||
|
from mutagen.id3 import ID3, TIT2, TPE1, TXXX, Encoding
|
||||||
|
tags = ID3(path)
|
||||||
|
|
||||||
|
# Sæt rigtig titel
|
||||||
|
tags["TIT2"] = TIT2(encoding=Encoding.UTF8, text=title)
|
||||||
|
|
||||||
|
# Sæt kunstner hvis vi har den
|
||||||
|
if artist:
|
||||||
|
tags["TPE1"] = TPE1(encoding=Encoding.UTF8, text=artist)
|
||||||
|
|
||||||
|
# Sæt LINEDANCE_DANCE_1
|
||||||
|
for key in [k for k in tags.keys() if "LINEDANCE_DANCE_" in k]:
|
||||||
|
del tags[key]
|
||||||
|
tags.add(TXXX(encoding=Encoding.UTF8, desc="LINEDANCE_DANCE_1", text=dance))
|
||||||
|
|
||||||
|
tags.save(path)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# Fallback: skriv raw ID3 — kræver mutagen
|
||||||
|
raise RuntimeError("mutagen skal installeres for at skrive tags: pip install mutagen")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="LineDance tag-analyse og -rettelse")
|
||||||
|
parser.add_argument("mappe", nargs="?", help="Mappe med MP3-filer")
|
||||||
|
parser.add_argument("--output", default="linedance_rapport.csv", help="Output CSV")
|
||||||
|
parser.add_argument("--apply", help="Anvend rettelser fra godkendt CSV")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Vis hvad der ville ske (med --apply)")
|
||||||
|
parser.add_argument("--ingen-acoustid", action="store_true", help="Spring AcoustID over (hurtigere)")
|
||||||
|
parser.add_argument("--max", type=int, default=0, help="Maks antal filer (0=alle)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Apply-tilstand
|
||||||
|
if args.apply:
|
||||||
|
apply_corrections(args.apply, dry_run=args.dry_run)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args.mappe:
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find alle MP3-filer
|
||||||
|
mp3_filer = []
|
||||||
|
for root, dirs, files in os.walk(args.mappe):
|
||||||
|
for f in files:
|
||||||
|
if f.lower().endswith(".mp3"):
|
||||||
|
mp3_filer.append(os.path.join(root, f))
|
||||||
|
|
||||||
|
mp3_filer.sort()
|
||||||
|
if args.max:
|
||||||
|
mp3_filer = mp3_filer[:args.max]
|
||||||
|
|
||||||
|
total = len(mp3_filer)
|
||||||
|
print(f"\nFundet {total} MP3-filer i {args.mappe}")
|
||||||
|
print(f"Output: {args.output}")
|
||||||
|
|
||||||
|
# Tjek fpcalc
|
||||||
|
if not args.ingen_acoustid:
|
||||||
|
fpcalc_sti = find_fpcalc()
|
||||||
|
if fpcalc_sti:
|
||||||
|
print(f"fpcalc: fundet ({fpcalc_sti})")
|
||||||
|
print(f"AcoustID API: {ACOUSTID_API_KEY[:6]}...")
|
||||||
|
else:
|
||||||
|
print(f"fpcalc: IKKE FUNDET — AcoustID deaktiveres")
|
||||||
|
print(f" Download fpcalc fra: https://acoustid.org/chromaprint")
|
||||||
|
print(f" Placer fpcalc.exe i samme mappe som scriptet, eller opdater FPCALC_PATH")
|
||||||
|
args.ingen_acoustid = True
|
||||||
|
else:
|
||||||
|
print("AcoustID: SLÅET FRA")
|
||||||
|
print(f"\nStarter analyse...\n")
|
||||||
|
|
||||||
|
resultater = []
|
||||||
|
tæller = {"SIKKER": 0, "SANDSYNLIG": 0, "ALLEREDE_RETTET": 0,
|
||||||
|
"UKONTROLLERET": 0, "FRA_FILNAVN": 0,
|
||||||
|
"USIKKER": 0, "KONFLIKT": 0, "UKENDT": 0, "FEJL": 0}
|
||||||
|
|
||||||
|
for i, path in enumerate(mp3_filer, 1):
|
||||||
|
navn = os.path.basename(path)
|
||||||
|
print(f"[{i:4}/{total}] {navn[:60]}", end="", flush=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = analyse_file(path, use_acoustid=not args.ingen_acoustid)
|
||||||
|
except Exception as e:
|
||||||
|
res = {"fil": path, "filename": navn, "status": "FEJL", "noter": str(e),
|
||||||
|
"handling": "", "dans_forslag": "", "rigtig_titel": "", "rigtig_artist": "",
|
||||||
|
"mb_title": "", "mb_artist": "", "match_score": 0, "tit2": "", "tpe1": "",
|
||||||
|
"talb": "", "tcon": "", "mbid": ""}
|
||||||
|
|
||||||
|
status = res.get("status", "UKENDT")
|
||||||
|
tæller[status] = tæller.get(status, 0) + 1
|
||||||
|
resultater.append(res)
|
||||||
|
|
||||||
|
print(f" → {status}")
|
||||||
|
|
||||||
|
# Gem løbende hvert 50. fil
|
||||||
|
if i % 50 == 0:
|
||||||
|
write_csv(resultater, args.output)
|
||||||
|
|
||||||
|
# Gem endelig rapport
|
||||||
|
write_csv(resultater, args.output)
|
||||||
|
|
||||||
|
# Statistik
|
||||||
|
print("\n" + "="*55)
|
||||||
|
print("RAPPORT")
|
||||||
|
print("="*55)
|
||||||
|
print(f" SIKKER : {tæller.get('SIKKER',0):4} → kan rettes automatisk")
|
||||||
|
print(f" SANDSYNLIG : {tæller.get('SANDSYNLIG',0):4} → bør godkendes")
|
||||||
|
print(f" ALLEREDE_RETTET : {tæller.get('ALLEREDE_RETTET',0):4} → spring over")
|
||||||
|
print(f" UKONTROLLERET : {tæller.get('UKONTROLLERET',0):4} → ingen MBID, men mønster muligt")
|
||||||
|
print(f" FRA_FILNAVN : {tæller.get('FRA_FILNAVN',0):4} → dans udtrukket fra filnavn")
|
||||||
|
print(f" USIKKER : {tæller.get('USIKKER',0):4} → manuel gennemgang")
|
||||||
|
print(f" KONFLIKT : {tæller.get('KONFLIKT',0):4} → tags modstrider hinanden")
|
||||||
|
print(f" UKENDT : {tæller.get('UKENDT',0):4} → kan ikke vurderes")
|
||||||
|
print(f" FEJL : {tæller.get('FEJL',0):4} → teknisk fejl")
|
||||||
|
print("="*55)
|
||||||
|
# Niveau-statistik
|
||||||
|
niveau_værdier = {}
|
||||||
|
for r in resultater:
|
||||||
|
rå = r.get("niveau_rå", "").strip()
|
||||||
|
if rå:
|
||||||
|
niveau_værdier[rå] = niveau_værdier.get(rå, 0) + 1
|
||||||
|
|
||||||
|
if niveau_værdier:
|
||||||
|
print("\nNiveau-værdier fundet i TCON:")
|
||||||
|
for val, antal in sorted(niveau_værdier.items(), key=lambda x: -x[1]):
|
||||||
|
norm, genkendt = normaliser_niveau(val)
|
||||||
|
mærke = "✓" if genkendt else "?"
|
||||||
|
print(f" {mærke} '{val}' ({antal}x) → '{norm}'")
|
||||||
|
|
||||||
|
print(f"\nÅbn {args.output} i Excel og:")
|
||||||
|
print(" 1. Gennemgå SANDSYNLIG og UKONTROLLERET")
|
||||||
|
print(" 2. Sæt 'handling' til RET_AUTOMATISK eller SPRING for hver")
|
||||||
|
print(" 3. Kør: python linedance_tag_analyse.py --apply rapport.csv --dry-run")
|
||||||
|
print(" 4. Tjek output, kør så uden --dry-run")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
2036
Henriks Musik/rapport.csv
Normal file
2036
Henriks Musik/rapport.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
|
||||||
)
|
|
||||||
57
README.md
57
README.md
@@ -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.
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +0,0 @@
|
|||||||
from pydantic_settings import BaseSettings
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
DATABASE_URL: str
|
|
||||||
SECRET_KEY: str
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 dage
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
env_file = ".env"
|
|
||||||
|
|
||||||
settings = Settings()
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from jose import JWTError, jwt
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.database import get_db
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
|
||||||
|
|
||||||
ALGORITHM = "HS256"
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
|
||||||
return pwd_context.hash(password)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain: str, hashed: str) -> bool:
|
|
||||||
return pwd_context.verify(plain, hashed)
|
|
||||||
|
|
||||||
|
|
||||||
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=ALGORITHM)
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(
|
|
||||||
token: str = Depends(oauth2_scheme),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
credentials_exception = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Kunne ikke validere token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
|
|
||||||
user_id: str = payload.get("sub")
|
|
||||||
if user_id is None:
|
|
||||||
raise credentials_exception
|
|
||||||
except JWTError:
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
|
||||||
if user is None:
|
|
||||||
raise credentials_exception
|
|
||||||
return user
|
|
||||||
33
app/main.py
33
app/main.py
@@ -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"}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from sqlalchemy import (
|
|
||||||
Boolean, DateTime, ForeignKey, Integer, String, Text
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
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")
|
|
||||||
songs: Mapped[list["Song"]] = relationship("Song", back_populates="owner")
|
|
||||||
alternatives: Mapped[list["DanceAlternative"]] = relationship("DanceAlternative", foreign_keys="DanceAlternative.created_by", back_populates="creator")
|
|
||||||
alt_ratings: Mapped[list["DanceAlternativeRating"]] = relationship("DanceAlternativeRating", foreign_keys="DanceAlternativeRating.user_id", back_populates="user")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Project ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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="")
|
|
||||||
is_public: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, onupdate=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")
|
|
||||||
|
|
||||||
|
|
||||||
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") # owner | editor | viewer
|
|
||||||
status: Mapped[str] = mapped_column(String(16), default="pending") # pending | accepted
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Song ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class Song(Base):
|
|
||||||
__tablename__ = "songs"
|
|
||||||
|
|
||||||
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)
|
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
artist: Mapped[str] = mapped_column(String(255), default="")
|
|
||||||
local_path: Mapped[str] = mapped_column(String(512), default="") # kun relevant på PC
|
|
||||||
bpm: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
duration_sec: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
|
||||||
|
|
||||||
owner: Mapped["User"] = relationship("User", back_populates="songs")
|
|
||||||
dances: Mapped[list["SongDance"]] = relationship("SongDance", back_populates="song", order_by="SongDance.dance_order", cascade="all, delete-orphan")
|
|
||||||
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song")
|
|
||||||
|
|
||||||
|
|
||||||
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") # pending | playing | played | skipped
|
|
||||||
|
|
||||||
project: Mapped["Project"] = relationship("Project", back_populates="project_songs")
|
|
||||||
song: Mapped["Song"] = relationship("Song", back_populates="project_songs")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Dance ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class SongDance(Base):
|
|
||||||
__tablename__ = "song_dances"
|
|
||||||
|
|
||||||
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_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
dance_order: Mapped[int] = mapped_column(Integer, default=1)
|
|
||||||
|
|
||||||
song: Mapped["Song"] = relationship("Song", back_populates="dances")
|
|
||||||
alternatives: Mapped[list["DanceAlternative"]] = relationship("DanceAlternative", foreign_keys="DanceAlternative.song_dance_id", back_populates="song_dance", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
|
|
||||||
class DanceAlternative(Base):
|
|
||||||
__tablename__ = "dance_alternatives"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
|
||||||
song_dance_id: Mapped[str] = mapped_column(String(36), ForeignKey("song_dances.id"), nullable=False)
|
|
||||||
alt_song_dance_id: Mapped[str] = mapped_column(String(36), ForeignKey("song_dances.id"), nullable=False)
|
|
||||||
created_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
|
||||||
note: Mapped[str] = mapped_column(Text, default="")
|
|
||||||
bayesian_score: Mapped[float] = mapped_column(default=0.0) # genberegnes ved hver rating
|
|
||||||
|
|
||||||
song_dance: Mapped["SongDance"] = relationship("SongDance", foreign_keys=[song_dance_id], back_populates="alternatives")
|
|
||||||
alt_song_dance: Mapped["SongDance"] = relationship("SongDance", foreign_keys=[alt_song_dance_id])
|
|
||||||
creator: Mapped["User"] = relationship("User", foreign_keys=[created_by])
|
|
||||||
ratings: Mapped[list["DanceAlternativeRating"]] = relationship("DanceAlternativeRating", back_populates="alternative", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
|
|
||||||
class DanceAlternativeRating(Base):
|
|
||||||
__tablename__ = "dance_alternative_ratings"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
|
||||||
alternative_id: Mapped[str] = mapped_column(String(36), ForeignKey("dance_alternatives.id"), nullable=False)
|
|
||||||
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
|
||||||
score: Mapped[int] = mapped_column(Integer, nullable=False) # 1-5
|
|
||||||
|
|
||||||
alternative: Mapped["DanceAlternative"] = relationship("DanceAlternative", back_populates="ratings")
|
|
||||||
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,235 +0,0 @@
|
|||||||
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, SongDance, DanceAlternative, DanceAlternativeRating
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/alternatives", tags=["alternatives"])
|
|
||||||
|
|
||||||
# Bayesiansk minimum — alternativer med færre ratings trækkes mod gennemsnittet
|
|
||||||
BAYESIAN_MIN_VOTES = 5
|
|
||||||
|
|
||||||
|
|
||||||
# ── Schemas ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class AlternativeCreate(BaseModel):
|
|
||||||
song_dance_id: str # dans der foreslås alternativ TIL
|
|
||||||
alt_song_dance_id: str # den alternative dans
|
|
||||||
note: str = ""
|
|
||||||
|
|
||||||
class AlternativeOut(BaseModel):
|
|
||||||
id: str
|
|
||||||
song_dance_id: str
|
|
||||||
alt_song_dance_id: str
|
|
||||||
alt_dance_name: str
|
|
||||||
alt_song_title: str
|
|
||||||
created_by_username: str
|
|
||||||
note: str
|
|
||||||
my_score: int | None # den indloggede brugers egen rating
|
|
||||||
avg_score: float | None # simpelt gennemsnit (til visning)
|
|
||||||
bayesian_score: float # bruges til sortering
|
|
||||||
rating_count: int
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
class RatingUpsert(BaseModel):
|
|
||||||
score: int # 1-5
|
|
||||||
|
|
||||||
|
|
||||||
# ── Hjælpefunktion: genberegn bayesian score ─────────────────────────────────
|
|
||||||
|
|
||||||
def _recalculate_bayesian(alternative: DanceAlternative, db: Session):
|
|
||||||
"""
|
|
||||||
Bayesiansk score: vægter gennemsnittet mod et globalt gennemsnit
|
|
||||||
når der er få ratings, så nye alternativer ikke dominerer listen.
|
|
||||||
|
|
||||||
Formel: (n × avg + m × global_avg) / (n + m)
|
|
||||||
n = antal ratings på dette alternativ
|
|
||||||
avg = gennemsnit for dette alternativ
|
|
||||||
m = BAYESIAN_MIN_VOTES (tillid-konstant)
|
|
||||||
global_avg = gennemsnit på tværs af ALLE ratings
|
|
||||||
"""
|
|
||||||
# Beregn stats for dette alternativ
|
|
||||||
result = db.query(
|
|
||||||
func.count(DanceAlternativeRating.id),
|
|
||||||
func.avg(DanceAlternativeRating.score),
|
|
||||||
).filter(DanceAlternativeRating.alternative_id == alternative.id).one()
|
|
||||||
|
|
||||||
n = result[0] or 0
|
|
||||||
avg = float(result[1]) if result[1] else 0.0
|
|
||||||
|
|
||||||
# Globalt gennemsnit på tværs af alle ratings
|
|
||||||
global_avg_result = db.query(func.avg(DanceAlternativeRating.score)).scalar()
|
|
||||||
global_avg = float(global_avg_result) if global_avg_result else 3.0 # 3.0 som neutral fallback
|
|
||||||
|
|
||||||
m = BAYESIAN_MIN_VOTES
|
|
||||||
bayesian = (n * avg + m * global_avg) / (n + m) if (n + m) > 0 else global_avg
|
|
||||||
|
|
||||||
alternative.bayesian_score = round(bayesian, 4)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/", status_code=201)
|
|
||||||
def create_alternative(
|
|
||||||
data: AlternativeCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
me: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Opret et nyt alternativ-dans forslag. Alle registrerede brugere kan bidrage."""
|
|
||||||
dance = db.query(SongDance).filter(SongDance.id == data.song_dance_id).first()
|
|
||||||
if not dance:
|
|
||||||
raise HTTPException(404, "Dans ikke fundet")
|
|
||||||
|
|
||||||
alt_dance = db.query(SongDance).filter(SongDance.id == data.alt_song_dance_id).first()
|
|
||||||
if not alt_dance:
|
|
||||||
raise HTTPException(404, "Alternativ-dans ikke fundet")
|
|
||||||
|
|
||||||
if data.song_dance_id == data.alt_song_dance_id:
|
|
||||||
raise HTTPException(400, "En dans kan ikke være sit eget alternativ")
|
|
||||||
|
|
||||||
# Undgå dubletter fra samme bruger
|
|
||||||
existing = db.query(DanceAlternative).filter_by(
|
|
||||||
song_dance_id=data.song_dance_id,
|
|
||||||
alt_song_dance_id=data.alt_song_dance_id,
|
|
||||||
created_by=me.id,
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(400, "Du har allerede foreslået dette alternativ")
|
|
||||||
|
|
||||||
alt = DanceAlternative(
|
|
||||||
song_dance_id=data.song_dance_id,
|
|
||||||
alt_song_dance_id=data.alt_song_dance_id,
|
|
||||||
created_by=me.id,
|
|
||||||
note=data.note,
|
|
||||||
bayesian_score=3.0, # starter på globalt neutral
|
|
||||||
)
|
|
||||||
db.add(alt)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(alt)
|
|
||||||
return {"id": alt.id, "detail": "Alternativ oprettet"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/for-dance/{song_dance_id}", response_model=list[AlternativeOut])
|
|
||||||
def list_alternatives_for_dance(
|
|
||||||
song_dance_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
me: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Hent alle alternativer til en given dans, sorteret efter bayesiansk score.
|
|
||||||
Viser din egen rating og gennemsnittet.
|
|
||||||
"""
|
|
||||||
alternatives = (
|
|
||||||
db.query(DanceAlternative)
|
|
||||||
.filter(DanceAlternative.song_dance_id == song_dance_id)
|
|
||||||
.order_by(DanceAlternative.bayesian_score.desc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for alt in alternatives:
|
|
||||||
# Din egen rating
|
|
||||||
my_rating = db.query(DanceAlternativeRating).filter_by(
|
|
||||||
alternative_id=alt.id, user_id=me.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
# Aggregeret stats
|
|
||||||
stats = db.query(
|
|
||||||
func.count(DanceAlternativeRating.id),
|
|
||||||
func.avg(DanceAlternativeRating.score),
|
|
||||||
).filter(DanceAlternativeRating.alternative_id == alt.id).one()
|
|
||||||
|
|
||||||
result.append(AlternativeOut(
|
|
||||||
id=alt.id,
|
|
||||||
song_dance_id=alt.song_dance_id,
|
|
||||||
alt_song_dance_id=alt.alt_song_dance_id,
|
|
||||||
alt_dance_name=alt.alt_song_dance.dance_name,
|
|
||||||
alt_song_title=alt.alt_song_dance.song.title,
|
|
||||||
created_by_username=alt.creator.username,
|
|
||||||
note=alt.note,
|
|
||||||
my_score=my_rating.score if my_rating else None,
|
|
||||||
avg_score=round(float(stats[1]), 1) if stats[1] else None,
|
|
||||||
bayesian_score=alt.bayesian_score,
|
|
||||||
rating_count=stats[0] or 0,
|
|
||||||
))
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{alternative_id}/rate")
|
|
||||||
def rate_alternative(
|
|
||||||
alternative_id: str,
|
|
||||||
data: RatingUpsert,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
me: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Sæt eller opdater din rating (1-5) på et alternativ."""
|
|
||||||
if not 1 <= data.score <= 5:
|
|
||||||
raise HTTPException(400, "Score skal være mellem 1 og 5")
|
|
||||||
|
|
||||||
alt = db.query(DanceAlternative).filter(DanceAlternative.id == alternative_id).first()
|
|
||||||
if not alt:
|
|
||||||
raise HTTPException(404, "Alternativ ikke fundet")
|
|
||||||
|
|
||||||
# Upsert — opdater eksisterende rating eller opret ny
|
|
||||||
existing = db.query(DanceAlternativeRating).filter_by(
|
|
||||||
alternative_id=alternative_id, user_id=me.id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
existing.score = data.score
|
|
||||||
else:
|
|
||||||
db.add(DanceAlternativeRating(
|
|
||||||
alternative_id=alternative_id,
|
|
||||||
user_id=me.id,
|
|
||||||
score=data.score,
|
|
||||||
))
|
|
||||||
|
|
||||||
db.flush()
|
|
||||||
_recalculate_bayesian(alt, db)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"detail": "Rating gemt",
|
|
||||||
"my_score": data.score,
|
|
||||||
"bayesian_score": alt.bayesian_score,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{alternative_id}/rate", status_code=204)
|
|
||||||
def remove_rating(
|
|
||||||
alternative_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
me: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Fjern din rating fra et alternativ."""
|
|
||||||
rating = db.query(DanceAlternativeRating).filter_by(
|
|
||||||
alternative_id=alternative_id, user_id=me.id
|
|
||||||
).first()
|
|
||||||
if not rating:
|
|
||||||
raise HTTPException(404, "Du har ikke rated dette alternativ")
|
|
||||||
|
|
||||||
alt = db.query(DanceAlternative).filter(DanceAlternative.id == alternative_id).first()
|
|
||||||
db.delete(rating)
|
|
||||||
db.flush()
|
|
||||||
_recalculate_bayesian(alt, db)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{alternative_id}", status_code=204)
|
|
||||||
def delete_alternative(
|
|
||||||
alternative_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
me: User = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Slet et alternativ — kun den der oprettede det."""
|
|
||||||
alt = db.query(DanceAlternative).filter(DanceAlternative.id == alternative_id).first()
|
|
||||||
if not alt:
|
|
||||||
raise HTTPException(404, "Alternativ ikke fundet")
|
|
||||||
if alt.created_by != me.id:
|
|
||||||
raise HTTPException(403, "Du kan kun slette dine egne forslag")
|
|
||||||
db.delete(alt)
|
|
||||||
db.commit()
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from app.core.database import get_db
|
|
||||||
from app.core.security import hash_password, verify_password, create_access_token
|
|
||||||
from app.models import User
|
|
||||||
from app.schemas import UserCreate, UserOut, Token
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=UserOut, status_code=201)
|
|
||||||
def register(data: UserCreate, db: Session = Depends(get_db)):
|
|
||||||
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-mailen er allerede i brug")
|
|
||||||
|
|
||||||
user = User(
|
|
||||||
username=data.username,
|
|
||||||
email=data.email,
|
|
||||||
password_hash=hash_password(data.password),
|
|
||||||
)
|
|
||||||
db.add(user)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@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).first()
|
|
||||||
if not user or not verify_password(form.password, user.password_hash):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Forkert brugernavn eller kodeord",
|
|
||||||
)
|
|
||||||
token = create_access_token({"sub": user.id})
|
|
||||||
return {"access_token": token}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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, Song, SongDance, DanceAlternative
|
|
||||||
from app.schemas import (
|
|
||||||
SongCreate, SongOut,
|
|
||||||
SongDanceCreate, SongDanceOut,
|
|
||||||
DanceAlternativeCreate, DanceAlternativeOut,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/songs", tags=["songs"])
|
|
||||||
|
|
||||||
|
|
||||||
# ── Sange ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@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.post("/", response_model=SongOut, status_code=201)
|
|
||||||
def create_song(data: SongCreate, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
|
||||||
song = Song(owner_id=me.id, **data.model_dump())
|
|
||||||
db.add(song)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(song)
|
|
||||||
return song
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{song_id}", response_model=SongOut)
|
|
||||||
def get_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")
|
|
||||||
return song
|
|
||||||
|
|
||||||
|
|
||||||
@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()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Danse på en sang ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/{song_id}/dances", response_model=SongDanceOut, status_code=201)
|
|
||||||
def add_dance(song_id: str, data: SongDanceCreate, 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")
|
|
||||||
dance = SongDance(song_id=song_id, **data.model_dump())
|
|
||||||
db.add(dance)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(dance)
|
|
||||||
return dance
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{song_id}/dances/{dance_id}", status_code=204)
|
|
||||||
def remove_dance(song_id: str, dance_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")
|
|
||||||
dance = db.query(SongDance).filter(SongDance.id == dance_id, SongDance.song_id == song_id).first()
|
|
||||||
if not dance:
|
|
||||||
raise HTTPException(404, "Dans ikke fundet")
|
|
||||||
db.delete(dance)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ── Alternativ-danse ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.post("/{song_id}/dances/{dance_id}/alternatives", response_model=DanceAlternativeOut, status_code=201)
|
|
||||||
def add_alternative(song_id: str, dance_id: str, data: DanceAlternativeCreate, 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")
|
|
||||||
dance = db.query(SongDance).filter(SongDance.id == dance_id, SongDance.song_id == song_id).first()
|
|
||||||
if not dance:
|
|
||||||
raise HTTPException(404, "Dans ikke fundet")
|
|
||||||
alt_dance = db.query(SongDance).filter(SongDance.id == data.alt_song_dance_id).first()
|
|
||||||
if not alt_dance:
|
|
||||||
raise HTTPException(404, "Alternativ-dans ikke fundet")
|
|
||||||
|
|
||||||
alt = DanceAlternative(song_dance_id=dance_id, **data.model_dump())
|
|
||||||
db.add(alt)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(alt)
|
|
||||||
return alt
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{song_id}/dances/{dance_id}/alternatives", response_model=list[DanceAlternativeOut])
|
|
||||||
def list_alternatives(song_id: str, dance_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
|
||||||
dance = db.query(SongDance).filter(SongDance.id == dance_id, SongDance.song_id == song_id).first()
|
|
||||||
if not dance:
|
|
||||||
raise HTTPException(404, "Dans ikke fundet")
|
|
||||||
return dance.alternatives
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{song_id}/dances/{dance_id}/alternatives/{alt_id}", status_code=204)
|
|
||||||
def remove_alternative(song_id: str, dance_id: str, alt_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
|
||||||
alt = db.query(DanceAlternative).filter(DanceAlternative.id == alt_id, DanceAlternative.song_dance_id == dance_id).first()
|
|
||||||
if not alt:
|
|
||||||
raise HTTPException(404, "Alternativ ikke fundet")
|
|
||||||
db.delete(alt)
|
|
||||||
db.commit()
|
|
||||||
Binary file not shown.
Binary file not shown.
68
build.bat
68
build.bat
@@ -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
51
full_reset.sh
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ================================================================
|
||||||
|
# full_reset.sh — KOMPLET nulstilling af LineDance-systemet
|
||||||
|
#
|
||||||
|
# Kør dette script på APP-SERVEREN:
|
||||||
|
# bash full_reset.sh
|
||||||
|
#
|
||||||
|
# Herefter skal du selv:
|
||||||
|
# docker compose down && docker compose up -d --build
|
||||||
|
# ================================================================
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}╔══════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${RED}║ KOMPLET NULSTILLING — LINEDANCE AFSPILLER ║${NC}"
|
||||||
|
echo -e "${RED}║ Sletter ALT: sange, danse, playlister, synk-data ║${NC}"
|
||||||
|
echo -e "${RED}╚══════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Dette kan IKKE fortrydes. Al data går tabt.${NC}"
|
||||||
|
echo ""
|
||||||
|
read -p "Skriv 'SLET ALT' for at fortsætte: " confirm
|
||||||
|
[ "$confirm" = "SLET ALT" ] || { echo "Afbrudt."; exit 1; }
|
||||||
|
|
||||||
|
COMPOSE_DIR="/opt/docker/linedanceafspiller/linedance-api"
|
||||||
|
|
||||||
|
# ── MySQL: drop og genskab tom database ───────────────────────
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}▶ Dropper og genskaber MySQL-database...${NC}"
|
||||||
|
docker compose -f "$COMPOSE_DIR/docker-compose.yml" exec -T db \
|
||||||
|
mysql -u root -proot << 'MYSQL'
|
||||||
|
DROP DATABASE IF EXISTS linedance;
|
||||||
|
CREATE DATABASE linedance CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
MYSQL
|
||||||
|
echo -e "${GREEN} ✓ MySQL klar — tom database oprettet${NC}"
|
||||||
|
|
||||||
|
# ── Færdig ────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${GREEN}║ ✓ Server-database nulstillet ║${NC}"
|
||||||
|
echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Gør nu dette:"
|
||||||
|
echo " 1. Rebuild og genstart Docker:"
|
||||||
|
echo " cd $COMPOSE_DIR"
|
||||||
|
echo " docker compose down && docker compose up -d --build"
|
||||||
|
echo ""
|
||||||
17
linedance-api/.env.example
Normal file
17
linedance-api/.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=mysql+pymysql://linedanceplayer:KODEORD@mysql.ckvist.lan:3306/linedanceplayer
|
||||||
|
|
||||||
|
# Sikkerhed — generer med: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
SECRET_KEY=skift-denne-til-en-rigtig-nøgle
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||||
|
|
||||||
|
# Mail
|
||||||
|
MAIL_HOST=mail.miraca.dk
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_FROM=noreply@linedanceplayer.dk
|
||||||
|
MAIL_USERNAME=noreply@linedanceplayer.dk
|
||||||
|
MAIL_PASSWORD=skift-dette
|
||||||
|
MAIL_TLS=true
|
||||||
|
|
||||||
|
# URL til denne server (bruges i verificerings-mails)
|
||||||
|
BASE_URL=http://localhost:8000
|
||||||
18
linedance-api/.gitignore
vendored
Normal file
18
linedance-api/.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
1
linedance-api/=4.0.0
Normal file
1
linedance-api/=4.0.0
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Requirement already satisfied: bcrypt in ./venv/lib/python3.12/site-packages (5.0.0)
|
||||||
22
linedance-api/Dockerfile
Normal file
22
linedance-api/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Installer system-dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
gcc \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Installer Python-pakker
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Kopier kode
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Vent på DB og start server
|
||||||
|
COPY start.sh .
|
||||||
|
RUN chmod +x start.sh
|
||||||
|
CMD ["./start.sh"]
|
||||||
39
linedance-api/README.md
Normal file
39
linedance-api/README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# LineDance API
|
||||||
|
|
||||||
|
## Hurtig start med Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Kopiér miljøfil
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 2. Rediger .env — sæt stærke kodeord
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# 3. Start hele stacken
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 4. Tjek at alt kører
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tilgængelige services
|
||||||
|
|
||||||
|
| Service | URL | Beskrivelse |
|
||||||
|
|----------|----------------------------|--------------------------|
|
||||||
|
| API | http://localhost:8000 | FastAPI |
|
||||||
|
| Docs | http://localhost:8000/docs | Swagger UI |
|
||||||
|
| Adminer | http://localhost:8080 | Database admin |
|
||||||
|
| MailHog | http://localhost:8025 | Test-mails |
|
||||||
|
|
||||||
|
## Adminer login
|
||||||
|
- Server: `db`
|
||||||
|
- Bruger: `linedance`
|
||||||
|
- Kodeord: (fra .env MYSQL_PASSWORD)
|
||||||
|
- Database: `linedance`
|
||||||
|
|
||||||
|
## Produktion
|
||||||
|
- Skift `MAIL_HOST` til rigtig SMTP-server
|
||||||
|
- Sæt `BASE_URL` til dit domæne
|
||||||
|
- Brug `SECRET_KEY` med mindst 32 tilfældige tegn
|
||||||
|
- Fjern `adminer` og `mailhog` fra docker-compose
|
||||||
24
linedance-api/app/core/config.py
Normal file
24
linedance-api/app/core/config.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
DATABASE_URL: str
|
||||||
|
SECRET_KEY: str
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 dage
|
||||||
|
|
||||||
|
# Mail
|
||||||
|
MAIL_HOST: str = "mailhog"
|
||||||
|
MAIL_PORT: int = 1025
|
||||||
|
MAIL_FROM: str = "noreply@linedance.local"
|
||||||
|
MAIL_USERNAME: str = ""
|
||||||
|
MAIL_PASSWORD: str = ""
|
||||||
|
MAIL_TLS: bool = False
|
||||||
|
|
||||||
|
# Base URL til verificerings-links
|
||||||
|
BASE_URL: str = "http://localhost:8000"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
104
linedance-api/app/core/mail.py
Normal file
104
linedance-api/app/core/mail.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
mail.py — Asynkron mail-sending via aiosmtplib.
|
||||||
|
I udvikling bruges MailHog som SMTP-server.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import secrets
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
import aiosmtplib
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def generate_verify_token() -> str:
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_verification_email(email: str, username: str, token: str):
|
||||||
|
verify_url = f"{settings.BASE_URL}/auth/verify/{token}"
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = "Bekræft din LineDance-konto"
|
||||||
|
msg["From"] = settings.MAIL_FROM
|
||||||
|
msg["To"] = email
|
||||||
|
|
||||||
|
text = f"""Hej {username},
|
||||||
|
|
||||||
|
Tak for at oprette en konto på LineDance Player.
|
||||||
|
|
||||||
|
Klik på linket nedenfor for at bekræfte din e-mailadresse:
|
||||||
|
{verify_url}
|
||||||
|
|
||||||
|
Linket udløber ikke — men kontoen er ikke aktiv før du har bekræftet.
|
||||||
|
|
||||||
|
Hilsen
|
||||||
|
LineDance Player
|
||||||
|
"""
|
||||||
|
|
||||||
|
html = f"""<html><body>
|
||||||
|
<h2>Velkommen til LineDance Player, {username}!</h2>
|
||||||
|
<p>Klik på knappen nedenfor for at bekræfte din e-mailadresse:</p>
|
||||||
|
<p>
|
||||||
|
<a href="{verify_url}"
|
||||||
|
style="background:#e8a020;color:#111;padding:12px 24px;
|
||||||
|
border-radius:6px;text-decoration:none;font-weight:bold;">
|
||||||
|
Bekræft e-mail
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>Eller kopier dette link:<br>
|
||||||
|
<a href="{verify_url}">{verify_url}</a></p>
|
||||||
|
<p>Linket udløber ikke.</p>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
msg.attach(MIMEText(text, "plain", "utf-8"))
|
||||||
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await aiosmtplib.send(
|
||||||
|
msg,
|
||||||
|
hostname=settings.MAIL_HOST,
|
||||||
|
port=settings.MAIL_PORT,
|
||||||
|
username=settings.MAIL_USERNAME or None,
|
||||||
|
password=settings.MAIL_PASSWORD or None,
|
||||||
|
start_tls=settings.MAIL_TLS, # STARTTLS på port 587
|
||||||
|
use_tls=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Mail-fejl: {e}")
|
||||||
|
raise # Vis fejlen i serverlogs
|
||||||
|
|
||||||
|
|
||||||
|
async def send_share_invitation(email: str, owner_name: str,
|
||||||
|
playlist_name: str, permission: str,
|
||||||
|
accept_url: str):
|
||||||
|
perm_text = {"view": "se", "copy": "kopiere", "edit": "redigere"}.get(permission, "se")
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = f"{owner_name} har delt en danseliste med dig"
|
||||||
|
msg["From"] = settings.MAIL_FROM
|
||||||
|
msg["To"] = email
|
||||||
|
|
||||||
|
html = f"""<html><body>
|
||||||
|
<h2>Du er inviteret!</h2>
|
||||||
|
<p>{owner_name} har delt danselisten <strong>{playlist_name}</strong> med dig.</p>
|
||||||
|
<p>Du har fået adgang til at <strong>{perm_text}</strong> listen.</p>
|
||||||
|
<p>
|
||||||
|
<a href="{accept_url}"
|
||||||
|
style="background:#e8a020;color:#111;padding:12px 24px;
|
||||||
|
border-radius:6px;text-decoration:none;font-weight:bold;">
|
||||||
|
Se danseliste
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||||
|
try:
|
||||||
|
await aiosmtplib.send(
|
||||||
|
msg,
|
||||||
|
hostname=settings.MAIL_HOST,
|
||||||
|
port=settings.MAIL_PORT,
|
||||||
|
use_tls=settings.MAIL_TLS,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Mail-fejl (share): {e}")
|
||||||
40
linedance-api/app/core/security.py
Normal file
40
linedance-api/app/core/security.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import bcrypt
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from fastapi import Depends, HTTPException
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return bcrypt.hashpw(password[:72].encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
return bcrypt.checkpw(plain[:72].encode(), hashed.encode())
|
||||||
|
|
||||||
|
def create_access_token(data: dict) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
|
)
|
||||||
|
return jwt.encode({**data, "exp": expire}, settings.SECRET_KEY, algorithm="HS256")
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
from app.models import User
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(401, "Ugyldig token")
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(401, "Ugyldig token")
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "Bruger ikke fundet")
|
||||||
|
return user
|
||||||
67
linedance-api/app/main.py
Normal file
67
linedance-api/app/main.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from app.core.database import engine, Base
|
||||||
|
|
||||||
|
# Importer ALLE modeller så create_all kender dem
|
||||||
|
from app.models import (
|
||||||
|
User, Song, Dance, DanceLevel, Project, ProjectMember, ProjectSong,
|
||||||
|
PlaylistShare, CommunityDance, CommunityDanceAlt, DanceAltRating,
|
||||||
|
SongDance, SongAltDance,
|
||||||
|
)
|
||||||
|
from app.routers import auth, projects, songs, alternatives, dances, sync, sharing, live
|
||||||
|
from app.websocket.manager import router as ws_router
|
||||||
|
|
||||||
|
# Opret tabeller hvis de ikke findes
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Linedance API",
|
||||||
|
version="0.1.0",
|
||||||
|
description="Backend for linedance-afspiller og projektstyring",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # Stram til i produktion
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(projects.router)
|
||||||
|
app.include_router(songs.router)
|
||||||
|
app.include_router(alternatives.router)
|
||||||
|
app.include_router(dances.router)
|
||||||
|
app.include_router(sync.router)
|
||||||
|
app.include_router(sharing.router)
|
||||||
|
app.include_router(live.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def seed_dance_levels():
|
||||||
|
"""Opret standard dans-niveauer hvis tabellen er tom."""
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.models import DanceLevel
|
||||||
|
with Session(engine) as db:
|
||||||
|
if db.query(DanceLevel).count() == 0:
|
||||||
|
defaults = [
|
||||||
|
DanceLevel(sort_order=10, name="Absolute Beginner", description="Ingen tidligere danse-erfaring kræves"),
|
||||||
|
DanceLevel(sort_order=20, name="Beginner", description="Lidt tidligere erfaring"),
|
||||||
|
DanceLevel(sort_order=30, name="High Beginner", description="God begynder, klar til mere"),
|
||||||
|
DanceLevel(sort_order=40, name="Low Improver", description="Begyndende øvet"),
|
||||||
|
DanceLevel(sort_order=50, name="Improver", description="Grundlæggende færdigheder på plads"),
|
||||||
|
DanceLevel(sort_order=60, name="High Improver", description="Stærk øvet, næsten intermediate"),
|
||||||
|
DanceLevel(sort_order=70, name="Low Intermediate", description="Begyndende intermediate"),
|
||||||
|
DanceLevel(sort_order=80, name="Intermediate", description="Erfaren danser"),
|
||||||
|
DanceLevel(sort_order=90, name="High Intermediate", description="Stærk intermediate"),
|
||||||
|
DanceLevel(sort_order=99, name="Advanced", description="Fuld beherskelse af trin og teknik"),
|
||||||
|
]
|
||||||
|
db.add_all(defaults)
|
||||||
|
db.commit()
|
||||||
|
app.include_router(ws_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def root():
|
||||||
|
return {"status": "ok", "service": "Linedance API"}
|
||||||
222
linedance-api/app/models/__init__.py
Normal file
222
linedance-api/app/models/__init__.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, Float, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
def new_uuid() -> str:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
def now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── User ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
full_name: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
verify_token: Mapped[str|None] = mapped_column(String(64), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
|
projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner")
|
||||||
|
memberships: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="user")
|
||||||
|
alt_ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="user")
|
||||||
|
playlist_shares: Mapped[list["PlaylistShare"]] = relationship("PlaylistShare", foreign_keys="PlaylistShare.shared_with_id", back_populates="shared_with")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Song (global — ikke knyttet til en bruger) ────────────────────────────────
|
||||||
|
|
||||||
|
class Song(Base):
|
||||||
|
__tablename__ = "songs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
artist: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
album: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
bpm: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
duration_sec: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
mbid: Mapped[str|None] = mapped_column(String(36), nullable=True, unique=True)
|
||||||
|
acoustid: Mapped[str|None] = mapped_column(String(64), nullable=True)
|
||||||
|
synced_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
|
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="song")
|
||||||
|
song_dances: Mapped[list["SongDance"]] = relationship("SongDance", back_populates="song", cascade="all, delete-orphan")
|
||||||
|
song_alt_dances: Mapped[list["SongAltDance"]] = relationship("SongAltDance", back_populates="song", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dans-entitet ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class DanceLevel(Base):
|
||||||
|
__tablename__ = "dance_levels"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
|
||||||
|
|
||||||
|
class Dance(Base):
|
||||||
|
__tablename__ = "dances"
|
||||||
|
__table_args__ = (UniqueConstraint("name", "level_id", name="uq_dance_name_level"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
level_id: Mapped[int|None] = mapped_column(Integer, ForeignKey("dance_levels.id"), nullable=True)
|
||||||
|
choreographer: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
video_url: Mapped[str] = mapped_column(String(512), default="")
|
||||||
|
stepsheet_url: Mapped[str] = mapped_column(String(512), default="")
|
||||||
|
notes: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
use_count: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
synced_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
level: Mapped["DanceLevel|None"] = relationship("DanceLevel")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Project / Playlist ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
owner_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
description: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
visibility: Mapped[str] = mapped_column(String(16), default="private")
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc, onupdate=now_utc)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
|
owner: Mapped["User"] = relationship("User", back_populates="projects")
|
||||||
|
members: Mapped[list["ProjectMember"]] = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
|
||||||
|
project_songs: Mapped[list["ProjectSong"]] = relationship("ProjectSong", back_populates="project", order_by="ProjectSong.position", cascade="all, delete-orphan")
|
||||||
|
shares: Mapped[list["PlaylistShare"]] = relationship("PlaylistShare", back_populates="project", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectMember(Base):
|
||||||
|
__tablename__ = "project_members"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(16), default="viewer")
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||||
|
invited_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
|
project: Mapped["Project"] = relationship("Project", back_populates="members")
|
||||||
|
user: Mapped["User"] = relationship("User", back_populates="memberships")
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectSong(Base):
|
||||||
|
__tablename__ = "project_songs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
|
||||||
|
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
|
||||||
|
position: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||||
|
is_workshop: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
dance_override: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
|
||||||
|
project: Mapped["Project"] = relationship("Project", back_populates="project_songs")
|
||||||
|
song: Mapped["Song"] = relationship("Song", back_populates="project_songs")
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistShare(Base):
|
||||||
|
__tablename__ = "playlist_shares"
|
||||||
|
__table_args__ = (UniqueConstraint("project_id", "shared_with_id", name="uq_share"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id"), nullable=False)
|
||||||
|
shared_with_id: Mapped[str|None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True)
|
||||||
|
invited_email: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
permission: Mapped[str] = mapped_column(String(16), default="view")
|
||||||
|
accepted_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
|
project: Mapped["Project"] = relationship("Project", back_populates="shares")
|
||||||
|
shared_with: Mapped["User|None"] = relationship("User", foreign_keys=[shared_with_id], back_populates="playlist_shares")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sang-dans tags ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SongDance(Base):
|
||||||
|
__tablename__ = "song_dances"
|
||||||
|
__table_args__ = (UniqueConstraint("song_id", "dance_id", name="uq_song_dance"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
|
||||||
|
dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False)
|
||||||
|
dance_order: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
|
||||||
|
song: Mapped["Song"] = relationship("Song", back_populates="song_dances")
|
||||||
|
dance: Mapped["Dance"] = relationship("Dance")
|
||||||
|
|
||||||
|
|
||||||
|
class SongAltDance(Base):
|
||||||
|
__tablename__ = "song_alt_dances"
|
||||||
|
__table_args__ = (UniqueConstraint("song_id", "dance_id", name="uq_song_alt_dance"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
song_id: Mapped[str] = mapped_column(String(36), ForeignKey("songs.id"), nullable=False)
|
||||||
|
dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False)
|
||||||
|
note: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
|
||||||
|
song: Mapped["Song"] = relationship("Song", back_populates="song_alt_dances")
|
||||||
|
dance: Mapped["Dance"] = relationship("Dance")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Community dans-tags ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CommunityDance(Base):
|
||||||
|
__tablename__ = "community_dances"
|
||||||
|
__table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "dance_id", name="uq_comm_dance"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
|
||||||
|
song_title: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
song_artist: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False)
|
||||||
|
submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
|
dance: Mapped["Dance"] = relationship("Dance")
|
||||||
|
|
||||||
|
|
||||||
|
class CommunityDanceAlt(Base):
|
||||||
|
__tablename__ = "community_dance_alts"
|
||||||
|
__table_args__ = (UniqueConstraint("song_mbid", "song_title", "song_artist", "alt_dance_id", name="uq_comm_alt"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
song_mbid: Mapped[str|None] = mapped_column(String(36), nullable=True)
|
||||||
|
song_title: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
song_artist: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
alt_dance_id: Mapped[int] = mapped_column(Integer, ForeignKey("dances.id"), nullable=False)
|
||||||
|
note: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
submitted_by: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
|
avg_rating: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
rating_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
|
alt_dance: Mapped["Dance"] = relationship("Dance")
|
||||||
|
ratings: Mapped[list["DanceAltRating"]] = relationship("DanceAltRating", back_populates="alternative", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class DanceAltRating(Base):
|
||||||
|
__tablename__ = "dance_alt_ratings"
|
||||||
|
__table_args__ = (UniqueConstraint("alternative_id", "user_id", name="uq_rating"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
alternative_id: Mapped[str] = mapped_column(String(36), ForeignKey("community_dance_alts.id"), nullable=False)
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), nullable=False)
|
||||||
|
score: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now_utc)
|
||||||
|
|
||||||
|
alternative: Mapped["CommunityDanceAlt"] = relationship("CommunityDanceAlt", back_populates="ratings")
|
||||||
|
user: Mapped["User"] = relationship("User", back_populates="alt_ratings")
|
||||||
121
linedance-api/app/routers/alt_dance_ratings.py
Normal file
121
linedance-api/app/routers/alt_dance_ratings.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
alt_dance_ratings.py — Community alternativ-dans ratings endpoint.
|
||||||
|
"""
|
||||||
|
import uuid as _uuid
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models import User, Song, Dance, CommunityDanceAlt, DanceAltRating
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/alt-ratings", tags=["alt-ratings"])
|
||||||
|
|
||||||
|
|
||||||
|
class SubmitAltRequest(BaseModel):
|
||||||
|
song_id: str # server song UUID
|
||||||
|
dance_name: str
|
||||||
|
rating: int # 1-5
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/submit")
|
||||||
|
def submit_alt_rating(
|
||||||
|
req: SubmitAltRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Indsend eller opdater rating for en alternativ-dans på en sang."""
|
||||||
|
if not 1 <= req.rating <= 5:
|
||||||
|
raise HTTPException(400, "Rating skal være 1-5")
|
||||||
|
|
||||||
|
song = db.query(Song).filter_by(id=req.song_id).first()
|
||||||
|
if not song:
|
||||||
|
raise HTTPException(404, "Sang ikke fundet")
|
||||||
|
|
||||||
|
dance = db.query(Dance).filter(
|
||||||
|
Dance.name.ilike(req.dance_name)
|
||||||
|
).first()
|
||||||
|
if not dance:
|
||||||
|
raise HTTPException(404, "Dans ikke fundet")
|
||||||
|
|
||||||
|
# Find eller opret community alt-dans
|
||||||
|
alt = db.query(CommunityDanceAlt).filter_by(
|
||||||
|
song_mbid=song.mbid or None,
|
||||||
|
song_title=song.title,
|
||||||
|
song_artist=song.artist,
|
||||||
|
alt_dance_id=dance.id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not alt:
|
||||||
|
alt = CommunityDanceAlt(
|
||||||
|
id=str(_uuid.uuid4()),
|
||||||
|
song_mbid=song.mbid or None,
|
||||||
|
song_title=song.title,
|
||||||
|
song_artist=song.artist,
|
||||||
|
alt_dance_id=dance.id,
|
||||||
|
submitted_by=me.id,
|
||||||
|
avg_rating=float(req.rating),
|
||||||
|
rating_count=1,
|
||||||
|
)
|
||||||
|
db.add(alt)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Opdater eller indsæt brugerens rating
|
||||||
|
existing_rating = db.query(DanceAltRating).filter_by(
|
||||||
|
alternative_id=alt.id,
|
||||||
|
user_id=me.id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_rating:
|
||||||
|
old_score = existing_rating.score
|
||||||
|
existing_rating.score = req.rating
|
||||||
|
# Opdater gennemsnit
|
||||||
|
total = alt.avg_rating * alt.rating_count - old_score + req.rating
|
||||||
|
alt.avg_rating = total / alt.rating_count
|
||||||
|
else:
|
||||||
|
db.add(DanceAltRating(
|
||||||
|
id=str(_uuid.uuid4()),
|
||||||
|
alternative_id=alt.id,
|
||||||
|
user_id=me.id,
|
||||||
|
score=req.rating,
|
||||||
|
))
|
||||||
|
# Opdater gennemsnit
|
||||||
|
total = alt.avg_rating * alt.rating_count + req.rating
|
||||||
|
alt.rating_count += 1
|
||||||
|
alt.avg_rating = total / alt.rating_count
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"status": "ok", "avg_rating": alt.avg_rating, "rating_count": alt.rating_count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/for-song/{song_id}")
|
||||||
|
def get_alt_ratings_for_song(
|
||||||
|
song_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Hent community alternativ-danse med ratings for en sang."""
|
||||||
|
song = db.query(Song).filter_by(id=song_id).first()
|
||||||
|
if not song:
|
||||||
|
raise HTTPException(404, "Sang ikke fundet")
|
||||||
|
|
||||||
|
alts = db.query(CommunityDanceAlt).filter(
|
||||||
|
(CommunityDanceAlt.song_mbid == song.mbid) if song.mbid else
|
||||||
|
((CommunityDanceAlt.song_title == song.title) &
|
||||||
|
(CommunityDanceAlt.song_artist == song.artist))
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for alt in alts:
|
||||||
|
my_rating = db.query(DanceAltRating).filter_by(
|
||||||
|
alternative_id=alt.id,
|
||||||
|
user_id=me.id,
|
||||||
|
).first()
|
||||||
|
result.append({
|
||||||
|
"dance_name": alt.alt_dance.name,
|
||||||
|
"avg_rating": round(alt.avg_rating, 1),
|
||||||
|
"rating_count": alt.rating_count,
|
||||||
|
"my_rating": my_rating.score if my_rating else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
3
linedance-api/app/routers/alternatives.py
Normal file
3
linedance-api/app/routers/alternatives.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""alternatives.py — Placeholder (håndteres via /sync)."""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
router = APIRouter(prefix="/alternatives", tags=["alternatives"])
|
||||||
151
linedance-api/app/routers/auth.py
Normal file
151
linedance-api/app/routers/auth.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
auth.py — Register, verify, login, profil.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import hash_password, verify_password, create_access_token, get_current_user
|
||||||
|
from app.core.mail import generate_verify_token, send_verification_email
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
full_name: str = ""
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
full_name: str
|
||||||
|
is_verified: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: UserOut
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/register", response_model=dict, status_code=201)
|
||||||
|
async def register(
|
||||||
|
data: UserCreate,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
# Tjek om brugernavn eller email allerede er i brug
|
||||||
|
if db.query(User).filter(User.username == data.username).first():
|
||||||
|
raise HTTPException(400, "Brugernavnet er allerede i brug")
|
||||||
|
if db.query(User).filter(User.email == data.email).first():
|
||||||
|
raise HTTPException(400, "E-mailadressen er allerede registreret")
|
||||||
|
|
||||||
|
token = generate_verify_token()
|
||||||
|
user = User(
|
||||||
|
username=data.username,
|
||||||
|
email=data.email,
|
||||||
|
full_name=data.full_name,
|
||||||
|
password_hash=hash_password(data.password),
|
||||||
|
is_verified=False,
|
||||||
|
verify_token=token,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Send verificerings-mail i baggrunden
|
||||||
|
background_tasks.add_task(
|
||||||
|
send_verification_email, data.email, data.username, token
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Konto oprettet. Tjek din e-mail ({data.email}) for at bekræfte.",
|
||||||
|
"email": data.email,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/verify/{token}", response_class=HTMLResponse)
|
||||||
|
def verify_email(token: str, db: Session = Depends(get_db)):
|
||||||
|
user = db.query(User).filter(User.verify_token == token).first()
|
||||||
|
if not user:
|
||||||
|
return HTMLResponse("""
|
||||||
|
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
|
||||||
|
<h2>❌ Ugyldigt eller udløbet link</h2>
|
||||||
|
<p>Prøv at registrere dig igen.</p>
|
||||||
|
</body></html>
|
||||||
|
""", status_code=400)
|
||||||
|
|
||||||
|
user.is_verified = True
|
||||||
|
user.verify_token = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return HTMLResponse("""
|
||||||
|
<html><body style="font-family:sans-serif;text-align:center;padding:60px;
|
||||||
|
background:#1a1d23;color:#e0e4f0">
|
||||||
|
<h2 style="color:#e8a020">✓ E-mail bekræftet!</h2>
|
||||||
|
<p>Din konto er nu aktiv. Du kan logge ind i LineDance Player.</p>
|
||||||
|
<p style="color:#5a6070;font-size:14px">Du kan lukke dette vindue.</p>
|
||||||
|
</body></html>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
def login(
|
||||||
|
form: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
user = db.query(User).filter(
|
||||||
|
(User.username == form.username) | (User.email == form.username)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user or not verify_password(form.password, user.password_hash):
|
||||||
|
raise HTTPException(401, "Forkert brugernavn eller kodeord")
|
||||||
|
|
||||||
|
if not user.is_verified:
|
||||||
|
raise HTTPException(403, "E-mailadressen er ikke bekræftet endnu. Tjek din indbakke.")
|
||||||
|
|
||||||
|
token = create_access_token({"sub": user.id})
|
||||||
|
return Token(
|
||||||
|
access_token=token,
|
||||||
|
user=UserOut.model_validate(user)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
def me(current_user: User = Depends(get_current_user)):
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/resend-verification")
|
||||||
|
async def resend_verification(
|
||||||
|
email: str,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
user = db.query(User).filter(User.email == email).first()
|
||||||
|
if not user or user.is_verified:
|
||||||
|
# Svar altid OK — afslør ikke om email eksisterer
|
||||||
|
return {"message": "Hvis e-mailen er registreret, sendes et nyt link."}
|
||||||
|
|
||||||
|
token = generate_verify_token()
|
||||||
|
user.verify_token = token
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
background_tasks.add_task(
|
||||||
|
send_verification_email, user.email, user.username, token
|
||||||
|
)
|
||||||
|
return {"message": "Nyt verificerings-link er sendt."}
|
||||||
108
linedance-api/app/routers/dances.py
Normal file
108
linedance-api/app/routers/dances.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
dances.py — Endpoints til dans-navne, niveauer og community alternativer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dances", tags=["dances"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class DanceLevelOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
sort_order: int
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
class DanceNameOut(BaseModel):
|
||||||
|
name: str
|
||||||
|
use_count: int
|
||||||
|
|
||||||
|
class DanceNameSubmit(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
class CommunityDanceOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
song_mbid: str | None
|
||||||
|
dance_name: str
|
||||||
|
level_id: int | None
|
||||||
|
level_name: str | None
|
||||||
|
submitted_by: str
|
||||||
|
use_count: int
|
||||||
|
|
||||||
|
class CommunityAltOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
song_mbid: str | None
|
||||||
|
dance_name: str
|
||||||
|
alt_dance_name: str
|
||||||
|
level_id: int | None
|
||||||
|
level_name: str | None
|
||||||
|
note: str
|
||||||
|
bayesian_score: float
|
||||||
|
rating_count: int
|
||||||
|
my_rating: int | None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dans-niveauer ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/levels", response_model=list[DanceLevelOut])
|
||||||
|
def get_levels(db: Session = Depends(get_db)):
|
||||||
|
"""Hent alle dans-niveauer — bruges til synkronisering i appen."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
rows = db.execute(text(
|
||||||
|
"SELECT id, sort_order, name, description FROM dance_levels ORDER BY sort_order"
|
||||||
|
)).fetchall()
|
||||||
|
return [{"id": r[0], "sort_order": r[1], "name": r[2], "description": r[3]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dans-navne ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/names", response_model=list[DanceNameOut])
|
||||||
|
def get_dance_names(prefix: str = "", limit: int = 50, db: Session = Depends(get_db)):
|
||||||
|
"""Hent kendte dans-navne — bruges til autoudfyld og synkronisering."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
pattern = f"{prefix}%"
|
||||||
|
rows = db.execute(text(
|
||||||
|
"SELECT name, use_count FROM dance_names "
|
||||||
|
"WHERE name LIKE :pattern "
|
||||||
|
"ORDER BY use_count DESC, name "
|
||||||
|
"LIMIT :limit"
|
||||||
|
), {"pattern": pattern, "limit": limit}).fetchall()
|
||||||
|
return [{"name": r[0], "use_count": r[1]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/names", status_code=201)
|
||||||
|
def submit_dance_name(
|
||||||
|
data: DanceNameSubmit,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Indsend et dans-navn — opretter eller tæller op."""
|
||||||
|
from sqlalchemy import text
|
||||||
|
name = data.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "Navn må ikke være tomt")
|
||||||
|
existing = db.execute(
|
||||||
|
text("SELECT id FROM dance_names WHERE name = :name COLLATE NOCASE"),
|
||||||
|
{"name": name}
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
db.execute(
|
||||||
|
text("UPDATE dance_names SET use_count = use_count + 1 WHERE id = :id"),
|
||||||
|
{"id": existing[0]}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
db.execute(
|
||||||
|
text("INSERT INTO dance_names (name, use_count) VALUES (:name, 1)"),
|
||||||
|
{"name": name}
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "ok"}
|
||||||
109
linedance-api/app/routers/live.py
Normal file
109
linedance-api/app/routers/live.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
live.py — Live playliste-status til storskærm/mobil.
|
||||||
|
Appen pusher status hertil, storskærmen poller hvert 5 sek.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models import User, Project
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/live", tags=["live"])
|
||||||
|
|
||||||
|
# In-memory cache: server_id → {songs, updated_at}
|
||||||
|
_live_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class SongStatus(BaseModel):
|
||||||
|
title: str
|
||||||
|
artist: str = ""
|
||||||
|
status: str = "pending"
|
||||||
|
position: int
|
||||||
|
dance: str = ""
|
||||||
|
duration: int = 0
|
||||||
|
is_workshop: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class LiveStatus(BaseModel):
|
||||||
|
songs: list[SongStatus]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Push fra app ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{project_id}/status")
|
||||||
|
def push_status(
|
||||||
|
project_id: str,
|
||||||
|
data: LiveStatus,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""App pusher aktuel playliste-status."""
|
||||||
|
p = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
|
||||||
|
_live_cache[project_id] = {
|
||||||
|
"name": p.name,
|
||||||
|
"songs": [s.model_dump() for s in data.songs],
|
||||||
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pull til storskærm ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{project_id}")
|
||||||
|
def get_live_status(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Storskærm poller dette endpoint — ingen login krævet."""
|
||||||
|
# Tjek at playlisten eksisterer og er tilgængelig
|
||||||
|
p = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
|
||||||
|
cached = _live_cache.get(project_id)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# Ingen live data endnu — returner statisk data fra DB
|
||||||
|
from app.models import ProjectSong, Song
|
||||||
|
songs = []
|
||||||
|
for ps in sorted(p.project_songs, key=lambda x: x.position):
|
||||||
|
song = db.query(Song).filter_by(id=ps.song_id).first()
|
||||||
|
if not song:
|
||||||
|
continue
|
||||||
|
songs.append({
|
||||||
|
"title": song.title,
|
||||||
|
"artist": song.artist,
|
||||||
|
"status": ps.status or "pending",
|
||||||
|
"position": ps.position,
|
||||||
|
"dance": ps.dance_override or "",
|
||||||
|
"duration": song.duration_sec or 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": p.name,
|
||||||
|
"songs": songs,
|
||||||
|
"updated_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Liste over aktive live-playlister ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def list_live(db: Session = Depends(get_db)):
|
||||||
|
"""Hvilke playlister har aktiv live-data?"""
|
||||||
|
result = []
|
||||||
|
for pid, data in _live_cache.items():
|
||||||
|
playing = next((s for s in data["songs"] if s["status"] == "playing"), None)
|
||||||
|
result.append({
|
||||||
|
"id": pid,
|
||||||
|
"name": data["name"],
|
||||||
|
"updated_at": data["updated_at"],
|
||||||
|
"now_playing": playing["title"] if playing else None,
|
||||||
|
})
|
||||||
|
return result
|
||||||
@@ -24,7 +24,7 @@ def _assert_role(project: Project, user: User, db: Session, min_role: str = "vie
|
|||||||
return # ejer har altid adgang
|
return # ejer har altid adgang
|
||||||
member = db.query(ProjectMember).filter_by(project_id=project.id, user_id=user.id, status="accepted").first()
|
member = db.query(ProjectMember).filter_by(project_id=project.id, user_id=user.id, status="accepted").first()
|
||||||
if not member:
|
if not member:
|
||||||
if project.is_public and min_role == "viewer":
|
if project.visibility == "public" and min_role == "viewer":
|
||||||
return
|
return
|
||||||
raise HTTPException(403, "Du har ikke adgang til dette projekt")
|
raise HTTPException(403, "Du har ikke adgang til dette projekt")
|
||||||
if roles.index(member.role) < roles.index(min_role):
|
if roles.index(member.role) < roles.index(min_role):
|
||||||
@@ -33,6 +33,21 @@ def _assert_role(project: Project, user: User, db: Session, min_role: str = "vie
|
|||||||
|
|
||||||
# ── CRUD ──────────────────────────────────────────────────────────────────────
|
# ── 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])
|
@router.get("/", response_model=list[ProjectOut])
|
||||||
def list_projects(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
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()
|
owned = db.query(Project).filter(Project.owner_id == me.id).all()
|
||||||
220
linedance-api/app/routers/sharing.py
Normal file
220
linedance-api/app/routers/sharing.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"""
|
||||||
|
sharing.py — Forenklet deling af playlister.
|
||||||
|
Kun ejeren kan redigere. Delte brugere får read-only via sync.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models import User, Project, PlaylistShare
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/sharing", tags=["sharing"])
|
||||||
|
|
||||||
|
|
||||||
|
class ShareRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
# ── Del med bruger ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/playlists/{project_id}/share", status_code=201)
|
||||||
|
async def share_playlist(
|
||||||
|
project_id: str,
|
||||||
|
data: ShareRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Del en playliste med en bruger — de får listen ved næste sync."""
|
||||||
|
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet eller du er ikke ejer")
|
||||||
|
|
||||||
|
target = db.query(User).filter_by(email=data.email).first()
|
||||||
|
|
||||||
|
existing = db.query(PlaylistShare).filter_by(
|
||||||
|
project_id=project_id, invited_email=data.email
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return {"detail": "Allerede delt med denne bruger"}
|
||||||
|
|
||||||
|
share = PlaylistShare(
|
||||||
|
project_id=project_id,
|
||||||
|
shared_with_id=target.id if target else None,
|
||||||
|
invited_email=data.email,
|
||||||
|
permission="view",
|
||||||
|
)
|
||||||
|
db.add(share)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Send invitation-mail
|
||||||
|
try:
|
||||||
|
from app.core.mail import send_share_invitation
|
||||||
|
from app.core.config import settings
|
||||||
|
background_tasks.add_task(
|
||||||
|
send_share_invitation,
|
||||||
|
email=data.email,
|
||||||
|
owner_name=me.username,
|
||||||
|
playlist_name=project.name,
|
||||||
|
permission="view",
|
||||||
|
accept_url=f"{settings.BASE_URL}/sharing/playlists/{project_id}",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"detail": f"Delt med {data.email}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/playlists/{project_id}/share/{share_id}", status_code=204)
|
||||||
|
def remove_share(
|
||||||
|
project_id: str,
|
||||||
|
share_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
share = db.query(PlaylistShare).filter_by(id=share_id, project_id=project_id).first()
|
||||||
|
if not share:
|
||||||
|
raise HTTPException(404, "Deling ikke fundet")
|
||||||
|
db.delete(share)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/playlists/{project_id}/shares")
|
||||||
|
def list_shares(
|
||||||
|
project_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
shares = db.query(PlaylistShare).filter_by(project_id=project_id).all()
|
||||||
|
return [{"id": s.id, "email": s.invited_email} for s in shares]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Visibility ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.patch("/playlists/{project_id}/visibility")
|
||||||
|
def set_visibility(
|
||||||
|
project_id: str,
|
||||||
|
visibility: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if visibility not in ("private", "shared", "public"):
|
||||||
|
raise HTTPException(400, "Brug private, shared eller public")
|
||||||
|
project = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
project.visibility = visibility
|
||||||
|
db.commit()
|
||||||
|
return {"detail": f"Synlighed: {visibility}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Hent playliste-indhold ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/public")
|
||||||
|
def list_public_playlists(db: Session = Depends(get_db)):
|
||||||
|
"""Hent alle public playlister — ingen login krævet."""
|
||||||
|
projects = db.query(Project).filter_by(visibility="public").all()
|
||||||
|
result = []
|
||||||
|
for p in projects:
|
||||||
|
owner = db.query(User).filter_by(id=p.owner_id).first()
|
||||||
|
result.append({
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"owner": owner.username if owner else "?",
|
||||||
|
"song_count": len(p.project_songs),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/playlists/{project_id}/copy", status_code=201)
|
||||||
|
def copy_playlist(
|
||||||
|
project_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Kopiér en public playliste til brugerens egen konto."""
|
||||||
|
p = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
if p.visibility != "public":
|
||||||
|
raise HTTPException(403, "Kun public playlister kan kopieres")
|
||||||
|
if p.owner_id == me.id:
|
||||||
|
raise HTTPException(400, "Du ejer allerede denne playliste")
|
||||||
|
|
||||||
|
from app.models import Song
|
||||||
|
owner = db.query(User).filter_by(id=p.owner_id).first()
|
||||||
|
new_name = f"{p.name} (kopi fra {owner.username if owner else '?'})"
|
||||||
|
|
||||||
|
new_p = Project(
|
||||||
|
owner_id=me.id,
|
||||||
|
name=new_name,
|
||||||
|
description=p.description or "",
|
||||||
|
visibility="private",
|
||||||
|
)
|
||||||
|
db.add(new_p)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
for ps in p.project_songs:
|
||||||
|
from app.models import ProjectSong
|
||||||
|
db.add(ProjectSong(
|
||||||
|
project_id=new_p.id,
|
||||||
|
song_id=ps.song_id,
|
||||||
|
position=ps.position,
|
||||||
|
status="pending",
|
||||||
|
is_workshop=ps.is_workshop,
|
||||||
|
dance_override=ps.dance_override or "",
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"detail": "Kopieret", "id": new_p.id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/playlists/{project_id}")
|
||||||
|
def get_shared_playlist(
|
||||||
|
project_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
p = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "Playliste ikke fundet")
|
||||||
|
if p.owner_id != me.id:
|
||||||
|
if p.visibility != "public":
|
||||||
|
share = db.query(PlaylistShare).filter(
|
||||||
|
PlaylistShare.project_id == project_id,
|
||||||
|
(PlaylistShare.shared_with_id == me.id) |
|
||||||
|
(PlaylistShare.invited_email == me.email)
|
||||||
|
).first()
|
||||||
|
if not share:
|
||||||
|
raise HTTPException(403, "Ingen adgang")
|
||||||
|
|
||||||
|
from app.models import Song
|
||||||
|
songs = []
|
||||||
|
for ps in p.project_songs:
|
||||||
|
song = db.query(Song).filter_by(id=ps.song_id).first()
|
||||||
|
if not song:
|
||||||
|
continue
|
||||||
|
songs.append({
|
||||||
|
"title": song.title,
|
||||||
|
"artist": song.artist,
|
||||||
|
"position": ps.position,
|
||||||
|
"status": ps.status,
|
||||||
|
"is_workshop": ps.is_workshop,
|
||||||
|
"dance_override": ps.dance_override or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"description": p.description or "",
|
||||||
|
"visibility": p.visibility,
|
||||||
|
"songs": sorted(songs, key=lambda x: x["position"]),
|
||||||
|
}
|
||||||
29
linedance-api/app/routers/songs.py
Normal file
29
linedance-api/app/routers/songs.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""songs.py — Simpel sang-router (basis CRUD)."""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models import User, Song
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/songs", tags=["songs"])
|
||||||
|
|
||||||
|
|
||||||
|
class SongOut(BaseModel):
|
||||||
|
id: str; title: str; artist: str; album: str
|
||||||
|
bpm: int; duration_sec: int; file_format: str
|
||||||
|
class Config: from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[SongOut])
|
||||||
|
def list_songs(db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||||
|
return db.query(Song).filter(Song.owner_id == me.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{song_id}", status_code=204)
|
||||||
|
def delete_song(song_id: str, db: Session = Depends(get_db), me: User = Depends(get_current_user)):
|
||||||
|
song = db.query(Song).filter(Song.id == song_id, Song.owner_id == me.id).first()
|
||||||
|
if not song:
|
||||||
|
raise HTTPException(404, "Sang ikke fundet")
|
||||||
|
db.delete(song)
|
||||||
|
db.commit()
|
||||||
473
linedance-api/app/routers/sync.py
Normal file
473
linedance-api/app/routers/sync.py
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
"""
|
||||||
|
sync.py — Push/pull synkronisering mellem lokal app og server.
|
||||||
|
|
||||||
|
POST /sync/push — send lokal data op til server
|
||||||
|
GET /sync/pull — hent server-data ned til app
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from app.models import (
|
||||||
|
User, Song, Dance, DanceLevel, Project, ProjectSong,
|
||||||
|
PlaylistShare, CommunityDance, SongDance, SongAltDance,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/sync", tags=["sync"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SongData(BaseModel):
|
||||||
|
local_id: str
|
||||||
|
title: str
|
||||||
|
artist: str = ""
|
||||||
|
album: str = ""
|
||||||
|
bpm: int = 0
|
||||||
|
duration_sec: int = 0
|
||||||
|
mbid: str = ""
|
||||||
|
acoustid: str = ""
|
||||||
|
|
||||||
|
class DanceData(BaseModel):
|
||||||
|
name: str
|
||||||
|
level_name: str = ""
|
||||||
|
choreographer: str = ""
|
||||||
|
video_url: str = ""
|
||||||
|
stepsheet_url: str = ""
|
||||||
|
notes: str = ""
|
||||||
|
|
||||||
|
class SongDanceData(BaseModel):
|
||||||
|
song_local_id: str
|
||||||
|
dance_name: str
|
||||||
|
level_name: str = ""
|
||||||
|
dance_order: int = 1
|
||||||
|
|
||||||
|
class SongAltDanceData(BaseModel):
|
||||||
|
song_local_id: str
|
||||||
|
dance_name: str
|
||||||
|
level_name: str = ""
|
||||||
|
note: str = ""
|
||||||
|
user_rating: Optional[int] = None
|
||||||
|
|
||||||
|
class PlaylistSongData(BaseModel):
|
||||||
|
song_local_id: str
|
||||||
|
song_title: str = ""
|
||||||
|
song_artist: str = ""
|
||||||
|
position: int
|
||||||
|
status: str = "pending"
|
||||||
|
is_workshop: bool = False
|
||||||
|
dance_override: str = ""
|
||||||
|
|
||||||
|
class PlaylistData(BaseModel):
|
||||||
|
local_id: str
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
tags: str = ""
|
||||||
|
visibility: str = "private"
|
||||||
|
songs: list[PlaylistSongData] = []
|
||||||
|
|
||||||
|
class PushPayload(BaseModel):
|
||||||
|
songs: list[SongData] = []
|
||||||
|
dances: list[DanceData] = []
|
||||||
|
song_dances: list[SongDanceData] = []
|
||||||
|
song_alts: list[SongAltDanceData] = []
|
||||||
|
playlists: list[PlaylistData] = []
|
||||||
|
deleted_playlists: list[str] = [] # server-IDs (Project.id)
|
||||||
|
songs_with_dances_synced: list[str] = [] # sang-IDs der er fuldt synkroniseret
|
||||||
|
|
||||||
|
|
||||||
|
# ── Hjælpefunktion: find eller opret sang globalt ─────────────────────────────
|
||||||
|
|
||||||
|
def _find_or_create_song(db: Session, title: str, artist: str = "",
|
||||||
|
mbid: str = "", acoustid: str = "",
|
||||||
|
album: str = "", bpm: int = 0,
|
||||||
|
duration_sec: int = 0) -> Song:
|
||||||
|
"""
|
||||||
|
Match-hierarki:
|
||||||
|
1. MBID — sikreste
|
||||||
|
2. AcoustID
|
||||||
|
3. Titel + artist
|
||||||
|
4. Opret ny
|
||||||
|
"""
|
||||||
|
if mbid:
|
||||||
|
song = db.query(Song).filter_by(mbid=mbid).first()
|
||||||
|
if song:
|
||||||
|
return song
|
||||||
|
|
||||||
|
if acoustid:
|
||||||
|
song = db.query(Song).filter_by(acoustid=acoustid).first()
|
||||||
|
if song:
|
||||||
|
# Tilføj mbid hvis den mangler
|
||||||
|
if mbid and not song.mbid:
|
||||||
|
song.mbid = mbid
|
||||||
|
return song
|
||||||
|
|
||||||
|
if title:
|
||||||
|
song = db.query(Song).filter(
|
||||||
|
Song.title == title,
|
||||||
|
Song.artist == artist,
|
||||||
|
).first()
|
||||||
|
if song:
|
||||||
|
# Opdater med bedre data hvis tilgængeligt
|
||||||
|
if mbid and not song.mbid:
|
||||||
|
song.mbid = mbid
|
||||||
|
if acoustid and not song.acoustid:
|
||||||
|
song.acoustid = acoustid
|
||||||
|
if bpm and not song.bpm:
|
||||||
|
song.bpm = bpm
|
||||||
|
return song
|
||||||
|
|
||||||
|
# Opret ny global sang
|
||||||
|
song = Song(
|
||||||
|
title=title, artist=artist, album=album,
|
||||||
|
bpm=bpm, duration_sec=duration_sec,
|
||||||
|
mbid=mbid or None,
|
||||||
|
acoustid=acoustid or None,
|
||||||
|
)
|
||||||
|
db.add(song)
|
||||||
|
db.flush()
|
||||||
|
return song
|
||||||
|
|
||||||
|
|
||||||
|
# ── Push ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/push")
|
||||||
|
def push(
|
||||||
|
payload: PushPayload,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Upload lokal data til server. Returnerer server-IDs."""
|
||||||
|
import sqlalchemy as _sa
|
||||||
|
|
||||||
|
song_id_map = {} # local_id → server Song.id
|
||||||
|
dance_id_map = {} # "name|level_id" → Dance.id
|
||||||
|
level_map = {} # level_name.lower() → DanceLevel.id
|
||||||
|
|
||||||
|
# ── Dans-niveauer ─────────────────────────────────────────────────────────
|
||||||
|
for lvl in db.query(DanceLevel).all():
|
||||||
|
level_map[lvl.name.lower()] = lvl.id
|
||||||
|
|
||||||
|
# ── Sange (globale) ───────────────────────────────────────────────────────
|
||||||
|
for s in payload.songs:
|
||||||
|
if not s.title:
|
||||||
|
continue
|
||||||
|
song = _find_or_create_song(
|
||||||
|
db, s.title, s.artist,
|
||||||
|
mbid=s.mbid, acoustid=s.acoustid,
|
||||||
|
album=s.album, bpm=s.bpm, duration_sec=s.duration_sec,
|
||||||
|
)
|
||||||
|
song_id_map[s.local_id] = song.id
|
||||||
|
|
||||||
|
# ── Danse ─────────────────────────────────────────────────────────────────
|
||||||
|
for d in payload.dances:
|
||||||
|
level_id = level_map.get(d.level_name.lower()) if d.level_name else None
|
||||||
|
key = f"{d.name.lower()}|{level_id}"
|
||||||
|
existing = db.query(Dance).filter_by(name=d.name, level_id=level_id).first()
|
||||||
|
if existing:
|
||||||
|
if d.choreographer: existing.choreographer = d.choreographer
|
||||||
|
if d.video_url: existing.video_url = d.video_url
|
||||||
|
if d.stepsheet_url: existing.stepsheet_url = d.stepsheet_url
|
||||||
|
dance_id_map[key] = existing.id
|
||||||
|
else:
|
||||||
|
dance = Dance(
|
||||||
|
name=d.name, level_id=level_id,
|
||||||
|
choreographer=d.choreographer,
|
||||||
|
video_url=d.video_url,
|
||||||
|
stepsheet_url=d.stepsheet_url,
|
||||||
|
notes=d.notes,
|
||||||
|
)
|
||||||
|
db.add(dance)
|
||||||
|
db.flush()
|
||||||
|
dance_id_map[key] = dance.id
|
||||||
|
|
||||||
|
# ── Sang-dans tags — synkroniser fuldt per sang ──────────────────────────
|
||||||
|
# Slet eksisterende tags for sange der er med i push, genindsæt fra klient
|
||||||
|
synced_song_ids = set()
|
||||||
|
for sd in payload.song_dances:
|
||||||
|
song_id = song_id_map.get(sd.song_local_id)
|
||||||
|
if not song_id:
|
||||||
|
continue
|
||||||
|
if song_id not in synced_song_ids:
|
||||||
|
db.execute(_sa.text("DELETE FROM song_dances WHERE song_id=:sid"),
|
||||||
|
{"sid": song_id})
|
||||||
|
synced_song_ids.add(song_id)
|
||||||
|
level_id = level_map.get(sd.level_name.lower()) if sd.level_name else None
|
||||||
|
key = f"{sd.dance_name.lower()}|{level_id}"
|
||||||
|
dance_id = dance_id_map.get(key)
|
||||||
|
if not dance_id:
|
||||||
|
continue
|
||||||
|
db.execute(_sa.text(
|
||||||
|
"INSERT IGNORE INTO song_dances (id, song_id, dance_id, dance_order) "
|
||||||
|
"VALUES (:id, :song_id, :dance_id, :dance_order)"
|
||||||
|
), {"id": str(uuid.uuid4()), "song_id": song_id,
|
||||||
|
"dance_id": dance_id, "dance_order": sd.dance_order})
|
||||||
|
|
||||||
|
# Sange der er fuldt synkroniseret men har ingen dans-tags — slet på server
|
||||||
|
for local_id in payload.songs_with_dances_synced:
|
||||||
|
song_id = song_id_map.get(local_id)
|
||||||
|
if song_id and song_id not in synced_song_ids:
|
||||||
|
db.execute(_sa.text("DELETE FROM song_dances WHERE song_id=:sid"),
|
||||||
|
{"sid": song_id})
|
||||||
|
|
||||||
|
for sa in payload.song_alts:
|
||||||
|
song_id = song_id_map.get(sa.song_local_id)
|
||||||
|
if not song_id:
|
||||||
|
continue
|
||||||
|
level_id = level_map.get(sa.level_name.lower()) if sa.level_name else None
|
||||||
|
key = f"{sa.dance_name.lower()}|{level_id}"
|
||||||
|
dance_id = dance_id_map.get(key)
|
||||||
|
if not dance_id:
|
||||||
|
continue
|
||||||
|
# Opdater community rating hvis bruger har givet en vurdering
|
||||||
|
if sa.user_rating and 1 <= sa.user_rating <= 5:
|
||||||
|
from app.models import CommunityDanceAlt, DanceAltRating
|
||||||
|
song_obj = db.query(Song).filter_by(id=song_id).first()
|
||||||
|
if song_obj:
|
||||||
|
alt = db.query(CommunityDanceAlt).filter_by(
|
||||||
|
song_title=song_obj.title,
|
||||||
|
song_artist=song_obj.artist,
|
||||||
|
alt_dance_id=dance_id,
|
||||||
|
).first()
|
||||||
|
if not alt:
|
||||||
|
alt = CommunityDanceAlt(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
song_mbid=song_obj.mbid or None,
|
||||||
|
song_title=song_obj.title,
|
||||||
|
song_artist=song_obj.artist,
|
||||||
|
alt_dance_id=dance_id,
|
||||||
|
submitted_by=me.id,
|
||||||
|
avg_rating=float(sa.user_rating),
|
||||||
|
rating_count=1,
|
||||||
|
)
|
||||||
|
db.add(alt)
|
||||||
|
db.flush()
|
||||||
|
existing_r = db.query(DanceAltRating).filter_by(
|
||||||
|
alternative_id=alt.id, user_id=me.id
|
||||||
|
).first()
|
||||||
|
if existing_r:
|
||||||
|
old_score = existing_r.score
|
||||||
|
existing_r.score = sa.user_rating
|
||||||
|
total = alt.avg_rating * alt.rating_count - old_score + sa.user_rating
|
||||||
|
alt.avg_rating = total / alt.rating_count
|
||||||
|
else:
|
||||||
|
db.add(DanceAltRating(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
alternative_id=alt.id,
|
||||||
|
user_id=me.id,
|
||||||
|
score=sa.user_rating,
|
||||||
|
))
|
||||||
|
total = alt.avg_rating * alt.rating_count + sa.user_rating
|
||||||
|
alt.rating_count += 1
|
||||||
|
alt.avg_rating = total / alt.rating_count
|
||||||
|
db.execute(_sa.text(
|
||||||
|
"INSERT IGNORE INTO song_alt_dances (id, song_id, dance_id, note) "
|
||||||
|
"VALUES (:id, :song_id, :dance_id, :note)"
|
||||||
|
), {"id": str(uuid.uuid4()), "song_id": song_id,
|
||||||
|
"dance_id": dance_id, "note": sa.note or ""})
|
||||||
|
|
||||||
|
# ── Playlister ────────────────────────────────────────────────────────────
|
||||||
|
playlist_id_map = {}
|
||||||
|
for pl in payload.playlists:
|
||||||
|
# Find eksisterende via server-ID (local_id er api_project_id på klienten)
|
||||||
|
existing = None
|
||||||
|
if pl.local_id:
|
||||||
|
existing = db.query(Project).filter_by(
|
||||||
|
id=pl.local_id, owner_id=me.id
|
||||||
|
).first()
|
||||||
|
if not existing:
|
||||||
|
existing = db.query(Project).filter_by(
|
||||||
|
owner_id=me.id, name=pl.name
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.name = pl.name
|
||||||
|
existing.description = pl.description
|
||||||
|
existing.visibility = pl.visibility
|
||||||
|
if pl.songs:
|
||||||
|
db.query(ProjectSong).filter_by(project_id=existing.id).delete()
|
||||||
|
project = existing
|
||||||
|
else:
|
||||||
|
project = Project(
|
||||||
|
owner_id=me.id, name=pl.name,
|
||||||
|
description=pl.description, visibility=pl.visibility,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
db.flush()
|
||||||
|
playlist_id_map[pl.local_id] = project.id
|
||||||
|
|
||||||
|
for ps in pl.songs:
|
||||||
|
# Find sang via song_id_map eller titel+artist
|
||||||
|
song_id = song_id_map.get(ps.song_local_id)
|
||||||
|
if not song_id and ps.song_title:
|
||||||
|
song = _find_or_create_song(db, ps.song_title, ps.song_artist)
|
||||||
|
song_id = song.id
|
||||||
|
if not song_id:
|
||||||
|
continue
|
||||||
|
db.add(ProjectSong(
|
||||||
|
project_id=project.id, song_id=song_id,
|
||||||
|
position=ps.position, status=ps.status,
|
||||||
|
is_workshop=ps.is_workshop,
|
||||||
|
dance_override=ps.dance_override,
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── Slet playlister ───────────────────────────────────────────────────────
|
||||||
|
for project_id in payload.deleted_playlists:
|
||||||
|
proj = db.query(Project).filter_by(id=project_id, owner_id=me.id).first()
|
||||||
|
if proj:
|
||||||
|
db.query(ProjectSong).filter_by(project_id=proj.id).delete()
|
||||||
|
db.delete(proj)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"songs_synced": len(song_id_map),
|
||||||
|
"playlists_synced": len(playlist_id_map),
|
||||||
|
"song_id_map": {k: str(v) for k, v in song_id_map.items()},
|
||||||
|
"playlist_id_map": {k: str(v) for k, v in playlist_id_map.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pull ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/pull")
|
||||||
|
def pull(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
me: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Hent server-data til lokal app."""
|
||||||
|
|
||||||
|
# Dans-niveauer
|
||||||
|
levels = [
|
||||||
|
{"id": l.id, "name": l.name, "sort_order": l.sort_order}
|
||||||
|
for l in db.query(DanceLevel).order_by(DanceLevel.sort_order).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Danse
|
||||||
|
dances = [
|
||||||
|
{
|
||||||
|
"name": d.name,
|
||||||
|
"level_id": d.level_id,
|
||||||
|
"choreographer": d.choreographer,
|
||||||
|
"video_url": d.video_url,
|
||||||
|
"stepsheet_url": d.stepsheet_url,
|
||||||
|
"notes": d.notes,
|
||||||
|
"use_count": d.use_count,
|
||||||
|
}
|
||||||
|
for d in db.query(Dance).order_by(Dance.use_count.desc()).limit(500).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Delte playlister
|
||||||
|
shared_ids = {
|
||||||
|
s.project_id for s in db.query(PlaylistShare).filter(
|
||||||
|
(PlaylistShare.shared_with_id == me.id) |
|
||||||
|
(PlaylistShare.invited_email == me.email)
|
||||||
|
).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
shared = []
|
||||||
|
for p in db.query(Project).filter(Project.id.in_(shared_ids)).all():
|
||||||
|
if p.owner_id == me.id:
|
||||||
|
continue
|
||||||
|
owner = db.query(User).filter_by(id=p.owner_id).first()
|
||||||
|
shared.append({
|
||||||
|
"server_id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"owner": owner.username if owner else "?",
|
||||||
|
"songs": [
|
||||||
|
{
|
||||||
|
"song_id": str(ps.song_id),
|
||||||
|
"title": ps.song.title,
|
||||||
|
"artist": ps.song.artist,
|
||||||
|
"mbid": ps.song.mbid or "",
|
||||||
|
"acoustid": ps.song.acoustid or "",
|
||||||
|
"bpm": ps.song.bpm,
|
||||||
|
"duration_sec": ps.song.duration_sec,
|
||||||
|
"position": ps.position,
|
||||||
|
"status": ps.status,
|
||||||
|
"is_workshop": ps.is_workshop,
|
||||||
|
"dance_override": ps.dance_override or "",
|
||||||
|
}
|
||||||
|
for ps in sorted(p.project_songs, key=lambda x: x.position)
|
||||||
|
if ps.song
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Egne playlister
|
||||||
|
my_playlists = []
|
||||||
|
for p in db.query(Project).filter_by(owner_id=me.id).all():
|
||||||
|
my_playlists.append({
|
||||||
|
"server_id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"description": p.description or "",
|
||||||
|
"songs": [
|
||||||
|
{
|
||||||
|
"song_id": str(ps.song_id),
|
||||||
|
"title": ps.song.title,
|
||||||
|
"artist": ps.song.artist,
|
||||||
|
"mbid": ps.song.mbid or "",
|
||||||
|
"acoustid": ps.song.acoustid or "",
|
||||||
|
"bpm": ps.song.bpm,
|
||||||
|
"duration_sec": ps.song.duration_sec,
|
||||||
|
"position": ps.position,
|
||||||
|
"status": ps.status,
|
||||||
|
"is_workshop": ps.is_workshop,
|
||||||
|
"dance_override": ps.dance_override or "",
|
||||||
|
}
|
||||||
|
for ps in sorted(p.project_songs, key=lambda x: x.position)
|
||||||
|
if ps.song
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Pull: {len(my_playlists)} playlister for {me.username}")
|
||||||
|
|
||||||
|
# Dans-tags (brugerens egne)
|
||||||
|
song_tags = []
|
||||||
|
for sd in db.query(SongDance).all():
|
||||||
|
dance = db.query(Dance).filter_by(id=sd.dance_id).first()
|
||||||
|
if not dance:
|
||||||
|
continue
|
||||||
|
level = db.query(DanceLevel).filter_by(id=dance.level_id).first() if dance.level_id else None
|
||||||
|
song_tags.append({
|
||||||
|
"song_id": sd.song_id,
|
||||||
|
"dance_name": dance.name,
|
||||||
|
"choreographer": dance.choreographer or "",
|
||||||
|
"level_name": level.name if level else "",
|
||||||
|
"dance_order": sd.dance_order,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Community alternativ-danse (top 500 mest ratede)
|
||||||
|
from app.models import CommunityDanceAlt, DanceAltRating
|
||||||
|
community_alts = []
|
||||||
|
for alt in db.query(CommunityDanceAlt).order_by(
|
||||||
|
CommunityDanceAlt.avg_rating.desc()
|
||||||
|
).limit(500).all():
|
||||||
|
my_rating = db.query(DanceAltRating).filter_by(
|
||||||
|
alternative_id=alt.id, user_id=me.id
|
||||||
|
).first()
|
||||||
|
community_alts.append({
|
||||||
|
"song_mbid": alt.song_mbid or "",
|
||||||
|
"song_title": alt.song_title,
|
||||||
|
"song_artist": alt.song_artist,
|
||||||
|
"dance_name": alt.alt_dance.name if alt.alt_dance else "",
|
||||||
|
"avg_rating": round(alt.avg_rating, 1),
|
||||||
|
"rating_count": alt.rating_count,
|
||||||
|
"my_rating": my_rating.score if my_rating else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"levels": levels,
|
||||||
|
"dances": dances,
|
||||||
|
"shared": shared,
|
||||||
|
"my_playlists": my_playlists,
|
||||||
|
"song_tags": song_tags,
|
||||||
|
"community_alts": community_alts,
|
||||||
|
}
|
||||||
33
linedance-api/docker-compose.yml
Normal file
33
linedance-api/docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
networks:
|
||||||
|
- linedance
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: ./web
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
networks:
|
||||||
|
- linedance
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
image: adminer
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080" # kun tilgængelig lokalt på serveren
|
||||||
|
networks:
|
||||||
|
- linedance
|
||||||
|
|
||||||
|
networks:
|
||||||
|
linedance:
|
||||||
|
name: linedance
|
||||||
@@ -191,42 +191,40 @@ 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 på disk med SQLite og synkroniserer forskelle.
|
Sammenligner filer på 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 på 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,
|
continue
|
||||||
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
|
|
||||||
path_str = str(file_path)
|
|
||||||
found_paths.add(path_str)
|
|
||||||
disk_modified = get_file_modified_at(file_path)
|
|
||||||
|
|
||||||
if path_str not in known or known[path_str] != disk_modified:
|
path_str = str(file_path)
|
||||||
tags = read_tags(file_path)
|
found_paths.add(path_str)
|
||||||
tags["library_id"] = library_id
|
disk_modified = get_file_modified_at(file_path)
|
||||||
upsert_song(tags)
|
|
||||||
processed += 1
|
# Ny fil eller ændret siden sidst
|
||||||
if self.on_change:
|
if path_str not in known or known[path_str] != disk_modified:
|
||||||
self.on_change("upserted", path_str, None)
|
try:
|
||||||
|
tags = read_tags(file_path)
|
||||||
|
tags["library_id"] = library_id
|
||||||
|
upsert_song(tags)
|
||||||
|
processed += 1
|
||||||
|
if self.on_change:
|
||||||
|
self.on_change("upserted", path_str, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Scan-fejl for {file_path}: {e}")
|
logger.error(f"Scan-fejl for {file_path}: {e}")
|
||||||
errors += 1
|
errors += 1
|
||||||
@@ -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 ────────────────────────────────────────────────
|
||||||
|
|
||||||
330
linedance-api/local/local_db.py
Normal file
330
linedance-api/local/local_db.py
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
"""
|
||||||
|
local_db.py — Lokal SQLite database til offline brug.
|
||||||
|
|
||||||
|
Håndterer:
|
||||||
|
- Musikbiblioteker (stier der overvåges)
|
||||||
|
- Sange høstet fra filsystemet
|
||||||
|
- Lokale afspilningslister (offline-projekter)
|
||||||
|
- Synkroniseringsstatus mod API
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path.home() / ".linedance" / "local.db"
|
||||||
|
|
||||||
|
_local = threading.local()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_conn() -> sqlite3.Connection:
|
||||||
|
"""Returnerer en thread-lokal forbindelse."""
|
||||||
|
if not hasattr(_local, "conn") or _local.conn is None:
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL") # bedre concurrent adgang
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
_local.conn = conn
|
||||||
|
return _local.conn
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_db():
|
||||||
|
conn = _get_conn()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Opret alle tabeller hvis de ikke findes."""
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.executescript("""
|
||||||
|
-- Musikbiblioteker der overvåges
|
||||||
|
CREATE TABLE IF NOT EXISTS libraries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path TEXT NOT NULL UNIQUE,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_full_scan TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sange høstet fra filsystemet
|
||||||
|
CREATE TABLE IF NOT EXISTS songs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
library_id INTEGER REFERENCES libraries(id),
|
||||||
|
local_path TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
artist TEXT NOT NULL DEFAULT '',
|
||||||
|
album TEXT NOT NULL DEFAULT '',
|
||||||
|
bpm INTEGER NOT NULL DEFAULT 0,
|
||||||
|
duration_sec INTEGER NOT NULL DEFAULT 0,
|
||||||
|
file_format TEXT NOT NULL DEFAULT '',
|
||||||
|
file_modified_at TEXT NOT NULL,
|
||||||
|
file_missing INTEGER NOT NULL DEFAULT 0,
|
||||||
|
api_song_id TEXT, -- NULL hvis ikke synkroniseret
|
||||||
|
last_synced_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Danse knyttet til en sang (kun MP3 kan skrive tags)
|
||||||
|
CREATE TABLE IF NOT EXISTS song_dances (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
|
||||||
|
dance_name TEXT NOT NULL,
|
||||||
|
dance_order INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Lokale afspilningslister (offline-projekter)
|
||||||
|
CREATE TABLE IF NOT EXISTS playlists (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
api_project_id TEXT, -- NULL hvis ikke synkroniseret
|
||||||
|
last_synced_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sange i en afspilningsliste
|
||||||
|
CREATE TABLE IF NOT EXISTS playlist_songs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
song_id TEXT NOT NULL REFERENCES songs(id),
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- pending|playing|played|skipped
|
||||||
|
UNIQUE(playlist_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Synkroniseringskø — ændringer der venter på at komme online
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_queue (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_type TEXT NOT NULL, -- 'song'|'playlist'|'playlist_song'
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL, -- 'create'|'update'|'delete'
|
||||||
|
payload TEXT NOT NULL, -- JSON
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indekser til hurtig søgning
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_songs_missing ON songs(file_missing);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_songs_library ON songs(library_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id);
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Biblioteker ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def add_library(path: str) -> int:
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO libraries (path) VALUES (?)", (path,)
|
||||||
|
)
|
||||||
|
if cur.lastrowid:
|
||||||
|
return cur.lastrowid
|
||||||
|
row = conn.execute("SELECT id FROM libraries WHERE path=?", (path,)).fetchone()
|
||||||
|
return row["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_libraries(active_only: bool = True) -> list[sqlite3.Row]:
|
||||||
|
with get_db() as conn:
|
||||||
|
if active_only:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT * FROM libraries WHERE is_active=1 ORDER BY path"
|
||||||
|
).fetchall()
|
||||||
|
return conn.execute("SELECT * FROM libraries ORDER BY path").fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def remove_library(library_id: int):
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute("UPDATE libraries SET is_active=0 WHERE id=?", (library_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def update_library_scan_time(library_id: int):
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE libraries SET last_full_scan=? WHERE id=?", (now, library_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Sange ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def upsert_song(song_data: dict) -> str:
|
||||||
|
"""
|
||||||
|
Indsæt eller opdater en sang baseret på local_path.
|
||||||
|
Returnerer song_id.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
with get_db() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM songs WHERE local_path=?", (song_data["local_path"],)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
song_id = existing["id"]
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE songs SET
|
||||||
|
title=?, artist=?, album=?, bpm=?, duration_sec=?,
|
||||||
|
file_format=?, file_modified_at=?, file_missing=0
|
||||||
|
WHERE id=?
|
||||||
|
""", (
|
||||||
|
song_data.get("title", ""),
|
||||||
|
song_data.get("artist", ""),
|
||||||
|
song_data.get("album", ""),
|
||||||
|
song_data.get("bpm", 0),
|
||||||
|
song_data.get("duration_sec", 0),
|
||||||
|
song_data.get("file_format", ""),
|
||||||
|
song_data.get("file_modified_at", ""),
|
||||||
|
song_id,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
song_id = str(uuid.uuid4())
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO songs
|
||||||
|
(id, library_id, local_path, title, artist, album,
|
||||||
|
bpm, duration_sec, file_format, file_modified_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||||
|
""", (
|
||||||
|
song_id,
|
||||||
|
song_data.get("library_id"),
|
||||||
|
song_data["local_path"],
|
||||||
|
song_data.get("title", ""),
|
||||||
|
song_data.get("artist", ""),
|
||||||
|
song_data.get("album", ""),
|
||||||
|
song_data.get("bpm", 0),
|
||||||
|
song_data.get("duration_sec", 0),
|
||||||
|
song_data.get("file_format", ""),
|
||||||
|
song_data.get("file_modified_at", ""),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Opdater danse hvis de er med i data
|
||||||
|
if "dances" in song_data:
|
||||||
|
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
|
||||||
|
for i, dance_name in enumerate(song_data["dances"], start=1):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO song_dances (song_id, dance_name, dance_order) VALUES (?,?,?)",
|
||||||
|
(song_id, dance_name, i),
|
||||||
|
)
|
||||||
|
|
||||||
|
return song_id
|
||||||
|
|
||||||
|
|
||||||
|
def mark_song_missing(local_path: str):
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE songs SET file_missing=1 WHERE local_path=?", (local_path,)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_song_by_path(local_path: str) -> sqlite3.Row | None:
|
||||||
|
with get_db() as conn:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT * FROM songs WHERE local_path=?", (local_path,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def search_songs(query: str, limit: int = 50) -> list[sqlite3.Row]:
|
||||||
|
"""Søg i titel, artist og dansenavne."""
|
||||||
|
pattern = f"%{query}%"
|
||||||
|
with get_db() as conn:
|
||||||
|
return conn.execute("""
|
||||||
|
SELECT DISTINCT s.* FROM songs s
|
||||||
|
LEFT JOIN song_dances sd ON sd.song_id = s.id
|
||||||
|
WHERE s.file_missing = 0
|
||||||
|
AND (s.title LIKE ? OR s.artist LIKE ? OR s.album LIKE ? OR sd.dance_name LIKE ?)
|
||||||
|
ORDER BY s.artist, s.title
|
||||||
|
LIMIT ?
|
||||||
|
""", (pattern, pattern, pattern, pattern, limit)).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def get_songs_for_library(library_id: int) -> list[sqlite3.Row]:
|
||||||
|
with get_db() as conn:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT * FROM songs WHERE library_id=? ORDER BY artist, title",
|
||||||
|
(library_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_song_paths_for_library(library_id: int) -> dict[str, str]:
|
||||||
|
"""Returnerer {local_path: file_modified_at} — bruges til fuld scan."""
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT local_path, file_modified_at FROM songs WHERE library_id=?",
|
||||||
|
(library_id,)
|
||||||
|
).fetchall()
|
||||||
|
return {row["local_path"]: row["file_modified_at"] for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Afspilningslister ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_playlist(name: str, description: str = "") -> int:
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO playlists (name, description) VALUES (?,?)",
|
||||||
|
(name, description)
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_playlists() -> list[sqlite3.Row]:
|
||||||
|
with get_db() as conn:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT * FROM playlists ORDER BY created_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def add_song_to_playlist(playlist_id: int, song_id: str, position: int | None = None) -> int:
|
||||||
|
with get_db() as conn:
|
||||||
|
if position is None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT MAX(position) as max_pos FROM playlist_songs WHERE playlist_id=?",
|
||||||
|
(playlist_id,)
|
||||||
|
).fetchone()
|
||||||
|
position = (row["max_pos"] or 0) + 1
|
||||||
|
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO playlist_songs (playlist_id, song_id, position) VALUES (?,?,?)",
|
||||||
|
(playlist_id, song_id, position)
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def update_playlist_song_status(playlist_song_id: int, status: str):
|
||||||
|
valid = {"pending", "playing", "played", "skipped"}
|
||||||
|
if status not in valid:
|
||||||
|
raise ValueError(f"Ugyldig status: {status}")
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE playlist_songs SET status=? WHERE id=?",
|
||||||
|
(status, playlist_song_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_playlist_with_songs(playlist_id: int) -> dict:
|
||||||
|
with get_db() as conn:
|
||||||
|
playlist = conn.execute(
|
||||||
|
"SELECT * FROM playlists WHERE id=?", (playlist_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not playlist:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
songs = conn.execute("""
|
||||||
|
SELECT ps.id as ps_id, ps.position, ps.status,
|
||||||
|
s.*, GROUP_CONCAT(sd.dance_name ORDER BY sd.dance_order) as dances
|
||||||
|
FROM playlist_songs ps
|
||||||
|
JOIN songs s ON s.id = ps.song_id
|
||||||
|
LEFT JOIN song_dances sd ON sd.song_id = s.id
|
||||||
|
WHERE ps.playlist_id = ?
|
||||||
|
GROUP BY ps.id
|
||||||
|
ORDER BY ps.position
|
||||||
|
""", (playlist_id,)).fetchall()
|
||||||
|
|
||||||
|
return {"playlist": dict(playlist), "songs": [dict(s) for s in songs]}
|
||||||
280
linedance-api/local/tag_reader.py
Normal file
280
linedance-api/local/tag_reader.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""
|
||||||
|
tag_reader.py — Læser og skriver metadata fra lydfiler.
|
||||||
|
|
||||||
|
Understøttede formater og danse-tag support:
|
||||||
|
MP3 — læs + skriv danse (ID3 TXXX-felter)
|
||||||
|
FLAC — læs + skriv danse (Vorbis Comments)
|
||||||
|
OGG — læs + skriv danse (Vorbis Comments)
|
||||||
|
OPUS — læs + skriv danse (Vorbis Comments)
|
||||||
|
M4A — læs + skriv danse (MP4 custom felt ----:LINEDANCE:DANCE)
|
||||||
|
WAV — læs metadata, ingen danse-tag support
|
||||||
|
WMA — læs metadata, ingen danse-tag support
|
||||||
|
AIFF — læs metadata, ingen danse-tag support
|
||||||
|
|
||||||
|
Danse gemmes ALTID i SQLite uanset format.
|
||||||
|
Fil-skrivning er kun muligt for de formater der understøtter custom tags.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from mutagen import File as MutagenFile
|
||||||
|
from mutagen.id3 import ID3, TXXX
|
||||||
|
from mutagen.flac import FLAC
|
||||||
|
from mutagen.mp4 import MP4, MP4FreeForm
|
||||||
|
MUTAGEN_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
MUTAGEN_AVAILABLE = False
|
||||||
|
print("Advarsel: mutagen ikke installeret — tag-læsning deaktiveret")
|
||||||
|
|
||||||
|
|
||||||
|
# Filtyper vi høster metadata fra
|
||||||
|
SUPPORTED_EXTENSIONS = {
|
||||||
|
".mp3", ".flac", ".wav", ".m4a", ".aac",
|
||||||
|
".ogg", ".opus", ".wma", ".aiff", ".aif",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Formater der understøtter skrivning af danse-tags til fil
|
||||||
|
WRITABLE_DANCE_FORMATS = {".mp3", ".flac", ".ogg", ".opus", ".m4a"}
|
||||||
|
|
||||||
|
# Tag-nøgler brugt på tværs af formater
|
||||||
|
TXXX_DANCE_PREFIX = "LINEDANCE_DANCE_" # MP3: TXXX:LINEDANCE_DANCE_1
|
||||||
|
VORBIS_DANCE_KEY = "linedance_dance" # FLAC/OGG: linedance_dance.1
|
||||||
|
M4A_DANCE_FREEFORM = "----:LINEDANCE:DANCE" # M4A: ----:LINEDANCE:DANCE (liste)
|
||||||
|
|
||||||
|
|
||||||
|
def is_supported(path: str | Path) -> bool:
|
||||||
|
return Path(path).suffix.lower() in SUPPORTED_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def can_write_dances(path: str | Path) -> bool:
|
||||||
|
"""Returnerer True hvis formatet understøtter skrivning af danse-tags til fil."""
|
||||||
|
return Path(path).suffix.lower() in WRITABLE_DANCE_FORMATS
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_modified_at(path: str | Path) -> str:
|
||||||
|
ts = os.path.getmtime(str(path))
|
||||||
|
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Læsning ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def read_tags(path: str | Path) -> dict:
|
||||||
|
"""
|
||||||
|
Læser metadata og danse fra en lydfil.
|
||||||
|
Returnerer dict med: title, artist, album, bpm, duration_sec,
|
||||||
|
file_format, file_modified_at, dances, can_write_dances.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
result = {
|
||||||
|
"local_path": str(path),
|
||||||
|
"title": path.stem,
|
||||||
|
"artist": "",
|
||||||
|
"album": "",
|
||||||
|
"bpm": 0,
|
||||||
|
"duration_sec": 0,
|
||||||
|
"file_format": path.suffix.lower().lstrip("."),
|
||||||
|
"file_modified_at": get_file_modified_at(path),
|
||||||
|
"dances": [],
|
||||||
|
"can_write_dances": can_write_dances(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not MUTAGEN_AVAILABLE:
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio = MutagenFile(str(path), easy=False)
|
||||||
|
if audio is None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
if hasattr(audio, "info") and audio.info:
|
||||||
|
result["duration_sec"] = int(getattr(audio.info, "length", 0))
|
||||||
|
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
|
||||||
|
if ext == ".mp3":
|
||||||
|
_read_mp3(audio, result)
|
||||||
|
elif ext == ".flac":
|
||||||
|
_read_vorbis(audio, result)
|
||||||
|
elif ext in (".ogg", ".opus"):
|
||||||
|
_read_vorbis(audio, result)
|
||||||
|
elif ext in (".m4a", ".aac", ".mp4"):
|
||||||
|
_read_m4a(audio, result)
|
||||||
|
else:
|
||||||
|
_read_generic(audio, result)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Fejl ved læsning af {path}: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _read_mp3(audio, result: dict):
|
||||||
|
tags = audio.tags
|
||||||
|
if not tags:
|
||||||
|
return
|
||||||
|
if "TIT2" in tags:
|
||||||
|
result["title"] = str(tags["TIT2"].text[0])
|
||||||
|
if "TPE1" in tags:
|
||||||
|
result["artist"] = str(tags["TPE1"].text[0])
|
||||||
|
if "TALB" in tags:
|
||||||
|
result["album"] = str(tags["TALB"].text[0])
|
||||||
|
if "TBPM" in tags:
|
||||||
|
try:
|
||||||
|
result["bpm"] = int(float(str(tags["TBPM"].text[0])))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
dances = {}
|
||||||
|
for key, frame in tags.items():
|
||||||
|
if key.startswith("TXXX:") and TXXX_DANCE_PREFIX in key:
|
||||||
|
try:
|
||||||
|
num = int(key.replace(f"TXXX:{TXXX_DANCE_PREFIX}", ""))
|
||||||
|
dances[num] = str(frame.text[0])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
result["dances"] = [dances[k] for k in sorted(dances.keys())]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_vorbis(audio, result: dict):
|
||||||
|
"""FLAC og OGG/Opus bruger begge Vorbis Comments."""
|
||||||
|
tags = audio.tags
|
||||||
|
if not tags:
|
||||||
|
return
|
||||||
|
result["title"] = tags.get("title", [result["title"]])[0]
|
||||||
|
result["artist"] = tags.get("artist", [""])[0]
|
||||||
|
result["album"] = tags.get("album", [""])[0]
|
||||||
|
try:
|
||||||
|
result["bpm"] = int(tags.get("bpm", [0])[0])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
# Danse gemmes som linedance_dance.1, linedance_dance.2 ...
|
||||||
|
dances = {}
|
||||||
|
for key, values in tags.items():
|
||||||
|
if key.lower().startswith(f"{VORBIS_DANCE_KEY}."):
|
||||||
|
try:
|
||||||
|
num = int(key.split(".")[-1])
|
||||||
|
dances[num] = values[0]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
# Fallback: enkelt felt linedance_dance med komma-separeret liste
|
||||||
|
if not dances and VORBIS_DANCE_KEY in tags:
|
||||||
|
result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()]
|
||||||
|
return
|
||||||
|
result["dances"] = [dances[k] for k in sorted(dances.keys())]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_m4a(audio, result: dict):
|
||||||
|
tags = audio.tags
|
||||||
|
if not tags:
|
||||||
|
return
|
||||||
|
if "\xa9nam" in tags:
|
||||||
|
result["title"] = str(tags["\xa9nam"][0])
|
||||||
|
if "\xa9ART" in tags:
|
||||||
|
result["artist"] = str(tags["\xa9ART"][0])
|
||||||
|
if "\xa9alb" in tags:
|
||||||
|
result["album"] = str(tags["\xa9alb"][0])
|
||||||
|
if "tmpo" in tags:
|
||||||
|
try:
|
||||||
|
result["bpm"] = int(tags["tmpo"][0])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
# Danse gemmes som ----:LINEDANCE:DANCE — én værdi per dans
|
||||||
|
if M4A_DANCE_FREEFORM in tags:
|
||||||
|
result["dances"] = [
|
||||||
|
v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v)
|
||||||
|
for v in tags[M4A_DANCE_FREEFORM]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_generic(audio, result: dict):
|
||||||
|
try:
|
||||||
|
easy = MutagenFile(result["local_path"], easy=True)
|
||||||
|
if easy and easy.tags:
|
||||||
|
result["title"] = easy.tags.get("title", [result["title"]])[0]
|
||||||
|
result["artist"] = easy.tags.get("artist", [""])[0]
|
||||||
|
result["album"] = easy.tags.get("album", [""])[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Skrivning ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def write_dances(path: str | Path, dances: list[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Skriver danse til filen hvis formatet understøtter det.
|
||||||
|
Returnerer True ved succes, False hvis formatet ikke understøtter det.
|
||||||
|
Kaster Exception ved fejl under skrivning.
|
||||||
|
"""
|
||||||
|
if not MUTAGEN_AVAILABLE:
|
||||||
|
return False
|
||||||
|
|
||||||
|
path = Path(path)
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
|
||||||
|
if ext not in WRITABLE_DANCE_FORMATS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if ext == ".mp3":
|
||||||
|
return _write_mp3_dances(path, dances)
|
||||||
|
elif ext in (".flac", ".ogg", ".opus"):
|
||||||
|
return _write_vorbis_dances(path, dances)
|
||||||
|
elif ext in (".m4a", ".aac"):
|
||||||
|
return _write_m4a_dances(path, dances)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _write_mp3_dances(path: Path, dances: list[str]) -> bool:
|
||||||
|
try:
|
||||||
|
tags = ID3(str(path))
|
||||||
|
for key in [k for k in tags.keys() if TXXX_DANCE_PREFIX in k]:
|
||||||
|
del tags[key]
|
||||||
|
for i, name in enumerate(dances, start=1):
|
||||||
|
tags.add(TXXX(encoding=3, desc=f"{TXXX_DANCE_PREFIX}{i}", text=name))
|
||||||
|
tags.save(str(path))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"MP3 skrivefejl {path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _write_vorbis_dances(path: Path, dances: list[str]) -> bool:
|
||||||
|
try:
|
||||||
|
audio = MutagenFile(str(path), easy=False)
|
||||||
|
if audio is None or audio.tags is None:
|
||||||
|
return False
|
||||||
|
# Slet eksisterende danse-felter
|
||||||
|
keys_to_delete = [k for k in audio.tags.keys() if k.lower().startswith(f"{VORBIS_DANCE_KEY}.")]
|
||||||
|
for key in keys_to_delete:
|
||||||
|
del audio.tags[key]
|
||||||
|
# Skriv nye — ét felt per dans
|
||||||
|
for i, name in enumerate(dances, start=1):
|
||||||
|
audio.tags[f"{VORBIS_DANCE_KEY}.{i}"] = name
|
||||||
|
audio.save()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Vorbis skrivefejl {path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _write_m4a_dances(path: Path, dances: list[str]) -> bool:
|
||||||
|
try:
|
||||||
|
audio = MP4(str(path))
|
||||||
|
audio.tags[M4A_DANCE_FREEFORM] = [
|
||||||
|
MP4FreeForm(name.encode("utf-8")) for name in dances
|
||||||
|
]
|
||||||
|
audio.save()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"M4A skrivefejl {path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Hurtig læsning af kun danse (uden fuld tag-scan) ─────────────────────────
|
||||||
|
|
||||||
|
def read_dances_from_file(path: str | Path) -> list[str]:
|
||||||
|
"""Læser kun danse fra en fil — hurtigere end fuld read_tags()."""
|
||||||
|
tags = read_tags(path)
|
||||||
|
return tags.get("dances", [])
|
||||||
14
linedance-api/requirements.txt
Normal file
14
linedance-api/requirements.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
fastapi>=0.111.0
|
||||||
|
uvicorn[standard]>=0.29.0
|
||||||
|
sqlalchemy>=2.0.0
|
||||||
|
pymysql>=1.1.0
|
||||||
|
alembic>=1.13.0
|
||||||
|
bcrypt>=4.0.0
|
||||||
|
python-jose[cryptography]>=3.3.0
|
||||||
|
pydantic[email]>=2.0.0
|
||||||
|
pydantic-settings>=2.0.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
aiosmtplib>=3.0.0
|
||||||
|
jinja2>=3.1.0
|
||||||
|
cryptography>=42.0.0
|
||||||
24
linedance-api/start.sh
Executable file
24
linedance-api/start.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
echo "Forbinder til database..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
python -c "
|
||||||
|
import pymysql, os, re
|
||||||
|
url = os.environ.get('DATABASE_URL', '')
|
||||||
|
m = re.match(r'mysql\+pymysql://([^:]+):([^@]+)@([^:/]+):?(\d+)?/(\w+)', url)
|
||||||
|
if not m:
|
||||||
|
exit(1)
|
||||||
|
user, password, host, port, db = m.groups()
|
||||||
|
port = int(port or 3306)
|
||||||
|
try:
|
||||||
|
conn = pymysql.connect(host=host, port=port, user=user, password=password, database=db)
|
||||||
|
conn.close()
|
||||||
|
print('Database OK')
|
||||||
|
exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Venter på database... ({e})')
|
||||||
|
exit(1)
|
||||||
|
" && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
14
linedance-api/start_local.bat
Normal file
14
linedance-api/start_local.bat
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@echo off
|
||||||
|
echo Starter LineDance API lokalt...
|
||||||
|
cd /d %~dp0
|
||||||
|
if not exist venv (
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\pip install -r requirements.txt
|
||||||
|
)
|
||||||
|
if not exist .env (
|
||||||
|
copy .env.example .env
|
||||||
|
echo.
|
||||||
|
echo VIGTIGT: Rediger .env med dine database-indstillinger!
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
venv\Scripts\uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
3
linedance-api/web/Dockerfile
Normal file
3
linedance-api/web/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY public /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
25
linedance-api/web/nginx.conf
Normal file
25
linedance-api/web/nginx.conf
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
server {
|
||||||
|
listen 8001;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index app.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /download/ {
|
||||||
|
alias /usr/share/nginx/html/download/;
|
||||||
|
add_header Content-Disposition "attachment";
|
||||||
|
autoindex off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/html text/css application/javascript;
|
||||||
|
}
|
||||||
586
linedance-api/web/public/app.html
Normal file
586
linedance-api/web/public/app.html
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LineDance Player</title>
|
||||||
|
<meta name="description" content="Professionel afspiller til linedance-arrangører. Styr din danseliste, tag danse og del med holdet.">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,wght@0,300;0,400;0,500;0,700;1,400&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0c0d10;
|
||||||
|
--surface: #14161b;
|
||||||
|
--border: #24272f;
|
||||||
|
--accent: #e8a020;
|
||||||
|
--accent2: #c47a10;
|
||||||
|
--text: #eceef4;
|
||||||
|
--muted: #6b7080;
|
||||||
|
--green: #2ecc71;
|
||||||
|
--mono: 'DM Mono', monospace;
|
||||||
|
--sans: 'DM Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: var(--sans); line-height: 1.7; overflow-x: hidden; }
|
||||||
|
|
||||||
|
/* ── Nav ── */
|
||||||
|
nav {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||||
|
display: flex; align-items: center; padding: 0 2rem; height: 64px;
|
||||||
|
background: rgba(12,13,16,.92); backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.nav-logo { font-family: var(--mono); font-size: .95rem; letter-spacing: .06em; }
|
||||||
|
.nav-logo span { color: var(--accent); }
|
||||||
|
.nav-links { margin-left: auto; display: flex; gap: 2rem; align-items: center; }
|
||||||
|
.nav-links a { color: var(--muted); text-decoration: none; font-size: .9rem; transition: color .15s; }
|
||||||
|
.nav-links a:hover { color: var(--text); }
|
||||||
|
.lang-btn {
|
||||||
|
font-family: var(--mono); font-size: .75rem; padding: .3rem .7rem;
|
||||||
|
border: 1px solid var(--border); border-radius: 5px; background: none;
|
||||||
|
color: var(--muted); cursor: pointer; transition: all .15s;
|
||||||
|
}
|
||||||
|
.lang-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||||
|
.lang-btn.active { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Hero ── */
|
||||||
|
.hero {
|
||||||
|
min-height: 100vh; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
padding: 8rem 2rem 4rem; text-align: center;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute; top: 20%; left: 50%; transform: translateX(-50%);
|
||||||
|
width: 600px; height: 600px;
|
||||||
|
background: radial-gradient(circle, rgba(232,160,32,.07) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.hero-tag {
|
||||||
|
font-family: var(--mono); font-size: .72rem; letter-spacing: .2em;
|
||||||
|
text-transform: uppercase; color: var(--accent); margin-bottom: 1.5rem;
|
||||||
|
display: inline-flex; align-items: center; gap: .5rem;
|
||||||
|
}
|
||||||
|
.hero-tag::before, .hero-tag::after {
|
||||||
|
content: ''; display: block; width: 24px; height: 1px; background: var(--accent); opacity: .5;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(2.5rem, 7vw, 5rem);
|
||||||
|
font-weight: 700; line-height: 1.05; margin-bottom: 1.5rem;
|
||||||
|
letter-spacing: -.02em;
|
||||||
|
}
|
||||||
|
h1 em { color: var(--accent); font-style: normal; }
|
||||||
|
.hero-sub {
|
||||||
|
font-size: clamp(1rem, 2.5vw, 1.25rem); color: var(--muted);
|
||||||
|
max-width: 540px; margin: 0 auto 2.5rem;
|
||||||
|
}
|
||||||
|
.hero-btns { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: .5rem;
|
||||||
|
font-family: var(--sans); font-size: .95rem; font-weight: 600;
|
||||||
|
padding: .75rem 1.75rem; border-radius: 8px;
|
||||||
|
border: 1px solid var(--border); text-decoration: none;
|
||||||
|
transition: all .2s; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn.primary { background: var(--accent); border-color: var(--accent); color: #111; }
|
||||||
|
.btn.primary:hover { background: var(--accent2); border-color: var(--accent2); transform: translateY(-1px); }
|
||||||
|
.btn.secondary { background: transparent; color: var(--text); }
|
||||||
|
.btn.secondary:hover { background: var(--surface); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.hero-note { font-size: .8rem; color: var(--muted); margin-top: 1rem; }
|
||||||
|
|
||||||
|
/* ── Screenshot mockup ── */
|
||||||
|
.hero-screen {
|
||||||
|
margin-top: 4rem; position: relative; max-width: 900px; width: 100%;
|
||||||
|
}
|
||||||
|
.screen-frame {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; overflow: hidden;
|
||||||
|
box-shadow: 0 40px 80px rgba(0,0,0,.5);
|
||||||
|
}
|
||||||
|
.screen-bar {
|
||||||
|
background: #1a1c22; padding: .5rem 1rem;
|
||||||
|
display: flex; align-items: center; gap: .5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.screen-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||||
|
.screen-title { font-size: .75rem; color: var(--muted); margin: 0 auto; font-family: var(--mono); }
|
||||||
|
.screen-body {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr; min-height: 280px;
|
||||||
|
}
|
||||||
|
.screen-left { padding: 1.25rem; border-right: 1px solid var(--border); }
|
||||||
|
.screen-right { padding: 1.25rem; }
|
||||||
|
|
||||||
|
.screen-section { font-family: var(--mono); font-size: .6rem; letter-spacing: .12em; color: var(--muted); text-transform: uppercase; margin-bottom: .75rem; padding-bottom: .5rem; border-bottom: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.screen-song { margin-bottom: .6rem; padding: .5rem .75rem; border-radius: 6px; background: rgba(232,160,32,.08); border: 1px solid rgba(232,160,32,.2); }
|
||||||
|
.screen-song .s-num { font-size: .65rem; color: var(--accent); font-family: var(--mono); }
|
||||||
|
.screen-song .s-title { font-size: .78rem; font-weight: 600; }
|
||||||
|
.screen-song .s-dance { font-size: .65rem; color: var(--accent); }
|
||||||
|
.screen-song-dim { margin-bottom: .4rem; padding: .4rem .75rem; border-radius: 6px; opacity: .45; }
|
||||||
|
.screen-song-dim .s-title { font-size: .75rem; }
|
||||||
|
.screen-song-dim .s-dance { font-size: .63rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.screen-lib-item { display: flex; align-items: center; gap: .5rem; padding: .3rem 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.screen-lib-item:last-child { border: none; }
|
||||||
|
.screen-lib-item .li-title { font-size: .72rem; flex: 1; }
|
||||||
|
.screen-lib-item .li-dance { font-size: .63rem; color: var(--accent); font-family: var(--mono); }
|
||||||
|
.screen-lib-item.selected { background: rgba(255,255,255,.04); border-radius: 4px; padding-left: .4rem; }
|
||||||
|
|
||||||
|
/* ── Features ── */
|
||||||
|
section { padding: 5rem 2rem; max-width: 1100px; margin: 0 auto; }
|
||||||
|
.section-tag { font-family: var(--mono); font-size: .7rem; letter-spacing: .18em; text-transform: uppercase; color: var(--accent); margin-bottom: 1rem; }
|
||||||
|
h2 { font-size: clamp(1.8rem, 4vw, 2.8rem); font-weight: 700; line-height: 1.15; margin-bottom: 1rem; letter-spacing: -.02em; }
|
||||||
|
h2 em { color: var(--accent); font-style: normal; }
|
||||||
|
.section-sub { color: var(--muted); font-size: 1.05rem; max-width: 540px; margin-bottom: 3rem; }
|
||||||
|
|
||||||
|
.features { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.25rem; }
|
||||||
|
.feature {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 1.5rem; transition: border-color .2s;
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.feature::before {
|
||||||
|
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
|
||||||
|
background: var(--accent); transform: scaleX(0); transition: transform .2s; transform-origin: left;
|
||||||
|
}
|
||||||
|
.feature:hover { border-color: rgba(232,160,32,.4); }
|
||||||
|
.feature:hover::before { transform: scaleX(1); }
|
||||||
|
.feature-icon { font-size: 1.5rem; margin-bottom: .75rem; }
|
||||||
|
.feature h3 { font-size: 1rem; font-weight: 600; margin-bottom: .4rem; }
|
||||||
|
.feature p { font-size: .88rem; color: var(--muted); line-height: 1.6; }
|
||||||
|
|
||||||
|
/* ── How it works ── */
|
||||||
|
.how-section { background: var(--surface); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||||
|
.how-section section { padding: 5rem 2rem; }
|
||||||
|
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 2rem; }
|
||||||
|
.step { text-align: center; }
|
||||||
|
.step-num {
|
||||||
|
font-family: var(--mono); font-size: 2.5rem; font-weight: 500;
|
||||||
|
color: var(--accent); opacity: .3; line-height: 1; margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
.step h3 { font-size: .95rem; font-weight: 600; margin-bottom: .4rem; }
|
||||||
|
.step p { font-size: .83rem; color: var(--muted); }
|
||||||
|
|
||||||
|
/* ── Download ── */
|
||||||
|
.download-section { text-align: center; }
|
||||||
|
.download-cards { display: flex; gap: 1.25rem; justify-content: center; flex-wrap: wrap; margin-top: 2.5rem; }
|
||||||
|
.download-card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 2rem 2.5rem; min-width: 220px;
|
||||||
|
transition: all .2s;
|
||||||
|
}
|
||||||
|
.download-card:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||||
|
.download-card .platform { font-size: 2rem; margin-bottom: .75rem; }
|
||||||
|
.download-card h3 { font-size: 1rem; font-weight: 600; margin-bottom: .25rem; }
|
||||||
|
.download-card .version { font-family: var(--mono); font-size: .72rem; color: var(--muted); margin-bottom: 1.25rem; }
|
||||||
|
.download-card .coming-soon { font-size: .8rem; color: var(--muted); padding: .5rem 0; }
|
||||||
|
|
||||||
|
/* ── Guide ── */
|
||||||
|
.guide-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; align-items: start; }
|
||||||
|
@media (max-width: 700px) { .guide-grid { grid-template-columns: 1fr; } .screen-body { grid-template-columns: 1fr; } }
|
||||||
|
.guide-steps { display: flex; flex-direction: column; gap: 1.5rem; }
|
||||||
|
.guide-step { display: flex; gap: 1rem; }
|
||||||
|
.guide-num {
|
||||||
|
font-family: var(--mono); font-size: .75rem; font-weight: 500;
|
||||||
|
color: var(--accent); background: rgba(232,160,32,.1);
|
||||||
|
border: 1px solid rgba(232,160,32,.3); border-radius: 5px;
|
||||||
|
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0; margin-top: .15rem;
|
||||||
|
}
|
||||||
|
.guide-step h4 { font-size: .92rem; font-weight: 600; margin-bottom: .2rem; }
|
||||||
|
.guide-step p { font-size: .84rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.guide-visual {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; overflow: hidden; position: sticky; top: 5rem;
|
||||||
|
}
|
||||||
|
.guide-visual-bar {
|
||||||
|
background: #1a1c22; padding: .4rem 1rem; border-bottom: 1px solid var(--border);
|
||||||
|
font-family: var(--mono); font-size: .65rem; color: var(--muted);
|
||||||
|
}
|
||||||
|
.guide-visual-body { padding: 1.25rem; }
|
||||||
|
.gv-row { display: flex; align-items: center; gap: .6rem; padding: .4rem 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.gv-row:last-child { border: none; }
|
||||||
|
.gv-num { font-family: var(--mono); font-size: .65rem; color: var(--muted); width: 1.2rem; }
|
||||||
|
.gv-dance { font-size: .82rem; font-weight: 600; flex: 1; }
|
||||||
|
.gv-song { font-size: .72rem; color: var(--muted); }
|
||||||
|
.gv-status { font-size: .65rem; color: var(--green); }
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--border); padding: 2rem;
|
||||||
|
text-align: center; font-size: .82rem; color: var(--muted);
|
||||||
|
}
|
||||||
|
footer a { color: var(--accent); text-decoration: none; }
|
||||||
|
|
||||||
|
/* ── Animations ── */
|
||||||
|
.fade-up { opacity: 0; transform: translateY(24px); transition: opacity .6s ease, transform .6s ease; }
|
||||||
|
.fade-up.visible { opacity: 1; transform: none; }
|
||||||
|
|
||||||
|
/* Hide by lang */
|
||||||
|
[data-lang="en"] { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<div class="nav-logo">LINE<span>DANCE</span> PLAYER</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="#features" data-lang="da">Funktioner</a>
|
||||||
|
<a href="#features" data-lang="en" style="display:none">Features</a>
|
||||||
|
<a href="#guide" data-lang="da">Guide</a>
|
||||||
|
<a href="#guide" data-lang="en" style="display:none">Guide</a>
|
||||||
|
<a href="#download" data-lang="da">Download</a>
|
||||||
|
<a href="#download" data-lang="en" style="display:none">Download</a>
|
||||||
|
<a href="index.html" data-lang="da">Playlister</a>
|
||||||
|
<a href="index.html" data-lang="en" style="display:none">Playlists</a>
|
||||||
|
<button class="lang-btn active" id="btn-da" onclick="setLang('da')">DA</button>
|
||||||
|
<button class="lang-btn" id="btn-en" onclick="setLang('en')">EN</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- HERO -->
|
||||||
|
<div class="hero">
|
||||||
|
<div class="hero-tag" data-lang="da">Til linedance-arrangører</div>
|
||||||
|
<div class="hero-tag" data-lang="en">For linedance organizers</div>
|
||||||
|
|
||||||
|
<h1 data-lang="da">Styr din<br><em>danseliste</em></h1>
|
||||||
|
<h1 data-lang="en">Control your<br><em>dance list</em></h1>
|
||||||
|
|
||||||
|
<p class="hero-sub" data-lang="da">LineDance Player er et gratis afspilningsprogram til Windows der gør det nemt at styre musik, dans-tags og danselister til linedance-events.</p>
|
||||||
|
<p class="hero-sub" data-lang="en">LineDance Player is a free Windows application that makes it easy to manage music, dance tags and playlists for linedance events.</p>
|
||||||
|
|
||||||
|
<div class="hero-btns">
|
||||||
|
<a href="#download" class="btn primary" data-lang="da">⬇ Download til Windows</a>
|
||||||
|
<a href="#download" class="btn primary" data-lang="en">⬇ Download for Windows</a>
|
||||||
|
<a href="#features" class="btn secondary" data-lang="da">Se funktioner</a>
|
||||||
|
<a href="#features" class="btn secondary" data-lang="en">See features</a>
|
||||||
|
</div>
|
||||||
|
<p class="hero-note" data-lang="da">Gratis · Open source · Kræver Windows 10/11</p>
|
||||||
|
<p class="hero-note" data-lang="en">Free · Open source · Requires Windows 10/11</p>
|
||||||
|
|
||||||
|
<!-- Skærm-mockup -->
|
||||||
|
<div class="hero-screen fade-up">
|
||||||
|
<div class="screen-frame">
|
||||||
|
<div class="screen-bar">
|
||||||
|
<div class="screen-dot" style="background:#e74c3c"></div>
|
||||||
|
<div class="screen-dot" style="background:#f39c12"></div>
|
||||||
|
<div class="screen-dot" style="background:#2ecc71"></div>
|
||||||
|
<div class="screen-title">LineDance Player</div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-body">
|
||||||
|
<div class="screen-left">
|
||||||
|
<div class="screen-section" data-lang="da">Danseliste — Tirsdag hold 1</div>
|
||||||
|
<div class="screen-section" data-lang="en">Dance list — Tuesday group 1</div>
|
||||||
|
<div class="screen-song">
|
||||||
|
<div class="s-num">▶ 3.</div>
|
||||||
|
<div class="s-title">Roll back the rug</div>
|
||||||
|
<div class="s-dance">Cut a Rug · Begynder</div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-song-dim">
|
||||||
|
<div class="s-title">The boys and me</div>
|
||||||
|
<div class="s-dance">Cowboy Strut · Begynder</div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-song-dim">
|
||||||
|
<div class="s-title">Mambo No. 5</div>
|
||||||
|
<div class="s-dance">Mambo No. 5 · Begynder</div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-song-dim">
|
||||||
|
<div class="s-title">Achy Breaky Heart</div>
|
||||||
|
<div class="s-dance">Electric Slide · Absolut begynder</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-right">
|
||||||
|
<div class="screen-section" data-lang="da">Bibliotek — 185 sange</div>
|
||||||
|
<div class="screen-section" data-lang="en">Library — 185 songs</div>
|
||||||
|
<div class="screen-lib-item selected">
|
||||||
|
<div class="li-title">How Much Beer</div>
|
||||||
|
<div class="li-dance">How Much Beer - High beginner</div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-lib-item">
|
||||||
|
<div class="li-title">The boys and me</div>
|
||||||
|
<div class="li-dance">Cowboy Strut - Begynder</div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-lib-item">
|
||||||
|
<div class="li-title">Mama Tried</div>
|
||||||
|
<div class="li-dance">Mama Tried - High beginner</div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-lib-item">
|
||||||
|
<div class="li-title">You Just Can't See Him from the Road</div>
|
||||||
|
<div class="li-dance">Sunset Road - Beginner</div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-lib-item">
|
||||||
|
<div class="li-title">Risk It All</div>
|
||||||
|
<div class="li-dance">Risk It All Rumba - Improver</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FEATURES -->
|
||||||
|
<section id="features">
|
||||||
|
<div class="section-tag" data-lang="da">Funktioner</div>
|
||||||
|
<div class="section-tag" data-lang="en">Features</div>
|
||||||
|
<h2 data-lang="da">Alt du har brug for<br><em>til dit event</em></h2>
|
||||||
|
<h2 data-lang="en">Everything you need<br><em>for your event</em></h2>
|
||||||
|
<p class="section-sub" data-lang="da">Designet specifikt til linedance-arrangører — fra musikbibliotek til storskærm.</p>
|
||||||
|
<p class="section-sub" data-lang="en">Designed specifically for linedance organizers — from music library to big screen.</p>
|
||||||
|
|
||||||
|
<div class="features">
|
||||||
|
<div class="feature fade-up">
|
||||||
|
<div class="feature-icon">🎵</div>
|
||||||
|
<h3 data-lang="da">Musikbibliotek</h3>
|
||||||
|
<h3 data-lang="en">Music library</h3>
|
||||||
|
<p data-lang="da">Scan dine mapper automatisk. MP3, FLAC, M4A og mere. BPM-analyse og dans-tags gemmes direkte i filen.</p>
|
||||||
|
<p data-lang="en">Automatically scan your folders. MP3, FLAC, M4A and more. BPM analysis and dance tags saved directly in the file.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature fade-up">
|
||||||
|
<div class="feature-icon">💃</div>
|
||||||
|
<h3 data-lang="da">Dans-tags</h3>
|
||||||
|
<h3 data-lang="en">Dance tags</h3>
|
||||||
|
<p data-lang="da">Tag hver sang med dans, niveau og koreograf. Tags synkroniseres til serveren og deles med andre arrangører.</p>
|
||||||
|
<p data-lang="en">Tag each song with dance, level and choreographer. Tags sync to the server and are shared with other organizers.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature fade-up">
|
||||||
|
<div class="feature-icon">📋</div>
|
||||||
|
<h3 data-lang="da">Danselister</h3>
|
||||||
|
<h3 data-lang="en">Dance playlists</h3>
|
||||||
|
<p data-lang="da">Opret og gem danselister. Del med andre arrangører. Kopiér public lister fra hjemmesiden.</p>
|
||||||
|
<p data-lang="en">Create and save dance playlists. Share with other organizers. Copy public lists from the website.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature fade-up">
|
||||||
|
<div class="feature-icon">🖥️</div>
|
||||||
|
<h3 data-lang="da">Storskærm</h3>
|
||||||
|
<h3 data-lang="en">Big screen</h3>
|
||||||
|
<p data-lang="da">Live-visning til storskærm og mobil. Vis aktuel dans og næste på programmet — opdateres automatisk.</p>
|
||||||
|
<p data-lang="en">Live display for big screen and mobile. Shows current dance and what's next — updates automatically.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature fade-up">
|
||||||
|
<div class="feature-icon">🎧</div>
|
||||||
|
<h3 data-lang="da">To lydudgange</h3>
|
||||||
|
<h3 data-lang="en">Two audio outputs</h3>
|
||||||
|
<p data-lang="da">Hoved-afspiller til salen og preview-afspiller til høretelefonerne. Hør næste sang inden du starter den.</p>
|
||||||
|
<p data-lang="en">Main player for the hall and preview player for headphones. Listen to the next song before you start it.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature fade-up">
|
||||||
|
<div class="feature-icon">☁️</div>
|
||||||
|
<h3 data-lang="da">Cloud-sync</h3>
|
||||||
|
<h3 data-lang="en">Cloud sync</h3>
|
||||||
|
<p data-lang="da">Synkronisér bibliotek, tags og playlister til linedanceplayer.dk. Fungerer offline og synkroniserer når du er online.</p>
|
||||||
|
<p data-lang="en">Sync library, tags and playlists to linedanceplayer.dk. Works offline and syncs when you're online.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HOW IT WORKS -->
|
||||||
|
<div class="how-section">
|
||||||
|
<section>
|
||||||
|
<div class="section-tag" data-lang="da">Sådan virker det</div>
|
||||||
|
<div class="section-tag" data-lang="en">How it works</div>
|
||||||
|
<h2 style="margin-bottom:2.5rem" data-lang="da">Klar på <em>få minutter</em></h2>
|
||||||
|
<h2 style="margin-bottom:2.5rem" data-lang="en">Ready in <em>minutes</em></h2>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step fade-up">
|
||||||
|
<div class="step-num">01</div>
|
||||||
|
<h3 data-lang="da">Download og installer</h3>
|
||||||
|
<h3 data-lang="en">Download and install</h3>
|
||||||
|
<p data-lang="da">Kør installationsprogrammet. VLC installeres automatisk hvis det mangler.</p>
|
||||||
|
<p data-lang="en">Run the installer. VLC is installed automatically if missing.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step fade-up">
|
||||||
|
<div class="step-num">02</div>
|
||||||
|
<h3 data-lang="da">Tilføj musikmappe</h3>
|
||||||
|
<h3 data-lang="en">Add music folder</h3>
|
||||||
|
<p data-lang="da">Peg på din musikmappe — appen scanner automatisk og finder alle sange.</p>
|
||||||
|
<p data-lang="en">Point to your music folder — the app scans automatically and finds all songs.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step fade-up">
|
||||||
|
<div class="step-num">03</div>
|
||||||
|
<h3 data-lang="da">Tag dine sange</h3>
|
||||||
|
<h3 data-lang="en">Tag your songs</h3>
|
||||||
|
<p data-lang="da">Klik på "Danse" ved siden af en sang og tildel dans, niveau og koreograf.</p>
|
||||||
|
<p data-lang="en">Click "Dance" next to a song and assign dance, level and choreographer.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step fade-up">
|
||||||
|
<div class="step-num">04</div>
|
||||||
|
<h3 data-lang="da">Byg din danseliste</h3>
|
||||||
|
<h3 data-lang="en">Build your dance list</h3>
|
||||||
|
<p data-lang="da">Træk sange ind i danselisten. Gem og del med dit hold.</p>
|
||||||
|
<p data-lang="en">Drag songs into the dance list. Save and share with your team.</p>
|
||||||
|
</div>
|
||||||
|
<div class="step fade-up">
|
||||||
|
<div class="step-num">05</div>
|
||||||
|
<h3 data-lang="da">Start event</h3>
|
||||||
|
<h3 data-lang="en">Start event</h3>
|
||||||
|
<p data-lang="da">Tryk "START EVENT" og styr afspilningen. Storskærmen opdateres automatisk.</p>
|
||||||
|
<p data-lang="en">Press "START EVENT" and control playback. The big screen updates automatically.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GUIDE -->
|
||||||
|
<section id="guide">
|
||||||
|
<div class="section-tag" data-lang="da">Brugerguide</div>
|
||||||
|
<div class="section-tag" data-lang="en">User guide</div>
|
||||||
|
<h2 data-lang="da">Danselisten <em>under eventet</em></h2>
|
||||||
|
<h2 data-lang="en">The dance list <em>during the event</em></h2>
|
||||||
|
<p class="section-sub" data-lang="da">Når eventet starter styrer du alt fra ét vindue.</p>
|
||||||
|
<p class="section-sub" data-lang="en">When the event starts you control everything from one window.</p>
|
||||||
|
|
||||||
|
<div class="guide-grid">
|
||||||
|
<div class="guide-steps">
|
||||||
|
<div class="guide-step fade-up">
|
||||||
|
<div class="guide-num">1</div>
|
||||||
|
<div>
|
||||||
|
<h4 data-lang="da">Vælg afspilningstilstand</h4>
|
||||||
|
<h4 data-lang="en">Choose playback mode</h4>
|
||||||
|
<p data-lang="da">Manuel, auto-demo eller auto-play. Auto-demo afspiller en forsmag på næste sang inden den starter.</p>
|
||||||
|
<p data-lang="en">Manual, auto-demo or auto-play. Auto-demo plays a preview of the next song before it starts.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="guide-step fade-up">
|
||||||
|
<div class="guide-num">2</div>
|
||||||
|
<div>
|
||||||
|
<h4 data-lang="da">Tryk START EVENT</h4>
|
||||||
|
<h4 data-lang="en">Press START EVENT</h4>
|
||||||
|
<p data-lang="da">Den første sang indlæses klar. Tryk ▶ for at starte musikken.</p>
|
||||||
|
<p data-lang="en">The first song is loaded and ready. Press ▶ to start the music.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="guide-step fade-up">
|
||||||
|
<div class="guide-num">3</div>
|
||||||
|
<div>
|
||||||
|
<h4 data-lang="da">Følg listen</h4>
|
||||||
|
<h4 data-lang="en">Follow the list</h4>
|
||||||
|
<p data-lang="da">Orange = spiller. Blå = næste. Grøn = afspillet. Brug højreklik til at springe over eller ændre status.</p>
|
||||||
|
<p data-lang="en">Orange = playing. Blue = next. Green = played. Right-click to skip or change status.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="guide-step fade-up">
|
||||||
|
<div class="guide-num">4</div>
|
||||||
|
<div>
|
||||||
|
<h4 data-lang="da">Storskærm til deltagerne</h4>
|
||||||
|
<h4 data-lang="en">Big screen for participants</h4>
|
||||||
|
<p data-lang="da">Åbn storskærm-linket på en tablet eller TV. Viser aktuel dans og resten af programmet live.</p>
|
||||||
|
<p data-lang="en">Open the big screen link on a tablet or TV. Shows current dance and the rest of the program live.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="guide-visual fade-up">
|
||||||
|
<div class="guide-visual-bar">Danseliste — Tirsdag hold 1 · 3/8 afspillet</div>
|
||||||
|
<div class="guide-visual-body">
|
||||||
|
<div class="gv-row" style="opacity:.4">
|
||||||
|
<span class="gv-num">1</span>
|
||||||
|
<span class="gv-dance">Cut a Rug</span>
|
||||||
|
<span class="gv-song">Angeleyes</span>
|
||||||
|
<span class="gv-status">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="gv-row" style="opacity:.4">
|
||||||
|
<span class="gv-num">2</span>
|
||||||
|
<span class="gv-dance">Cowboy Strut</span>
|
||||||
|
<span class="gv-song">Waterloo</span>
|
||||||
|
<span class="gv-status">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="gv-row" style="background:rgba(232,160,32,.08);border-radius:6px;padding:.3rem .5rem;border:1px solid rgba(232,160,32,.2)">
|
||||||
|
<span class="gv-num" style="color:var(--accent)">▶</span>
|
||||||
|
<span class="gv-dance" style="color:var(--accent)">Electric Slide</span>
|
||||||
|
<span class="gv-song">Dancing Queen</span>
|
||||||
|
<span class="gv-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="gv-row" style="color:var(--muted)">
|
||||||
|
<span class="gv-num">4</span>
|
||||||
|
<span class="gv-dance">Mambo No. 5</span>
|
||||||
|
<span class="gv-song">Gimme! Gimme!</span>
|
||||||
|
<span class="gv-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="gv-row" style="color:var(--muted)">
|
||||||
|
<span class="gv-num">5</span>
|
||||||
|
<span class="gv-dance">Boot Scootin'</span>
|
||||||
|
<span class="gv-song">Fernando</span>
|
||||||
|
<span class="gv-status"></span>
|
||||||
|
</div>
|
||||||
|
<div class="gv-row" style="color:var(--muted)">
|
||||||
|
<span class="gv-num">6</span>
|
||||||
|
<span class="gv-dance">Tush Push</span>
|
||||||
|
<span class="gv-song">Super Trouper</span>
|
||||||
|
<span class="gv-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- DOWNLOAD -->
|
||||||
|
<div class="how-section">
|
||||||
|
<section id="download" class="download-section">
|
||||||
|
<div class="section-tag" data-lang="da">Download</div>
|
||||||
|
<div class="section-tag" data-lang="en">Download</div>
|
||||||
|
<h2 data-lang="da">Kom i gang <em>i dag</em></h2>
|
||||||
|
<h2 data-lang="en">Get started <em>today</em></h2>
|
||||||
|
<p class="section-sub" style="margin:0 auto" data-lang="da">Gratis at downloade og bruge. Opret en konto for at synkronisere og dele.</p>
|
||||||
|
<p class="section-sub" style="margin:0 auto" data-lang="en">Free to download and use. Create an account to sync and share.</p>
|
||||||
|
|
||||||
|
<div class="download-cards">
|
||||||
|
<div class="download-card">
|
||||||
|
<div class="platform">🪟</div>
|
||||||
|
<h3>Windows</h3>
|
||||||
|
<div class="version" id="win-version">Version 1.0 · 64-bit</div>
|
||||||
|
<a href="/download/LineDancePlayer-Setup.exe" class="btn primary" style="width:100%;justify-content:center" data-lang="da">⬇ Download .exe</a>
|
||||||
|
<a href="/download/LineDancePlayer-Setup.exe" class="btn primary" style="width:100%;justify-content:center" data-lang="en">⬇ Download .exe</a>
|
||||||
|
<p style="font-size:.72rem;color:var(--muted);margin-top:.75rem" data-lang="da">Kræver Windows 10/11 · VLC inkluderet</p>
|
||||||
|
<p style="font-size:.72rem;color:var(--muted);margin-top:.75rem" data-lang="en">Requires Windows 10/11 · VLC included</p>
|
||||||
|
</div>
|
||||||
|
<div class="download-card">
|
||||||
|
<div class="platform">🍎</div>
|
||||||
|
<h3>macOS</h3>
|
||||||
|
<div class="version">—</div>
|
||||||
|
<div class="coming-soon" data-lang="da">Kommer senere</div>
|
||||||
|
<div class="coming-soon" data-lang="en">Coming soon</div>
|
||||||
|
</div>
|
||||||
|
<div class="download-card">
|
||||||
|
<div class="platform">🐧</div>
|
||||||
|
<h3>Linux</h3>
|
||||||
|
<div class="version">—</div>
|
||||||
|
<div class="coming-soon" data-lang="da">Klon og kør fra kildekode</div>
|
||||||
|
<div class="coming-soon" data-lang="en">Clone and run from source</div>
|
||||||
|
<a href="https://github.com" class="btn secondary" style="width:100%;justify-content:center;margin-top:.75rem;font-size:.82rem">GitHub →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>LineDance Player · <a href="index.html" data-lang="da">Playlister</a><a href="index.html" data-lang="en" style="display:none">Playlists</a> · <a href="live.html" data-lang="da">Storskærm</a><a href="live.html" data-lang="en" style="display:none">Live screen</a></p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ── Sprog ──────────────────────────────────────────────────────────────────────
|
||||||
|
let lang = localStorage.getItem('ld_lang') || 'da';
|
||||||
|
function setLang(l) {
|
||||||
|
lang = l;
|
||||||
|
localStorage.setItem('ld_lang', l);
|
||||||
|
document.querySelectorAll('[data-lang]').forEach(el => {
|
||||||
|
el.style.display = el.dataset.lang === l ? '' : 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('btn-da').classList.toggle('active', l === 'da');
|
||||||
|
document.getElementById('btn-en').classList.toggle('active', l === 'en');
|
||||||
|
document.documentElement.lang = l;
|
||||||
|
}
|
||||||
|
setLang(lang);
|
||||||
|
|
||||||
|
// ── Scroll animations ──────────────────────────────────────────────────────────
|
||||||
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible'); });
|
||||||
|
}, { threshold: 0.1 });
|
||||||
|
document.querySelectorAll('.fade-up').forEach(el => observer.observe(el));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
linedance-api/web/public/download/.gitkeep
Normal file
1
linedance-api/web/public/download/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Placer LineDancePlayer-Setup.exe her
|
||||||
BIN
linedance-api/web/public/download/LineDancePlayer-Setup.exe
Normal file
BIN
linedance-api/web/public/download/LineDancePlayer-Setup.exe
Normal file
Binary file not shown.
667
linedance-api/web/public/index.html
Normal file
667
linedance-api/web/public/index.html
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LineDance Player — Danselister</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0e0f11;
|
||||||
|
--surface: #16181c;
|
||||||
|
--border: #2a2d35;
|
||||||
|
--accent: #e8a020;
|
||||||
|
--accent2: #c47a10;
|
||||||
|
--text: #e8eaf0;
|
||||||
|
--muted: #6b7080;
|
||||||
|
--green: #2ecc71;
|
||||||
|
--red: #e74c3c;
|
||||||
|
--mono: 'DM Mono', monospace;
|
||||||
|
--sans: 'DM Sans', sans-serif;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: var(--sans); min-height: 100vh; }
|
||||||
|
|
||||||
|
header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 2rem; display: flex; align-items: center; gap: 1.5rem;
|
||||||
|
height: 60px; position: sticky; top: 0; background: var(--bg); z-index: 100;
|
||||||
|
}
|
||||||
|
.logo { font-family: var(--mono); font-size: 1rem; letter-spacing: .05em; }
|
||||||
|
.logo span { color: var(--accent); }
|
||||||
|
nav { margin-left: auto; display: flex; gap: .75rem; align-items: center; }
|
||||||
|
#user-label { font-size: .85rem; color: var(--muted); }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-family: var(--sans); font-size: .85rem; font-weight: 500;
|
||||||
|
padding: .4rem 1rem; border-radius: 6px; border: 1px solid var(--border);
|
||||||
|
background: transparent; color: var(--text); cursor: pointer;
|
||||||
|
transition: all .15s; text-decoration: none;
|
||||||
|
display: inline-flex; align-items: center; gap: .4rem;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--surface); border-color: var(--accent); }
|
||||||
|
.btn.primary { background: var(--accent); border-color: var(--accent); color: #111; font-weight: 700; }
|
||||||
|
.btn.primary:hover { background: var(--accent2); }
|
||||||
|
.btn.danger { border-color: var(--red); color: var(--red); }
|
||||||
|
.btn.danger:hover { background: rgba(231,76,60,.1); }
|
||||||
|
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
.btn.sm { font-size: .75rem; padding: .25rem .6rem; }
|
||||||
|
|
||||||
|
.tabs { display: flex; border-bottom: 1px solid var(--border); padding: 0 2rem; max-width: 900px; margin: 0 auto; }
|
||||||
|
.tab {
|
||||||
|
font-size: .85rem; font-weight: 500; padding: .75rem 1.25rem;
|
||||||
|
color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px; transition: all .15s;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text); }
|
||||||
|
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
|
.hero { padding: 3rem 2rem 2rem; max-width: 900px; margin: 0 auto; }
|
||||||
|
.hero h1 { font-size: clamp(1.8rem,4vw,3rem); font-weight: 700; line-height: 1.1; margin-bottom: .75rem; }
|
||||||
|
.hero h1 em { color: var(--accent); font-style: normal; }
|
||||||
|
.hero p { color: var(--muted); font-size: 1rem; }
|
||||||
|
|
||||||
|
/* Tag-søgning */
|
||||||
|
.search-row {
|
||||||
|
display: flex; gap: .6rem; flex-wrap: wrap;
|
||||||
|
margin-bottom: 1.25rem; align-items: center;
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: .4rem .8rem; color: var(--text);
|
||||||
|
font-family: var(--sans); font-size: .85rem; outline: none;
|
||||||
|
transition: border-color .15s; flex: 1; min-width: 180px; max-width: 320px;
|
||||||
|
}
|
||||||
|
.search-input:focus { border-color: var(--accent); }
|
||||||
|
.tag-btn {
|
||||||
|
font-family: var(--mono); font-size: .72rem; padding: .2rem .6rem;
|
||||||
|
border-radius: 4px; border: 1px solid var(--border);
|
||||||
|
background: transparent; color: var(--muted); cursor: pointer; transition: all .15s;
|
||||||
|
}
|
||||||
|
.tag-btn:hover, .tag-btn.active { background: rgba(232,160,32,.12); color: var(--accent); border-color: rgba(232,160,32,.3); }
|
||||||
|
|
||||||
|
.section { max-width: 900px; margin: 0 auto; padding: 0 2rem 4rem; }
|
||||||
|
.section-title {
|
||||||
|
font-family: var(--mono); font-size: .72rem; letter-spacing: .15em;
|
||||||
|
color: var(--muted); text-transform: uppercase;
|
||||||
|
margin-bottom: 1.25rem; padding-bottom: .6rem; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1rem; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 1.25rem; transition: all .2s; position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.card.clickable { cursor: pointer; }
|
||||||
|
.card.clickable::before {
|
||||||
|
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px;
|
||||||
|
background: var(--accent); transform: scaleX(0); transition: transform .2s; transform-origin: left;
|
||||||
|
}
|
||||||
|
.card.clickable:hover { border-color: var(--accent); transform: translateY(-2px); }
|
||||||
|
.card.clickable:hover::before { transform: scaleX(1); }
|
||||||
|
.card.locked { border-color: rgba(107,112,128,.4); opacity: .75; }
|
||||||
|
.card-title { font-weight: 600; font-size: .95rem; margin-bottom: .3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.card-owner { font-size: .78rem; color: var(--muted); font-family: var(--mono); margin-bottom: .75rem; }
|
||||||
|
.card-meta { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
|
||||||
|
.card-tags { display: flex; gap: .35rem; flex-wrap: wrap; margin-top: .5rem; }
|
||||||
|
.badge {
|
||||||
|
font-family: var(--mono); font-size: .68rem; padding: .18rem .45rem;
|
||||||
|
border-radius: 4px; border: 1px solid;
|
||||||
|
}
|
||||||
|
.badge.orange { background: rgba(232,160,32,.12); color: var(--accent); border-color: rgba(232,160,32,.3); }
|
||||||
|
.badge.green { background: rgba(46,204,113,.12); color: var(--green); border-color: rgba(46,204,113,.3); }
|
||||||
|
.badge.muted { background: rgba(107,112,128,.12); color: var(--muted); border-color: rgba(107,112,128,.3); }
|
||||||
|
.badge.red { background: rgba(231,76,60,.12); color: var(--red); border-color: rgba(231,76,60,.3); }
|
||||||
|
.card-actions { display: flex; gap: .5rem; margin-top: .75rem; padding-top: .75rem; border-top: 1px solid var(--border); flex-wrap: wrap; }
|
||||||
|
|
||||||
|
#detail {
|
||||||
|
display: none; position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
|
||||||
|
z-index: 200; align-items: center; justify-content: center; padding: 2rem;
|
||||||
|
}
|
||||||
|
#detail.open { display: flex; }
|
||||||
|
.detail-box {
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 14px; width: 100%; max-width: 540px; max-height: 80vh;
|
||||||
|
display: flex; flex-direction: column; overflow: hidden; animation: fadeUp .25s ease;
|
||||||
|
}
|
||||||
|
.detail-header { padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border); display: flex; align-items: flex-start; gap: 1rem; }
|
||||||
|
.detail-header-text { flex: 1; min-width: 0; }
|
||||||
|
.detail-header h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: .2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.detail-header p { font-size: .82rem; color: var(--muted); }
|
||||||
|
.detail-songs { flex: 1; overflow-y: auto; padding: .4rem 0; }
|
||||||
|
.detail-songs::-webkit-scrollbar { width: 4px; }
|
||||||
|
.detail-songs::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
|
.song-row { display: flex; align-items: center; gap: .75rem; padding: .55rem 1.5rem; transition: background .1s; }
|
||||||
|
.song-row:hover { background: rgba(255,255,255,.03); }
|
||||||
|
.song-num { font-family: var(--mono); font-size: .72rem; color: var(--muted); width: 1.5rem; text-align: right; flex-shrink: 0; }
|
||||||
|
.song-info { flex: 1; min-width: 0; }
|
||||||
|
.song-title { font-size: .88rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.song-artist { font-size: .76rem; color: var(--muted); }
|
||||||
|
.song-dance { font-family: var(--mono); font-size: .7rem; color: var(--accent); flex-shrink: 0; }
|
||||||
|
.detail-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; gap: .6rem; justify-content: flex-end; }
|
||||||
|
|
||||||
|
#login-modal {
|
||||||
|
display: none; position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
|
||||||
|
z-index: 300; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
#login-modal.open { display: flex; }
|
||||||
|
.login-box { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 2rem; width: 100%; max-width: 360px; animation: fadeUp .25s ease; }
|
||||||
|
.login-box h3 { font-size: 1.05rem; margin-bottom: 1.25rem; }
|
||||||
|
.form-row { margin-bottom: .9rem; }
|
||||||
|
.form-row label { display: block; font-size: .78rem; color: var(--muted); margin-bottom: .35rem; }
|
||||||
|
.form-row input {
|
||||||
|
width: 100%; background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: .45rem .7rem; color: var(--text);
|
||||||
|
font-family: var(--sans); font-size: .88rem; outline: none; transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.form-row input:focus { border-color: var(--accent); }
|
||||||
|
.msg { font-size: .8rem; padding: .5rem .7rem; border-radius: 6px; margin-bottom: .9rem; }
|
||||||
|
.msg.error { background: rgba(231,76,60,.12); color: var(--red); border: 1px solid rgba(231,76,60,.3); }
|
||||||
|
.msg.success { background: rgba(46,204,113,.12); color: var(--green); border: 1px solid rgba(46,204,113,.3); }
|
||||||
|
|
||||||
|
/* Mine danselister — sidebar layout */
|
||||||
|
.mine-layout { display: flex; gap: 1.5rem; align-items: flex-start; }
|
||||||
|
.mine-sidebar {
|
||||||
|
width: 180px; flex-shrink: 0;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: .75rem; position: sticky; top: 80px;
|
||||||
|
}
|
||||||
|
.mine-sidebar-title {
|
||||||
|
font-family: var(--mono); font-size: .65rem; letter-spacing: .15em;
|
||||||
|
text-transform: uppercase; color: var(--muted);
|
||||||
|
padding-bottom: .5rem; margin-bottom: .5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mine-tag-btn {
|
||||||
|
display: block; width: 100%; text-align: left;
|
||||||
|
font-size: .8rem; padding: .3rem .5rem; border-radius: 5px;
|
||||||
|
border: none; background: none; color: var(--muted);
|
||||||
|
cursor: pointer; transition: all .15s;
|
||||||
|
}
|
||||||
|
.mine-tag-btn:hover { color: var(--text); background: rgba(255,255,255,.04); }
|
||||||
|
.mine-tag-btn.active { color: var(--accent); background: rgba(232,160,32,.1); font-weight: 500; }
|
||||||
|
.mine-tag-btn .mine-tag-count {
|
||||||
|
float: right; font-family: var(--mono); font-size: .68rem; color: var(--muted);
|
||||||
|
}
|
||||||
|
.mine-grid-wrap { flex: 1; min-width: 0; }
|
||||||
|
.mine-search { margin-bottom: .75rem; }
|
||||||
|
.mine-search input {
|
||||||
|
width: 100%; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: .4rem .8rem; color: var(--text);
|
||||||
|
font-family: var(--sans); font-size: .85rem; outline: none; transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.mine-search input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.empty { text-align: center; padding: 3rem 1rem; color: var(--muted); font-size: .9rem; grid-column: 1/-1; }
|
||||||
|
.spinner { width: 28px; height: 28px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto .75rem; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||||
|
.fade-in { animation: fadeUp .25s ease; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="logo">LINE<span>DANCE</span> PLAYER</div>
|
||||||
|
<nav>
|
||||||
|
<span id="user-label"></span>
|
||||||
|
<button class="btn" id="btn-login">Log ind</button>
|
||||||
|
<button class="btn" id="btn-logout" style="display:none">Log ud</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" data-tab="public">Offentlige danselister</div>
|
||||||
|
<div class="tab" id="tab-mine" data-tab="mine" style="display:none">Mine danselister</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1 id="hero-title">Offentlige<br><em>danselister</em></h1>
|
||||||
|
<p id="hero-sub">Browse og kopiér danselister delt af LineDance Player-brugere.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div id="pane-public">
|
||||||
|
<div class="search-row">
|
||||||
|
<input class="search-input" id="search-public" placeholder="Søg på navn eller tag..." oninput="filterPublic()">
|
||||||
|
<div id="tag-btns" style="display:flex;gap:.4rem;flex-wrap:wrap;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="section-title" id="public-title">Alle offentlige danselister</div>
|
||||||
|
<div id="grid-public" class="grid">
|
||||||
|
<div class="empty"><div class="spinner"></div>Henter danselister...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="pane-mine" style="display:none">
|
||||||
|
<div class="section-title" id="mine-title">Mine danselister</div>
|
||||||
|
<div class="mine-layout">
|
||||||
|
<div class="mine-sidebar">
|
||||||
|
<div class="mine-sidebar-title">Tags</div>
|
||||||
|
<button class="mine-tag-btn active" data-tag="" onclick="setMineTag('')">
|
||||||
|
Alle <span class="mine-tag-count" id="mine-all-count"></span>
|
||||||
|
</button>
|
||||||
|
<div id="mine-tag-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mine-grid-wrap">
|
||||||
|
<div class="mine-search">
|
||||||
|
<input id="search-mine" placeholder="Søg danseliste..." oninput="filterMine()">
|
||||||
|
</div>
|
||||||
|
<div id="grid-mine" class="grid">
|
||||||
|
<div class="empty"><div class="spinner"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="detail">
|
||||||
|
<div class="detail-box">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="detail-header-text">
|
||||||
|
<h2 id="d-title">—</h2>
|
||||||
|
<p id="d-meta">—</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn sm" id="btn-close-detail">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="detail-songs" id="d-songs"></div>
|
||||||
|
<div class="detail-footer">
|
||||||
|
<button class="btn" id="btn-close-detail2">Luk</button>
|
||||||
|
<button class="btn primary" id="btn-copy">Kopiér til min konto</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="qr-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);backdrop-filter:blur(4px);z-index:300;align-items:center;justify-content:center;">
|
||||||
|
<div style="background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:2rem;width:100%;max-width:340px;text-align:center;animation:fadeUp .25s ease;">
|
||||||
|
<h3 id="qr-title" style="font-size:1rem;margin-bottom:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></h3>
|
||||||
|
<canvas id="qr-canvas" style="margin:0 auto 1rem;display:block;border-radius:8px;"></canvas>
|
||||||
|
<div id="qr-url" style="font-size:.72rem;color:var(--muted);word-break:break-all;margin-bottom:1.25rem;"></div>
|
||||||
|
<div style="display:flex;gap:.6rem;justify-content:center;">
|
||||||
|
<button class="btn sm" onclick="copyLiveUrl()">Kopiér link</button>
|
||||||
|
<button class="btn sm" onclick="document.getElementById('qr-modal').style.display='none'">Luk</button>
|
||||||
|
</div>
|
||||||
|
<div id="copy-msg" style="font-size:.75rem;color:var(--green);margin-top:.6rem;height:1rem;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="login-modal">
|
||||||
|
<div class="login-box">
|
||||||
|
<h3>Log ind</h3>
|
||||||
|
<div id="login-msg"></div>
|
||||||
|
<div class="form-row"><label>Brugernavn eller e-mail</label><input type="text" id="inp-user" placeholder="dit@email.dk"></div>
|
||||||
|
<div class="form-row"><label>Adgangskode</label><input type="password" id="inp-pass" placeholder="••••••••"></div>
|
||||||
|
<div style="display:flex;gap:.6rem;justify-content:flex-end;margin-top:1rem">
|
||||||
|
<button class="btn" id="btn-cancel-login">Annuller</button>
|
||||||
|
<button class="btn primary" id="btn-do-login">Log ind</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrious/4.0.2/qrious.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const API = '/api';
|
||||||
|
let token = localStorage.getItem('ld_token') || '';
|
||||||
|
let username = localStorage.getItem('ld_user') || '';
|
||||||
|
let currentPlaylistId = null;
|
||||||
|
let currentTab = 'public';
|
||||||
|
let allPublicLists = [];
|
||||||
|
let activeTag = '';
|
||||||
|
let allMineLists = [];
|
||||||
|
let activeMineTag = '';
|
||||||
|
|
||||||
|
function updateAuthUI() {
|
||||||
|
document.getElementById('btn-login').style.display = token ? 'none' : '';
|
||||||
|
document.getElementById('btn-logout').style.display = token ? '' : 'none';
|
||||||
|
document.getElementById('tab-mine').style.display = token ? '' : 'none';
|
||||||
|
document.getElementById('user-label').textContent = token ? username : '';
|
||||||
|
if (!token && currentTab === 'mine') switchTab('public');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-login').onclick = () =>
|
||||||
|
document.getElementById('login-modal').classList.add('open');
|
||||||
|
document.getElementById('btn-cancel-login').onclick = () =>
|
||||||
|
document.getElementById('login-modal').classList.remove('open');
|
||||||
|
document.getElementById('btn-logout').onclick = () => {
|
||||||
|
token = ''; username = '';
|
||||||
|
localStorage.removeItem('ld_token'); localStorage.removeItem('ld_user');
|
||||||
|
updateAuthUI();
|
||||||
|
};
|
||||||
|
document.getElementById('inp-pass').onkeydown = e => {
|
||||||
|
if (e.key === 'Enter') document.getElementById('btn-do-login').click();
|
||||||
|
};
|
||||||
|
document.getElementById('btn-do-login').onclick = async () => {
|
||||||
|
const user = document.getElementById('inp-user').value.trim();
|
||||||
|
const pass = document.getElementById('inp-pass').value;
|
||||||
|
const msg = document.getElementById('login-msg');
|
||||||
|
msg.innerHTML = '';
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('username', user); fd.append('password', pass);
|
||||||
|
const r = await fetch(`${API}/auth/login`, { method: 'POST', body: fd });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.detail || 'Login fejlede');
|
||||||
|
token = d.access_token; username = user;
|
||||||
|
localStorage.setItem('ld_token', token); localStorage.setItem('ld_user', username);
|
||||||
|
document.getElementById('login-modal').classList.remove('open');
|
||||||
|
updateAuthUI();
|
||||||
|
// Skift til mine danselister ved login
|
||||||
|
switchTab('mine');
|
||||||
|
loadMyPlaylists();
|
||||||
|
} catch(e) {
|
||||||
|
msg.innerHTML = `<div class="msg error">${e.message}</div>`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
currentTab = tab;
|
||||||
|
document.querySelectorAll('.tab').forEach(t =>
|
||||||
|
t.classList.toggle('active', t.dataset.tab === tab));
|
||||||
|
document.getElementById('pane-public').style.display = tab === 'public' ? '' : 'none';
|
||||||
|
document.getElementById('pane-mine').style.display = tab === 'mine' ? '' : 'none';
|
||||||
|
if (tab === 'public') {
|
||||||
|
document.getElementById('hero-title').innerHTML = 'Offentlige<br><em>danselister</em>';
|
||||||
|
document.getElementById('hero-sub').textContent = 'Browse og kopiér danselister delt af LineDance Player-brugere.';
|
||||||
|
} else {
|
||||||
|
document.getElementById('hero-title').innerHTML = 'Mine<br><em>danselister</em>';
|
||||||
|
document.getElementById('hero-sub').textContent = 'Administrér dine danselister.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.onclick = () => switchTab(t.dataset.tab));
|
||||||
|
|
||||||
|
// ── Tag-søgning ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function filterPublic() {
|
||||||
|
const q = document.getElementById('search-public').value.trim().toLowerCase();
|
||||||
|
const filtered = allPublicLists.filter(p => {
|
||||||
|
const matchText = !q ||
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
(p.tags || '').toLowerCase().includes(q) ||
|
||||||
|
(p.owner || '').toLowerCase().includes(q);
|
||||||
|
const matchTag = !activeTag ||
|
||||||
|
(p.tags || '').toLowerCase().split(',').map(t => t.trim()).includes(activeTag);
|
||||||
|
return matchText && matchTag;
|
||||||
|
});
|
||||||
|
renderPublicGrid(filtered);
|
||||||
|
const n = filtered.length;
|
||||||
|
document.getElementById('public-title').textContent =
|
||||||
|
(q || activeTag) ? `${n} danseliste${n !== 1 ? 'r' : ''} fundet` : 'Alle offentlige danselister';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTag(tag) {
|
||||||
|
activeTag = (activeTag === tag) ? '' : tag;
|
||||||
|
document.querySelectorAll('.tag-btn').forEach(b =>
|
||||||
|
b.classList.toggle('active', b.dataset.tag === activeTag));
|
||||||
|
filterPublic();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPublicGrid(lists) {
|
||||||
|
const grid = document.getElementById('grid-public');
|
||||||
|
if (!lists.length) {
|
||||||
|
grid.innerHTML = '<div class="empty">Ingen danselister matcher søgningen.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = lists.map(p => {
|
||||||
|
const tags = (p.tags || '').split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
const tagHtml = tags.map(t =>
|
||||||
|
`<span class="badge muted" style="cursor:pointer" onclick="setTag('${esc(t)}')">${esc(t)}</span>`
|
||||||
|
).join('');
|
||||||
|
return `
|
||||||
|
<div class="card clickable fade-in" data-id="${p.id}">
|
||||||
|
<div class="card-title">${esc(p.name)}</div>
|
||||||
|
<div class="card-owner">@ ${esc(p.owner)}</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span class="badge orange">${p.song_count} sange</span>
|
||||||
|
<span class="badge green">offentlig</span>
|
||||||
|
</div>
|
||||||
|
${tagHtml ? `<div class="card-tags">${tagHtml}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
grid.querySelectorAll('.card').forEach(c =>
|
||||||
|
c.onclick = () => openDetail(c.dataset.id, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTagButtons(lists) {
|
||||||
|
const tagSet = new Set();
|
||||||
|
lists.forEach(p => (p.tags || '').split(',').forEach(t => {
|
||||||
|
const tt = t.trim().toLowerCase();
|
||||||
|
if (tt) tagSet.add(tt);
|
||||||
|
}));
|
||||||
|
const tags = [...tagSet].sort();
|
||||||
|
const container = document.getElementById('tag-btns');
|
||||||
|
container.innerHTML = tags.map(t =>
|
||||||
|
`<button class="tag-btn" data-tag="${esc(t)}" onclick="setTag('${esc(t)}')">${esc(t)}</button>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPublicPlaylists() {
|
||||||
|
const grid = document.getElementById('grid-public');
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/sharing/public`);
|
||||||
|
allPublicLists = await r.json();
|
||||||
|
buildTagButtons(allPublicLists);
|
||||||
|
renderPublicGrid(allPublicLists);
|
||||||
|
} catch(e) {
|
||||||
|
grid.innerHTML = `<div class="empty">Kunne ikke hente danselister.<br>${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mine danselister ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildMineSidebar(lists) {
|
||||||
|
const tagCounts = {};
|
||||||
|
lists.forEach(p => (p.tags || '').split(',').forEach(t => {
|
||||||
|
const tt = t.trim().toLowerCase();
|
||||||
|
if (tt) tagCounts[tt] = (tagCounts[tt] || 0) + 1;
|
||||||
|
}));
|
||||||
|
document.getElementById('mine-all-count').textContent = lists.length;
|
||||||
|
const container = document.getElementById('mine-tag-list');
|
||||||
|
container.innerHTML = Object.entries(tagCounts)
|
||||||
|
.sort((a,b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([tag, count]) => `
|
||||||
|
<button class="mine-tag-btn${activeMineTag === tag ? ' active' : ''}"
|
||||||
|
data-tag="${esc(tag)}" onclick="setMineTag('${esc(tag)}')">
|
||||||
|
${esc(tag)} <span class="mine-tag-count">${count}</span>
|
||||||
|
</button>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMineTag(tag) {
|
||||||
|
activeMineTag = tag;
|
||||||
|
document.querySelectorAll('.mine-tag-btn').forEach(b =>
|
||||||
|
b.classList.toggle('active', b.dataset.tag === tag));
|
||||||
|
filterMine();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterMine() {
|
||||||
|
const q = (document.getElementById('search-mine')?.value || '').trim().toLowerCase();
|
||||||
|
const filtered = allMineLists.filter(p => {
|
||||||
|
const matchText = !q ||
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
(p.tags || '').toLowerCase().includes(q);
|
||||||
|
const matchTag = !activeMineTag ||
|
||||||
|
(p.tags || '').toLowerCase().split(',').map(t => t.trim()).includes(activeMineTag);
|
||||||
|
return matchText && matchTag;
|
||||||
|
});
|
||||||
|
renderMineGrid(filtered);
|
||||||
|
const n = filtered.length;
|
||||||
|
document.getElementById('mine-title').textContent =
|
||||||
|
(q || activeMineTag) ? `${n} danseliste${n !== 1 ? 'r' : ''} fundet` : 'Mine danselister';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMineGrid(lists) {
|
||||||
|
const grid = document.getElementById('grid-mine');
|
||||||
|
if (!lists.length) {
|
||||||
|
grid.innerHTML = '<div class="empty">Ingen danselister matcher søgningen.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
grid.innerHTML = lists.map(p => {
|
||||||
|
const vis = p.visibility || 'private';
|
||||||
|
const locked = p.locked || false;
|
||||||
|
const bc = locked ? 'muted' : vis === 'public' ? 'green' : vis === 'shared' ? 'orange' : 'muted';
|
||||||
|
const bl = locked ? '🔒 låst' : vis === 'public' ? 'offentlig' : vis === 'shared' ? 'delt' : 'privat';
|
||||||
|
const sc = p.song_count || (p.songs || []).length || 0;
|
||||||
|
const tags = (p.tags || '').split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
const tagHtml = tags.map(t =>
|
||||||
|
`<span class="badge muted" style="cursor:pointer" onclick="setMineTag('${esc(t)}')">${esc(t)}</span>`
|
||||||
|
).join('');
|
||||||
|
return `
|
||||||
|
<div class="card fade-in${locked ? ' locked' : ''}">
|
||||||
|
<div class="card-title">${locked ? '🔒 ' : ''}${esc(p.name)}</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
<span class="badge orange">${sc} sange</span>
|
||||||
|
<span class="badge ${bc}" id="vis-badge-${p.id}">${bl}</span>
|
||||||
|
</div>
|
||||||
|
${tagHtml ? `<div class="card-tags">${tagHtml}</div>` : ''}
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn sm" onclick="openDetail('${p.id}',true)">Se sange</button>
|
||||||
|
${!locked ? `
|
||||||
|
<button class="btn sm${vis==='public'?' danger':''}" id="vis-btn-${p.id}"
|
||||||
|
onclick="toggleVis('${p.id}','${vis}')">
|
||||||
|
${vis === 'public' ? 'Gør privat' : 'Gør offentlig'}
|
||||||
|
</button>
|
||||||
|
<button class="btn sm danger" onclick="confirmLock('${p.id}','${esc(p.name)}')" title="Lås permanent">🔒</button>
|
||||||
|
` : ''}
|
||||||
|
<a class="btn sm" href="/live.html?id=${p.id}" target="_blank" title="Storskærm">📺</a>
|
||||||
|
<button class="btn sm" onclick="showQR('${p.id}','${esc(p.name)}')" title="QR-kode">QR</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMyPlaylists() {
|
||||||
|
const grid = document.getElementById('grid-mine');
|
||||||
|
grid.innerHTML = '<div class="empty"><div class="spinner"></div></div>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/projects/my`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('Ikke autoriseret');
|
||||||
|
allMineLists = await r.json();
|
||||||
|
if (!allMineLists.length) {
|
||||||
|
document.getElementById('grid-mine').innerHTML = '<div class="empty">Ingen danselister endnu.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buildMineSidebar(allMineLists);
|
||||||
|
filterMine();
|
||||||
|
} catch(e) {
|
||||||
|
grid.innerHTML = `<div class="empty">Kunne ikke hente danselister.<br>${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleVis(id, current) {
|
||||||
|
const newVis = current === 'public' ? 'private' : 'public';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/sharing/playlists/${id}/visibility?visibility=${newVis}`, {
|
||||||
|
method: 'PATCH', headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('Fejl');
|
||||||
|
loadMyPlaylists();
|
||||||
|
loadPublicPlaylists();
|
||||||
|
} catch(e) { alert('Fejl: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmLock(id, name) {
|
||||||
|
if (!confirm(`Lås "${name}" permanent?\n\nEn låst danseliste kan ikke længere redigeres eller opdateres fra appen. Dette kan ikke fortrydes.`)) return;
|
||||||
|
lockPlaylist(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockPlaylist(id) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/projects/${id}/lock`, {
|
||||||
|
method: 'POST', headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error((await r.json()).detail || 'Fejl');
|
||||||
|
loadMyPlaylists();
|
||||||
|
} catch(e) { alert('Fejl: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail-visning ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function openDetail(id, isOwn) {
|
||||||
|
currentPlaylistId = id;
|
||||||
|
document.getElementById('btn-copy').style.display = isOwn ? 'none' : '';
|
||||||
|
document.getElementById('detail').classList.add('open');
|
||||||
|
document.getElementById('d-songs').innerHTML = '<div class="empty"><div class="spinner"></div></div>';
|
||||||
|
try {
|
||||||
|
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||||
|
const r = await fetch(`${API}/sharing/playlists/${id}`, { headers });
|
||||||
|
const p = await r.json();
|
||||||
|
document.getElementById('d-title').textContent = p.name;
|
||||||
|
document.getElementById('d-meta').textContent = `${p.songs.length} sange${p.owner ? ' · @ '+p.owner : ''}`;
|
||||||
|
document.getElementById('d-songs').innerHTML = p.songs.length
|
||||||
|
? p.songs.map((s,i) => `
|
||||||
|
<div class="song-row">
|
||||||
|
<span class="song-num">${i+1}</span>
|
||||||
|
<div class="song-info">
|
||||||
|
<div class="song-title">${esc(s.title)}</div>
|
||||||
|
<div class="song-artist">${esc(s.artist)}</div>
|
||||||
|
</div>
|
||||||
|
${s.dance_override ? `<span class="song-dance">${esc(s.dance_override)}</span>` : ''}
|
||||||
|
</div>`).join('')
|
||||||
|
: '<div class="empty">Ingen sange.</div>';
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('d-songs').innerHTML = '<div class="empty">Kunne ikke hente detaljer.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
document.getElementById('detail').classList.remove('open');
|
||||||
|
currentPlaylistId = null;
|
||||||
|
}
|
||||||
|
document.getElementById('btn-close-detail').onclick = closeDetail;
|
||||||
|
document.getElementById('btn-close-detail2').onclick = closeDetail;
|
||||||
|
document.getElementById('detail').onclick = e => {
|
||||||
|
if (e.target === document.getElementById('detail')) closeDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('btn-copy').onclick = async () => {
|
||||||
|
if (!token) { document.getElementById('login-modal').classList.add('open'); return; }
|
||||||
|
const btn = document.getElementById('btn-copy');
|
||||||
|
btn.disabled = true; btn.textContent = 'Kopierer...';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/sharing/playlists/${currentPlaylistId}/copy`, {
|
||||||
|
method: 'POST', headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) throw new Error(d.detail || 'Fejl');
|
||||||
|
btn.textContent = '✓ Kopieret!'; btn.style.background = 'var(--green)';
|
||||||
|
setTimeout(() => { btn.textContent = 'Kopiér til min konto'; btn.style.background = ''; btn.disabled = false; }, 2500);
|
||||||
|
loadMyPlaylists();
|
||||||
|
} catch(e) { btn.textContent = '⚠ ' + e.message; btn.disabled = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── QR ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let currentQRUrl = '';
|
||||||
|
|
||||||
|
function showQR(id, name) {
|
||||||
|
const url = `${location.protocol}//${location.host}/live.html?id=${id}`;
|
||||||
|
currentQRUrl = url;
|
||||||
|
document.getElementById('qr-title').textContent = name;
|
||||||
|
document.getElementById('qr-url').textContent = url;
|
||||||
|
document.getElementById('copy-msg').textContent = '';
|
||||||
|
document.getElementById('qr-modal').style.display = 'flex';
|
||||||
|
const canvas = document.getElementById('qr-canvas');
|
||||||
|
if (window.QRious) {
|
||||||
|
new QRious({ element: canvas, value: url, size: 220, backgroundAlpha: 0, foreground: '#eceef4' });
|
||||||
|
} else {
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLiveUrl() {
|
||||||
|
navigator.clipboard.writeText(currentQRUrl).then(() => {
|
||||||
|
const msg = document.getElementById('copy-msg');
|
||||||
|
msg.textContent = '✓ Kopieret!';
|
||||||
|
setTimeout(() => msg.textContent = '', 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
|
updateAuthUI();
|
||||||
|
loadPublicPlaylists();
|
||||||
|
if (token) loadMyPlaylists();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
529
linedance-api/web/public/live.html
Normal file
529
linedance-api/web/public/live.html
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="da">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>LineDance — Live</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0a0b0d;
|
||||||
|
--surface: #13151a;
|
||||||
|
--border: #252830;
|
||||||
|
--accent: #e8a020;
|
||||||
|
--text: #eceef4;
|
||||||
|
--muted: #60687a;
|
||||||
|
--green: #27ae60;
|
||||||
|
}
|
||||||
|
.light {
|
||||||
|
--bg: #f5f4ef;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #dddbd3;
|
||||||
|
--accent: #c47a10;
|
||||||
|
--text: #1a1c22;
|
||||||
|
--muted: #7a7a6a;
|
||||||
|
--green: #1e8449;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
min-height: 100vh; display: flex; flex-direction: column;
|
||||||
|
transition: background .3s, color .3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
header {
|
||||||
|
padding: .55rem 1.25rem;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.logo { font-size: .75rem; letter-spacing: .1em; color: var(--muted); font-weight: 600; }
|
||||||
|
.logo b { color: var(--accent); }
|
||||||
|
.header-right { display: flex; align-items: center; gap: .75rem; }
|
||||||
|
|
||||||
|
/* Countdown ring */
|
||||||
|
.countdown-wrap { display: flex; align-items: center; gap: .35rem; }
|
||||||
|
.countdown-svg { width: 22px; height: 22px; flex-shrink: 0; }
|
||||||
|
.countdown-bg { fill: none; stroke: var(--border); stroke-width: 3; }
|
||||||
|
.countdown-arc {
|
||||||
|
fill: none; stroke: var(--accent); stroke-width: 3;
|
||||||
|
stroke-linecap: round; stroke-dasharray: 56.5; stroke-dashoffset: 0;
|
||||||
|
transform: rotate(-90deg); transform-origin: 50% 50%;
|
||||||
|
transition: stroke-dashoffset .9s linear;
|
||||||
|
}
|
||||||
|
.countdown-num { font-size: .68rem; color: var(--muted); min-width: 1rem; }
|
||||||
|
.live-dot { display: flex; align-items: center; gap: .35rem; font-size: .7rem; color: var(--muted); }
|
||||||
|
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
|
||||||
|
.dot.active { background: var(--green); animation: pulse 2s infinite; }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.35} }
|
||||||
|
.theme-btn {
|
||||||
|
background: none; border: 1px solid var(--border); border-radius: 5px;
|
||||||
|
padding: .2rem .5rem; cursor: pointer; color: var(--muted); font-size: .72rem;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.theme-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Now playing ── */
|
||||||
|
#now-playing { padding: 1.5rem 1.25rem 1rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.np-label {
|
||||||
|
font-size: .62rem; letter-spacing: .18em; text-transform: uppercase;
|
||||||
|
color: var(--accent); font-weight: 700; margin-bottom: .4rem;
|
||||||
|
}
|
||||||
|
/* Dans primær — stor */
|
||||||
|
.np-dance {
|
||||||
|
font-size: clamp(2.2rem, 8vw, 5rem);
|
||||||
|
font-weight: 800; line-height: 1; color: var(--text);
|
||||||
|
letter-spacing: -.02em; margin-bottom: .3rem;
|
||||||
|
}
|
||||||
|
.np-dance:empty::before { content: '—'; color: var(--muted); }
|
||||||
|
|
||||||
|
/* Nummer under dans */
|
||||||
|
.np-number {
|
||||||
|
font-size: clamp(.75rem, 2vw, 1rem);
|
||||||
|
color: var(--muted); margin-bottom: .2rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.np-number b { color: var(--text); }
|
||||||
|
|
||||||
|
/* Sang + artist sekundær */
|
||||||
|
.np-song {
|
||||||
|
font-size: clamp(.85rem, 2.2vw, 1.2rem);
|
||||||
|
color: var(--muted); margin-bottom: .9rem;
|
||||||
|
}
|
||||||
|
.np-song span { color: var(--text); }
|
||||||
|
|
||||||
|
/* Estimeret tid til næste */
|
||||||
|
.np-eta {
|
||||||
|
font-size: clamp(.72rem, 1.8vw, .9rem);
|
||||||
|
color: var(--muted); margin-bottom: .5rem;
|
||||||
|
display: flex; align-items: center; gap: .4rem;
|
||||||
|
}
|
||||||
|
.np-eta b { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Fremgangsbar — NEDERST på now-playing ── */
|
||||||
|
.progress-section { margin-top: .5rem; }
|
||||||
|
.progress-bar { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: .35rem; }
|
||||||
|
.progress-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width .4s; width: 0%; }
|
||||||
|
.progress-text {
|
||||||
|
font-size: .7rem; color: var(--muted);
|
||||||
|
display: flex; justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Divider ── */
|
||||||
|
.divider { border: none; border-top: 1px solid var(--border); margin: .4rem 1.25rem; flex-shrink: 0; }
|
||||||
|
.next-label {
|
||||||
|
padding: .4rem 1.25rem .2rem;
|
||||||
|
font-size: .62rem; letter-spacing: .15em; text-transform: uppercase;
|
||||||
|
color: var(--muted); font-weight: 600; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Song list ── */
|
||||||
|
#song-list { flex: 1; overflow-y: auto; padding: .2rem .75rem .5rem; }
|
||||||
|
#song-list::-webkit-scrollbar { width: 3px; }
|
||||||
|
#song-list::-webkit-scrollbar-thumb { background: var(--border); }
|
||||||
|
|
||||||
|
.song-item {
|
||||||
|
display: flex; align-items: center; gap: .6rem;
|
||||||
|
padding: .4rem .5rem; border-radius: 7px; margin-bottom: 1px;
|
||||||
|
}
|
||||||
|
.song-item.playing {
|
||||||
|
background: rgba(232,160,32,.09);
|
||||||
|
border: 1px solid rgba(232,160,32,.22);
|
||||||
|
}
|
||||||
|
.song-item.played { opacity: .4; }
|
||||||
|
.song-item.skipped { opacity: .25; }
|
||||||
|
|
||||||
|
.song-num { font-size: .68rem; color: var(--muted); width: 1.4rem; text-align: right; flex-shrink: 0; font-variant-numeric: tabular-nums; }
|
||||||
|
.song-check { width: 1rem; flex-shrink: 0; text-align: center; font-size: .72rem; color: var(--muted); }
|
||||||
|
.song-item.played .song-check { color: var(--green); }
|
||||||
|
|
||||||
|
.song-info { flex: 1; min-width: 0; }
|
||||||
|
.song-dance-name {
|
||||||
|
font-size: clamp(.8rem, 2vw, .95rem); font-weight: 600;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.song-title-sm {
|
||||||
|
font-size: clamp(.68rem, 1.6vw, .8rem); color: var(--muted);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.song-eta-sm {
|
||||||
|
font-size: .65rem; color: var(--muted); flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums; text-align: right; min-width: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── States ── */
|
||||||
|
#empty, #no-event, #picker {
|
||||||
|
display: none; flex-direction: column; align-items: center;
|
||||||
|
justify-content: center; flex: 1; gap: .75rem;
|
||||||
|
color: var(--muted); text-align: center; padding: 2rem;
|
||||||
|
}
|
||||||
|
.spinner { width: 26px; height: 26px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.big-text { font-size: 1rem; color: var(--text); }
|
||||||
|
.playlist-pick-btn {
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||||||
|
padding: .75rem 1.1rem; width: 100%; max-width: 320px; text-align: left;
|
||||||
|
cursor: pointer; color: var(--text); font-family: inherit; transition: all .15s;
|
||||||
|
}
|
||||||
|
.playlist-pick-btn:hover { border-color: var(--accent); }
|
||||||
|
.playlist-pick-btn strong { display: block; font-size: .88rem; margin-bottom: .15rem; }
|
||||||
|
.playlist-pick-btn span { font-size: .74rem; color: var(--muted); }
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
footer {
|
||||||
|
padding: .45rem 1.25rem; border-top: 1px solid var(--border);
|
||||||
|
font-size: .67rem; color: var(--muted);
|
||||||
|
display: flex; justify-content: space-between; align-items: center; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
#now-playing, .next-label, footer { padding-left: 2rem; padding-right: 2rem; }
|
||||||
|
.divider { margin-left: 2rem; margin-right: 2rem; }
|
||||||
|
#song-list { padding-left: 1.25rem; padding-right: 1.25rem; }
|
||||||
|
header { padding-left: 2rem; padding-right: 2rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="logo">LINE<b>DANCE</b></div>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="countdown-wrap" title="Opdateres om...">
|
||||||
|
<svg class="countdown-svg" viewBox="0 0 20 20">
|
||||||
|
<circle class="countdown-bg" cx="10" cy="10" r="9"/>
|
||||||
|
<circle class="countdown-arc" id="countdown-arc" cx="10" cy="10" r="9"/>
|
||||||
|
</svg>
|
||||||
|
<span class="countdown-num" id="countdown-num">5</span>
|
||||||
|
</div>
|
||||||
|
<div class="live-dot">
|
||||||
|
<div class="dot" id="live-dot"></div>
|
||||||
|
<span id="live-label">Forbinder...</span>
|
||||||
|
</div>
|
||||||
|
<button class="theme-btn" id="theme-btn" onclick="toggleTheme()">☀ Lyst</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="picker">
|
||||||
|
<h2 style="font-size:1rem;color:var(--text)">Vælg playliste</h2>
|
||||||
|
<p style="max-width:300px">Brug <code>?id=PLAYLIST_ID</code> i URL eller vælg herunder</p>
|
||||||
|
<div id="picker-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="no-event">
|
||||||
|
<div style="font-size:2.5rem;opacity:.2">🎵</div>
|
||||||
|
<div class="big-text">Ingen aktiv playliste</div>
|
||||||
|
<div>Åbn LineDance Player og start et event</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="empty"><div class="spinner"></div></div>
|
||||||
|
|
||||||
|
<div id="now-playing" style="display:none">
|
||||||
|
<div class="np-label" id="np-label">▶ Spiller nu</div>
|
||||||
|
<div class="np-dance" id="np-dance"></div>
|
||||||
|
<div class="np-number" id="np-number"></div>
|
||||||
|
<div class="np-song" id="np-song"></div>
|
||||||
|
<div class="np-eta" id="np-eta" style="display:none">
|
||||||
|
⏱ Næste dans starter ca. kl. <b id="np-eta-val"></b>
|
||||||
|
</div>
|
||||||
|
<!-- Fremgangsbar NEDERST -->
|
||||||
|
<div class="progress-section">
|
||||||
|
<div class="progress-bar"><div class="progress-fill" id="np-progress"></div></div>
|
||||||
|
<div class="progress-text">
|
||||||
|
<span id="np-played">0 afspillet</span>
|
||||||
|
<span id="np-remaining">0 tilbage</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="divider" id="divider" style="display:none">
|
||||||
|
<div class="next-label" id="next-label" style="display:none">Kommende</div>
|
||||||
|
<div id="song-list"></div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span id="pl-name"></span>
|
||||||
|
<span id="last-updated"></span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '/api';
|
||||||
|
const POLL_SECS = 5;
|
||||||
|
const BETWEEN_DANCE_SEC = 30; // pause mellem danse (bruges i estimat)
|
||||||
|
const WORKSHOP_MIN_SEC = 20 * 60; // default workshoptid hvis ingen varighed
|
||||||
|
|
||||||
|
let playlistId = new URLSearchParams(location.search).get('id');
|
||||||
|
let countdownTimer = null;
|
||||||
|
let countdownVal = POLL_SECS;
|
||||||
|
let dark = localStorage.getItem('ld_live_theme') !== 'light';
|
||||||
|
|
||||||
|
// ── Tema ──────────────────────────────────────────────────────────────────────
|
||||||
|
function applyTheme() {
|
||||||
|
document.body.classList.toggle('light', !dark);
|
||||||
|
document.getElementById('theme-btn').textContent = dark ? '☀ Lyst' : '● Mørkt';
|
||||||
|
}
|
||||||
|
function toggleTheme() {
|
||||||
|
dark = !dark;
|
||||||
|
localStorage.setItem('ld_live_theme', dark ? 'dark' : 'light');
|
||||||
|
applyTheme();
|
||||||
|
}
|
||||||
|
applyTheme();
|
||||||
|
|
||||||
|
// ── Countdown ring ────────────────────────────────────────────────────────────
|
||||||
|
const CIRC = 56.5;
|
||||||
|
function startCountdown() {
|
||||||
|
countdownVal = POLL_SECS;
|
||||||
|
updateCountdown();
|
||||||
|
clearInterval(countdownTimer);
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
countdownVal--;
|
||||||
|
if (countdownVal <= 0) countdownVal = POLL_SECS;
|
||||||
|
updateCountdown();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
function updateCountdown() {
|
||||||
|
const frac = countdownVal / POLL_SECS;
|
||||||
|
document.getElementById('countdown-arc').style.strokeDashoffset = CIRC * (1 - frac);
|
||||||
|
document.getElementById('countdown-num').textContent = countdownVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hjælpefunktioner ─────────────────────────────────────────────────────────
|
||||||
|
function fmt(s) { return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
|
||||||
|
function fmtDur(secs) {
|
||||||
|
if (!secs || secs <= 0) return '';
|
||||||
|
const m = Math.floor(secs / 60);
|
||||||
|
const s = Math.round(secs % 60);
|
||||||
|
if (m === 0) return `${s}s`;
|
||||||
|
return s === 0 ? `${m} min` : `${m}:${String(s).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtClock(date) {
|
||||||
|
return date.toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(d) {
|
||||||
|
if (!d) return '';
|
||||||
|
return new Date(d).toLocaleTimeString('da-DK', { hour:'2-digit', minute:'2-digit', second:'2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dansenavn — workshop vises som "Workshop"
|
||||||
|
function getDanceName(song) {
|
||||||
|
if (song.is_workshop) return 'Workshop';
|
||||||
|
return song.dance || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimeret varighed for en sang (bruges til tidsestimat)
|
||||||
|
function songDuration(song) {
|
||||||
|
if (song.is_workshop) {
|
||||||
|
return song.duration > 0 ? song.duration : WORKSHOP_MIN_SEC;
|
||||||
|
}
|
||||||
|
return song.duration > 0 ? song.duration : 210; // default 3:30
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beregn estimeret sekunder til en bestemt index
|
||||||
|
function estimateSecsTo(songs, fromIdx) {
|
||||||
|
let secs = 0;
|
||||||
|
for (let i = 0; i < fromIdx; i++) {
|
||||||
|
const s = songs[i];
|
||||||
|
if (s.status === 'played' || s.status === 'skipped') continue;
|
||||||
|
secs += songDuration(s);
|
||||||
|
secs += BETWEEN_DANCE_SEC; // pause mellem danse
|
||||||
|
}
|
||||||
|
return secs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch & render ────────────────────────────────────────────────────────────
|
||||||
|
async function loadAndRender() {
|
||||||
|
if (!playlistId) { await showPicker(); return; }
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/live/${playlistId}`);
|
||||||
|
if (!r.ok) { showNoEvent(); return; }
|
||||||
|
render(await r.json());
|
||||||
|
} catch(e) { setDot(false, 'Forbindelsesfejl'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showPicker() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/live/`);
|
||||||
|
const lists = await r.json();
|
||||||
|
if (!lists.length) { showNoEvent(); return; }
|
||||||
|
if (lists.length === 1) { playlistId = lists[0].id; await loadAndRender(); return; }
|
||||||
|
show('picker');
|
||||||
|
document.getElementById('picker-list').innerHTML = lists.map(p => `
|
||||||
|
<button class="playlist-pick-btn" onclick="selectPlaylist('${p.id}')">
|
||||||
|
<strong>${fmt(p.name)}</strong>
|
||||||
|
<span>${p.now_playing ? '▶ '+fmt(p.now_playing) : 'Afventer start'}</span>
|
||||||
|
</button>`).join('');
|
||||||
|
} catch(e) { showNoEvent(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPlaylist(id) {
|
||||||
|
playlistId = id;
|
||||||
|
history.replaceState(null, '', `?id=${id}`);
|
||||||
|
loadAndRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNoEvent() {
|
||||||
|
show('no-event');
|
||||||
|
setDot(false, 'Ikke aktiv');
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(id) {
|
||||||
|
['picker','no-event','empty','now-playing'].forEach(x => {
|
||||||
|
document.getElementById(x).style.display = x === id ? '' : 'none';
|
||||||
|
});
|
||||||
|
if (id !== 'now-playing') {
|
||||||
|
document.getElementById('divider').style.display = 'none';
|
||||||
|
document.getElementById('next-label').style.display = 'none';
|
||||||
|
document.getElementById('song-list').innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const songs = data.songs || [];
|
||||||
|
const hasLive = !!data.updated_at;
|
||||||
|
|
||||||
|
document.getElementById('picker').style.display = 'none';
|
||||||
|
document.getElementById('no-event').style.display = 'none';
|
||||||
|
document.getElementById('empty').style.display = 'none';
|
||||||
|
document.getElementById('pl-name').textContent = data.name || '';
|
||||||
|
document.getElementById('last-updated').textContent =
|
||||||
|
data.updated_at ? 'Opdateret ' + fmtTime(data.updated_at) : '';
|
||||||
|
|
||||||
|
const playing = songs.find(s => s.status === 'playing');
|
||||||
|
const pending = songs.filter(s => s.status === 'pending');
|
||||||
|
const playedN = songs.filter(s => s.status === 'played' || s.status === 'skipped').length;
|
||||||
|
const total = songs.length;
|
||||||
|
|
||||||
|
// ── Now playing ────────────────────────────────────────────────────────────
|
||||||
|
if (songs.length > 0) {
|
||||||
|
document.getElementById('now-playing').style.display = '';
|
||||||
|
const current = playing || pending[0];
|
||||||
|
const done = !playing && pending.length === 0;
|
||||||
|
const currentIdx = current ? songs.indexOf(current) : -1;
|
||||||
|
|
||||||
|
document.getElementById('np-label').textContent =
|
||||||
|
playing ? '▶ Spiller nu' : done ? '✓ Afsluttet' : '⏸ Pause';
|
||||||
|
|
||||||
|
// Dans primær
|
||||||
|
document.getElementById('np-dance').textContent =
|
||||||
|
current ? getDanceName(current) : '';
|
||||||
|
|
||||||
|
// Nummer under dansen
|
||||||
|
if (current && currentIdx >= 0) {
|
||||||
|
document.getElementById('np-number').innerHTML =
|
||||||
|
`Nr. <b>${currentIdx + 1}</b> af ${total}`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('np-number').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sang sekundær (ikke ved workshop)
|
||||||
|
if (current && !current.is_workshop) {
|
||||||
|
const t = current.title || '—';
|
||||||
|
const a = current.artist || '';
|
||||||
|
document.getElementById('np-song').innerHTML =
|
||||||
|
`<span>${fmt(t)}</span>${a ? ' · ' + fmt(a) : ''}`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('np-song').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ETA til næste dans — klokkeslæt
|
||||||
|
const etaEl = document.getElementById('np-eta');
|
||||||
|
const etaValEl = document.getElementById('np-eta-val');
|
||||||
|
if (playing && currentIdx >= 0 && data.updated_at) {
|
||||||
|
const nextIdx = songs.findIndex((s, i) => i > currentIdx && s.status === 'pending');
|
||||||
|
if (nextIdx >= 0) {
|
||||||
|
const updatedAt = new Date(data.updated_at);
|
||||||
|
const remainSecs = songDuration(playing) + BETWEEN_DANCE_SEC;
|
||||||
|
const nextStart = new Date(updatedAt.getTime() + remainSecs * 1000);
|
||||||
|
etaEl.style.display = '';
|
||||||
|
etaValEl.textContent = fmtClock(nextStart);
|
||||||
|
} else {
|
||||||
|
etaEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
etaEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fremgangsbar
|
||||||
|
const pct = total > 0 ? Math.round(playedN / total * 100) : 0;
|
||||||
|
document.getElementById('np-progress').style.width = pct + '%';
|
||||||
|
document.getElementById('np-played').textContent = `${playedN} afspillet`;
|
||||||
|
document.getElementById('np-remaining').textContent = `${pending.length} tilbage`;
|
||||||
|
|
||||||
|
setDot(hasLive, hasLive ? 'Live' : 'Afventer');
|
||||||
|
} else {
|
||||||
|
document.getElementById('now-playing').style.display = 'none';
|
||||||
|
setDot(false, 'Ingen sange');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Songliste ──────────────────────────────────────────────────────────────
|
||||||
|
const hasList = songs.length > 0;
|
||||||
|
document.getElementById('divider').style.display = hasList ? '' : 'none';
|
||||||
|
document.getElementById('next-label').style.display = hasList ? '' : 'none';
|
||||||
|
|
||||||
|
// Beregn klokkeslæt for alle sange ud fra updated_at
|
||||||
|
// Udgangspunkt: updated_at = hvornår nuværende sang startede
|
||||||
|
const baseTime = data.updated_at ? new Date(data.updated_at) : null;
|
||||||
|
const playingIdx = songs.findIndex(s => s.status === 'playing');
|
||||||
|
let cumSecs = 0; // akkumuleret tid fra nuværende sang
|
||||||
|
|
||||||
|
// Nuværende sang: start = baseTime, slutter baseTime + songDuration
|
||||||
|
// Næste sang: starter baseTime + songDuration + BETWEEN_DANCE_SEC
|
||||||
|
// Osv.
|
||||||
|
|
||||||
|
document.getElementById('song-list').innerHTML = songs.map((s, i) => {
|
||||||
|
const icon = s.status === 'played' ? '✓' :
|
||||||
|
s.status === 'skipped' ? '—' :
|
||||||
|
s.status === 'playing' ? '▶' : '';
|
||||||
|
const cls = 'song-item ' + (s.status || 'pending');
|
||||||
|
const name = getDanceName(s);
|
||||||
|
const sub = (!s.is_workshop && s.title) ? s.title + (s.artist ? ' · ' + s.artist : '') : '';
|
||||||
|
|
||||||
|
let etaTxt = '';
|
||||||
|
|
||||||
|
if (s.status === 'playing' && baseTime) {
|
||||||
|
// Aktuel sang — viser ikke ETA (den vises i np-eta)
|
||||||
|
cumSecs = songDuration(s) + BETWEEN_DANCE_SEC;
|
||||||
|
|
||||||
|
} else if (s.status === 'pending' && playingIdx >= 0 && i > playingIdx && baseTime) {
|
||||||
|
// Pending sang efter den aktive — beregn klokkeslæt
|
||||||
|
const startTime = new Date(baseTime.getTime() + cumSecs * 1000);
|
||||||
|
etaTxt = 'ca. ' + fmtClock(startTime);
|
||||||
|
cumSecs += songDuration(s) + BETWEEN_DANCE_SEC;
|
||||||
|
|
||||||
|
} else if (s.status === 'pending' && playingIdx < 0 && baseTime) {
|
||||||
|
// Ingen sang spiller — estimér fra nu
|
||||||
|
const startTime = new Date(baseTime.getTime() + cumSecs * 1000);
|
||||||
|
etaTxt = 'ca. ' + fmtClock(startTime);
|
||||||
|
cumSecs += songDuration(s) + BETWEEN_DANCE_SEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="${cls}">
|
||||||
|
<span class="song-num">${i+1}</span>
|
||||||
|
<span class="song-check">${icon}</span>
|
||||||
|
<div class="song-info">
|
||||||
|
<div class="song-dance-name">${fmt(name)}</div>
|
||||||
|
${sub ? `<div class="song-title-sm">${fmt(sub)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
${etaTxt ? `<span class="song-eta-sm">${etaTxt}</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDot(active, label) {
|
||||||
|
document.getElementById('live-dot').className = 'dot' + (active ? ' active' : '');
|
||||||
|
document.getElementById('live-label').textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
document.getElementById('empty').style.display = 'flex';
|
||||||
|
loadAndRender();
|
||||||
|
startCountdown();
|
||||||
|
setInterval(loadAndRender, POLL_SECS * 1000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
94
linedance-app/BUILD.md
Normal file
94
linedance-app/BUILD.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# LineDance Player — Windows Build Guide
|
||||||
|
|
||||||
|
## Forudsætninger
|
||||||
|
|
||||||
|
Installer følgende på din Windows-maskine:
|
||||||
|
|
||||||
|
1. **Python 3.11+**
|
||||||
|
https://www.python.org/downloads/
|
||||||
|
✅ Sæt flueben ved "Add Python to PATH"
|
||||||
|
|
||||||
|
2. **VLC** (64-bit)
|
||||||
|
https://www.videolan.org/vlc/
|
||||||
|
Kræves både til udvikling og til slutbrugere
|
||||||
|
|
||||||
|
3. **NSIS 3.x**
|
||||||
|
https://nsis.sourceforge.io/Download
|
||||||
|
Bruges til at bygge `.exe` installationsprogrammet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Første gang: Opsæt miljø
|
||||||
|
|
||||||
|
```bat
|
||||||
|
cd linedance-app
|
||||||
|
python -m venv venv
|
||||||
|
venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Byg installer
|
||||||
|
|
||||||
|
```bat
|
||||||
|
build.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
Det gør automatisk:
|
||||||
|
1. PyInstaller → `dist\LineDancePlayer\` (hele programmet)
|
||||||
|
2. NSIS → `dist\LineDancePlayer-Setup.exe` (installer til brugerne)
|
||||||
|
|
||||||
|
Tager 2-5 minutter første gang.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upload til server
|
||||||
|
|
||||||
|
```bat
|
||||||
|
scp dist\LineDancePlayer-Setup.exe bruger@linedanceplayer.dk:/opt/docker/linedanceafspiller/linedance-api/web/public/download/
|
||||||
|
```
|
||||||
|
|
||||||
|
Filen er tilgængelig på:
|
||||||
|
`https://linedanceplayer.dk/download/LineDancePlayer-Setup.exe`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ikoner (valgfrit men anbefalet)
|
||||||
|
|
||||||
|
Placer disse filer i `installer\` mappen:
|
||||||
|
|
||||||
|
| Fil | Størrelse | Beskrivelse |
|
||||||
|
|-----|-----------|-------------|
|
||||||
|
| `icon.ico` | 256×256 | Program-ikon (Windows .ico format) |
|
||||||
|
| `welcome.bmp` | 164×314 | Velkomst-billede i installer |
|
||||||
|
| `header.bmp` | 150×57 | Header-billede i installer |
|
||||||
|
|
||||||
|
Uden ikoner bygges der med standard NSIS-udseende.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fejlfinding
|
||||||
|
|
||||||
|
**PyInstaller fejler med "module not found"**
|
||||||
|
Tilføj modulet til `hiddenimports` i `build_windows.spec`
|
||||||
|
|
||||||
|
**VLC ikke fundet ved kørsel**
|
||||||
|
Sørg for at VLC er installeret som 64-bit — samme arkitektur som Python
|
||||||
|
|
||||||
|
**NSIS fejler**
|
||||||
|
Kør `makensis /V4 installer.nsi` for detaljeret output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Versionsnummer
|
||||||
|
|
||||||
|
Opdater versionsnummeret i `installer.nsi`:
|
||||||
|
```nsis
|
||||||
|
!define APP_VERSION "1.0.1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Og i `app.html` på hjemmesiden:
|
||||||
|
```html
|
||||||
|
<div class="version" id="win-version">Version 1.0.1 · 64-bit</div>
|
||||||
|
```
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
if exist "dist\LineDancePlayer" rmdir /s /q "dist\LineDancePlayer"
|
:: ── Installer/opdater PyInstaller ─────────────────────────────────────────────
|
||||||
if exist "build\LineDancePlayer" rmdir /s /q "build\LineDancePlayer"
|
echo [1/4] Installerer PyInstaller...
|
||||||
|
pip install pyinstaller --quiet
|
||||||
|
|
||||||
echo Bygger... (1-3 minutter)
|
:: ── Ryd gamle builds ──────────────────────────────────────────────────────────
|
||||||
|
echo [2/4] Rydder gamle builds...
|
||||||
|
if exist "dist\LineDancePlayer" rmdir /s /q "dist\LineDancePlayer"
|
||||||
|
if exist "build\LineDancePlayer" rmdir /s /q "build\LineDancePlayer"
|
||||||
|
|
||||||
|
:: ── PyInstaller build ─────────────────────────────────────────────────────────
|
||||||
|
echo [3/4] Bygger med PyInstaller (2-5 minutter)...
|
||||||
echo.
|
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
@@ -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')
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
|
||||||
Binary file not shown.
@@ -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')])
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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',
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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: ...
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user