From 3031b7153b1651219abb06f8878f2dc68091512e Mon Sep 17 00:00:00 2001 From: Carsten Kvist Date: Fri, 10 Apr 2026 11:12:49 +0200 Subject: [PATCH] Version 15 --- .../__pycache__/file_watcher.cpython-312.pyc | Bin 12715 -> 13165 bytes .../__pycache__/local_db.cpython-312.pyc | Bin 16687 -> 29040 bytes .../__pycache__/tag_reader.cpython-312.pyc | Bin 13264 -> 16113 bytes linedance-app/local/file_watcher.py | 64 ++- linedance-app/local/local_db.py | 278 ++++++++++- linedance-app/local/tag_reader.py | 80 +++- .../library_manager.cpython-312.pyc | Bin 0 -> 7325 bytes .../__pycache__/library_panel.cpython-312.pyc | Bin 15220 -> 16878 bytes .../__pycache__/main_window.cpython-312.pyc | Bin 42644 -> 57203 bytes .../playlist_panel.cpython-312.pyc | Bin 16025 -> 31148 bytes .../__pycache__/scan_worker.cpython-312.pyc | Bin 2919 -> 3101 bytes .../settings_dialog.cpython-312.pyc | Bin 0 -> 15958 bytes .../ui/__pycache__/tag_editor.cpython-312.pyc | Bin 0 -> 26437 bytes linedance-app/ui/library_manager.py | 119 +++++ linedance-app/ui/library_panel.py | 33 +- linedance-app/ui/main_window.py | 330 +++++++++++-- linedance-app/ui/playlist_panel.py | 441 ++++++++++++++---- linedance-app/ui/scan_worker.py | 18 +- linedance-app/ui/settings_dialog.py | 262 +++++++++++ linedance-app/ui/tag_editor.py | 437 +++++++++++++++++ 20 files changed, 1866 insertions(+), 196 deletions(-) create mode 100644 linedance-app/ui/__pycache__/library_manager.cpython-312.pyc create mode 100644 linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc create mode 100644 linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc create mode 100644 linedance-app/ui/library_manager.py create mode 100644 linedance-app/ui/settings_dialog.py create mode 100644 linedance-app/ui/tag_editor.py diff --git a/linedance-app/local/__pycache__/file_watcher.cpython-312.pyc b/linedance-app/local/__pycache__/file_watcher.cpython-312.pyc index 4761673003dbb36b6131e05e69d407807adb8e15..a8f158b5fe69d964cd8c6c130e3996b64223b37b 100644 GIT binary patch delta 1602 zcmZuxTTB#J7(TN*dl~jNd!c|JI|vk(n^ZJjig*Wv^$Oe5TV!DIwnkFed0YVfS(9&%Fuu3bQRSiOuYl3PKXw?dzO`rv{WLNFP9`zeu zuXZKhkcrvID_8{U2k3L#B=FMLVRZI3LTBKlGHRacAP&%6gOgLSfVpGEi7-YuaSl4b z=wgskuwnurF2gEQqoo^)uIp7du|$is_Z|LWx8X}6rTD)YR1a|z`XVLU2~FHZdUj82 zCqj6iviX0!Swaz3us4HA39j4OS2c)LU@)f$G6u|s5~Q8C{|{Qs)B zn?_1VJ67Fv#H|m2;2~B(miPd@#0KcQyWV{m3UfJ9wiuZ!5Uy3dig85IO8r&`9mEB3 zPU3}uqBrogt?{&-MuJ)O4td9@(XFdeKh=*enNOpBD&+6GfwY3q$sO&ppCklD#3JVU zl!z3beUJ!?QY0*LasI*u&?dv2JP?;6iI~jEkyuQ`N`uy1Q+lMY6et~xoRcC+MT~K} ziQAY#*~4{W6Zo|Dtd0B16=*vgoqa5r4+vpC5#|!J55kHF z`9dbV3)Gkg2vPVbY_gu4!yMpW_GXEXpA-0&!6q$Hxk-CoYkRMuSxWZslH3Bqs&QVA z(ueK}`sU)Zp|6M1r+U+oXu3C+J}J$&#jhZ&_daCGI?g90DcQer_pB|M5HU_-tUrlg z^{RM3fQD;M@6BJOwOd!}PY(A0WUUdoTZrHlwphP%)+~!k)|8a9bU!b}@Cn`3q5WO+ z1{Kg6YMblvknUxJpbNaxo3(?G;Nzm4WzI(=aaFV%q86<^#snaM)0Z|=d%*r1(7J+N z=4n)2>F}b*f9>+s%cE-Q@TVR3J3c<1bNa4zUhPb8eskunnU-|%u?1&G#_9WoZI~FG zXZMU)a<3I>;)o*?$RA-cUf+my*@_rnrmo@3w$Iug9h#x1kAAn~r@iqdGpgUdjHvn~ z^#TPLZq8Xf(l&N#d|xq`YW%T(_}&*Qy6Holz; zR!VNI^EJau@XFqA1QOTBZ;J1Iw$r_ zU3wf?C~5ukk07vGvH;hm2JPp%N9Fo|4mwtG5zk=?Nov8o%M8g^NB=K;gv`-A$ND< Q8`{0b&D2NQSB<~_11Q9pJOBUy delta 1199 zcmZuvO>7%Q6rQnny0+-@ekq8+?6GfqJB~Va$C@P0Y+z2@#Aw{uN%L)=gTsTk#lmpzDjpJfOpR}Le z&imf?-n@CgU)^|D{K4gN0BS$of2@7q{y&AA@I%;0C_vMr(Gn)22bk(a@Pc~y+$+OLM!t8 z|2)rQHZ7441!7Soo9ybO0{Q&#u+uCaJ6FLPQ`Zl7)DJff6fW|bs!$y!!1w!TENCXZcuLqlpEzdt5#-OMVws??IqgxsW(LR6qim%FZ{3{`(iMe^j< z7p8VM(lV-nQkls}oz_519?t2;jj61XRn6k2R3@cgr%FaHBWEu==%$+?5Ka8Og)T3T8M# z2BLk?P1NW)@?-RM#$0>S*8Pfog$jQF4jM_i`}q8U4S0IiXLlRU7Fg>0gG4O|Wv{Qm zmpzRI+s{occY`az;%lp|e*%e@3m3~$=&=;uk-{aZ^TEXCU?~e*;cJ6swi%iMeQTDbJJ)ev&UM%~Y@60XDl>?!>;)+x$)tXpr21RnXJo$r$v?fHD);~Z diff --git a/linedance-app/local/__pycache__/local_db.cpython-312.pyc b/linedance-app/local/__pycache__/local_db.cpython-312.pyc index 04b8fbc1a48661f57c429497544f59742f35be4d..2db9ccec881756ab050e182188c6c5f8dd0d042a 100644 GIT binary patch literal 29040 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 diff --git a/linedance-app/local/__pycache__/tag_reader.cpython-312.pyc b/linedance-app/local/__pycache__/tag_reader.cpython-312.pyc index 45b2725f8ee182bc2eeb4e495018807010924b44..b9d8ca3184fc4b2185b0cc738f1f20e93b0ca266 100644 GIT binary patch delta 4885 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

1Oarx(ed-DfnXOd%YR)T~Cj{wO!@U0hF}&l0 delta 2115 zcmZuyYiv|S6rQ<{?YnrNKXgN40!aE;V#F9E8sSC~W1{}hAEm8`K-6>Y650?Zx!;^QkC`*) zoH_SF-|gMbTMmbnz_@?z$BwrPKXd+U5v~b5R2ASB>t!h_owg>fS^uU&lJG0`!SqsV z9t@o@0c;-AQpL*eJKFe4=T%z6O|Ijf^}t^OpSG70a+FNi`6sURmIq#&({!}{udIaV z_R|HWfn}9s$Xr(h({Pywa71(?dJQbLBVB2UWQ!wB5RRG2mG;g@WdE#X+Hr@$U$1}qYAfkUHq{;xNbCQ`D641CUK`U^K1Y-(y+#L8eRwg^CR>Qbz2x5gA$0I42~u?X+?H*eETS}%*KQC;d%4{3^-twEbc zgv|(Ot&1SjWf9=WQA8h&G7cclCe1|L9)g}ulOgMQ-PTr9_QpTeRFY#obZn*6# z8Y9%=xo3BeiZDN!a1(Fvg7x;a*}!*r@3}L8@9;kTC9_VO1qTmSl|}4iI9L-22_p;b zkxD6PcSWX)$y|yUaz!fSwmUP8^?CJ2Wpl{y*7kOH#8@jzWi7@!_Odoq-wB{tUX~Bmj=HA(ZQc7+jj6;1D;j0HfU8*m z`09W^Z2(tiC_k6~kh=Lt1>sTc=|BlEQ7wk;oGDLd|&?GQ`Z z@njLY$r=iH zle+4s$v4v}U@GiO4hyA)-3nuSuXeS;q}%hwv$U zn!tZk-lt|BpVbh)WA=}V#GL@$EevN#!?G)!DGvK6 zVB+`Lt&~>r6?1|$VLZrqWt~W^0#L*$?qQ$MuqdBDv6ZNYaDyZ+%=ti|OL@;zP^ns@9l8X8SJ^sXz2xa1MoolY$@$9_qPq&$ZK~PEDSQ z)jH+CI9el^5}OxoqUrdzW0-bDNbRwi->52+g6Vz$ 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 0000000000000000000000000000000000000000..bd29d8002860d96a13da9eca8679412dcd23f968 GIT binary patch literal 7325 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 literal 0 HcmV?d00001 diff --git a/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc b/linedance-app/ui/__pycache__/library_panel.cpython-312.pyc index c7041403d19e2b697684f1c19c06a93e87cbde50..d107e3d001309ebd0c1936d7277c10849f97872b 100644 GIT binary patch delta 3014 zcmaJ@e{2)i9lv+6FX!Js&ga;HI2XqWF+UuK1d>1k=@vFvNd|+hA}xdqcL@$o49`v= zsUxVG(nMN;^sS{U(1mVNp>!LqwF&KyNfVl?s$GefOb(tls!Y?SRYNKp9pbNj-#M2C zm}beJ`@Zjw&-=c6@B4lC@8P>IBKyZSo0);%Klu4rs_K3F_f6<7xnOEN$ueq@YPg1| zkzu;{$Hpe5WP<5M3K}9QzAfL6t)7*v~LRPDV6g&Q|^Ih10MS%n`rbU781 z>QW7UU<8dBG;YNLQG#Lx^e8sfpx9Nr;!qoHy`16%2T^f>$*Z{45(p7M<5N7K@hc)= zsp17(F~OWbT`Tu6VSnbZxr#;W$rSxz8#92E|uZbwWS^;eSLtPccJReJcfMIp^}3%V}N^jDM1c7c2* zcpTPc;ky1)r-%4lc2w}%(&2Iy{8260EeOORnaMeq4|KbQ zGV-oq_B(YB$i=rTm*`S1F5Lr`!t`tU$JnBjt)gC!UbebYT?dg3lo2)3_vvsy!db91 zkuTg~@(E;l$6+B&f)6zSZ*38m=E!F?VzHoj6!?=L38G)r<00$7vN>AxtZmD)=E#(* z1hoL$N*2hb*LujgH9UDou-Uvi(O7Z1n8;4?i9nGYiC7HUCQf-^6SwNqEnB9f>tLB} zdJ2vbom+cxnjU?PE)uoVR1CUv`8W0Oub!#xy4?RQO8BZ5UW;f$1(r^YWfx^3je;Mv zvbP-h$P3=##vuztF1-0eN$QY~9#!#B2*!J3@!p#^`Xx0USFzOFui`;zU?>?o(i7{6 z#}X;^2)XZl8->XmC2zZ3-2?KV+^2TOV?9{LF*TWK_U%Q8CujUCEasln;GzS$;x9My zKrfMx{b8;Jq-2IX^e=RZ(sbFZSUX{!=iNEJBFk4y2j}=IZB&-!%Vq-YbNn`KSeoTa zb9^w%2d5iymCf18<~e@TJa5nOzAW#1qjZiBW&T*Yz@n?<*JT~(bjBKZi4DeJuDl_6 zIB^^gf`|tI!bP}($`ceONk(c$lbL@?rO2(h)KM3<;BtoegYD>bav*pXO_96726TZ` zR-Sgf3_eNPwj@kn8^+{Ul_7LFbEonbESI2~Gvv+cI+P@zRQI5tWNKI05SUwQdXeKI zn8QVRHWJOFUVNE+5Z;E~ClA8MeV4(KECP_Eg9YA8Fc=ldt+0r$XD+S&YY}+97}KD8XtZa9(knTWhAExB=3>Y+O_B=d8anC_NSDvxKeGBqwAw#0jFq` z@lafqF)ri{3VeaWMe?88?dSv27WKodUC}jujoJh@Y4O)qm|&W`1wOyb+=zaUMW>0Q zK8S|Ndccp!ckB1D582H1dOM=E#K93a_Pey&UTS?~Jm>^Cq*~vB26@sUIyk*3#!P9G z7O|2WGlOFZ*)59XX1#}WZ}5=M>rG2FK4wW<(%g|6$bDs+99!olQ=I2jr^PwKfT4KN z!1M>S$}8AxnvK_kZgGzWnU{is%YwKOW`qgzWPHQ=eM9t~S-5yclD?xVu|B98MIKD9 zOUZr7NF*}K?@L>_JkJ$0J8-2})BFmtM#PT7ssV?$~(r7C$L z2{S_h#^PTo!&dEB=ZlUeU>dSv0ahKfH0TXHiZKi;_Gpy)q;VCuk3sAX1cohY6!45zoxit8r$dET1i{aM~W$FEql z%!IbiifvDto|}4ZdNmC)&5MDYSd$fNX2Pwr;ugX+KGQUB6V9FZy{&xS>daX~S!*a~ z4QH+4E1^GFYyY2(>~EAxY5Bd(oM#lIuyJMpjUmLaaQRL$-Uul9_?$yFwW~{IL$0v28C;4CTIwSjh&c<3BoAv*{OBKkq)g#;`W#vPxOTm4wAM? zkAq(4|7rqw4V`Bu5s@pSC-*RTGidNQC1CTYTidK8<&z4f{E%;n%L6@%yk#`HNWV)S zRO68+I&q{Wo;W1OlUqP2M0^f3NiV>IQ_OWwuC6^>*Pg4}k*(V?Tle%S23MV=({g$D zP%Kht=p&1)7~cj{o;w~JR1(MY4&92?fyA(?;GJN_4K#YOu@>AFZN*wYJE$L+Uho9@$=!6`}kAk#nbn{T_SuI&B!vsa(JwtvpGd4kh=ejv*Sa(s1`uby4?^c=qv zlxHoImRD?~r}0bfLkPwTU%UL zD6{U~L|jqvT{7ErsICmRP{vjYTPWB8@&+Y#D5X^({vB1mM1d9qzD4#o2U=gD;yDU$ zQ6LmBg$@d|wc}|D9ED*D&r_i92=|dp^Qvt(KzuOH-1fnpH|wi8)v+Xf8>9=?Q=PX< zL(_X_OIObrN|uTD4ZP8R&+`prth%?Cm^SrS_>9qe4F;q4ODktwi|Gd@{yIQsxCGM= d75pJNyXgn;RUX>B)wQDttv%OxpP^69^}nIPCTaix delta 1911 zcmZuweQZ-z6z^?cU)OehukCwlyDz%69a~vb+wrk4=>`Hq!UjkpafyMl`|3Kojox<5 z&59s{5I_Wui6FWxYE&FxNTV1Hi64ZJ7{UyHfW?0_(L_V|iWn0A(0kuop%`D%-#zzt z&iS2t&b__08~wRm_}*r-=;-(PKi85+>(2@Y&FB)GFfV--yY}f42%|}JUm4a<>iW#s zK+%HTm{Y9SsFWu-%ugEoY}iC?JuaK%`UGrNM1`xQf|RjP#*VEt%Yn-&I`+iD7iMzx(PCmrV{G2)N5xvyW)`c>*dJ5WJWIeK z22mT;ssX=TiCUN{^vOI2E&MIbgC0kPSbB8h zppD+bLS%$3RvEgz7EejKTugt3&+%}#;{Q@5kuOHkNfa;jrrqkxE}iSM^j5t^jy50D zCRtIE&qy{&;*#Nxf| z+SiVD<#+k2kW&p|k0q$rLJq)b{|a;n?)l$G}mpio!0MbmwoZYC<$CbyC}8DAeCR#M^3L-B0?fRgz*9}WF#Kxe=mS&3%g z`A97~4KGL1z7JJ>@)2DM2$K@goto%L5TYUU6?8@ep-)%=lV53zM_M95iDX!ou|K86 z3DNqX1UbgwI2?$suKtt-nBo|($tpuZvo@$!=05|sqRk7{a_sTf&3)Zk(RV2|AwS*; zQHJTIp2@Fmc~*~h!~2VTXcR6|{04qs^qhVcOQXoGI;W?(QexdV9chk>+L2 z>%h>lrb=?Z6+04}^>tj8I`^5bYuo9jt5P$Z>evLGopms~^ag){)w=>`W0l?lKb9QE zBVz^AKs*!AW(k>P=is#QUV-0Y^SlYlOn4rjAiampiIEhY;x+Jh^*2_LCz&Yw(~}qj zfkJ`9$^NX`8M(yB*Wg^&yte(!e4W7w28S7>8T2qV9<9P0LqZ^LO?v9l13LS2}uVbqk`Y+in(%Jw3 diff --git a/linedance-app/ui/__pycache__/main_window.cpython-312.pyc b/linedance-app/ui/__pycache__/main_window.cpython-312.pyc index 6018ec41564ab4b41ea28c62952e175e537b2b1b..5e2c910559bccd8ab9bcd5da76aca07fdc7beed8 100644 GIT binary patch delta 21076 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 delta 5871 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 JDbnhC{|6oZ3I+fG diff --git a/linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc b/linedance-app/ui/__pycache__/scan_worker.cpython-312.pyc index e2fd36471181a16176a46f8069dd3e346311da5c..16196ef1d3efdc7998ae2192d091a054a783eb3f 100644 GIT binary patch delta 1429 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 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}")