diff --git a/linedance-app/local/__pycache__/file_watcher.cpython-312.pyc b/linedance-app/local/__pycache__/file_watcher.cpython-312.pyc index 47616730..a8f158b5 100644 Binary files a/linedance-app/local/__pycache__/file_watcher.cpython-312.pyc and b/linedance-app/local/__pycache__/file_watcher.cpython-312.pyc differ diff --git a/linedance-app/local/__pycache__/local_db.cpython-312.pyc b/linedance-app/local/__pycache__/local_db.cpython-312.pyc index 04b8fbc1..2db9ccec 100644 Binary files a/linedance-app/local/__pycache__/local_db.cpython-312.pyc and b/linedance-app/local/__pycache__/local_db.cpython-312.pyc differ diff --git a/linedance-app/local/__pycache__/tag_reader.cpython-312.pyc b/linedance-app/local/__pycache__/tag_reader.cpython-312.pyc index 45b2725f..b9d8ca31 100644 Binary files a/linedance-app/local/__pycache__/tag_reader.cpython-312.pyc and b/linedance-app/local/__pycache__/tag_reader.cpython-312.pyc differ diff --git a/linedance-app/local/file_watcher.py b/linedance-app/local/file_watcher.py index 97847e5f..db739ae2 100644 --- a/linedance-app/local/file_watcher.py +++ b/linedance-app/local/file_watcher.py @@ -191,40 +191,42 @@ class LibraryWatcher: def _full_scan_library(self, library_id: int, library_path: str): """ Sammenligner filer på disk med SQLite og synkroniserer forskelle. - - Tre operationer: - 1. Nye filer → indsæt i SQLite - 2. Ændrede filer → opdater SQLite (baseret på fil-timestamp) - 3. Forsvundne → marker som missing i SQLite + Håndterer utilgængelige mapper og symlinks sikkert. """ logger.info(f"Fuld scan starter: {library_path}") base = Path(library_path) - # Hvad SQLite kender til - known = get_all_song_paths_for_library(library_id) + # Tjek at mappen faktisk er tilgængelig — med timeout + if not self._path_accessible(base): + logger.warning(f"Bibliotek ikke tilgængeligt (timeout eller ingen adgang): {library_path}") + return - # Hvad der faktisk er på disk + known = get_all_song_paths_for_library(library_id) found_paths = set() processed = 0 errors = 0 - for file_path in base.rglob("*"): - if not file_path.is_file() or not is_supported(file_path): - continue - - path_str = str(file_path) - found_paths.add(path_str) - disk_modified = get_file_modified_at(file_path) - - # Ny fil eller ændret siden sidst - if path_str not in known or known[path_str] != disk_modified: + import os + for dirpath, dirnames, filenames in os.walk( + str(base), followlinks=False, + onerror=lambda e: logger.warning(f"Adgang nægtet: {e}") + ): + for filename in filenames: + file_path = Path(dirpath) / filename try: - tags = read_tags(file_path) - tags["library_id"] = library_id - upsert_song(tags) - processed += 1 - if self.on_change: - self.on_change("upserted", path_str, None) + if not is_supported(file_path): + continue + path_str = str(file_path) + found_paths.add(path_str) + disk_modified = get_file_modified_at(file_path) + + if path_str not in known or known[path_str] != disk_modified: + tags = read_tags(file_path) + tags["library_id"] = library_id + upsert_song(tags) + processed += 1 + if self.on_change: + self.on_change("upserted", path_str, None) except Exception as e: logger.error(f"Scan-fejl for {file_path}: {e}") errors += 1 @@ -244,6 +246,20 @@ class LibraryWatcher: f"{processed} opdateret, {missing_count} mangler, {errors} fejl" ) + def _path_accessible(self, path: Path, timeout_sec: float = 5.0) -> bool: + """Tjek om en sti er tilgængelig inden for timeout.""" + import threading + result = [False] + def check(): + try: + result[0] = path.exists() and path.is_dir() + except Exception: + result[0] = False + t = threading.Thread(target=check, daemon=True) + t.start() + t.join(timeout=timeout_sec) + return result[0] + # ── Singleton til brug i appen ──────────────────────────────────────────────── diff --git a/linedance-app/local/local_db.py b/linedance-app/local/local_db.py index 55d66818..62a67569 100644 --- a/linedance-app/local/local_db.py +++ b/linedance-app/local/local_db.py @@ -81,6 +81,16 @@ def init_db(): dance_order INTEGER NOT NULL DEFAULT 1 ); + -- Alternativ-danse relationer (kun online hvis logget ind, men caches lokalt) + CREATE TABLE IF NOT EXISTS dance_alternatives ( + id TEXT PRIMARY KEY, + song_dance_id INTEGER NOT NULL REFERENCES song_dances(id) ON DELETE CASCADE, + alt_song_dance_id INTEGER NOT NULL REFERENCES song_dances(id) ON DELETE CASCADE, + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(song_dance_id, alt_song_dance_id) + ); + -- Lokale afspilningslister (offline-projekter) CREATE TABLE IF NOT EXISTS playlists ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -119,6 +129,76 @@ def init_db(): CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id); """) + # Migration: tilføj tabeller der måske mangler i ældre databaser + _run_migrations(conn) + + +def _run_migrations(conn): + """Kør migrations sikkert — CREATE IF NOT EXISTS er idempotent.""" + conn.executescript(""" + CREATE TABLE IF NOT EXISTS dance_alternatives ( + id TEXT PRIMARY KEY, + song_dance_id INTEGER NOT NULL REFERENCES song_dances(id) ON DELETE CASCADE, + alt_dance_name TEXT NOT NULL, + level_id INTEGER REFERENCES dance_levels(id), + note TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT 'local', + created_by TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS event_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS dance_names ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + source TEXT NOT NULL DEFAULT 'local', + use_count INTEGER NOT NULL DEFAULT 1, + synced_at TEXT + ); + + CREATE TABLE IF NOT EXISTS dance_levels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sort_order INTEGER NOT NULL, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + synced_at TEXT + ); + """) + + # Tilføj kolonner der måske mangler i ældre databaser + migrations = [ + "ALTER TABLE songs ADD COLUMN extra_tags TEXT NOT NULL DEFAULT '{}'", + "ALTER TABLE song_dances ADD COLUMN level_id INTEGER REFERENCES dance_levels(id)", + "ALTER TABLE dance_alternatives ADD COLUMN alt_dance_name TEXT NOT NULL DEFAULT ''", + "ALTER TABLE dance_alternatives ADD COLUMN level_id INTEGER REFERENCES dance_levels(id)", + "ALTER TABLE dance_alternatives ADD COLUMN source TEXT NOT NULL DEFAULT 'local'", + "ALTER TABLE dance_alternatives ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", + ] + for sql in migrations: + try: + conn.execute(sql) + except Exception: + pass # kolonnen eksisterer allerede + + # Indlæs standard-niveauer hvis tabellen er tom + count = conn.execute("SELECT COUNT(*) FROM dance_levels").fetchone()[0] + if count == 0: + defaults = [ + (1, "Begynder", "Passer til alle"), + (2, "Let øvet", "Lidt erfaring kræves"), + (3, "Øvet", "Kræver regelmæssig træning"), + (4, "Erfaren", "For dedikerede dansere"), + (5, "Ekspert", "Konkurrenceniveau"), + ] + conn.executemany( + "INSERT OR IGNORE INTO dance_levels (sort_order, name, description) VALUES (?,?,?)", + defaults + ) + # ── Biblioteker ─────────────────────────────────────────────────────────────── @@ -144,7 +224,12 @@ def get_libraries(active_only: bool = True) -> list[sqlite3.Row]: def remove_library(library_id: int): with get_db() as conn: - conn.execute("UPDATE libraries SET is_active=0 WHERE id=?", (library_id,)) + # Marker sange som manglende + conn.execute( + "UPDATE songs SET file_missing=1 WHERE library_id=?", (library_id,) + ) + # Slet biblioteket helt + conn.execute("DELETE FROM libraries WHERE id=?", (library_id,)) def update_library_scan_time(library_id: int): @@ -162,18 +247,20 @@ def upsert_song(song_data: dict) -> str: Indsæt eller opdater en sang baseret på local_path. Returnerer song_id. """ - import uuid + import uuid, json with get_db() as conn: existing = conn.execute( "SELECT id FROM songs WHERE local_path=?", (song_data["local_path"],) ).fetchone() + extra_tags_json = json.dumps(song_data.get("extra_tags", {}), ensure_ascii=False) + if existing: song_id = existing["id"] conn.execute(""" UPDATE songs SET title=?, artist=?, album=?, bpm=?, duration_sec=?, - file_format=?, file_modified_at=?, file_missing=0 + file_format=?, file_modified_at=?, file_missing=0, extra_tags=? WHERE id=? """, ( song_data.get("title", ""), @@ -183,6 +270,7 @@ def upsert_song(song_data: dict) -> str: song_data.get("duration_sec", 0), song_data.get("file_format", ""), song_data.get("file_modified_at", ""), + extra_tags_json, song_id, )) else: @@ -190,8 +278,8 @@ def upsert_song(song_data: dict) -> str: conn.execute(""" INSERT INTO songs (id, library_id, local_path, title, artist, album, - bpm, duration_sec, file_format, file_modified_at) - VALUES (?,?,?,?,?,?,?,?,?,?) + bpm, duration_sec, file_format, file_modified_at, extra_tags) + VALUES (?,?,?,?,?,?,?,?,?,?,?) """, ( song_id, song_data.get("library_id"), @@ -203,16 +291,33 @@ def upsert_song(song_data: dict) -> str: song_data.get("duration_sec", 0), song_data.get("file_format", ""), song_data.get("file_modified_at", ""), + extra_tags_json, )) # Opdater danse hvis de er med i data if "dances" in song_data: conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,)) - for i, dance_name in enumerate(song_data["dances"], start=1): + for i, dance in enumerate(song_data["dances"], start=1): + # dance kan være str eller dict med {name, level_id} + if isinstance(dance, dict): + name = dance.get("name", "") + level_id = dance.get("level_id") + else: + name = dance + level_id = None conn.execute( - "INSERT INTO song_dances (song_id, dance_name, dance_order) VALUES (?,?,?)", - (song_id, dance_name, i), + "INSERT INTO song_dances (song_id, dance_name, dance_order, level_id) VALUES (?,?,?,?)", + (song_id, name, i, level_id), ) + # Registrer navne i ordbogen + try: + from local.local_db import register_dance_name as _reg + for dance in song_data["dances"]: + nm = dance.get("name", dance) if isinstance(dance, dict) else dance + if nm: + _reg(nm) + except Exception: + pass return song_id @@ -232,17 +337,23 @@ def get_song_by_path(local_path: str) -> sqlite3.Row | None: def search_songs(query: str, limit: int = 50) -> list[sqlite3.Row]: - """Søg i titel, artist og dansenavne.""" + """Søg i alle tags — titel, artist, album, danse og alle øvrige tags.""" pattern = f"%{query}%" with get_db() as conn: return conn.execute(""" SELECT DISTINCT s.* FROM songs s LEFT JOIN song_dances sd ON sd.song_id = s.id WHERE s.file_missing = 0 - AND (s.title LIKE ? OR s.artist LIKE ? OR s.album LIKE ? OR sd.dance_name LIKE ?) + AND ( + s.title LIKE ? OR + s.artist LIKE ? OR + s.album LIKE ? OR + sd.dance_name LIKE ? OR + s.extra_tags LIKE ? + ) ORDER BY s.artist, s.title LIMIT ? - """, (pattern, pattern, pattern, pattern, limit)).fetchall() + """, (pattern, pattern, pattern, pattern, pattern, limit)).fetchall() def get_songs_for_library(library_id: int) -> list[sqlite3.Row]: @@ -328,3 +439,148 @@ def get_playlist_with_songs(playlist_id: int) -> dict: """, (playlist_id,)).fetchall() return {"playlist": dict(playlist), "songs": [dict(s) for s in songs]} + + +# ── Event-state (gemmes løbende så man kan genstarte efter strømsvigt) ──────── + +def save_event_state(current_idx: int, statuses: list[str]): + """Gem event-fremgang — overskrives ved hver ændring.""" + import json + with get_db() as conn: + conn.execute("INSERT OR REPLACE INTO event_state (key,value) VALUES ('current_idx',?)", + (str(current_idx),)) + conn.execute("INSERT OR REPLACE INTO event_state (key,value) VALUES ('statuses',?)", + (json.dumps(statuses),)) + + +def load_event_state() -> tuple[int, list[str]] | None: + """Indlæs gemt event-fremgang. Returnerer None hvis ingen gemt tilstand.""" + import json + with get_db() as conn: + idx_row = conn.execute( + "SELECT value FROM event_state WHERE key='current_idx'" + ).fetchone() + sta_row = conn.execute( + "SELECT value FROM event_state WHERE key='statuses'" + ).fetchone() + if not idx_row or not sta_row: + return None + return int(idx_row["value"]), json.loads(sta_row["value"]) + + +def clear_event_state(): + """Nulstil gemt event-tilstand (bruges ved 'Start event').""" + with get_db() as conn: + conn.execute("DELETE FROM event_state") + + +# ── Dans-navne ordbog ───────────────────────────────────────────────────────── + +def get_dance_name_suggestions(prefix: str, limit: int = 20) -> list[str]: + """Returnerer danse-navne der starter med prefix, sorteret efter popularitet.""" + with get_db() as conn: + rows = conn.execute(""" + SELECT name FROM dance_names + WHERE name LIKE ? COLLATE NOCASE + ORDER BY use_count DESC, name + LIMIT ? + """, (f"{prefix}%", limit)).fetchall() + return [r["name"] for r in rows] + + +def register_dance_name(name: str, source: str = "local"): + """Tilføj eller opdater et dans-navn i ordbogen.""" + name = name.strip() + if not name: + return + with get_db() as conn: + existing = conn.execute( + "SELECT id, use_count FROM dance_names WHERE name=? COLLATE NOCASE", + (name,) + ).fetchone() + if existing: + conn.execute( + "UPDATE dance_names SET use_count=use_count+1 WHERE id=?", + (existing["id"],) + ) + else: + conn.execute( + "INSERT INTO dance_names (name, source, use_count) VALUES (?,?,1)", + (name, source) + ) + + +def sync_dance_names_from_api(names: list[dict]): + """Synkroniser dans-navne fra API — {name, use_count}.""" + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + with get_db() as conn: + for item in names: + conn.execute(""" + INSERT INTO dance_names (name, source, use_count, synced_at) + VALUES (?, 'community', ?, ?) + ON CONFLICT(name) DO UPDATE SET + use_count = MAX(use_count, excluded.use_count), + synced_at = excluded.synced_at + """, (item["name"], item.get("use_count", 1), now)) + + +# ── Dans-niveauer ───────────────────────────────────────────────────────────── + +def get_dance_levels() -> list[sqlite3.Row]: + """Hent alle niveauer sorteret efter sort_order.""" + with get_db() as conn: + return conn.execute( + "SELECT * FROM dance_levels ORDER BY sort_order" + ).fetchall() + + +def sync_dance_levels_from_api(levels: list[dict]): + """Synkroniser niveauer fra API — {sort_order, name, description}.""" + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + with get_db() as conn: + for lvl in levels: + conn.execute(""" + INSERT INTO dance_levels (sort_order, name, description, synced_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + sort_order = excluded.sort_order, + description = excluded.description, + synced_at = excluded.synced_at + """, (lvl["sort_order"], lvl["name"], lvl.get("description", ""), now)) + + +# ── Dans-alternativer ───────────────────────────────────────────────────────── + +def get_alternatives_for_dance(song_dance_id: int) -> list[sqlite3.Row]: + with get_db() as conn: + return conn.execute(""" + SELECT da.*, dl.name as level_name, dl.sort_order as level_sort + FROM dance_alternatives da + LEFT JOIN dance_levels dl ON dl.id = da.level_id + WHERE da.song_dance_id = ? + ORDER BY da.source, dl.sort_order + """, (song_dance_id,)).fetchall() + + +def add_alternative(song_dance_id: int, alt_dance_name: str, + level_id: int | None = None, note: str = "", + source: str = "local", created_by: str = "") -> str: + import uuid as _uuid + alt_id = str(_uuid.uuid4()) + with get_db() as conn: + conn.execute(""" + INSERT INTO dance_alternatives + (id, song_dance_id, alt_dance_name, level_id, note, source, created_by) + VALUES (?,?,?,?,?,?,?) + """, (alt_id, song_dance_id, alt_dance_name.strip(), + level_id, note, source, created_by)) + # Registrer alt-dans-navne i ordbogen + register_dance_name(alt_dance_name, source=source) + return alt_id + + +def remove_alternative(alt_id: str): + with get_db() as conn: + conn.execute("DELETE FROM dance_alternatives WHERE id=?", (alt_id,)) diff --git a/linedance-app/local/tag_reader.py b/linedance-app/local/tag_reader.py index a869827c..101bb765 100644 --- a/linedance-app/local/tag_reader.py +++ b/linedance-app/local/tag_reader.py @@ -65,7 +65,8 @@ def read_tags(path: str | Path) -> dict: """ Læser metadata og danse fra en lydfil. Returnerer dict med: title, artist, album, bpm, duration_sec, - file_format, file_modified_at, dances, can_write_dances. + file_format, file_modified_at, dances, can_write_dances, + extra_tags (dict med alle øvrige tags som {navn: værdi}). """ path = Path(path) result = { @@ -79,6 +80,7 @@ def read_tags(path: str | Path) -> dict: "file_modified_at": get_file_modified_at(path), "dances": [], "can_write_dances": can_write_dances(path), + "extra_tags": {}, } if not MUTAGEN_AVAILABLE: @@ -127,6 +129,17 @@ def _read_mp3(audio, result: dict): except (ValueError, TypeError): pass dances = {} + extra = {} + # Kendte ID3-felt-navne til menneskelige navne + ID3_NAMES = { + "TIT2": "titel", "TPE1": "artist", "TALB": "album", "TBPM": "bpm", + "TYER": "år", "TDRC": "dato", "TCON": "genre", "TPE2": "albumartist", + "TPOS": "disknummer", "TRCK": "spornummer", "TCOM": "komponist", + "TLYR": "sangtekst", "TCOP": "copyright", "TPUB": "udgiver", + "TENC": "kodet_af", "TLAN": "sprog", "TMOO": "stemning", + "TPE3": "dirigent", "TPE4": "fortolket_af", "TOAL": "original_album", + "TOPE": "original_artist", "TORY": "original_år", + } for key, frame in tags.items(): if key.startswith("TXXX:") and TXXX_DANCE_PREFIX in key: try: @@ -134,7 +147,31 @@ def _read_mp3(audio, result: dict): dances[num] = str(frame.text[0]) except (ValueError, IndexError): pass + elif key.startswith("TXXX:"): + # Custom TXXX-felt — gem under dets beskrivelse + desc = key[5:] # fjern "TXXX:" + try: + extra[desc] = str(frame.text[0]) + except Exception: + pass + elif key in ID3_NAMES and key not in ("TIT2","TPE1","TALB","TBPM"): + # Standardfelt vi ikke allerede har gemt + try: + val = str(frame.text[0]) if hasattr(frame, "text") else str(frame) + if val: + extra[ID3_NAMES[key]] = val + except Exception: + pass + elif hasattr(frame, "text") and key not in ("TIT2","TPE1","TALB","TBPM"): + # Alle andre tekstfelter + try: + val = str(frame.text[0]) + if val and not key.startswith("APIC"): # spring albumcover over + extra[key] = val + except Exception: + pass result["dances"] = [dances[k] for k in sorted(dances.keys())] + result["extra_tags"] = extra def _read_vorbis(audio, result: dict): @@ -149,7 +186,7 @@ def _read_vorbis(audio, result: dict): result["bpm"] = int(tags.get("bpm", [0])[0]) except (ValueError, TypeError): pass - # Danse gemmes som linedance_dance.1, linedance_dance.2 ... + # Danse dances = {} for key, values in tags.items(): if key.lower().startswith(f"{VORBIS_DANCE_KEY}."): @@ -158,11 +195,21 @@ def _read_vorbis(audio, result: dict): dances[num] = values[0] except (ValueError, IndexError): pass - # Fallback: enkelt felt linedance_dance med komma-separeret liste if not dances and VORBIS_DANCE_KEY in tags: result["dances"] = [d.strip() for d in tags[VORBIS_DANCE_KEY][0].split(",") if d.strip()] - return - result["dances"] = [dances[k] for k in sorted(dances.keys())] + else: + result["dances"] = [dances[k] for k in sorted(dances.keys())] + # Alle øvrige tags som extra_tags + skip = {"title", "artist", "album", "bpm", VORBIS_DANCE_KEY} + extra = {} + for key, values in tags.items(): + k = key.lower() + if k not in skip and not k.startswith(VORBIS_DANCE_KEY): + try: + extra[k] = str(values[0]) + except Exception: + pass + result["extra_tags"] = extra def _read_m4a(audio, result: dict): @@ -180,12 +227,33 @@ def _read_m4a(audio, result: dict): result["bpm"] = int(tags["tmpo"][0]) except (ValueError, TypeError): pass - # Danse gemmes som ----:LINEDANCE:DANCE — én værdi per dans if M4A_DANCE_FREEFORM in tags: result["dances"] = [ v.decode("utf-8") if isinstance(v, (bytes, MP4FreeForm)) else str(v) for v in tags[M4A_DANCE_FREEFORM] ] + # Menneskelige navne til M4A-nøgler + M4A_NAMES = { + "\xa9nam": "titel", "\xa9ART": "artist", "\xa9alb": "album", + "\xa9day": "år", "\xa9gen": "genre", "\xa9wrt": "komponist", + "\xa9cmt": "kommentar", "aART": "albumartist", "trkn": "spornummer", + "disk": "disknummer", "cprt": "copyright", "\xa9lyr": "sangtekst", + "tmpo": "bpm", + } + skip_keys = {"\xa9nam", "\xa9ART", "\xa9alb", "tmpo", M4A_DANCE_FREEFORM, "covr"} + extra = {} + for key, values in tags.items(): + if key in skip_keys: + continue + label = M4A_NAMES.get(key, key) + try: + val = values[0] + if isinstance(val, (bytes, MP4FreeForm)): + val = val.decode("utf-8", errors="replace") + extra[label] = str(val) + except Exception: + pass + result["extra_tags"] = extra def _read_generic(audio, result: dict): diff --git a/linedance-app/ui/__pycache__/library_manager.cpython-312.pyc b/linedance-app/ui/__pycache__/library_manager.cpython-312.pyc new file mode 100644 index 00000000..bd29d800 Binary files /dev/null and b/linedance-app/ui/__pycache__/library_manager.cpython-312.pyc differ diff --git a/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc b/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc index c7041403..d107e3d0 100644 Binary files a/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc and b/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc differ diff --git a/linedance-app/ui/__pycache__/main_window.cpython-312.pyc b/linedance-app/ui/__pycache__/main_window.cpython-312.pyc index 6018ec41..5e2c9105 100644 Binary files a/linedance-app/ui/__pycache__/main_window.cpython-312.pyc and b/linedance-app/ui/__pycache__/main_window.cpython-312.pyc differ diff --git a/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc b/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc index fb19483a..48e4c3bd 100644 Binary files a/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc and b/linedance-app/ui/__pycache__/playlist_panel.cpython-312.pyc differ diff --git a/linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc b/linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc index e2fd3647..16196ef1 100644 Binary files a/linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc and b/linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc differ diff --git a/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc b/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc new file mode 100644 index 00000000..c87a0a4d Binary files /dev/null and b/linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc differ diff --git a/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc b/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc new file mode 100644 index 00000000..f4e36db1 Binary files /dev/null and b/linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc differ diff --git a/linedance-app/ui/library_manager.py b/linedance-app/ui/library_manager.py new file mode 100644 index 00000000..4cb33f12 --- /dev/null +++ b/linedance-app/ui/library_manager.py @@ -0,0 +1,119 @@ +""" +library_manager.py — Dialog til at se og fjerne musikbiblioteker. +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QListWidget, QListWidgetItem, QMessageBox, +) +from PyQt6.QtCore import Qt, pyqtSignal + + +class LibraryManagerDialog(QDialog): + library_removed = pyqtSignal(int) # library_id + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Administrer musikbiblioteker") + self.setMinimumWidth(500) + self.setMinimumHeight(320) + self._build_ui() + self._load() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(10) + + lbl = QLabel("Aktive musikbiblioteker:") + lbl.setObjectName("track_meta") + layout.addWidget(lbl) + + self._list = QListWidget() + layout.addWidget(self._list) + + note = QLabel( + "Når du fjerner et bibliotek, slettes det fra overvågningen.\n" + "Sangene forbliver i databasen men markeres som manglende (⚠)." + ) + note.setObjectName("result_count") + note.setWordWrap(True) + layout.addWidget(note) + + btn_row = QHBoxLayout() + btn_add = QPushButton("+ Tilføj mappe") + btn_add.clicked.connect(self._add_folder) + btn_row.addWidget(btn_add) + + btn_remove = QPushButton("✕ Fjern valgt") + btn_remove.clicked.connect(self._remove_selected) + btn_row.addWidget(btn_remove) + + btn_row.addStretch() + btn_close = QPushButton("Luk") + btn_close.clicked.connect(self.accept) + btn_row.addWidget(btn_close) + layout.addLayout(btn_row) + + def _load(self): + self._list.clear() + try: + from local.local_db import get_libraries, get_db + libs = get_libraries(active_only=True) # kun aktive + for lib in libs: + from pathlib import Path + path = lib["path"] + exists = Path(path).exists() + last_scan = lib["last_full_scan"] or "aldrig" + if isinstance(last_scan, str) and len(last_scan) > 10: + last_scan = last_scan[:10] + with get_db() as conn: + count = conn.execute( + "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0", + (lib["id"],) + ).fetchone()[0] + exist_icon = "" if exists else " ⚠ mappe ikke fundet" + label = f"{path}{exist_icon}\n {count} sange · senest scannet: {last_scan}" + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole, dict(lib)) + if not exists: + from PyQt6.QtGui import QColor + item.setForeground(QColor("#5a6070")) + self._list.addItem(item) + except Exception as e: + print(f"Library manager load fejl: {e}") + + def _add_folder(self): + from PyQt6.QtWidgets import QFileDialog + folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe") + if folder: + mw = self.parent() + if hasattr(mw, "add_library_path"): + mw.add_library_path(folder) + self._load() + + def _remove_selected(self): + item = self._list.currentItem() + if not item: + return + lib = item.data(Qt.ItemDataRole.UserRole) + reply = QMessageBox.question( + self, "Fjern bibliotek", + f"Fjern overvågningen af:\n{lib['path']}\n\n" + "Sange i biblioteket forbliver i databasen men markeres som manglende.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + try: + mw = self.parent() + if hasattr(mw, "_watcher") and mw._watcher: + mw._watcher.remove_library(lib["id"]) + else: + from local.local_db import remove_library + remove_library(lib["id"]) + self.library_removed.emit(lib["id"]) + if hasattr(mw, "_reload_library"): + mw._reload_library() + self._load() + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}") diff --git a/linedance-app/ui/library_panel.py b/linedance-app/ui/library_panel.py index ecf357e6..1ac9be14 100644 --- a/linedance-app/ui/library_panel.py +++ b/linedance-app/ui/library_panel.py @@ -41,9 +41,11 @@ class DraggableLibraryList(QListWidget): class LibraryPanel(QWidget): - song_selected = pyqtSignal(dict) - add_to_playlist = pyqtSignal(dict) - scan_requested = pyqtSignal() + song_selected = pyqtSignal(dict) + add_to_playlist = pyqtSignal(dict) + scan_requested = pyqtSignal() + edit_tags_requested = pyqtSignal(dict) + send_mail_requested = pyqtSignal(dict) def __init__(self, parent=None): super().__init__(parent) @@ -74,6 +76,12 @@ class LibraryPanel(QWidget): self._btn_scan.clicked.connect(self._on_scan_clicked) header.addWidget(self._btn_scan) + btn_manage = QPushButton("⚙ Mapper") + btn_manage.setFixedHeight(24) + btn_manage.setToolTip("Tilføj eller fjern musikbiblioteker") + btn_manage.clicked.connect(self._manage_libraries) + header.addWidget(btn_manage) + btn_add = QPushButton("+ MAPPE") btn_add.setFixedHeight(24) btn_add.clicked.connect(self._add_folder) @@ -204,13 +212,28 @@ class LibraryPanel(QWidget): if not song: return menu = QMenu(self) - act_add = menu.addAction("Tilføj til danseliste") - act_play = menu.addAction("Afspil") + act_add = menu.addAction("Tilføj til danseliste") + act_play = menu.addAction("Afspil") + menu.addSeparator() + act_tags = menu.addAction("✎ Rediger dans-tags...") + menu.addSeparator() + send_menu = menu.addMenu("Send til") + act_mail = send_menu.addAction("✉ Send som mail") action = menu.exec(self._list.mapToGlobal(pos)) if action == act_add: self.add_to_playlist.emit(song) elif action == act_play: self.song_selected.emit(song) + elif action == act_tags: + self.edit_tags_requested.emit(song) + elif action == act_mail: + self.send_mail_requested.emit(song) + + def _manage_libraries(self): + from ui.library_manager import LibraryManagerDialog + dialog = LibraryManagerDialog(parent=self.window()) + dialog.library_removed.connect(lambda _: self.scan_requested.emit()) + dialog.exec() def _add_folder(self): from PyQt6.QtWidgets import QFileDialog diff --git a/linedance-app/ui/main_window.py b/linedance-app/ui/main_window.py index 8a8e79a2..9e012588 100644 --- a/linedance-app/ui/main_window.py +++ b/linedance-app/ui/main_window.py @@ -11,15 +11,15 @@ from PyQt6.QtWidgets import ( from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QAction -from ui.vu_meter import VUMeter -from ui.playlist_panel import PlaylistPanel -from ui.library_panel import LibraryPanel -from ui.next_up_bar import NextUpBar -from ui.themes import apply_theme -from ui.scan_worker import ScanWorker -from ui.login_dialog import LoginDialog +from ui.vu_meter import VUMeter +from ui.playlist_panel import PlaylistPanel +from ui.library_panel import LibraryPanel +from ui.themes import apply_theme +from ui.scan_worker import ScanWorker +from ui.login_dialog import LoginDialog, API_URL from ui.playlist_manager import PlaylistManagerDialog -from player.player import Player +from ui.settings_dialog import SettingsDialog, load_settings +from player.player import Player class ProgressBar(QWidget): @@ -63,8 +63,8 @@ class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("LineDance Player") - self.setMinimumSize(860, 680) - self.resize(960, 760) + self.setMinimumSize(1000, 680) + self.resize(1600, 820) self._dark_theme = True self._player = Player(self) @@ -77,15 +77,28 @@ class MainWindow(QMainWindow): self._api_token: str | None = None self._api_username: str | None = None + # Indlæs indstillinger + self._settings = load_settings() + self._dark_theme = self._settings.get("dark_theme", True) + self._demo_seconds = self._settings.get("demo_seconds", 10) + self._connect_player_signals() self._build_menu() self._build_ui() self._build_statusbar() - apply_theme(self._app_ref(), dark=True) + apply_theme(self._app_ref(), dark=self._dark_theme) + self._theme_btn.setText("☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA") + + # Gendan gemt vinduestørrelse og splitter-position + self._restore_window_state() # Start DB og scanning ved opstart QTimer.singleShot(200, self._init_local_db) + # Auto-login hvis aktiveret i indstillinger + if self._settings.get("auto_login") and self._settings.get("password"): + QTimer.singleShot(800, self._auto_login) + def _app_ref(self): from PyQt6.QtWidgets import QApplication return QApplication.instance() @@ -163,6 +176,13 @@ class MainWindow(QMainWindow): act_theme.triggered.connect(self._toggle_theme) view_menu.addAction(act_theme) + view_menu.addSeparator() + + act_settings = QAction("Indstillinger...", self) + act_settings.setShortcut("Ctrl+,") + act_settings.triggered.connect(self._open_settings) + view_menu.addAction(act_settings) + # ── Statuslinje ─────────────────────────────────────────────────────────── def _build_statusbar(self): @@ -187,7 +207,6 @@ class MainWindow(QMainWindow): main_layout.addWidget(self._build_topbar()) main_layout.addWidget(self._build_now_playing()) main_layout.addWidget(self._build_progress()) - main_layout.addWidget(self._build_next_up()) main_layout.addWidget(self._build_transport()) main_layout.addWidget(self._build_panels(), stretch=1) @@ -272,11 +291,6 @@ class MainWindow(QMainWindow): return frame - def _build_next_up(self) -> NextUpBar: - self._next_up = NextUpBar() - self._next_up.play_next_clicked.connect(self._play_next) - return self._next_up - def _build_transport(self) -> QFrame: frame = QFrame() frame.setObjectName("transport_frame") @@ -297,7 +311,7 @@ class MainWindow(QMainWindow): self._btn_play = btn("▶", "btn_play", size=72) self._btn_stop = btn("⏹", "btn_stop", size=52) self._btn_next = btn("⏭", size=52) - self._btn_demo = btn("▶\n10 SEK", "btn_demo", size=64, checkable=True) + self._btn_demo = btn(f"▶\n{self._demo_seconds} SEK", "btn_demo", size=64, checkable=True) self._btn_prev.clicked.connect(self._prev_song) self._btn_play.clicked.connect(self._toggle_play) @@ -336,22 +350,43 @@ class MainWindow(QMainWindow): return frame def _build_panels(self) -> QSplitter: - splitter = QSplitter(Qt.Orientation.Horizontal) + self._splitter = QSplitter(Qt.Orientation.Horizontal) self._playlist_panel = PlaylistPanel() self._playlist_panel.song_selected.connect(self._load_song_by_idx) self._playlist_panel.song_dropped.connect(self._on_song_dropped) + self._playlist_panel.event_started.connect(self._on_event_started) + self._playlist_panel.next_song_ready.connect(self._load_song) self._library_panel = LibraryPanel() self._library_panel.song_selected.connect(self._on_library_song_selected) self._library_panel.add_to_playlist.connect(self._add_song_to_playlist) self._library_panel.scan_requested.connect(self.start_scan) + self._library_panel.edit_tags_requested.connect(self._open_tag_editor) + self._library_panel.send_mail_requested.connect(self._send_mail) - splitter.addWidget(self._playlist_panel) - splitter.addWidget(self._library_panel) - splitter.setSizes([480, 480]) + self._splitter.addWidget(self._playlist_panel) + self._splitter.addWidget(self._library_panel) + self._splitter.setSizes([700, 900]) - return splitter + return self._splitter + + def _restore_window_state(self): + from PyQt6.QtCore import QSettings, QByteArray + settings = QSettings("LineDance", "Player") + geom = settings.value("window/geometry") + if geom: + self.restoreGeometry(geom) + splitter_state = settings.value("window/splitter") + if splitter_state and hasattr(self, "_splitter"): + self._splitter.restoreState(splitter_state) + + def _save_window_state(self): + from PyQt6.QtCore import QSettings + settings = QSettings("LineDance", "Player") + settings.setValue("window/geometry", self.saveGeometry()) + if hasattr(self, "_splitter"): + settings.setValue("window/splitter", self._splitter.saveState()) # ── Lokal DB + scanning ─────────────────────────────────────────────────── @@ -373,6 +408,23 @@ class MainWindow(QMainWindow): # Indlæs hvad vi allerede kender fra SQLite self._reload_library() + # Gendan sidst aktive danseliste + restored = self._playlist_panel.restore_active_playlist() + + # Gendan event-fremgang hvis liste blev gendannet + if restored: + if self._playlist_panel.restore_event_state(): + # Indlæs den sang vi var nået til + idx = self._playlist_panel._current_idx + song = self._playlist_panel.get_song(idx) + if song: + self._current_idx = idx + self._load_song(song) + self._set_status( + f"Event genoptaget ved: {song.get('title','')} — tryk ▶ for at fortsætte", + 6000, + ) + # Kør automatisk scanning ved opstart self._set_status("Starter scanning af biblioteker...") QTimer.singleShot(100, self.start_scan) @@ -447,6 +499,55 @@ class MainWindow(QMainWindow): except Exception as e: self._set_status(f"Fejl: {e}") + def _open_settings(self): + dialog = SettingsDialog(parent=self) + if dialog.exec(): + self._settings = dialog.get_values() + self._demo_seconds = self._settings.get("demo_seconds", 10) + # Opdater tema hvis ændret + new_dark = self._settings.get("dark_theme", True) + if new_dark != self._dark_theme: + self._dark_theme = new_dark + apply_theme(self._app_ref(), dark=self._dark_theme) + self._theme_btn.setText( + "☀ LYS TEMA" if self._dark_theme else "● MØRKT TEMA" + ) + self._vu.set_dark(self._dark_theme) + # Opdater demo-knap tekst + self._btn_demo.setText(f"▶\n{self._demo_seconds} SEK") + # Opdater demo-markør hvis en sang er indlæst + if hasattr(self, "_current_song") and self._current_song: + dur = self._current_song.get("duration_sec", 0) + if dur > 0: + self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0)) + self._set_status("Indstillinger gemt", 2000) + + def _auto_login(self): + """Forsøg automatisk login med gemte oplysninger.""" + username = self._settings.get("username", "") + password = self._settings.get("password", "") + if not username or not password: + return + try: + import urllib.request, urllib.parse, json + data = urllib.parse.urlencode({"username": username, "password": password}).encode() + req = urllib.request.Request( + f"{API_URL}/auth/login", data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=8) as resp: + body = json.loads(resp.read()) + self._api_token = body.get("access_token") + self._api_url = API_URL + self._api_username = username + self._set_online_state(True) + self._set_status(f"Automatisk logget ind som {username}", 4000) + # Synkroniser dans-niveauer og navne + QTimer.singleShot(500, self._sync_dance_data) + except Exception: + self._set_status("Auto-login fejlede — kør Filer → Gå online manuelt", 5000) + def _go_online(self): dialog = LoginDialog(self) if dialog.exec(): @@ -456,6 +557,33 @@ class MainWindow(QMainWindow): self._api_username = username self._set_online_state(True) self._set_status(f"Online som {username}", 5000) + QTimer.singleShot(500, self._sync_dance_data) + + def _sync_dance_data(self): + """Synkroniser dans-niveauer og navne fra API.""" + if not self._api_token: + return + try: + import urllib.request, json + headers = {"Authorization": f"Bearer {self._api_token}"} + + # Hent niveauer + req = urllib.request.Request(f"{API_URL}/dances/levels", headers=headers) + with urllib.request.urlopen(req, timeout=8) as resp: + levels = json.loads(resp.read()) + from local.local_db import sync_dance_levels_from_api + sync_dance_levels_from_api(levels) + + # Hent populære dans-navne + req = urllib.request.Request(f"{API_URL}/dances/names?limit=500", headers=headers) + with urllib.request.urlopen(req, timeout=8) as resp: + names = json.loads(resp.read()) + from local.local_db import sync_dance_names_from_api + sync_dance_names_from_api(names) + + self._set_status(f"Synkroniseret {len(levels)} niveauer og {len(names)} dans-navne", 4000) + except Exception as e: + print(f"Dans-sync fejl: {e}") def _go_offline(self): self._api_url = self._api_token = self._api_username = None @@ -493,6 +621,120 @@ class MainWindow(QMainWindow): self._playlist_panel.set_playlist_name(name) self._set_status(f"Indlæst: {name} ({len(songs)} sange)", 3000) + def _open_tag_editor(self, song: dict): + from ui.tag_editor import TagEditorDialog + dialog = TagEditorDialog(song, parent=self) + if dialog.exec(): + # Genindlæs biblioteket så ændringer vises + QTimer.singleShot(200, self._reload_library) + + def _send_mail(self, song: dict): + import subprocess, sys, shutil, urllib.parse + from pathlib import Path + + path = song.get("local_path", "") + title = song.get("title", "") + artist = song.get("artist", "") + + if not path or not Path(path).exists(): + self._set_status("Filen blev ikke fundet — kan ikke sende mail", 4000) + return + + # ── Auto-detekter mailklient ─────────────────────────────────────────── + + def try_thunderbird() -> bool: + """Thunderbird: thunderbird -compose attachment='file:///sti'""" + candidates = [] + if sys.platform == "win32": + import winreg + for base in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER): + try: + key = winreg.OpenKey(base, + r"SOFTWARE\Mozilla\Mozilla Thunderbird") + inst, _ = winreg.QueryValueEx(key, "Install Directory") + candidates.append(str(Path(inst) / "thunderbird.exe")) + except Exception: + pass + candidates += [ + r"C:\Program Files\Mozilla Thunderbird\thunderbird.exe", + r"C:\Program Files (x86)\Mozilla Thunderbird\thunderbird.exe", + ] + elif sys.platform == "darwin": + candidates = [ + "/Applications/Thunderbird.app/Contents/MacOS/thunderbird", + ] + else: + candidates = [shutil.which("thunderbird") or "", + "/usr/bin/thunderbird", + "/usr/local/bin/thunderbird", + "/snap/bin/thunderbird"] + + tb = next((c for c in candidates if c and Path(c).exists()), None) + if not tb: + return False + + file_uri = Path(path).as_uri() + subject = f"Linedance sang: {title} — {artist}" + compose = ( + f"subject='{subject}'," + f"attachment='{file_uri}'" + ) + subprocess.Popen([tb, "-compose", compose]) + return True + + def try_outlook() -> bool: + """Outlook: outlook.exe /a 'filsti' (kun Windows)""" + if sys.platform != "win32": + return False + candidates = [ + shutil.which("outlook") or "", + r"C:\Program Files\Microsoft Office\root\Office16\OUTLOOK.EXE", + r"C:\Program Files (x86)\Microsoft Office\root\Office16\OUTLOOK.EXE", + r"C:\Program Files\Microsoft Office\Office16\OUTLOOK.EXE", + ] + ol = next((c for c in candidates if c and Path(c).exists()), None) + if not ol: + return False + subprocess.Popen([ol, "/a", path]) + return True + + def fallback_mailto(): + """Ingen vedhæftning — åbn standard-mailprogram via mailto:""" + subject = urllib.parse.quote(f"Linedance sang: {title} — {artist}") + body = urllib.parse.quote( + f"Sang: {title}\nArtist: {artist}\nFil: {path}\n\n" + f"(Vedhæft filen manuelt fra ovenstående sti)" + ) + mailto = f"mailto:?subject={subject}&body={body}" + if sys.platform == "win32": + import os; os.startfile(mailto) + elif sys.platform == "darwin": + subprocess.Popen(["open", mailto]) + else: + subprocess.Popen(["xdg-open", mailto]) + + # ── Prøv i rækkefølge ───────────────────────────────────────────────── + if try_thunderbird(): + self._set_status(f"Thunderbird åbnet med {Path(path).name} vedh.", 4000) + elif try_outlook(): + self._set_status(f"Outlook åbnet med {Path(path).name} vedh.", 4000) + else: + fallback_mailto() + self._set_status( + f"Ingen kendt mailklient fundet — åbnet mailto: (uden vedhæftning)", 5000 + ) + + def _on_event_started(self): + """Start event — indlæs første sang i afspilleren klar til afspilning.""" + first = self._playlist_panel.get_song(0) + if not first: + return + self._stop() + self._current_idx = 0 + self._song_ended = False + self._load_song(first) + self._set_status("Event klar — tryk ▶ for at starte", 5000) + def _on_song_dropped(self, song: dict): self._set_status(f"Tilføjet: {song.get('title','')}", 2000) @@ -508,7 +750,6 @@ class MainWindow(QMainWindow): self._song_ended = False self._demo_active = False self._btn_demo.setChecked(False) - self._next_up.hide_bar() dur = song.get("duration_sec", 0) self._player.load(song.get("local_path", ""), dur) @@ -524,7 +765,7 @@ class MainWindow(QMainWindow): ) if dur > 0: - self._progress.set_demo_marker(min(10 / dur, 1.0)) + self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0)) self._set_status(f"Indlæst: {song.get('title','—')}", 3000) @@ -537,9 +778,6 @@ class MainWindow(QMainWindow): self._playlist_panel.set_current(idx) def _toggle_play(self): - if self._song_ended: - self._play_next() - return if self._demo_active: self._player.stop() self._demo_active = False @@ -549,14 +787,15 @@ class MainWindow(QMainWindow): if self._player.is_playing(): self._player.pause() else: + self._song_ended = False self._player.play() + self._btn_play.setText("⏸") def _stop(self): self._player.stop() self._song_ended = False self._demo_active = False self._btn_demo.setChecked(False) - self._next_up.hide_bar() self._btn_play.setText("▶") self._vu.reset() @@ -569,7 +808,7 @@ class MainWindow(QMainWindow): else: self._demo_active = True self._btn_demo.setChecked(True) - self._player.play_demo(stop_at_sec=10) + self._player.play_demo(stop_at_sec=self._demo_seconds) self._btn_play.setText("⏸") def _prev_song(self): @@ -584,13 +823,9 @@ class MainWindow(QMainWindow): self._load_song_by_idx(self._current_idx + 1) def _play_next(self): - ni = self._current_idx + 1 - if ni < self._playlist_panel.count(): - self._song_ended = False - self._next_up.hide_bar() - self._load_song_by_idx(ni) - self._player.play() - self._btn_play.setText("⏸") + self._song_ended = False + self._player.play() + self._btn_play.setText("⏸") def _on_library_song_selected(self, song: dict): self._load_song(song) @@ -627,20 +862,18 @@ class MainWindow(QMainWindow): # Markér den afspillede sang self._playlist_panel.mark_played(self._current_idx) - # Fremhæv næste sang i listen — men afspil den IKKE - ni = self._current_idx + 1 - next_song = self._playlist_panel.get_song(ni) + # Find næste afspilbare sang — spring skippede og afspillede over + ni = self._playlist_panel.next_playable_idx(self._current_idx + 1) + next_song = self._playlist_panel.get_song(ni) if ni is not None else None if next_song: - # set_current med song_ended=True markerer næste som "next" (blå) - # uden at ændre _current_idx i main_window - self._playlist_panel.set_current(self._current_idx, song_ended=True) - self._next_up.show_next( - next_song.get("title", ""), - next_song.get("artist", ""), - next_song.get("dances", []), - ) + self._current_idx = ni + self._playlist_panel.set_next_ready(ni) + self._load_song(next_song) + self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte") else: self._lbl_title.setText("— Danseliste afsluttet —") + self._lbl_meta.setText("") + self._lbl_dances.setText("") self._set_status("Danselisten er afsluttet") def _on_state_changed(self, state: str): @@ -676,6 +909,7 @@ class MainWindow(QMainWindow): # ── Luk ─────────────────────────────────────────────────────────────────── def closeEvent(self, event): + self._save_window_state() self._player.stop() if self._scan_worker and self._scan_worker.isRunning(): self._scan_worker.quit() diff --git a/linedance-app/ui/playlist_panel.py b/linedance-app/ui/playlist_panel.py index d19431cd..63da0629 100644 --- a/linedance-app/ui/playlist_panel.py +++ b/linedance-app/ui/playlist_panel.py @@ -1,35 +1,29 @@ """ -playlist_panel.py — Danseliste med event-overblik, drag-and-drop og højreklik. +playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overblik. """ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView, - QMessageBox, + QMessageBox, QInputDialog, ) -from PyQt6.QtCore import Qt, pyqtSignal, QMimeData -from PyQt6.QtGui import QColor, QFont, QDragEnterEvent, QDropEvent +from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray +from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent + + +ACTIVE_PLAYLIST_NAME = "__aktiv__" # fast navn til autogem-listen class PlaylistPanel(QWidget): - song_selected = pyqtSignal(int) # dobbeltklik → indlæs sang - status_changed = pyqtSignal(int, str) # (indeks, ny_status) - song_dropped = pyqtSignal(dict) # sang droppet fra bibliotek + song_selected = pyqtSignal(int) + status_changed = pyqtSignal(int, str) + song_dropped = pyqtSignal(dict) + playlist_changed = pyqtSignal() + event_started = pyqtSignal() + next_song_ready = pyqtSignal(dict) # udsendes når næste sang ændres — main_window indlæser den # udsendes af Start event — main_window indlæser første sang # udsendes ved enhver ændring → trigger autogem - STATUS_ICON = { - "pending": " ", - "playing": " ▶ ", - "played": " ✓ ", - "skipped": " — ", - "next": " ▷ ", - } - STATUS_COLOR = { - "pending": "#5a6070", - "playing": "#e8a020", - "played": "#2ecc71", - "skipped": "#e74c3c", - "next": "#3b8fd4", - } + STATUS_ICON = {"pending": " ", "playing": " ▶ ", "played": " ✓ ", "skipped": " — ", "next": " ▷ "} + STATUS_COLOR = {"pending": "#5a6070", "playing": "#e8a020", "played": "#2ecc71", "skipped": "#e74c3c", "next": "#3b8fd4"} def __init__(self, parent=None): super().__init__(parent) @@ -37,35 +31,74 @@ class PlaylistPanel(QWidget): self._statuses: list[str] = [] self._current_idx = -1 self._song_ended = False + self._active_playlist_id: int | None = None self._build_ui() self.setAcceptDrops(True) + # Autogem-timer — venter 800ms efter sidst ændring + self._autosave_timer = QTimer(self) + self._autosave_timer.setSingleShot(True) + self._autosave_timer.setInterval(800) + self._autosave_timer.timeout.connect(self._autosave) + # Event-state gem — hurtig, kritisk for genopstart efter strømsvigt + self._event_state_timer = QTimer(self) + self._event_state_timer.setSingleShot(True) + self._event_state_timer.setInterval(300) + self._event_state_timer.timeout.connect(self._save_event_state) def _build_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - # Header + # ── Header med titel ────────────────────────────────────────────────── header = QHBoxLayout() header.setContentsMargins(10, 6, 10, 6) self._title_label = QLabel("DANSELISTE") self._title_label.setObjectName("section_title") header.addWidget(self._title_label) - header.addStretch() layout.addLayout(header) - # Event-kontrol-linje + # ── Ny / Gem / Hent knapper ─────────────────────────────────────────── + toolbar = QHBoxLayout() + toolbar.setContentsMargins(8, 2, 8, 4) + toolbar.setSpacing(4) + + btn_new = QPushButton("✚ Ny") + btn_new.setFixedHeight(26) + btn_new.setToolTip("Opret en ny tom danseliste") + btn_new.clicked.connect(self._new_playlist) + toolbar.addWidget(btn_new) + + btn_save = QPushButton("💾 Gem som...") + btn_save.setFixedHeight(26) + btn_save.setToolTip("Gem aktuel liste med et navn") + btn_save.clicked.connect(self._save_as) + toolbar.addWidget(btn_save) + + btn_load = QPushButton("📂 Hent...") + btn_load.setFixedHeight(26) + btn_load.setToolTip("Hent en tidligere gemt danseliste") + btn_load.clicked.connect(self._load_dialog) + toolbar.addWidget(btn_load) + + toolbar.addStretch() + + self._lbl_autosave = QLabel("") + self._lbl_autosave.setObjectName("result_count") + toolbar.addWidget(self._lbl_autosave) + + layout.addLayout(toolbar) + + # ── Event-kontrol ───────────────────────────────────────────────────── ctrl = QHBoxLayout() - ctrl.setContentsMargins(8, 4, 8, 4) + ctrl.setContentsMargins(8, 2, 8, 4) ctrl.setSpacing(6) self._btn_start = QPushButton("▶ START EVENT") - self._btn_start.setObjectName("btn_start_event") self._btn_start.setFixedHeight(28) - self._btn_start.setToolTip("Nulstil alle statusser og start eventet fra top") + self._btn_start.setToolTip("Nulstil alle statusser og gør klar til event") self._btn_start.clicked.connect(self._start_event) ctrl.addWidget(self._btn_start) - ctrl.addStretch() self._lbl_progress = QLabel("0 / 0") @@ -74,27 +107,16 @@ class PlaylistPanel(QWidget): layout.addLayout(ctrl) - # Kolonneheader - col_header = QHBoxLayout() - col_header.setContentsMargins(10, 2, 10, 2) - for text, stretch in [("#", 0), ("Titel / Dans", 1), ("Status", 0)]: - lbl = QLabel(text) - lbl.setObjectName("result_count") - if stretch: - col_header.addWidget(lbl, stretch=1) - else: - lbl.setFixedWidth(30 if text == "#" else 50) - col_header.addWidget(lbl) - layout.addLayout(col_header) - - # Liste + # ── Liste ───────────────────────────────────────────────────────────── self._list = QListWidget() self._list.setObjectName("playlist_list") - self._list.setDragDropMode(QAbstractItemView.DragDropMode.DropOnly) + self._list.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) + self._list.setDefaultDropAction(Qt.DropAction.MoveAction) self._list.setAcceptDrops(True) self._list.itemDoubleClicked.connect(self._on_double_click) self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self._list.customContextMenuRequested.connect(self._show_context_menu) + self._list.model().rowsMoved.connect(self._on_rows_moved) layout.addWidget(self._list) # ── Drag & drop ─────────────────────────────────────────────────────────── @@ -109,8 +131,7 @@ class PlaylistPanel(QWidget): mime = event.mimeData() if mime.hasFormat("application/x-linedance-song"): import json - data = mime.data("application/x-linedance-song").data() - song = json.loads(data.decode("utf-8")) + song = json.loads(mime.data("application/x-linedance-song").data().decode()) self._append_song(song) self.song_dropped.emit(song) event.acceptProposedAction() @@ -119,16 +140,20 @@ class PlaylistPanel(QWidget): self._songs.append(song) self._statuses.append("pending") self._refresh() + self._trigger_autosave() - # ── Data ────────────────────────────────────────────────────────────────── + # ── Data API ────────────────────────────────────────────────────────────── - def load_songs(self, songs: list[dict], reset_statuses: bool = True): + def load_songs(self, songs: list[dict], reset_statuses: bool = True, name: str = ""): self._songs = list(songs) if reset_statuses: self._statuses = ["pending"] * len(songs) self._current_idx = -1 self._song_ended = False + if name: + self._title_label.setText(f"DANSELISTE — {name.upper()}") self._refresh() + self._trigger_autosave() def set_current(self, idx: int, song_ended: bool = False): self._current_idx = idx @@ -142,6 +167,19 @@ class PlaylistPanel(QWidget): if 0 <= idx < len(self._statuses): self._statuses[idx] = "played" self._refresh() + self._trigger_autosave() + self._trigger_event_state_save() + + def set_next_ready(self, idx: int): + """Sæt næste sang klar — uden at overskrive skipped/played statusser.""" + self._current_idx = idx + self._song_ended = False + # Ændr KUN status hvis den er pending — rør ikke skipped/played + if 0 <= idx < len(self._statuses): + if self._statuses[idx] not in ("skipped", "played"): + self._statuses[idx] = "pending" + self._refresh() + self._scroll_to(idx) def get_song(self, idx: int) -> dict | None: return self._songs[idx] if 0 <= idx < len(self._songs) else None @@ -155,7 +193,236 @@ class PlaylistPanel(QWidget): def count(self) -> int: return len(self._songs) - # ── Event-styring ───────────────────────────────────────────────────────── + def set_playlist_name(self, name: str): + self._title_label.setText(f"DANSELISTE — {name.upper()}") + + # ── Drag-flytning ───────────────────────────────────────────────────────── + + def _on_rows_moved(self, parent, start, end, dest, dest_row): + """Opdater _songs og _statuses når en sang flyttes via drag.""" + new_songs = [] + new_statuses = [] + for i in range(self._list.count()): + old_idx = self._list.item(i).data(Qt.ItemDataRole.UserRole) + if old_idx is not None and 0 <= old_idx < len(self._songs): + new_songs.append(self._songs[old_idx]) + new_statuses.append(self._statuses[old_idx]) + self._songs = new_songs + self._statuses = new_statuses + self._current_idx = -1 + self._song_ended = False + self._refresh() + self._trigger_autosave() + + # Find første afspilbare sang og udsend signal så afspilleren opdateres + ni = self.next_playable_idx(0) + if ni is not None: + self._current_idx = ni + self._refresh() + self.next_song_ready.emit(self._songs[ni]) + + # ── Event-state ─────────────────────────────────────────────────────────── + + def _save_event_state(self): + """Gem current_idx og statuses — overlever strømsvigt.""" + try: + from local.local_db import save_event_state + save_event_state(self._current_idx, self._statuses) + except Exception as e: + print(f"Event-state gem fejl: {e}") + + def _trigger_event_state_save(self): + self._event_state_timer.start() + + def restore_event_state(self) -> bool: + """Gendan gemt event-fremgang. Returnerer True hvis gendannet.""" + try: + from local.local_db import load_event_state + result = load_event_state() + if not result: + return False + idx, statuses = result + if len(statuses) != len(self._songs): + return False # listen er ændret siden sidst + self._statuses = statuses + self._current_idx = idx + self._song_ended = False + self._refresh() + return True + except Exception as e: + print(f"Event-state gendan fejl: {e}") + return False + + def next_playable_idx(self, from_idx: int) -> int | None: + """Find næste sang der ikke er 'skipped' eller 'played' fra from_idx.""" + for i in range(from_idx, len(self._songs)): + if self._statuses[i] not in ("skipped", "played"): + return i + return None + + # ── Autogem ─────────────────────────────────────────────────────────────── + + def _trigger_autosave(self): + """Start/nulstil debounce-timer — gemmer 800ms efter sidst ændring.""" + self._autosave_timer.start() + self._lbl_autosave.setText("● ikke gemt") + + def _autosave(self): + """Gem til den faste 'Aktiv liste' i SQLite.""" + try: + from local.local_db import get_db, create_playlist, add_song_to_playlist + with get_db() as conn: + # Slet den gamle aktive liste + conn.execute( + "DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,) + ) + # Opret ny + pl_id = create_playlist(ACTIVE_PLAYLIST_NAME) + self._active_playlist_id = pl_id + for i, song in enumerate(self._songs, start=1): + if song.get("id"): + add_song_to_playlist(pl_id, song["id"], position=i) + self._lbl_autosave.setText("✓ gemt") + self.playlist_changed.emit() + except Exception as e: + self._lbl_autosave.setText(f"⚠ gemfejl") + print(f"Autogem fejl: {e}") + + def restore_active_playlist(self): + """Indlæs den sidst aktive liste ved opstart.""" + try: + from local.local_db import get_db + with get_db() as conn: + pl = conn.execute( + "SELECT id FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,) + ).fetchone() + if not pl: + return False + songs_raw = conn.execute(""" + SELECT s.*, ps.position FROM playlist_songs ps + JOIN songs s ON s.id = ps.song_id + WHERE ps.playlist_id=? ORDER BY ps.position + """, (pl["id"],)).fetchall() + songs = [] + for row in songs_raw: + dances = conn.execute( + "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order", + (row["id"],) + ).fetchall() + songs.append({ + "id": row["id"], "title": row["title"], + "artist": row["artist"], "album": row["album"], + "bpm": row["bpm"], "duration_sec": row["duration_sec"], + "local_path": row["local_path"], "file_format": row["file_format"], + "file_missing": bool(row["file_missing"]), + "dances": [d["dance_name"] for d in dances], + }) + if songs: + self._songs = songs + self._statuses = ["pending"] * len(songs) + self._refresh() + self._lbl_autosave.setText("✓ gendannet") + return True + except Exception as e: + print(f"Gendan aktiv liste fejl: {e}") + return False + + # ── Ny / Gem som / Hent ─────────────────────────────────────────────────── + + def _new_playlist(self): + if self._songs: + reply = QMessageBox.question( + self, "Ny danseliste", + "Ryd den aktuelle liste og start forfra?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + self._songs = [] + self._statuses = [] + self._current_idx = -1 + self._song_ended = False + self._title_label.setText("DANSELISTE — NY") + self._refresh() + self._trigger_autosave() + + def _save_as(self): + if not self._songs: + QMessageBox.information(self, "Gem", "Danselisten er tom.") + return + name, ok = QInputDialog.getText( + self, "Gem danseliste", "Navn på danselisten:", + ) + if not ok or not name.strip(): + return + name = name.strip() + try: + from local.local_db import create_playlist, add_song_to_playlist + pl_id = create_playlist(name) + for i, song in enumerate(self._songs, start=1): + if song.get("id"): + add_song_to_playlist(pl_id, song["id"], position=i) + self._title_label.setText(f"DANSELISTE — {name.upper()}") + self._lbl_autosave.setText(f"✓ gemt som \"{name}\"") + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}") + + def _load_dialog(self): + """Vis liste af gemte danselister og lad brugeren vælge.""" + try: + from local.local_db import get_db + with get_db() as conn: + lists = conn.execute( + "SELECT id, name, created_at FROM playlists " + "WHERE name != ? ORDER BY created_at DESC", + (ACTIVE_PLAYLIST_NAME,) + ).fetchall() + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke hente lister: {e}") + return + + if not lists: + QMessageBox.information(self, "Hent liste", "Ingen gemte danselister fundet.") + return + + names = [f"{row['name']} ({row['created_at'][:10]})" for row in lists] + choice, ok = QInputDialog.getItem( + self, "Hent danseliste", "Vælg en liste:", names, editable=False + ) + if not ok: + return + + idx = names.index(choice) + pl_id = lists[idx]["id"] + pl_name = lists[idx]["name"] + + try: + from local.local_db import get_db + with get_db() as conn: + songs_raw = conn.execute(""" + SELECT s.*, ps.position FROM playlist_songs ps + JOIN songs s ON s.id = ps.song_id + WHERE ps.playlist_id=? ORDER BY ps.position + """, (pl_id,)).fetchall() + songs = [] + for row in songs_raw: + dances = conn.execute( + "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order", + (row["id"],) + ).fetchall() + songs.append({ + "id": row["id"], "title": row["title"], + "artist": row["artist"], "album": row["album"], + "bpm": row["bpm"], "duration_sec": row["duration_sec"], + "local_path": row["local_path"], "file_format": row["file_format"], + "file_missing": bool(row["file_missing"]), + "dances": [d["dance_name"] for d in dances], + }) + self.load_songs(songs, name=pl_name) + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke indlæse listen: {e}") + + # ── Start event ─────────────────────────────────────────────────────────── def _start_event(self): if not self._songs: @@ -168,10 +435,17 @@ class PlaylistPanel(QWidget): if reply == QMessageBox.StandardButton.Yes: self._statuses = ["pending"] * len(self._songs) self._current_idx = -1 - self._song_ended = False + self._song_ended = True + try: + from local.local_db import clear_event_state + clear_event_state() + except Exception: + pass self._refresh() + self._scroll_to(0) + self.event_started.emit() - # ── Højreklik-menu ──────────────────────────────────────────────────────── + # ── Højreklik ───────────────────────────────────────────────────────────── def _show_context_menu(self, pos): item = self._list.itemAt(pos) @@ -180,97 +454,68 @@ class PlaylistPanel(QWidget): idx = item.data(Qt.ItemDataRole.UserRole) if idx is None: return - menu = QMenu(self) - menu.setStyleSheet("QMenu { padding: 4px; } QMenu::item { padding: 6px 20px; }") - - act_play = menu.addAction("▶ Afspil denne") + act_play = menu.addAction("▶ Afspil denne") menu.addSeparator() - act_skip = menu.addAction("— Spring over") + act_skip = menu.addAction("— Spring over") act_unplay = menu.addAction("↺ Sæt til ikke afspillet") act_played = menu.addAction("✓ Sæt til afspillet") menu.addSeparator() act_remove = menu.addAction("✕ Fjern fra liste") - action = menu.exec(self._list.mapToGlobal(pos)) - if action == act_play: self.song_selected.emit(idx) elif action == act_skip: self._statuses[idx] = "skipped" self.status_changed.emit(idx, "skipped") - self._refresh() + self._refresh(); self._trigger_autosave(); self._trigger_event_state_save() elif action == act_unplay: self._statuses[idx] = "pending" self.status_changed.emit(idx, "pending") - self._refresh() + self._refresh(); self._trigger_autosave(); self._trigger_event_state_save() elif action == act_played: self._statuses[idx] = "played" self.status_changed.emit(idx, "played") - self._refresh() + self._refresh(); self._trigger_autosave(); self._trigger_event_state_save() elif action == act_remove: self._songs.pop(idx) self._statuses.pop(idx) if self._current_idx >= idx: self._current_idx = max(-1, self._current_idx - 1) - self._refresh() + self._refresh(); self._trigger_autosave() # ── Render ──────────────────────────────────────────────────────────────── def _refresh(self): self._list.clear() - played_count = sum(1 for s in self._statuses if s == "played") - self._lbl_progress.setText(f"{played_count} / {len(self._songs)} afspillet") - + played = sum(1 for s in self._statuses if s == "played") + self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet") for i, song in enumerate(self._songs): is_current = (i == self._current_idx and not self._song_ended) - is_next = (self._song_ended and i == self._current_idx + 1) - - if is_current: - status = "playing" - elif is_next: - status = "next" - else: - status = self._statuses[i] - - icon = self.STATUS_ICON.get(status, " ") - color = self.STATUS_COLOR.get(status, "#5a6070") - + is_next = (self._song_ended and i == self._current_idx + 1) or \ + (self._current_idx == -1 and self._song_ended and i == 0) + status = "playing" if is_current else "next" if is_next else self._statuses[i] + icon = self.STATUS_ICON.get(status, " ") dances = " / ".join(song.get("dances", [])) or "ingen dans tagget" - text = f"{i+1:>2}. {song.get('title','—')}\n {song.get('artist','')} · {dances}" - - item = QListWidgetItem(f"{icon} {text}") + text = f"{i+1:>2}. {song.get('title','—')}\n {song.get('artist','')} · {dances}" + item = QListWidgetItem(f"{icon} {text}") item.setData(Qt.ItemDataRole.UserRole, i) - - # Farver - if status == "playing": - item.setForeground(QColor("#e8a020")) - font = item.font() - font.setBold(True) - item.setFont(font) - elif status == "next": - item.setForeground(QColor("#3b8fd4")) - font = item.font() - font.setBold(True) - item.setFont(font) + color = self.STATUS_COLOR.get(status, "#5a6070") + if status in ("playing", "next"): + item.setForeground(QColor(color)) + f = item.font(); f.setBold(True); item.setFont(f) elif status == "played": item.setForeground(QColor("#2ecc71")) elif status == "skipped": item.setForeground(QColor("#e74c3c")) else: item.setForeground(QColor("#9aa0b0")) - self._list.addItem(item) - def set_playlist_name(self, name: str): - self._title_label.setText(f"DANSELISTE — {name.upper()}") - def _scroll_to(self, idx: int): if 0 <= idx < self._list.count(): self._list.scrollToItem( - self._list.item(idx), - QListWidget.ScrollHint.PositionAtCenter, - ) + self._list.item(idx), QListWidget.ScrollHint.PositionAtCenter) def _on_double_click(self, item: QListWidgetItem): idx = item.data(Qt.ItemDataRole.UserRole) diff --git a/linedance-app/ui/scan_worker.py b/linedance-app/ui/scan_worker.py index ca0c00dd..13ae61ba 100644 --- a/linedance-app/ui/scan_worker.py +++ b/linedance-app/ui/scan_worker.py @@ -22,6 +22,8 @@ class ScanWorker(QThread): def run(self): try: from local.local_db import get_libraries + from local.tag_reader import is_supported + import os libraries = get_libraries(active_only=True) if not libraries: @@ -34,12 +36,20 @@ class ScanWorker(QThread): from pathlib import Path path = Path(lib["path"]) name = path.name + + if not path.exists(): + self.status_update.emit(f"⚠ Mappe ikke fundet: {path}") + continue + self.status_update.emit(f"Scanner: {name}...") - # Tæl filer først så vi kan vise fremgang - from local.tag_reader import is_supported - files = [f for f in path.rglob("*") if f.is_file() and is_supported(f)] - count = len(files) + # Tæl filer med os.walk — håndterer permission-fejl sikkert + count = 0 + for dirpath, _, filenames in os.walk(str(path), followlinks=False): + for f in filenames: + if is_supported(f): + count += 1 + self.status_update.emit(f"Scanner: {name} ({count} filer)...") # Kør scanning diff --git a/linedance-app/ui/settings_dialog.py b/linedance-app/ui/settings_dialog.py new file mode 100644 index 00000000..dcd7a3dc --- /dev/null +++ b/linedance-app/ui/settings_dialog.py @@ -0,0 +1,262 @@ +""" +settings_dialog.py — Indstillinger for LineDance Player. +Gemmes via QSettings og læses ved opstart. +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QComboBox, QSpinBox, QCheckBox, QFrame, + QTabWidget, QWidget, QFileDialog, QGroupBox, QFormLayout, +) +from PyQt6.QtCore import Qt, QSettings + + +SETTINGS_KEY_THEME = "appearance/dark_theme" +SETTINGS_KEY_DEMO_SEC = "playback/demo_seconds" +SETTINGS_KEY_MAIL_CLIENT = "mail/client" # "auto"|"thunderbird"|"outlook"|"mailto" +SETTINGS_KEY_MAIL_PATH = "mail/custom_path" +SETTINGS_KEY_AUTO_LOGIN = "online/auto_login" +SETTINGS_KEY_USERNAME = "online/username" +SETTINGS_KEY_PASSWORD = "online/password" # gemt i klartekst — ikke ideelt, men funktionelt + + +def load_settings() -> dict: + """Indlæs alle indstillinger med fornuftige standardværdier.""" + s = QSettings("LineDance", "Player") + return { + "dark_theme": s.value(SETTINGS_KEY_THEME, True, type=bool), + "demo_seconds": s.value(SETTINGS_KEY_DEMO_SEC, 10, type=int), + "mail_client": s.value(SETTINGS_KEY_MAIL_CLIENT, "auto"), + "mail_path": s.value(SETTINGS_KEY_MAIL_PATH, ""), + "auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool), + "username": s.value(SETTINGS_KEY_USERNAME, ""), + "password": s.value(SETTINGS_KEY_PASSWORD, ""), + } + + +def save_settings(values: dict): + s = QSettings("LineDance", "Player") + s.setValue(SETTINGS_KEY_THEME, values.get("dark_theme", True)) + s.setValue(SETTINGS_KEY_DEMO_SEC, values.get("demo_seconds", 10)) + s.setValue(SETTINGS_KEY_MAIL_CLIENT, values.get("mail_client", "auto")) + s.setValue(SETTINGS_KEY_MAIL_PATH, values.get("mail_path", "")) + s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False)) + s.setValue(SETTINGS_KEY_USERNAME, values.get("username", "")) + s.setValue(SETTINGS_KEY_PASSWORD, values.get("password", "")) + + +class SettingsDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Indstillinger") + self.setMinimumWidth(480) + self.setModal(True) + self._values = load_settings() + self._build_ui() + self._populate() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(12) + + tabs = QTabWidget() + tabs.addTab(self._build_appearance_tab(), "🎨 Udseende") + tabs.addTab(self._build_playback_tab(), "▶ Afspilning") + tabs.addTab(self._build_mail_tab(), "✉ Mail") + tabs.addTab(self._build_online_tab(), "🌐 Online") + layout.addWidget(tabs) + + # Knapper + btn_row = QHBoxLayout() + btn_row.addStretch() + btn_cancel = QPushButton("Annuller") + btn_cancel.clicked.connect(self.reject) + btn_row.addWidget(btn_cancel) + btn_save = QPushButton("💾 Gem indstillinger") + btn_save.setObjectName("btn_play") + btn_save.setDefault(True) + btn_save.clicked.connect(self._save_and_close) + btn_row.addWidget(btn_save) + layout.addLayout(btn_row) + + # ── Fane: Udseende ──────────────────────────────────────────────────────── + + def _build_appearance_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(12) + + grp = QGroupBox("Standard tema") + grp_layout = QVBoxLayout(grp) + + self._chk_dark = QCheckBox("Start med mørkt tema") + grp_layout.addWidget(self._chk_dark) + + note = QLabel("Du kan altid skifte tema mens programmet kører via topbar-knappen.") + note.setObjectName("result_count") + note.setWordWrap(True) + grp_layout.addWidget(note) + layout.addWidget(grp) + layout.addStretch() + return tab + + # ── Fane: Afspilning ────────────────────────────────────────────────────── + + def _build_playback_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(12) + + grp = QGroupBox("Forspil (▶ N SEK knappen)") + grp_layout = QFormLayout(grp) + + self._spin_demo = QSpinBox() + self._spin_demo.setRange(3, 60) + self._spin_demo.setSuffix(" sekunder") + self._spin_demo.setFixedWidth(140) + grp_layout.addRow("Forspil-længde:", self._spin_demo) + + note = QLabel( + "Forspillet afspiller begyndelsen af sangen så arrangøren kan bekræfte\n" + "at det er den rigtige sang og dans inden eventet starter." + ) + note.setObjectName("result_count") + note.setWordWrap(True) + grp_layout.addRow(note) + layout.addWidget(grp) + layout.addStretch() + return tab + + # ── Fane: Mail ──────────────────────────────────────────────────────────── + + def _build_mail_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(12) + + grp = QGroupBox("Mailklient") + grp_layout = QFormLayout(grp) + + self._mail_combo = QComboBox() + self._mail_combo.addItem("Auto-detekter (Thunderbird → Outlook → mailto:)", "auto") + self._mail_combo.addItem("Thunderbird", "thunderbird") + self._mail_combo.addItem("Outlook (Windows)", "outlook") + self._mail_combo.addItem("Brugerdefineret sti", "custom") + self._mail_combo.addItem("Kun mailto: (ingen vedhæftning)", "mailto") + self._mail_combo.currentIndexChanged.connect(self._on_mail_combo_changed) + grp_layout.addRow("Klient:", self._mail_combo) + + path_row = QHBoxLayout() + self._mail_path = QLineEdit() + self._mail_path.setPlaceholderText("/usr/bin/thunderbird eller C:\\...\\thunderbird.exe") + path_row.addWidget(self._mail_path) + btn_browse = QPushButton("...") + btn_browse.setFixedWidth(32) + btn_browse.clicked.connect(self._browse_mail_path) + path_row.addWidget(btn_browse) + self._mail_path_row_widget = QWidget() + self._mail_path_row_widget.setLayout(path_row) + grp_layout.addRow("Sti:", self._mail_path_row_widget) + + note = QLabel( + "Med Thunderbird og Outlook åbnes et nyt compose-vindue med filen vedhæftet.\n" + "mailto: åbner standard-mailprogrammet men uden automatisk vedhæftning." + ) + note.setObjectName("result_count") + note.setWordWrap(True) + grp_layout.addRow(note) + layout.addWidget(grp) + layout.addStretch() + return tab + + def _on_mail_combo_changed(self, idx: int): + is_custom = self._mail_combo.currentData() == "custom" + self._mail_path_row_widget.setVisible(is_custom) + + def _browse_mail_path(self): + path, _ = QFileDialog.getOpenFileName(self, "Vælg mailklient") + if path: + self._mail_path.setText(path) + + # ── Fane: Online ────────────────────────────────────────────────────────── + + def _build_online_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setSpacing(12) + + grp = QGroupBox("Automatisk login ved opstart") + grp_layout = QFormLayout(grp) + + self._chk_auto_login = QCheckBox("Log automatisk ind når programmet starter") + self._chk_auto_login.stateChanged.connect(self._on_auto_login_changed) + grp_layout.addRow(self._chk_auto_login) + + self._user_input = QLineEdit() + self._user_input.setPlaceholderText("dit-brugernavn") + grp_layout.addRow("Brugernavn:", self._user_input) + + self._pass_input = QLineEdit() + self._pass_input.setEchoMode(QLineEdit.EchoMode.Password) + self._pass_input.setPlaceholderText("••••••••") + grp_layout.addRow("Kodeord:", self._pass_input) + + note = QLabel( + "⚠ Kodeordet gemmes lokalt på denne computer.\n" + "Brug kun dette på en personlig maskine." + ) + note.setObjectName("result_count") + note.setWordWrap(True) + grp_layout.addRow(note) + layout.addWidget(grp) + layout.addStretch() + return tab + + def _on_auto_login_changed(self, state: int): + enabled = state == Qt.CheckState.Checked.value + self._user_input.setEnabled(enabled) + self._pass_input.setEnabled(enabled) + + # ── Populer fra gemte værdier ───────────────────────────────────────────── + + def _populate(self): + v = self._values + self._chk_dark.setChecked(v.get("dark_theme", True)) + self._spin_demo.setValue(v.get("demo_seconds", 10)) + + # Mail + client = v.get("mail_client", "auto") + for i in range(self._mail_combo.count()): + if self._mail_combo.itemData(i) == client: + self._mail_combo.setCurrentIndex(i) + break + self._mail_path.setText(v.get("mail_path", "")) + self._on_mail_combo_changed(self._mail_combo.currentIndex()) + + # Online + auto = v.get("auto_login", False) + self._chk_auto_login.setChecked(auto) + self._user_input.setText(v.get("username", "")) + self._pass_input.setText(v.get("password", "")) + self._user_input.setEnabled(auto) + self._pass_input.setEnabled(auto) + + # ── Gem ─────────────────────────────────────────────────────────────────── + + def _save_and_close(self): + values = { + "dark_theme": self._chk_dark.isChecked(), + "demo_seconds": self._spin_demo.value(), + "mail_client": self._mail_combo.currentData(), + "mail_path": self._mail_path.text().strip(), + "auto_login": self._chk_auto_login.isChecked(), + "username": self._user_input.text().strip(), + "password": self._pass_input.text(), + } + save_settings(values) + self._values = values + self.accept() + + def get_values(self) -> dict: + return self._values diff --git a/linedance-app/ui/tag_editor.py b/linedance-app/ui/tag_editor.py new file mode 100644 index 00000000..fbee58a6 --- /dev/null +++ b/linedance-app/ui/tag_editor.py @@ -0,0 +1,437 @@ +""" +tag_editor.py — Rediger danse og alternativ-danse med niveau og autoudfyld. + +Fire sektioner: + Mine danse | Fællesskabets danse + Mine alternativer | Fællesskabets alternativer +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QListWidget, QListWidgetItem, QFrame, + QSplitter, QWidget, QMessageBox, QComboBox, QCompleter, + QGridLayout, QGroupBox, +) +from PyQt6.QtCore import Qt, QTimer, QStringListModel, pyqtSignal +from PyQt6.QtGui import QColor + + +class AutoCompleteLineEdit(QLineEdit): + """QLineEdit med autoudfyld fra dans-navne databasen.""" + + def __init__(self, placeholder: str = "", parent=None): + super().__init__(parent) + self.setPlaceholderText(placeholder) + self._completer_model = QStringListModel() + self._completer = QCompleter(self._completer_model, self) + self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) + self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) + self._completer.setMaxVisibleItems(12) + self.setCompleter(self._completer) + self._timer = QTimer(self) + self._timer.setSingleShot(True) + self._timer.setInterval(150) + self._timer.timeout.connect(self._update_suggestions) + self.textChanged.connect(lambda _: self._timer.start()) + + def _update_suggestions(self): + prefix = self.text().strip() + if len(prefix) < 1: + return + try: + from local.local_db import get_dance_name_suggestions + names = get_dance_name_suggestions(prefix, limit=20) + self._completer_model.setStringList(names) + except Exception: + pass + + +class DanceRow(QWidget): + """Én dans med navn og niveau-dropdown.""" + removed = pyqtSignal() + + def __init__(self, dance_name: str = "", level_id: int | None = None, + levels: list = [], readonly: bool = False, parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 2, 0, 2) + layout.setSpacing(6) + + if readonly: + self._name_lbl = QLabel(dance_name) + self._name_lbl.setObjectName("track_meta") + layout.addWidget(self._name_lbl, stretch=1) + else: + self._name_edit = AutoCompleteLineEdit("Dansenavn...", self) + self._name_edit.setText(dance_name) + layout.addWidget(self._name_edit, stretch=1) + + self._level_combo = QComboBox() + self._level_combo.addItem("— intet niveau —", None) + self._level_data = [None] + for lvl in levels: + self._level_combo.addItem(lvl["name"], lvl["id"]) + self._level_data.append(lvl["id"]) + if level_id is not None: + for i, lid in enumerate(self._level_data): + if lid == level_id: + self._level_combo.setCurrentIndex(i) + break + self._level_combo.setFixedWidth(130) + self._level_combo.setEnabled(not readonly) + layout.addWidget(self._level_combo) + + if not readonly: + btn_rm = QPushButton("✕") + btn_rm.setFixedSize(24, 24) + btn_rm.clicked.connect(self.removed.emit) + layout.addWidget(btn_rm) + + def get_name(self) -> str: + if hasattr(self, "_name_edit"): + return self._name_edit.text().strip() + return self._name_lbl.text() + + def get_level_id(self) -> int | None: + return self._level_combo.currentData() + + +class AltRow(QWidget): + """Én alternativ-dans med navn, niveau og note.""" + removed = pyqtSignal() + copy_to_mine = pyqtSignal(str, object, str) # name, level_id, note + + def __init__(self, alt_name: str = "", level_id: int | None = None, + note: str = "", levels: list = [], + readonly: bool = False, source: str = "local", + rating: float = 0, rating_count: int = 0, parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 2, 0, 2) + layout.setSpacing(6) + + if readonly: + lbl = QLabel(f"→ {alt_name}") + lbl.setObjectName("track_meta") + layout.addWidget(lbl, stretch=1) + if rating_count > 0: + stars = "★" * round(rating) + "☆" * (5 - round(rating)) + lbl_r = QLabel(f"{stars} ({rating_count})") + lbl_r.setObjectName("result_count") + layout.addWidget(lbl_r) + else: + prefix_lbl = QLabel("→") + prefix_lbl.setObjectName("track_meta") + layout.addWidget(prefix_lbl) + self._name_edit = AutoCompleteLineEdit("Alternativ dansenavn...", self) + self._name_edit.setText(alt_name) + layout.addWidget(self._name_edit, stretch=1) + + self._level_combo = QComboBox() + self._level_combo.addItem("— niveau —", None) + self._level_data = [None] + for lvl in levels: + self._level_combo.addItem(lvl["name"], lvl["id"]) + self._level_data.append(lvl["id"]) + if level_id is not None: + for i, lid in enumerate(self._level_data): + if lid == level_id: + self._level_combo.setCurrentIndex(i) + break + self._level_combo.setFixedWidth(120) + self._level_combo.setEnabled(not readonly) + layout.addWidget(self._level_combo) + + if readonly: + btn_copy = QPushButton("← Kopier") + btn_copy.setFixedHeight(22) + btn_copy.clicked.connect( + lambda: self.copy_to_mine.emit(alt_name, self._level_combo.currentData(), note) + ) + layout.addWidget(btn_copy) + else: + self._note_edit = QLineEdit() + self._note_edit.setPlaceholderText("note...") + self._note_edit.setText(note) + self._note_edit.setFixedWidth(100) + layout.addWidget(self._note_edit) + btn_rm = QPushButton("✕") + btn_rm.setFixedSize(24, 24) + btn_rm.clicked.connect(self.removed.emit) + layout.addWidget(btn_rm) + + def get_name(self) -> str: + if hasattr(self, "_name_edit"): + return self._name_edit.text().strip() + return "" + + def get_level_id(self) -> int | None: + return self._level_combo.currentData() + + def get_note(self) -> str: + if hasattr(self, "_note_edit"): + return self._note_edit.text().strip() + return "" + + +class TagEditorDialog(QDialog): + def __init__(self, song: dict, parent=None): + super().__init__(parent) + self._song = song + self._levels = [] + self._my_dance_rows: list[DanceRow] = [] + self._my_alt_rows: list[AltRow] = [] + self.setWindowTitle(f"Rediger tags — {song.get('title','')}") + self.setMinimumSize(860, 620) + self._load_levels() + self._build_ui() + self._load_data() + + def _load_levels(self): + try: + from local.local_db import get_dance_levels + self._levels = [dict(r) for r in get_dance_levels()] + except Exception: + self._levels = [] + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(10) + + # ── Sang-info ───────────────────────────────────────────────────────── + info = QFrame() + info.setObjectName("track_display") + info_layout = QHBoxLayout(info) + info_layout.setContentsMargins(10, 8, 10, 8) + title_col = QVBoxLayout() + lbl_title = QLabel(self._song.get("title", "—")) + lbl_title.setObjectName("track_title") + title_col.addWidget(lbl_title) + meta = f"{self._song.get('artist','')} · {self._song.get('bpm',0)} BPM · {self._song.get('file_format','').upper()}" + lbl_meta = QLabel(meta) + lbl_meta.setObjectName("track_meta") + title_col.addWidget(lbl_meta) + can_write = self._song.get("file_format","").lower() in ("mp3","flac","ogg","opus","m4a") + lbl_write = QLabel("✓ Tags skrives til filen" if can_write else "⚠ Tags gemmes kun i database") + lbl_write.setObjectName("result_count") + title_col.addWidget(lbl_write) + info_layout.addLayout(title_col, stretch=1) + layout.addWidget(info) + + # ── Fire paneler i 2x2 grid ─────────────────────────────────────────── + grid = QWidget() + grid_layout = QGridLayout(grid) + grid_layout.setSpacing(8) + + grid_layout.addWidget(self._build_my_dances_panel(), 0, 0) + grid_layout.addWidget(self._build_community_dances_panel(), 0, 1) + grid_layout.addWidget(self._build_my_alts_panel(), 1, 0) + grid_layout.addWidget(self._build_community_alts_panel(), 1, 1) + + layout.addWidget(grid, stretch=1) + + # ── Knapper ─────────────────────────────────────────────────────────── + btn_row = QHBoxLayout() + btn_row.addStretch() + btn_cancel = QPushButton("Annuller") + btn_cancel.clicked.connect(self.reject) + btn_row.addWidget(btn_cancel) + btn_save = QPushButton("💾 Gem tags") + btn_save.setObjectName("btn_play") + btn_save.clicked.connect(self._save) + btn_row.addWidget(btn_save) + layout.addLayout(btn_row) + + # ── Mine danse ──────────────────────────────────────────────────────────── + + def _build_my_dances_panel(self) -> QGroupBox: + grp = QGroupBox("Mine danse") + layout = QVBoxLayout(grp) + layout.setSpacing(4) + + self._my_dances_container = QVBoxLayout() + layout.addLayout(self._my_dances_container) + layout.addStretch() + + add_row = QHBoxLayout() + self._new_dance_input = AutoCompleteLineEdit("Ny dans...", self) + self._new_dance_input.returnPressed.connect(self._add_my_dance) + add_row.addWidget(self._new_dance_input) + btn_add = QPushButton("+ Tilføj") + btn_add.clicked.connect(self._add_my_dance) + add_row.addWidget(btn_add) + layout.addLayout(add_row) + return grp + + def _add_my_dance(self, name: str = "", level_id=None): + n = name or self._new_dance_input.text().strip() + if not n: + return + row = DanceRow(n, level_id, self._levels, readonly=False, parent=self) + row.removed.connect(lambda r=row: self._remove_dance_row(r)) + self._my_dance_rows.append(row) + self._my_dances_container.addWidget(row) + self._new_dance_input.clear() + + def _remove_dance_row(self, row: DanceRow): + self._my_dance_rows.remove(row) + self._my_dances_container.removeWidget(row) + row.deleteLater() + + # ── Fællesskabets danse ─────────────────────────────────────────────────── + + def _build_community_dances_panel(self) -> QGroupBox: + grp = QGroupBox("Fællesskabets danse") + layout = QVBoxLayout(grp) + self._community_dances_container = QVBoxLayout() + layout.addLayout(self._community_dances_container) + layout.addStretch() + lbl = QLabel("Kræver online forbindelse") + lbl.setObjectName("result_count") + layout.addWidget(lbl) + return grp + + # ── Mine alternativer ───────────────────────────────────────────────────── + + def _build_my_alts_panel(self) -> QGroupBox: + grp = QGroupBox("Mine alternativ-danse") + layout = QVBoxLayout(grp) + layout.setSpacing(4) + self._my_alts_container = QVBoxLayout() + layout.addLayout(self._my_alts_container) + layout.addStretch() + + add_row = QHBoxLayout() + self._new_alt_input = AutoCompleteLineEdit("Alternativ dansenavn...", self) + self._new_alt_input.returnPressed.connect(self._add_my_alt) + add_row.addWidget(self._new_alt_input) + btn_add = QPushButton("+ Tilføj") + btn_add.clicked.connect(self._add_my_alt) + add_row.addWidget(btn_add) + layout.addLayout(add_row) + return grp + + def _add_my_alt(self, name: str = "", level_id=None, note: str = ""): + n = name or self._new_alt_input.text().strip() + if not n: + return + row = AltRow(n, level_id, note, self._levels, readonly=False, parent=self) + row.removed.connect(lambda r=row: self._remove_alt_row(r)) + self._my_alt_rows.append(row) + self._my_alts_container.addWidget(row) + self._new_alt_input.clear() + + def _remove_alt_row(self, row: AltRow): + self._my_alt_rows.remove(row) + self._my_alts_container.removeWidget(row) + row.deleteLater() + + # ── Fællesskabets alternativer ──────────────────────────────────────────── + + def _build_community_alts_panel(self) -> QGroupBox: + grp = QGroupBox("Fællesskabets alternativ-danse") + layout = QVBoxLayout(grp) + self._community_alts_container = QVBoxLayout() + layout.addLayout(self._community_alts_container) + layout.addStretch() + lbl = QLabel("Kræver online forbindelse") + lbl.setObjectName("result_count") + layout.addWidget(lbl) + return grp + + # ── Indlæs eksisterende data ────────────────────────────────────────────── + + def _load_data(self): + try: + from local.local_db import get_db, get_alternatives_for_dance + song_id = self._song.get("id") + with get_db() as conn: + dances = conn.execute( + "SELECT id, dance_name, dance_order, level_id FROM song_dances " + "WHERE song_id=? ORDER BY dance_order", + (song_id,) + ).fetchall() + + for d in dances: + self._add_my_dance(d["dance_name"], d["level_id"]) + # Indlæs alternativer for denne dans + alts = get_alternatives_for_dance(d["id"]) + for alt in alts: + if alt["source"] == "local": + self._add_my_alt( + alt["alt_dance_name"], + alt["level_id"], + alt["note"], + ) + else: + # Community-alternativ + row = AltRow( + alt["alt_dance_name"], alt["level_id"], + alt["note"], self._levels, + readonly=True, source="community", + parent=self, + ) + row.copy_to_mine.connect(self._add_my_alt) + self._community_alts_container.addWidget(row) + except Exception as e: + print(f"Tag editor load fejl: {e}") + + # ── Gem ─────────────────────────────────────────────────────────────────── + + def _save(self): + song_id = self._song.get("id") + local_path = self._song.get("local_path", "") + + try: + from local.local_db import get_db, register_dance_name, add_alternative + from local.tag_reader import write_dances, can_write_dances + + # Saml danse fra UI + dances = [(r.get_name(), r.get_level_id()) + for r in self._my_dance_rows if r.get_name()] + + with get_db() as conn: + # Slet eksisterende danse og alternativer + old_dances = conn.execute( + "SELECT id FROM song_dances WHERE song_id=?", (song_id,) + ).fetchall() + for od in old_dances: + conn.execute("DELETE FROM dance_alternatives WHERE song_dance_id=?", (od["id"],)) + conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,)) + + # Indsæt nye danse + dance_ids = [] + for i, (name, level_id) in enumerate(dances, start=1): + cur = conn.execute( + "INSERT INTO song_dances (song_id, dance_name, dance_order, level_id) VALUES (?,?,?,?)", + (song_id, name, i, level_id) + ) + dance_ids.append(cur.lastrowid) + register_dance_name(name) + + # Indsæt alternativer (knyttet til første dans hvis flere) + if dance_ids and self._my_alt_rows: + first_dance_id = dance_ids[0] + for row in self._my_alt_rows: + name = row.get_name() + if name: + add_alternative( + first_dance_id, name, + level_id=row.get_level_id(), + note=row.get_note(), + source="local", + ) + + # Skriv til fil + if local_path and can_write_dances(local_path): + dance_names = [n for n, _ in dances] + ok = write_dances(local_path, dance_names) + if not ok: + QMessageBox.warning(self, "Advarsel", + "Tags gemt i database, men kunne ikke skrives til filen.") + + self.accept() + + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme tags: {e}")