Version 15
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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,))
|
||||
|
||||
@@ -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):
|
||||
|
||||
BIN
linedance-app/ui/__pycache__/library_manager.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/library_manager.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/settings_dialog.cpython-312.pyc
Normal file
Binary file not shown.
BIN
linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc
Normal file
BIN
linedance-app/ui/__pycache__/tag_editor.cpython-312.pyc
Normal file
Binary file not shown.
119
linedance-app/ui/library_manager.py
Normal file
119
linedance-app/ui/library_manager.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
library_manager.py — Dialog til at se og fjerne musikbiblioteker.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QListWidget, QListWidgetItem, QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
|
||||
|
||||
class LibraryManagerDialog(QDialog):
|
||||
library_removed = pyqtSignal(int) # library_id
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Administrer musikbiblioteker")
|
||||
self.setMinimumWidth(500)
|
||||
self.setMinimumHeight(320)
|
||||
self._build_ui()
|
||||
self._load()
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(10)
|
||||
|
||||
lbl = QLabel("Aktive musikbiblioteker:")
|
||||
lbl.setObjectName("track_meta")
|
||||
layout.addWidget(lbl)
|
||||
|
||||
self._list = QListWidget()
|
||||
layout.addWidget(self._list)
|
||||
|
||||
note = QLabel(
|
||||
"Når du fjerner et bibliotek, slettes det fra overvågningen.\n"
|
||||
"Sangene forbliver i databasen men markeres som manglende (⚠)."
|
||||
)
|
||||
note.setObjectName("result_count")
|
||||
note.setWordWrap(True)
|
||||
layout.addWidget(note)
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
btn_add = QPushButton("+ Tilføj mappe")
|
||||
btn_add.clicked.connect(self._add_folder)
|
||||
btn_row.addWidget(btn_add)
|
||||
|
||||
btn_remove = QPushButton("✕ Fjern valgt")
|
||||
btn_remove.clicked.connect(self._remove_selected)
|
||||
btn_row.addWidget(btn_remove)
|
||||
|
||||
btn_row.addStretch()
|
||||
btn_close = QPushButton("Luk")
|
||||
btn_close.clicked.connect(self.accept)
|
||||
btn_row.addWidget(btn_close)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
def _load(self):
|
||||
self._list.clear()
|
||||
try:
|
||||
from local.local_db import get_libraries, get_db
|
||||
libs = get_libraries(active_only=True) # kun aktive
|
||||
for lib in libs:
|
||||
from pathlib import Path
|
||||
path = lib["path"]
|
||||
exists = Path(path).exists()
|
||||
last_scan = lib["last_full_scan"] or "aldrig"
|
||||
if isinstance(last_scan, str) and len(last_scan) > 10:
|
||||
last_scan = last_scan[:10]
|
||||
with get_db() as conn:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0",
|
||||
(lib["id"],)
|
||||
).fetchone()[0]
|
||||
exist_icon = "" if exists else " ⚠ mappe ikke fundet"
|
||||
label = f"{path}{exist_icon}\n {count} sange · senest scannet: {last_scan}"
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(Qt.ItemDataRole.UserRole, dict(lib))
|
||||
if not exists:
|
||||
from PyQt6.QtGui import QColor
|
||||
item.setForeground(QColor("#5a6070"))
|
||||
self._list.addItem(item)
|
||||
except Exception as e:
|
||||
print(f"Library manager load fejl: {e}")
|
||||
|
||||
def _add_folder(self):
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
||||
if folder:
|
||||
mw = self.parent()
|
||||
if hasattr(mw, "add_library_path"):
|
||||
mw.add_library_path(folder)
|
||||
self._load()
|
||||
|
||||
def _remove_selected(self):
|
||||
item = self._list.currentItem()
|
||||
if not item:
|
||||
return
|
||||
lib = item.data(Qt.ItemDataRole.UserRole)
|
||||
reply = QMessageBox.question(
|
||||
self, "Fjern bibliotek",
|
||||
f"Fjern overvågningen af:\n{lib['path']}\n\n"
|
||||
"Sange i biblioteket forbliver i databasen men markeres som manglende.",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
try:
|
||||
mw = self.parent()
|
||||
if hasattr(mw, "_watcher") and mw._watcher:
|
||||
mw._watcher.remove_library(lib["id"])
|
||||
else:
|
||||
from local.local_db import remove_library
|
||||
remove_library(lib["id"])
|
||||
self.library_removed.emit(lib["id"])
|
||||
if hasattr(mw, "_reload_library"):
|
||||
mw._reload_library()
|
||||
self._load()
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}")
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
262
linedance-app/ui/settings_dialog.py
Normal file
262
linedance-app/ui/settings_dialog.py
Normal file
@@ -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
|
||||
437
linedance-app/ui/tag_editor.py
Normal file
437
linedance-app/ui/tag_editor.py
Normal file
@@ -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}")
|
||||
Reference in New Issue
Block a user