En del opdateringer

This commit is contained in:
2026-04-19 00:58:48 +02:00
parent efe3739626
commit e4ab9caab6
14 changed files with 3412 additions and 189 deletions

View File

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

2036
Henriks Musik/rapport.csv Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -46,11 +46,16 @@ def seed_dance_levels():
with Session(engine) as db: with Session(engine) as db:
if db.query(DanceLevel).count() == 0: if db.query(DanceLevel).count() == 0:
defaults = [ defaults = [
DanceLevel(sort_order=1, name="Begynder", description="Passer til alle"), DanceLevel(sort_order=10, name="Absolute Beginner", description="Ingen tidligere danse-erfaring kræves"),
DanceLevel(sort_order=2, name="Let øvet", description="Lidt erfaring kræves"), DanceLevel(sort_order=20, name="Beginner", description="Lidt tidligere erfaring"),
DanceLevel(sort_order=3, name="Øvet", description="Kræver regelmæssig træning"), DanceLevel(sort_order=30, name="High Beginner", description="God begynder, klar til mere"),
DanceLevel(sort_order=4, name="Erfaren", description="For dedikerede dansere"), DanceLevel(sort_order=40, name="Low Improver", description="Begyndende øvet"),
DanceLevel(sort_order=5, name="Ekspert", description="Konkurrenceniveau"), 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.add_all(defaults)
db.commit() db.commit()

View File

@@ -155,6 +155,18 @@ def push(
# ── Sang-dans tags (brugerens egne) ─────────────────────────────────────── # ── Sang-dans tags (brugerens egne) ───────────────────────────────────────
from app.models import SongDance, SongAltDance from app.models import SongDance, SongAltDance
# Slet eksisterende song_dances for disse sange og genindsæt
# — sikrer at rækkefølge og ændringer altid er korrekte
affected_song_ids = set(
song_id_map[sd.song_local_id]
for sd in payload.song_dances
if sd.song_local_id in song_id_map
)
if affected_song_ids:
db.query(SongDance).filter(
SongDance.song_id.in_(affected_song_ids)
).delete(synchronize_session=False)
for sd in payload.song_dances: for sd in payload.song_dances:
song_id = song_id_map.get(sd.song_local_id) song_id = song_id_map.get(sd.song_local_id)
if not song_id: if not song_id:
@@ -164,14 +176,20 @@ def push(
dance_id = dance_id_map.get(key) dance_id = dance_id_map.get(key)
if not dance_id: if not dance_id:
continue continue
existing = db.query(SongDance).filter_by( db.add(SongDance(
song_id=song_id, dance_id=dance_id song_id=song_id, dance_id=dance_id,
).first() dance_order=sd.dance_order,
if not existing: ))
db.add(SongDance(
song_id=song_id, dance_id=dance_id, affected_alt_ids = set(
dance_order=sd.dance_order, song_id_map[sa.song_local_id]
)) for sa in payload.song_alts
if sa.song_local_id in song_id_map
)
if affected_alt_ids:
db.query(SongAltDance).filter(
SongAltDance.song_id.in_(affected_alt_ids)
).delete(synchronize_session=False)
for sa in payload.song_alts: for sa in payload.song_alts:
song_id = song_id_map.get(sa.song_local_id) song_id = song_id_map.get(sa.song_local_id)
@@ -182,14 +200,10 @@ def push(
dance_id = dance_id_map.get(key) dance_id = dance_id_map.get(key)
if not dance_id: if not dance_id:
continue continue
existing = db.query(SongAltDance).filter_by( db.add(SongAltDance(
song_id=song_id, dance_id=dance_id song_id=song_id, dance_id=dance_id,
).first() note=sa.note,
if not existing: ))
db.add(SongAltDance(
song_id=song_id, dance_id=dance_id,
note=sa.note,
))
# ── Playlister ──────────────────────────────────────────────────────────── # ── Playlister ────────────────────────────────────────────────────────────
playlist_id_map = {} playlist_id_map = {}

View File

@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
# AcoustID API nøgle — kan overskrives i Indstillinger → Afspilning # AcoustID API nøgle — kan overskrives i Indstillinger → Afspilning
# Registrér din egen på https://acoustid.org/new-application # Registrér din egen på https://acoustid.org/new-application
ACOUSTID_API_KEY = "71W9SJdajAI" ACOUSTID_API_KEY = "6fd9DGNDqG"
ACOUSTID_API_URL = "https://api.acoustid.org/v2/lookup" ACOUSTID_API_URL = "https://api.acoustid.org/v2/lookup"
# Pause mellem API-kald — rolig baggrundskørsel # Pause mellem API-kald — rolig baggrundskørsel
@@ -154,7 +154,8 @@ def run_acoustid_scan(db_path: str, api_key: str = "", on_progress=None, stop_ev
logger.info("AcoustID: stoppet af bruger") logger.info("AcoustID: stoppet af bruger")
break break
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
rows = conn.execute(""" rows = conn.execute("""
@@ -181,7 +182,8 @@ def run_acoustid_scan(db_path: str, api_key: str = "", on_progress=None, stop_ev
found = 0 found = 0
logger.info(f"AcoustID: batch {batch_num}{total} sange") logger.info(f"AcoustID: batch {batch_num}{total} sange")
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path, timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
for row in rows: for row in rows:

View File

@@ -35,9 +35,10 @@ def _get_conn() -> sqlite3.Connection:
def new_conn() -> sqlite3.Connection: def new_conn() -> sqlite3.Connection:
"""Åbn en frisk forbindelse til brug i tag_editor og dialogs.""" """Åbn en frisk forbindelse til brug i tag_editor og dialogs."""
conn = sqlite3.connect(str(DB_PATH), check_same_thread=False) conn = sqlite3.connect(str(DB_PATH), check_same_thread=False, timeout=10)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=OFF") # FK checker forhindrer level_id gem conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=OFF")
return conn return conn
@@ -186,11 +187,16 @@ def init_db():
count = conn.execute("SELECT COUNT(*) FROM dance_levels").fetchone()[0] count = conn.execute("SELECT COUNT(*) FROM dance_levels").fetchone()[0]
if count == 0: if count == 0:
defaults = [ defaults = [
(1, "Begynder", "Passer til alle"), (10, "Absolute Beginner", "Ingen tidligere danse-erfaring kræves"),
(2, "Let øvet", "Lidt erfaring kræves"), (20, "Beginner", "Lidt tidligere erfaring"),
(3, "Øvet", "Kræver regelmæssig træning"), (30, "High Beginner", "God begynder, klar til mere"),
(4, "Erfaren", "For dedikerede dansere"), (40, "Low Improver", "Begyndende øvet"),
(5, "Ekspert", "Konkurrenceniveau"), (50, "Improver", "Grundlæggende færdigheder på plads"),
(60, "High Improver", "Stærk øvet, næsten intermediate"),
(70, "Low Intermediate", "Begyndende intermediate"),
(80, "Intermediate", "Erfaren danser"),
(90, "High Intermediate", "Stærk intermediate"),
(99, "Advanced", "Fuld beherskelse af trin og teknik"),
] ]
for row in defaults: for row in defaults:
conn.execute( conn.execute(
@@ -261,6 +267,40 @@ MIGRATIONS: dict[int, list[str]] = {
"""ALTER TABLE songs ADD COLUMN mbid TEXT""", """ALTER TABLE songs ADD COLUMN mbid TEXT""",
"""ALTER TABLE songs ADD COLUMN acoustid TEXT""", """ALTER TABLE songs ADD COLUMN acoustid TEXT""",
], ],
9: [
# Opdater niveau-navne til korrekte betegnelser i rigtig rækkefølge
"DELETE FROM dance_levels",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (10, 'Absolute Beginner', 'Ingen tidligere danse-erfaring kræves')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (20, 'Beginner', 'Lidt tidligere erfaring')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (30, 'High Beginner', 'God begynder, klar til mere')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (40, 'Low Improver', 'Begyndende øvet')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (50, 'Improver', 'Grundlæggende færdigheder på plads')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (60, 'High Improver', 'Stærk øvet, næsten intermediate')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (70, 'Low Intermediate', 'Begyndende intermediate')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (80, 'Intermediate', 'Erfaren danser')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (90, 'High Intermediate', 'Stærk intermediate')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (99, 'Advanced', 'Fuld beherskelse af trin og teknik')",
],
10: [
# Ret stavefejl i eksisterende data
"UPDATE dance_levels SET name='Low Intermediate' WHERE name='Low Intermidiate' OR name='Low Intermidiat'",
"UPDATE dance_levels SET name='Intermediate' WHERE name='Intermidiate' OR name='Intermidate'",
"UPDATE dance_levels SET name='High Intermediate' WHERE name='High Intermidiate' OR name='High Intermidiat'",
],
11: [
# Genopret dance_levels med korrekte navne og rækkefølge
"DELETE FROM dance_levels",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (10, 'Absolute Beginner', 'Ingen tidligere danse-erfaring kræves')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (20, 'Beginner', 'Lidt tidligere erfaring')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (30, 'High Beginner', 'God begynder, klar til mere')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (40, 'Low Improver', 'Begyndende øvet')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (50, 'Improver', 'Grundlæggende færdigheder på plads')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (60, 'High Improver', 'Stærk øvet, næsten intermediate')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (70, 'Low Intermediate', 'Begyndende intermediate')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (80, 'Intermediate', 'Erfaren danser')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (90, 'High Intermediate', 'Stærk intermediate')",
"INSERT INTO dance_levels (sort_order, name, description) VALUES (99, 'Advanced', 'Fuld beherskelse af trin og teknik')",
],
} }
@@ -442,24 +482,31 @@ def get_song_by_path(local_path: str) -> sqlite3.Row | None:
def search_songs(query: str, limit: int = 50) -> list[sqlite3.Row]: def search_songs(query: str, limit: int = 50) -> list[sqlite3.Row]:
"""Søg i alle tags — titel, artist, album, danse og alle øvrige tags.""" """Søg i titel, artist, album, dans, koreograf, niveau og øvrige tags."""
import logging as _log
_log.getLogger(__name__).info(f"search_songs: '{query}'")
pattern = f"%{query}%" pattern = f"%{query}%"
with get_db() as conn: with get_db() as conn:
return conn.execute(""" rows = conn.execute("""
SELECT DISTINCT s.* FROM songs s SELECT DISTINCT s.* FROM songs s
LEFT JOIN song_dances sd ON sd.song_id = s.id LEFT JOIN song_dances sd ON sd.song_id = s.id
LEFT JOIN dances d ON d.id = sd.dance_id LEFT JOIN dances d ON d.id = sd.dance_id
LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE s.file_missing = 0 WHERE s.file_missing = 0
AND ( AND (
s.title LIKE ? OR s.title LIKE ? OR
s.artist LIKE ? OR s.artist LIKE ? OR
s.album LIKE ? OR s.album LIKE ? OR
d.name LIKE ? OR d.name LIKE ? OR
s.extra_tags LIKE ? d.choreographer LIKE ? OR
dl.name LIKE ? OR
s.extra_tags LIKE ?
) )
ORDER BY s.artist, s.title ORDER BY s.artist, s.title
LIMIT ? LIMIT ?
""", (pattern, pattern, pattern, pattern, pattern, limit)).fetchall() """, (pattern,)*7 + (limit,)).fetchall()
_log.getLogger(__name__).info(f"search_songs: '{query}'{len(rows)} resultater")
return rows
def get_songs_for_library(library_id: int) -> list[sqlite3.Row]: def get_songs_for_library(library_id: int) -> list[sqlite3.Row]:
@@ -672,10 +719,11 @@ def update_dance_info(dance_id: int, choreographer: str = "",
def get_or_create_dance(name: str, level_id: int | None, def get_or_create_dance(name: str, level_id: int | None,
conn=None) -> int: conn=None, choreographer: str = "") -> int:
"""Find eller opret en dans (name + level_id kombination). """Find eller opret en dans (name + level_id kombination).
Returnerer dance_id. conn er valgfri — bruges ved nested kald.""" Returnerer dance_id. conn er valgfri — bruges ved nested kald."""
name = name.strip() name = name.strip()
choreo = choreographer.strip()
close = False close = False
if conn is None: if conn is None:
conn = new_conn() conn = new_conn()
@@ -687,13 +735,15 @@ def get_or_create_dance(name: str, level_id: int | None,
).fetchone() ).fetchone()
if existing: if existing:
conn.execute( conn.execute(
"UPDATE dances SET use_count=use_count+1 WHERE id=?", "UPDATE dances SET use_count=use_count+1"
(existing["id"],) + (", choreographer=?" if choreo else "") +
" WHERE id=?",
((choreo, existing["id"]) if choreo else (existing["id"],))
) )
return existing["id"] return existing["id"]
conn.execute( conn.execute(
"INSERT INTO dances (name, level_id, use_count, source) VALUES (?,?,1,'local')", "INSERT INTO dances (name, level_id, choreographer, use_count, source) VALUES (?,?,?,1,'local')",
(name, level_id) (name, level_id, choreo)
) )
return conn.execute( return conn.execute(
"SELECT id FROM dances WHERE name=? COLLATE NOCASE AND level_id IS ?", "SELECT id FROM dances WHERE name=? COLLATE NOCASE AND level_id IS ?",
@@ -705,19 +755,34 @@ def get_or_create_dance(name: str, level_id: int | None,
conn.close() conn.close()
def get_choreographer_suggestions(prefix: str, limit: int = 20) -> list[str]:
"""Returnerer koreografer der starter med prefix, sorteret alfabetisk."""
with get_db() as conn:
rows = conn.execute("""
SELECT DISTINCT choreographer
FROM dances
WHERE choreographer LIKE ? COLLATE NOCASE
AND choreographer != ''
ORDER BY choreographer
LIMIT ?
""", (f"{prefix}%", limit)).fetchall()
return [r["choreographer"] for r in rows]
def get_dance_suggestions(prefix: str, limit: int = 20) -> list[dict]: def get_dance_suggestions(prefix: str, limit: int = 20) -> list[dict]:
"""Returnerer danse der starter med prefix som {id, name, level_id, level_name}. """Returnerer danse der matcher prefix i navn ELLER koreograf.
Sorteret efter popularitet — bruges til autoudfyld.""" Sorteret efter popularitet — bruges til autoudfyld."""
with get_db() as conn: with get_db() as conn:
rows = conn.execute(""" rows = conn.execute("""
SELECT d.id, d.name, d.level_id, d.use_count, SELECT d.id, d.name, d.level_id, d.use_count, d.choreographer,
dl.name as level_name, dl.sort_order dl.name as level_name, dl.sort_order
FROM dances d FROM dances d
LEFT JOIN dance_levels dl ON dl.id = d.level_id LEFT JOIN dance_levels dl ON dl.id = d.level_id
WHERE d.name LIKE ? COLLATE NOCASE WHERE d.name LIKE ? COLLATE NOCASE
OR d.choreographer LIKE ? COLLATE NOCASE
ORDER BY d.use_count DESC, dl.sort_order, d.name ORDER BY d.use_count DESC, dl.sort_order, d.name
LIMIT ? LIMIT ?
""", (f"{prefix}%", limit)).fetchall() """, (f"%{prefix}%", f"%{prefix}%", limit)).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
@@ -725,7 +790,7 @@ def get_dances_for_song(song_id: str) -> list[dict]:
"""Hent hoveddanse for en sang med niveau-info og workshop-flag.""" """Hent hoveddanse for en sang med niveau-info og workshop-flag."""
with get_db() as conn: with get_db() as conn:
rows = conn.execute(""" rows = conn.execute("""
SELECT d.id as dance_id, d.name, d.level_id, SELECT d.id as dance_id, d.name, d.level_id, d.choreographer,
dl.name as level_name, sd.dance_order, dl.name as level_name, sd.dance_order,
sd.id as song_dance_id, sd.is_workshop sd.id as song_dance_id, sd.is_workshop
FROM song_dances sd FROM song_dances sd

View File

@@ -224,7 +224,7 @@ def _read_vorbis(audio, result: dict):
result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()] result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()]
else: else:
result["dances"] = [dances[k] for k in sorted(dances.keys())] result["dances"] = [dances[k] for k in sorted(dances.keys())]
# Alle øvrige tags som extra_tags # Øvrige tags
skip = {"title", "artist", "album", "bpm", VORBIS_DANCE_KEY} skip = {"title", "artist", "album", "bpm", VORBIS_DANCE_KEY}
extra = {} extra = {}
for key, values in tags.items(): for key, values in tags.items():
@@ -243,6 +243,10 @@ def _read_vorbis(audio, result: dict):
except Exception: except Exception:
pass pass
break break
def _read_m4a(audio, result: dict):
"""M4A/AAC/MP4 — iTunes atoms."""
tags = audio.tags tags = audio.tags
if not tags: if not tags:
return return
@@ -262,7 +266,6 @@ def _read_vorbis(audio, result: dict):
v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v) v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v)
for v in tags[M4A_DANCE_FREEFORM] for v in tags[M4A_DANCE_FREEFORM]
] ]
# Menneskelige navne til M4A-nøgler
M4A_NAMES = { M4A_NAMES = {
"\xa9nam": "titel", "\xa9ART": "artist", "\xa9alb": "album", "\xa9nam": "titel", "\xa9ART": "artist", "\xa9alb": "album",
"\xa9day": "år", "\xa9gen": "genre", "\xa9wrt": "komponist", "\xa9day": "år", "\xa9gen": "genre", "\xa9wrt": "komponist",
@@ -284,7 +287,7 @@ def _read_vorbis(audio, result: dict):
except Exception: except Exception:
pass pass
result["extra_tags"] = extra result["extra_tags"] = extra
# MBID — gemmes som freeform atom ----:com.apple.iTunes:MusicBrainz Recording Id # MBID
for key in tags: for key in tags:
if "musicbrainz" in key.lower() and "recording" in key.lower(): if "musicbrainz" in key.lower() and "recording" in key.lower():
try: try:
@@ -295,14 +298,35 @@ def _read_vorbis(audio, result: dict):
except Exception: except Exception:
pass pass
break break
def _read_generic(audio, result: dict):
"""Generisk læsning for WMA, AIFF og andre formater via easy tags."""
try: try:
easy = MutagenFile(result["local_path"], easy=True) from mutagen import File as MutagenFileEasy
if easy and easy.tags: local_path = result.get("local_path", "")
result["title"] = easy.tags.get("title", [result["title"]])[0] if local_path:
result["artist"] = easy.tags.get("artist", [""])[0] easy = MutagenFileEasy(local_path, easy=True)
result["album"] = easy.tags.get("album", [""])[0] 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: except Exception:
pass # Fallback: læs direkte fra audio-objektet
try:
tags = audio.tags
if hasattr(tags, "items"):
for key, val in tags.items():
k = str(key).lower()
v = str(val[0]) if hasattr(val, "__iter__") and not isinstance(val, str) else str(val)
if "title" in k and not result["title"]:
result["title"] = v
elif "artist" in k and not result["artist"]:
result["artist"] = v
elif "album" in k and not result["album"]:
result["album"] = v
except Exception:
pass
# ── Skrivning ───────────────────────────────────────────────────────────────── # ── Skrivning ─────────────────────────────────────────────────────────────────

View File

@@ -8,6 +8,8 @@ Start:
import sys import sys
import os import os
APP_VERSION = "0.8.2"
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
from app_logger import setup_logging from app_logger import setup_logging

View File

@@ -1,5 +1,5 @@
""" """
dance_picker_dialog.py — Dialog til at vælge eller skrive en dans med autoudfyld. dance_picker_dialog.py — Dialog til at vælge dans og koreograf med autoudfyld.
""" """
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
@@ -10,16 +10,18 @@ from PyQt6.QtCore import Qt, QTimer
class DancePickerDialog(QDialog): class DancePickerDialog(QDialog):
def __init__(self, current_dance: str = "", song_title: str = "", parent=None): def __init__(self, current_dance: str = "", current_choreo: str = "",
song_title: str = "", parent=None):
super().__init__(parent) super().__init__(parent)
self._chosen = current_dance self._chosen_dance = current_dance
self._chosen_choreo = current_choreo
self.setWindowTitle("Vælg dans") self.setWindowTitle("Vælg dans")
self.setMinimumWidth(380) self.setMinimumWidth(400)
self.setFixedWidth(420) self.setFixedWidth(440)
self._build_ui(current_dance, song_title) self._build_ui(current_dance, current_choreo, song_title)
self._load_suggestions("") self._load_dance_suggestions("")
def _build_ui(self, current_dance: str, song_title: str): def _build_ui(self, current_dance: str, current_choreo: str, song_title: str):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(12, 12, 12, 12) layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(8) layout.setSpacing(8)
@@ -30,37 +32,65 @@ class DancePickerDialog(QDialog):
lbl.setWordWrap(True) lbl.setWordWrap(True)
layout.addWidget(lbl) layout.addWidget(lbl)
lbl2 = QLabel("Vælg eller skriv dans-navn:") # ── Dans ──────────────────────────────────────────────────────────────
lbl2 = QLabel("Dans:")
lbl2.setObjectName("track_meta") lbl2.setObjectName("track_meta")
layout.addWidget(lbl2) layout.addWidget(lbl2)
# Søgefelt med autoudfyld self._edit_dance = QLineEdit()
self._edit = QLineEdit() self._edit_dance.setText(current_dance)
self._edit.setText(current_dance) self._edit_dance.setPlaceholderText("Skriv dans-navn...")
self._edit.setPlaceholderText("Skriv dans-navn...") self._edit_dance.selectAll()
self._edit.selectAll() self._edit_dance.textChanged.connect(self._on_dance_text_changed)
self._edit.textChanged.connect(self._on_text_changed) self._edit_dance.returnPressed.connect(lambda: self._edit_choreo.setFocus())
self._edit.returnPressed.connect(self._on_accept) layout.addWidget(self._edit_dance)
layout.addWidget(self._edit)
# Liste med forslag self._dance_list = QListWidget()
self._suggestion_list = QListWidget() self._dance_list.setMaximumHeight(160)
self._suggestion_list.setMaximumHeight(180) self._dance_list.itemDoubleClicked.connect(self._on_dance_selected)
self._suggestion_list.itemDoubleClicked.connect(self._on_item_selected) self._dance_list.itemClicked.connect(
self._suggestion_list.itemClicked.connect( lambda item: self._edit_dance.setText(
lambda item: self._edit.setText(item.text()) item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0]
)
) )
layout.addWidget(self._suggestion_list) layout.addWidget(self._dance_list)
# Debounce timer # ── Koreograf ─────────────────────────────────────────────────────────
self._timer = QTimer(self) lbl3 = QLabel("Koreograf (valgfri):")
self._timer.setSingleShot(True) lbl3.setObjectName("track_meta")
self._timer.setInterval(200) layout.addWidget(lbl3)
self._timer.timeout.connect(
lambda: self._load_suggestions(self._edit.text().strip()) self._edit_choreo = QLineEdit()
self._edit_choreo.setText(current_choreo)
self._edit_choreo.setPlaceholderText("Koreografens navn...")
self._edit_choreo.textChanged.connect(self._on_choreo_text_changed)
self._edit_choreo.returnPressed.connect(self._on_accept)
layout.addWidget(self._edit_choreo)
self._choreo_list = QListWidget()
self._choreo_list.setMaximumHeight(100)
self._choreo_list.itemDoubleClicked.connect(self._on_choreo_selected)
self._choreo_list.itemClicked.connect(
lambda item: self._edit_choreo.setText(item.text())
)
layout.addWidget(self._choreo_list)
# ── Debounce timere ───────────────────────────────────────────────────
self._dance_timer = QTimer(self)
self._dance_timer.setSingleShot(True)
self._dance_timer.setInterval(200)
self._dance_timer.timeout.connect(
lambda: self._load_dance_suggestions(self._edit_dance.text().strip())
) )
# Knapper self._choreo_timer = QTimer(self)
self._choreo_timer.setSingleShot(True)
self._choreo_timer.setInterval(200)
self._choreo_timer.timeout.connect(
lambda: self._load_choreo_suggestions(self._edit_choreo.text().strip())
)
# ── Knapper ───────────────────────────────────────────────────────────
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_row.addStretch() btn_row.addStretch()
btn_cancel = QPushButton("Annuller") btn_cancel = QPushButton("Annuller")
@@ -72,34 +102,62 @@ class DancePickerDialog(QDialog):
btn_row.addWidget(btn_ok) btn_row.addWidget(btn_ok)
layout.addLayout(btn_row) layout.addLayout(btn_row)
self._edit.setFocus() self._edit_dance.setFocus()
def _on_text_changed(self, text: str): def _on_dance_text_changed(self):
self._timer.start() self._dance_timer.start()
def _load_suggestions(self, prefix: str): def _on_choreo_text_changed(self):
self._choreo_timer.start()
def _load_dance_suggestions(self, prefix: str):
try: try:
from local.local_db import get_dance_suggestions from local.local_db import get_dance_suggestions
suggestions = get_dance_suggestions(prefix or "", limit=20) suggestions = get_dance_suggestions(prefix or "", limit=20)
self._suggestion_list.clear() self._dance_list.clear()
for s in suggestions: for s in suggestions:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"] label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
if s.get("choreographer"):
label += f" ({s['choreographer']})"
item = QListWidgetItem(label) item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s["name"]) item.setData(Qt.ItemDataRole.UserRole, s["name"])
self._suggestion_list.addItem(item) item.setData(Qt.ItemDataRole.UserRole + 1, s.get("choreographer", ""))
self._dance_list.addItem(item)
except Exception: except Exception:
pass pass
def _on_item_selected(self, item: QListWidgetItem): def _load_choreo_suggestions(self, prefix: str):
name = item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0] try:
self._edit.setText(name) from local.local_db import get_choreographer_suggestions
self._chosen = name suggestions = get_choreographer_suggestions(prefix or "", limit=15)
self._choreo_list.clear()
for name in suggestions:
self._choreo_list.addItem(QListWidgetItem(name))
except Exception:
pass
def _on_dance_selected(self, item: QListWidgetItem):
name = item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0]
choreo = item.data(Qt.ItemDataRole.UserRole + 1) or ""
self._edit_dance.setText(name)
if choreo and not self._edit_choreo.text().strip():
self._edit_choreo.setText(choreo)
self._chosen_dance = name
self._chosen_choreo = self._edit_choreo.text().strip()
self.accept() self.accept()
def _on_choreo_selected(self, item: QListWidgetItem):
self._edit_choreo.setText(item.text())
self._choreo_list.clear()
def _on_accept(self): def _on_accept(self):
self._chosen = self._edit.text().strip() self._chosen_dance = self._edit_dance.text().strip()
if self._chosen: self._chosen_choreo = self._edit_choreo.text().strip()
if self._chosen_dance:
self.accept() self.accept()
def get_dance(self) -> str: def get_dance(self) -> str:
return self._chosen return self._chosen_dance
def get_choreo(self) -> str:
return self._chosen_choreo

View File

@@ -292,12 +292,16 @@ class LibraryPanel(QWidget):
def _matches(self, song: dict, q: str, incl_alt: bool = False) -> bool: def _matches(self, song: dict, q: str, incl_alt: bool = False) -> bool:
fields = [ fields = [
song.get("title", ""), song.get("artist", ""), song.get("title", ""),
song.get("album", ""), song.get("file_format", ""), song.get("artist", ""),
] + song.get("dances", []) song.get("album", ""),
song.get("file_format", ""),
] + song.get("dances", []) \
+ song.get("dance_choreographers", []) \
+ song.get("dance_levels", [])
if incl_alt: if incl_alt:
fields += song.get("alt_dances", []) fields += song.get("alt_dances", [])
return any(q in f.lower() for f in fields) return any(q in f.lower() for f in fields if f)
def _render(self): def _render(self):
self._list.clear() self._list.clear()

View File

@@ -162,6 +162,15 @@ class MainWindow(QMainWindow):
act_quit.triggered.connect(self.close) act_quit.triggered.connect(self.close)
file_menu.addAction(act_quit) file_menu.addAction(act_quit)
# ── Danse ─────────────────────────────────────────────────────────────
dance_menu = menubar.addMenu("Danse")
act_new_dance = QAction("Opret dans...", self)
act_new_dance.setShortcut("Ctrl+D")
act_new_dance.setToolTip("Opret en dans i databasen uden at knytte den til musik")
act_new_dance.triggered.connect(self._create_dance_dialog)
dance_menu.addAction(act_new_dance)
# ── Ingen Danseliste- eller Visning-menu ────────────────────────────── # ── Ingen Danseliste- eller Visning-menu ──────────────────────────────
# Ny/Gem/Hent ligger direkte i danseliste-panelet # Ny/Gem/Hent ligger direkte i danseliste-panelet
# Tema-skift ligger i topbar-knappen # Tema-skift ligger i topbar-knappen
@@ -178,6 +187,16 @@ class MainWindow(QMainWindow):
self.setStatusBar(self._statusbar) self.setStatusBar(self._statusbar)
self._statusbar.showMessage("Klar") self._statusbar.showMessage("Klar")
# Versionsnummer permanent til højre
try:
from main import APP_VERSION
except Exception:
APP_VERSION = "0.8.1"
version_lbl = QLabel(f"v{APP_VERSION}")
version_lbl.setObjectName("result_count")
version_lbl.setContentsMargins(0, 0, 8, 0)
self._statusbar.addPermanentWidget(version_lbl)
def _set_status(self, text: str, timeout_ms: int = 0): def _set_status(self, text: str, timeout_ms: int = 0):
"""Vis besked i statuslinjen. timeout_ms=0 = permanent.""" """Vis besked i statuslinjen. timeout_ms=0 = permanent."""
self._statusbar.showMessage(text, timeout_ms) self._statusbar.showMessage(text, timeout_ms)
@@ -481,9 +500,10 @@ class MainWindow(QMainWindow):
SELECT s.id, s.title, s.artist, s.album, s.bpm, SELECT s.id, s.title, s.artist, s.album, s.bpm,
s.duration_sec, s.local_path, s.file_format, s.duration_sec, s.local_path, s.file_format,
s.file_missing, s.file_missing,
GROUP_CONCAT(d.name, ',') AS dance_names, GROUP_CONCAT(d.name, ',') AS dance_names,
GROUP_CONCAT(COALESCE(dl.name,''), ',') AS dance_levels, GROUP_CONCAT(COALESCE(dl.name,''), ',') AS dance_levels,
GROUP_CONCAT(DISTINCT ad.name) AS alt_dance_names GROUP_CONCAT(COALESCE(d.choreographer,''), ',') AS dance_choreographers,
GROUP_CONCAT(DISTINCT ad.name) AS alt_dance_names
FROM songs s FROM songs s
LEFT JOIN song_dances sd ON sd.song_id = s.id LEFT JOIN song_dances sd ON sd.song_id = s.id
LEFT JOIN dances d ON d.id = sd.dance_id LEFT JOIN dances d ON d.id = sd.dance_id
@@ -498,22 +518,24 @@ class MainWindow(QMainWindow):
songs = [] songs = []
for row in rows: for row in rows:
dances = row["dance_names"].split(",") if row["dance_names"] else [] dances = row["dance_names"].split(",") if row["dance_names"] else []
levels = row["dance_levels"].split(",") if row["dance_levels"] else [] levels = row["dance_levels"].split(",") if row["dance_levels"] else []
alt_dances = row["alt_dance_names"].split(",") if row["alt_dance_names"] else [] choreos = row["dance_choreographers"].split(",") if row["dance_choreographers"] else []
alt_dances = row["alt_dance_names"].split(",") if row["alt_dance_names"] else []
songs.append({ songs.append({
"id": row["id"], "id": row["id"],
"title": row["title"], "title": row["title"],
"artist": row["artist"], "artist": row["artist"],
"album": row["album"], "album": row["album"],
"bpm": row["bpm"], "bpm": row["bpm"],
"duration_sec": row["duration_sec"], "duration_sec": row["duration_sec"],
"local_path": row["local_path"], "local_path": row["local_path"],
"file_format": row["file_format"], "file_format": row["file_format"],
"file_missing": bool(row["file_missing"]), "file_missing": bool(row["file_missing"]),
"dances": dances, "dances": dances,
"dance_levels": levels, "dance_levels": levels,
"alt_dances": alt_dances, "dance_choreographers": choreos,
"alt_dances": alt_dances,
}) })
self._library_loaded.emit(songs) self._library_loaded.emit(songs)
except Exception: except Exception:
@@ -652,6 +674,66 @@ class MainWindow(QMainWindow):
except Exception as e: except Exception as e:
self._set_status(f"Fejl ved tilføjelse: {e}") self._set_status(f"Fejl ved tilføjelse: {e}")
def _create_dance_dialog(self):
"""Opret en dans i databasen — fritliggende, uden tilknytning til musik."""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
QComboBox, QDialogButtonBox, QMessageBox
)
try:
from local.local_db import get_dance_levels, get_or_create_dance
except Exception as e:
QMessageBox.warning(self, "Fejl", str(e))
return
levels = [dict(r) for r in get_dance_levels()]
dlg = QDialog(self)
dlg.setWindowTitle("Opret dans")
dlg.setFixedWidth(380)
layout = QVBoxLayout(dlg)
layout.setSpacing(8)
layout.setContentsMargins(14, 14, 14, 14)
form = QFormLayout()
form.setSpacing(8)
name_edit = QLineEdit()
name_edit.setPlaceholderText("f.eks. Cowboy Cha Cha")
form.addRow("Dans-navn:", name_edit)
level_cb = QComboBox()
level_cb.addItem("— intet niveau —", None)
for lvl in levels:
level_cb.addItem(lvl["name"], lvl["id"])
form.addRow("Niveau:", level_cb)
choreo_edit = QLineEdit()
choreo_edit.setPlaceholderText("Koreografens navn (valgfri)")
form.addRow("Koreograf:", choreo_edit)
layout.addLayout(form)
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
btns.accepted.connect(dlg.accept)
btns.rejected.connect(dlg.reject)
layout.addWidget(btns)
name_edit.setFocus()
if dlg.exec():
name = name_edit.text().strip()
choreo = choreo_edit.text().strip()
level = level_cb.currentData()
if name:
try:
get_or_create_dance(name, level, choreographer=choreo)
self._set_status(f'Dans "{name}" oprettet', 3000)
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke oprette dans:\n{e}")
def _open_settings(self): def _open_settings(self):
dialog = SettingsDialog(parent=self) dialog = SettingsDialog(parent=self)
if dialog.exec(): if dialog.exec():

View File

@@ -732,15 +732,19 @@ class PlaylistPanel(QWidget):
if not current: if not current:
dances = song.get("dances", []) dances = song.get("dances", [])
current = dances[0] if dances else "" current = dances[0] if dances else ""
current_choreo = song.get("active_choreo", "")
dlg = DancePickerDialog( dlg = DancePickerDialog(
current_dance=current, current_dance=current,
current_choreo=current_choreo,
song_title=song.get("title", ""), song_title=song.get("title", ""),
parent=self.window() parent=self.window()
) )
if dlg.exec(): if dlg.exec():
chosen = dlg.get_dance() chosen = dlg.get_dance()
choreo = dlg.get_choreo()
if chosen: if chosen:
song["active_dance"] = chosen song["active_dance"] = chosen
song["active_choreo"] = choreo
self._refresh() self._refresh()
self._sync_dance_to_db(idx, song) self._sync_dance_to_db(idx, song)

View File

@@ -47,8 +47,8 @@ class TagEditorDialog(QDialog):
self._alts = [] # fra DB: {dance_id, name, level_id, level_name, note} self._alts = [] # fra DB: {dance_id, name, level_id, level_name, note}
self.setWindowTitle(f"Rediger tags — {song.get('title', '')}") self.setWindowTitle(f"Rediger tags — {song.get('title', '')}")
self.setMinimumSize(720, 500) self.setMinimumSize(860, 520)
self.resize(820, 580) self.resize(980, 600)
self._load_levels() self._load_levels()
self._load_existing() self._load_existing()
@@ -97,11 +97,11 @@ class TagEditorDialog(QDialog):
hint.setWordWrap(True) hint.setWordWrap(True)
layout.addWidget(hint) layout.addWidget(hint)
# To kolonner # To kolonner — hoveddanse får mere plads
cols = QHBoxLayout() cols = QHBoxLayout()
cols.setSpacing(12) cols.setSpacing(12)
cols.addWidget(self._build_dances_panel()) cols.addWidget(self._build_dances_panel(), stretch=3)
cols.addWidget(self._build_alts_panel()) cols.addWidget(self._build_alts_panel(), stretch=2)
layout.addLayout(cols, stretch=1) layout.addLayout(cols, stretch=1)
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
@@ -132,11 +132,11 @@ class TagEditorDialog(QDialog):
layout.addWidget(scroll, stretch=1) layout.addWidget(scroll, stretch=1)
self._dance_rows = [] self._dance_rows = []
for d in self._dances: for d in self._dances:
self._add_dance_row(d["name"], d["level_id"]) self._add_dance_row(d["name"], d["level_id"], d.get("choreographer", ""))
# Søgefelt # Søgefelt
self._new_dance = QLineEdit() self._new_dance = QLineEdit()
self._new_dance.setPlaceholderText(_("tags.new_dance")) self._new_dance.setPlaceholderText("Søg dans eller koreograf...")
self._new_dance.textChanged.connect(self._on_dance_search) self._new_dance.textChanged.connect(self._on_dance_search)
self._new_dance.returnPressed.connect(self._on_add_dance) self._new_dance.returnPressed.connect(self._on_add_dance)
layout.addWidget(self._new_dance) layout.addWidget(self._new_dance)
@@ -162,7 +162,7 @@ class TagEditorDialog(QDialog):
self._load_dance_suggestions("", self._dance_suggestions) self._load_dance_suggestions("", self._dance_suggestions)
return grp return grp
def _add_dance_row(self, name="", level_id=None): def _add_dance_row(self, name="", level_id=None, choreographer=""):
try: try:
from translations import _, translate_level from translations import _, translate_level
except Exception: except Exception:
@@ -175,14 +175,13 @@ class TagEditorDialog(QDialog):
edit = DanceLineEdit("Dans...", self) edit = DanceLineEdit("Dans...", self)
edit.setText(name) edit.setText(name)
row_layout.addWidget(edit, stretch=1) row_layout.addWidget(edit, stretch=2)
# Niveau-dropdown # Niveau-dropdown
level_cb = QComboBox() level_cb = QComboBox()
level_cb.addItem(_("tags.no_level"), None) level_cb.addItem(_("tags.no_level"), None)
for lvl in self._levels: for lvl in self._levels:
level_cb.addItem(translate_level(lvl["name"]), lvl["id"]) level_cb.addItem(translate_level(lvl["name"]), lvl["id"])
# Sæt til det rigtige niveau
if level_id is not None: if level_id is not None:
for i in range(level_cb.count()): for i in range(level_cb.count()):
if level_cb.itemData(i) == level_id: if level_cb.itemData(i) == level_id:
@@ -191,24 +190,102 @@ class TagEditorDialog(QDialog):
level_cb.setFixedWidth(130) level_cb.setFixedWidth(130)
row_layout.addWidget(level_cb) row_layout.addWidget(level_cb)
# Når autoudfyld vælger — opdater dropdown # Koreograf-felt med autocomplete
def on_dance_selected(dance_info, cb=level_cb): choreo_edit = QLineEdit()
choreo_edit.setText(choreographer)
choreo_edit.setPlaceholderText("Koreograf...")
choreo_edit.setFixedWidth(140)
choreo_edit.textChanged.connect(
lambda txt, ce=choreo_edit: self._show_choreo_suggestions(txt, ce)
)
row_layout.addWidget(choreo_edit)
# Når autoudfyld vælger — opdater dropdown og koreograf
def on_dance_selected(dance_info, cb=level_cb, ce=choreo_edit):
if dance_info.get("level_id") is not None: if dance_info.get("level_id") is not None:
for i in range(cb.count()): for i in range(cb.count()):
if cb.itemData(i) == dance_info["level_id"]: if cb.itemData(i) == dance_info["level_id"]:
cb.setCurrentIndex(i) cb.setCurrentIndex(i)
break break
if dance_info.get("choreographer") and not ce.text().strip():
ce.setText(dance_info["choreographer"])
edit.dance_selected.connect(on_dance_selected) edit.dance_selected.connect(on_dance_selected)
btn_info = QPushButton("•••")
btn_info.setFixedSize(36, 24)
btn_info.setToolTip("Åbn dans-info (link, video, noter)")
btn_info.setObjectName("btn_info_row")
btn_info.style().unpolish(btn_info)
btn_info.style().polish(btn_info)
row_layout.addWidget(btn_info)
btn_rm = QPushButton("") btn_rm = QPushButton("")
btn_rm.setFixedSize(24, 24) btn_rm.setFixedSize(32, 24)
btn_rm.setToolTip("Fjern dans")
btn_rm.setObjectName("btn_rm_row")
btn_rm.style().unpolish(btn_rm)
btn_rm.style().polish(btn_rm)
row_layout.addWidget(btn_rm) row_layout.addWidget(btn_rm)
idx = self._dance_layout.count() - 1 idx = self._dance_layout.count() - 1
self._dance_layout.insertWidget(idx, row_widget) self._dance_layout.insertWidget(idx, row_widget)
entry = {"widget": row_widget, "edit": edit, "level": level_cb} entry = {"widget": row_widget, "edit": edit, "level": level_cb, "choreo": choreo_edit}
self._dance_rows.append(entry) self._dance_rows.append(entry)
btn_rm.clicked.connect(lambda: self._remove_dance_row(entry)) btn_rm.clicked.connect(lambda: self._remove_dance_row(entry))
btn_info.clicked.connect(lambda: self._open_dance_info(entry))
def _create_dance(self):
"""Opret en ny dans i databasen uden at knytte den til musik."""
from PyQt6.QtWidgets import QDialog, QFormLayout, QDialogButtonBox
from PyQt6.QtCore import Qt
dlg = QDialog(self)
dlg.setWindowTitle("Opret dans")
dlg.setFixedWidth(360)
layout = QVBoxLayout(dlg)
layout.setSpacing(8)
form = QFormLayout()
name_edit = QLineEdit()
name_edit.setPlaceholderText("f.eks. Cowboy Cha Cha")
form.addRow("Dans-navn:", name_edit)
from PyQt6.QtWidgets import QComboBox
level_cb = QComboBox()
level_cb.addItem("— intet niveau —", None)
for lvl in self._levels:
level_cb.addItem(lvl["name"], lvl["id"])
form.addRow("Niveau:", level_cb)
choreo_edit = QLineEdit()
choreo_edit.setPlaceholderText("Koreografens navn (valgfri)")
form.addRow("Koreograf:", choreo_edit)
layout.addLayout(form)
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok |
QDialogButtonBox.StandardButton.Cancel
)
btns.accepted.connect(dlg.accept)
btns.rejected.connect(dlg.reject)
layout.addWidget(btns)
name_edit.setFocus()
if dlg.exec():
name = name_edit.text().strip()
choreo = choreo_edit.text().strip()
level = level_cb.currentData()
if name:
try:
from local.local_db import get_or_create_dance
get_or_create_dance(name, level, choreographer=choreo)
# Opdater forslagslisten
self._load_dance_suggestions("", self._dance_suggestions)
self._load_existing_dance_suggestions("", self._alt_suggestions)
self._new_dance.setPlaceholderText(f'"{name}" oprettet ✓')
except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke oprette dans:\n{e}")
def _remove_dance_row(self, entry): def _remove_dance_row(self, entry):
self._dance_rows.remove(entry) self._dance_rows.remove(entry)
@@ -217,9 +294,52 @@ class TagEditorDialog(QDialog):
def _on_dance_search(self): def _on_dance_search(self):
self._dance_search_timer.start() self._dance_search_timer.start()
def _load_existing_dance_suggestions(self, prefix: str, list_widget):
"""Kun eksisterende danse fra DB — ingen nye kan oprettes herfra."""
try:
from local.local_db import get_dance_suggestions
suggestions = get_dance_suggestions(prefix, limit=20)
list_widget.clear()
for s in suggestions:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
item.setData(Qt.ItemDataRole.UserRole + 2, s.get("choreographer", ""))
list_widget.addItem(item)
except Exception:
pass
def _on_alt_search(self): def _on_alt_search(self):
self._alt_search_timer.start() self._alt_search_timer.start()
def _open_dance_info(self, entry: dict):
"""Åbn dans-info dialog for den dans der er i denne række."""
name = entry["edit"].text().strip()
if not name:
entry["edit"].setFocus()
entry["edit"].setPlaceholderText("Skriv dans-navn først...")
return
from ui.dance_info_dialog import DanceInfoDialog
dlg = DanceInfoDialog(song=self._song, parent=self)
dlg.exec()
# Opdater koreograf-feltet fra DB bagefter
try:
from local.local_db import get_db
with get_db() as conn:
row = conn.execute(
"SELECT choreographer FROM dances "
"WHERE name=? COLLATE NOCASE LIMIT 1", (name,)
).fetchone()
if row and row["choreographer"]:
entry["choreo"].setText(row["choreographer"])
except Exception:
pass
def _show_choreo_suggestions(self, prefix: str, source_edit: 'QLineEdit'):
"""Vis autocomplete popup for koreograf direkte under feltet."""
pass # Simpel løsning: autocomplete via QCompleter nedenfor
def _load_dance_suggestions(self, prefix: str, list_widget): def _load_dance_suggestions(self, prefix: str, list_widget):
try: try:
from local.local_db import get_dance_suggestions from local.local_db import get_dance_suggestions
@@ -227,19 +347,23 @@ class TagEditorDialog(QDialog):
list_widget.clear() list_widget.clear()
for s in suggestions: for s in suggestions:
label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"] label = f"{s['name']} / {s['level_name']}" if s.get("level_name") else s["name"]
if s.get("choreographer"):
label += f" · {s['choreographer']}"
item = QListWidgetItem(label) item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, s.get("level_id")) item.setData(Qt.ItemDataRole.UserRole, s.get("level_id"))
item.setData(Qt.ItemDataRole.UserRole + 1, s["name"]) item.setData(Qt.ItemDataRole.UserRole + 1, s["name"])
item.setData(Qt.ItemDataRole.UserRole + 2, s.get("choreographer", ""))
list_widget.addItem(item) list_widget.addItem(item)
except Exception: except Exception:
pass pass
def _add_from_suggestion(self, item, panel: str): def _add_from_suggestion(self, item, panel: str):
"""Tilføj dans fra forslags-listen ved klik.""" """Tilføj dans fra forslags-listen ved klik."""
name = item.data(Qt.ItemDataRole.UserRole + 1) or item.text().split(" / ")[0] name = item.data(Qt.ItemDataRole.UserRole + 1) or item.text().split(" / ")[0]
level_id = item.data(Qt.ItemDataRole.UserRole) level_id = item.data(Qt.ItemDataRole.UserRole)
choreographer = item.data(Qt.ItemDataRole.UserRole + 2) or ""
if panel == "dance": if panel == "dance":
self._add_dance_row(name, level_id) self._add_dance_row(name, level_id, choreographer)
self._new_dance.clear() self._new_dance.clear()
self._new_dance.setFocus() self._new_dance.setFocus()
self._load_dance_suggestions("", self._dance_suggestions) self._load_dance_suggestions("", self._dance_suggestions)
@@ -273,6 +397,8 @@ class TagEditorDialog(QDialog):
from translations import _ from translations import _
grp = QGroupBox(_("tags.alts")) grp = QGroupBox(_("tags.alts"))
layout = QVBoxLayout(grp) layout = QVBoxLayout(grp)
# Eksisterende alternativ-rækker
scroll = QScrollArea() scroll = QScrollArea()
scroll.setWidgetResizable(True) scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame) scroll.setFrameShape(QFrame.Shape.NoFrame)
@@ -286,8 +412,13 @@ class TagEditorDialog(QDialog):
for a in self._alts: for a in self._alts:
self._add_alt_row(a["name"], a["level_id"], a.get("note", "")) self._add_alt_row(a["name"], a["level_id"], a.get("note", ""))
# Søgefelt — kun eksisterende danse
hint = QLabel("Søg blandt eksisterende danse:")
hint.setObjectName("result_count")
layout.addWidget(hint)
self._new_alt = QLineEdit() self._new_alt = QLineEdit()
self._new_alt.setPlaceholderText(_("tags.new_alt")) self._new_alt.setPlaceholderText("Søg dans...")
self._new_alt.textChanged.connect(self._on_alt_search) self._new_alt.textChanged.connect(self._on_alt_search)
self._new_alt.returnPressed.connect(self._on_add_alt) self._new_alt.returnPressed.connect(self._on_add_alt)
layout.addWidget(self._new_alt) layout.addWidget(self._new_alt)
@@ -304,11 +435,11 @@ class TagEditorDialog(QDialog):
self._alt_search_timer.setSingleShot(True) self._alt_search_timer.setSingleShot(True)
self._alt_search_timer.setInterval(150) self._alt_search_timer.setInterval(150)
self._alt_search_timer.timeout.connect( self._alt_search_timer.timeout.connect(
lambda: self._load_dance_suggestions( lambda: self._load_existing_dance_suggestions(
self._new_alt.text().strip(), self._alt_suggestions self._new_alt.text().strip(), self._alt_suggestions
) )
) )
self._load_dance_suggestions("", self._alt_suggestions) self._load_existing_dance_suggestions("", self._alt_suggestions)
return grp return grp
def _add_alt_row(self, name="", level_id=None, note=""): def _add_alt_row(self, name="", level_id=None, note=""):
@@ -326,44 +457,34 @@ class TagEditorDialog(QDialog):
lbl.setObjectName("track_meta") lbl.setObjectName("track_meta")
row_layout.addWidget(lbl) row_layout.addWidget(lbl)
edit = DanceLineEdit("Dans...", self) # Vis dans-navn — ikke redigerbart, kun valgt fra listen
edit.setText(name) lbl_name = QLabel(name)
row_layout.addWidget(edit, stretch=1) lbl_name.setObjectName("track_title")
row_layout.addWidget(lbl_name, stretch=1)
# Niveau-dropdown # Niveau (read-only label)
level_cb = QComboBox() level_name = ""
level_cb.addItem(_("tags.no_level"), None)
for lvl in self._levels: for lvl in self._levels:
level_cb.addItem(translate_level(lvl["name"]), lvl["id"]) if lvl["id"] == level_id:
if level_id is not None: level_name = lvl["name"]
for i in range(level_cb.count()): break
if level_cb.itemData(i) == level_id: if level_name:
level_cb.setCurrentIndex(i) lbl_level = QLabel(level_name)
break lbl_level.setObjectName("result_count")
level_cb.setFixedWidth(130) lbl_level.setFixedWidth(110)
row_layout.addWidget(level_cb) row_layout.addWidget(lbl_level)
def on_dance_selected(dance_info, cb=level_cb):
if dance_info.get("level_id") is not None:
for i in range(cb.count()):
if cb.itemData(i) == dance_info["level_id"]:
cb.setCurrentIndex(i)
break
edit.dance_selected.connect(on_dance_selected)
note_edit = QLineEdit()
note_edit.setPlaceholderText(_("tags.note"))
note_edit.setText(note)
note_edit.setFixedWidth(80)
row_layout.addWidget(note_edit)
btn_rm = QPushButton("") btn_rm = QPushButton("")
btn_rm.setFixedSize(24, 24) btn_rm.setFixedSize(32, 24)
btn_rm.setToolTip("Fjern alternativ-dans")
btn_rm.setObjectName("btn_rm_row")
btn_rm.style().unpolish(btn_rm)
btn_rm.style().polish(btn_rm)
row_layout.addWidget(btn_rm) row_layout.addWidget(btn_rm)
idx = self._alt_layout.count() - 1 idx = self._alt_layout.count() - 1
self._alt_layout.insertWidget(idx, row_widget) self._alt_layout.insertWidget(idx, row_widget)
entry = {"widget": row_widget, "edit": edit, "level": level_cb, "note": note_edit} entry = {"widget": row_widget, "name": name, "level_id": level_id}
self._alt_rows.append(entry) self._alt_rows.append(entry)
btn_rm.clicked.connect(lambda: self._remove_alt_row(entry)) btn_rm.clicked.connect(lambda: self._remove_alt_row(entry))
@@ -372,12 +493,10 @@ class TagEditorDialog(QDialog):
entry["widget"].deleteLater() entry["widget"].deleteLater()
def _on_add_alt(self): def _on_add_alt(self):
text = self._new_alt.text().strip() """Alternativ-danse kan kun tilføjes fra forslagslisten, ikke som fri tekst."""
if text: if self._alt_suggestions.count() > 0:
name, level_id = self._parse_name_level(text) self._alt_suggestions.setCurrentRow(0)
self._add_alt_row(name, level_id) self._add_from_suggestion(self._alt_suggestions.currentItem(), "alt")
self._new_alt.clear()
self._load_dance_suggestions("", self._alt_suggestions)
def _save(self): def _save(self):
song_id = self._song.get("id") song_id = self._song.get("id")
@@ -387,24 +506,25 @@ class TagEditorDialog(QDialog):
from local.local_db import new_conn, get_or_create_dance from local.local_db import new_conn, get_or_create_dance
from local.tag_reader import write_dances, can_write_dances from local.tag_reader import write_dances, can_write_dances
# Saml data fra UI — niveau kommer fra dropdown, ikke fra tekstfeltet # Saml data fra UI
dances = [] dances = []
for row in self._dance_rows: for row in self._dance_rows:
name = row["edit"].text().strip() name = row["edit"].text().strip()
if name: if name:
dances.append({ dances.append({
"name": name, "name": name,
"level_id": row["level"].currentData(), "level_id": row["level"].currentData(),
"choreographer": row["choreo"].text().strip(),
}) })
alts = [] alts = []
for row in self._alt_rows: for row in self._alt_rows:
name = row["edit"].text().strip() name = row.get("name", "")
if name: if name:
alts.append({ alts.append({
"name": name, "name": name,
"level_id": row["level"].currentData(), "level_id": row.get("level_id"),
"note": row["note"].text().strip(), "note": "",
}) })
conn = new_conn() conn = new_conn()
@@ -415,7 +535,8 @@ class TagEditorDialog(QDialog):
# Indsæt hoveddanse # Indsæt hoveddanse
for i, d in enumerate(dances, 1): for i, d in enumerate(dances, 1):
dance_id = get_or_create_dance(d["name"], d["level_id"], conn) dance_id = get_or_create_dance(d["name"], d["level_id"], conn,
choreographer=d.get("choreographer", ""))
conn.execute( conn.execute(
"INSERT OR IGNORE INTO song_dances (song_id, dance_id, dance_order) " "INSERT OR IGNORE INTO song_dances (song_id, dance_id, dance_order) "
"VALUES (?,?,?)", "VALUES (?,?,?)",

View File

@@ -79,6 +79,24 @@ QPushButton#btn_demo:hover, QPushButton#btn_demo:checked {
color: #111214; color: #111214;
border-color: #3b8fd4; border-color: #3b8fd4;
} }
QPushButton#btn_info_row {
color: #3b8fd4;
border-color: #3b8fd4;
padding: 2px 6px;
}
QPushButton#btn_info_row:hover {
background-color: rgba(59,143,212,0.15);
border-color: #3b8fd4;
}
QPushButton#btn_rm_row {
color: #e74c3c;
border-color: #e74c3c;
padding: 2px 6px;
}
QPushButton#btn_rm_row:hover {
background-color: rgba(231,76,60,0.15);
border-color: #e74c3c;
}
/* Slider */ /* Slider */
QSlider::groove:horizontal { QSlider::groove:horizontal {
@@ -328,6 +346,22 @@ QPushButton#btn_play_small {
QPushButton#btn_play_small:hover { QPushButton#btn_play_small:hover {
background-color: #a05808; background-color: #a05808;
} }
QPushButton#btn_info_row {
color: #1a6fb0;
border-color: #1a6fb0;
padding: 2px 6px;
}
QPushButton#btn_info_row:hover {
background-color: rgba(26,111,176,0.12);
}
QPushButton#btn_rm_row {
color: #c0392b;
border-color: #c0392b;
padding: 2px 6px;
}
QPushButton#btn_rm_row:hover {
background-color: rgba(192,57,43,0.12);
}
QListWidget { QListWidget {
background-color: #d8dae0; background-color: #d8dae0;
color: #1a1c22; color: #1a1c22;