En del opdateringer
This commit is contained in:
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
@@ -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()
|
||||||
|
|||||||
@@ -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,15 +176,21 @@ 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(
|
|
||||||
song_id=song_id, dance_id=dance_id
|
|
||||||
).first()
|
|
||||||
if not existing:
|
|
||||||
db.add(SongDance(
|
db.add(SongDance(
|
||||||
song_id=song_id, dance_id=dance_id,
|
song_id=song_id, dance_id=dance_id,
|
||||||
dance_order=sd.dance_order,
|
dance_order=sd.dance_order,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
affected_alt_ids = set(
|
||||||
|
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)
|
||||||
if not song_id:
|
if not song_id:
|
||||||
@@ -182,10 +200,6 @@ 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(
|
|
||||||
song_id=song_id, dance_id=dance_id
|
|
||||||
).first()
|
|
||||||
if not existing:
|
|
||||||
db.add(SongAltDance(
|
db.add(SongAltDance(
|
||||||
song_id=song_id, dance_id=dance_id,
|
song_id=song_id, dance_id=dance_id,
|
||||||
note=sa.note,
|
note=sa.note,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
d.choreographer LIKE ? OR
|
||||||
|
dl.name LIKE ? OR
|
||||||
s.extra_tags LIKE ?
|
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
|
||||||
|
|||||||
@@ -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,12 +298,33 @@ 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
|
||||||
|
local_path = result.get("local_path", "")
|
||||||
|
if local_path:
|
||||||
|
easy = MutagenFileEasy(local_path, easy=True)
|
||||||
if easy and easy.tags:
|
if easy and easy.tags:
|
||||||
result["title"] = easy.tags.get("title", [result["title"]])[0]
|
result["title"] = easy.tags.get("title", [result["title"]])[0]
|
||||||
result["artist"] = easy.tags.get("artist", [""])[0]
|
result["artist"] = easy.tags.get("artist", [""])[0]
|
||||||
result["album"] = easy.tags.get("album", [""])[0]
|
result["album"] = easy.tags.get("album", [""])[0]
|
||||||
|
except Exception:
|
||||||
|
# Fallback: læs direkte fra audio-objektet
|
||||||
|
try:
|
||||||
|
tags = audio.tags
|
||||||
|
if hasattr(tags, "items"):
|
||||||
|
for key, val in tags.items():
|
||||||
|
k = str(key).lower()
|
||||||
|
v = str(val[0]) if hasattr(val, "__iter__") and not isinstance(val, str) else str(val)
|
||||||
|
if "title" in k and not result["title"]:
|
||||||
|
result["title"] = v
|
||||||
|
elif "artist" in k and not result["artist"]:
|
||||||
|
result["artist"] = v
|
||||||
|
elif "album" in k and not result["album"]:
|
||||||
|
result["album"] = v
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
try:
|
||||||
|
from local.local_db import get_choreographer_suggestions
|
||||||
|
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]
|
name = item.data(Qt.ItemDataRole.UserRole) or item.text().split(" / ")[0]
|
||||||
self._edit.setText(name)
|
choreo = item.data(Qt.ItemDataRole.UserRole + 1) or ""
|
||||||
self._chosen = name
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -483,6 +502,7 @@ class MainWindow(QMainWindow):
|
|||||||
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(COALESCE(d.choreographer,''), ',') AS dance_choreographers,
|
||||||
GROUP_CONCAT(DISTINCT ad.name) AS alt_dance_names
|
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
|
||||||
@@ -500,6 +520,7 @@ class MainWindow(QMainWindow):
|
|||||||
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 []
|
||||||
|
choreos = row["dance_choreographers"].split(",") if row["dance_choreographers"] else []
|
||||||
alt_dances = row["alt_dance_names"].split(",") if row["alt_dance_names"] else []
|
alt_dances = row["alt_dance_names"].split(",") if row["alt_dance_names"] else []
|
||||||
songs.append({
|
songs.append({
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
@@ -513,6 +534,7 @@ class MainWindow(QMainWindow):
|
|||||||
"file_missing": bool(row["file_missing"]),
|
"file_missing": bool(row["file_missing"]),
|
||||||
"dances": dances,
|
"dances": dances,
|
||||||
"dance_levels": levels,
|
"dance_levels": levels,
|
||||||
|
"dance_choreographers": choreos,
|
||||||
"alt_dances": alt_dances,
|
"alt_dances": alt_dances,
|
||||||
})
|
})
|
||||||
self._library_loaded.emit(songs)
|
self._library_loaded.emit(songs)
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 +347,12 @@ 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
|
||||||
@@ -238,8 +361,9 @@ class TagEditorDialog(QDialog):
|
|||||||
"""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()):
|
|
||||||
if level_cb.itemData(i) == level_id:
|
|
||||||
level_cb.setCurrentIndex(i)
|
|
||||||
break
|
break
|
||||||
level_cb.setFixedWidth(130)
|
if level_name:
|
||||||
row_layout.addWidget(level_cb)
|
lbl_level = QLabel(level_name)
|
||||||
|
lbl_level.setObjectName("result_count")
|
||||||
def on_dance_selected(dance_info, cb=level_cb):
|
lbl_level.setFixedWidth(110)
|
||||||
if dance_info.get("level_id") is not None:
|
row_layout.addWidget(lbl_level)
|
||||||
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,7 +506,7 @@ 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()
|
||||||
@@ -395,16 +514,17 @@ class TagEditorDialog(QDialog):
|
|||||||
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 (?,?,?)",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user