Version 1

This commit is contained in:
2026-04-10 23:59:23 +02:00
parent 9d7adf42c1
commit d55859c593
17 changed files with 743 additions and 490 deletions

View File

@@ -0,0 +1,33 @@
"""
app_logger.py — Central logging til fil i stedet for konsol.
P<EFBFBD> Windows uden konsol skrives alt til ~/.linedance/app.log
"""
import logging
import sys
from pathlib import Path
LOG_PATH = Path.home() / ".linedance" / "app.log"
def setup_logging():
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
handlers = [logging.FileHandler(LOG_PATH, encoding="utf-8")]
# Kun tilføj konsol-handler hvis vi kører med konsol (development)
if sys.stdout and hasattr(sys.stdout, 'write'):
try:
sys.stdout.write("") # test om konsol virker
handlers.append(logging.StreamHandler(sys.stdout))
except Exception:
pass
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
handlers=handlers,
force=True,
)
logger = logging.getLogger("linedance")

View File

@@ -64,7 +64,7 @@ exe = EXE(
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
upx=False, # UPX kan give problemer med PyQt6 DLL-filer upx=False, # UPX kan give problemer med PyQt6 DLL-filer
console=True, # Vis fejlbeskeder console=False, # Ingen konsol-vindue
disable_windowed_traceback=False, disable_windowed_traceback=False,
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,

View File

@@ -17,36 +17,51 @@ from pathlib import Path
DB_PATH = Path.home() / ".linedance" / "local.db" DB_PATH = Path.home() / ".linedance" / "local.db"
_local = threading.local() _local = threading.local()
_global_conn: sqlite3.Connection | None = None
def _get_conn() -> sqlite3.Connection: def _get_conn() -> sqlite3.Connection:
"""Returnerer en thread-lokal forbindelse.""" """Returnerer en global forbindelse i autocommit mode."""
if not hasattr(_local, "conn") or _local.conn is None: global _global_conn
if _global_conn is None:
DB_PATH.parent.mkdir(parents=True, exist_ok=True) DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(DB_PATH, check_same_thread=False) _global_conn = sqlite3.connect(str(DB_PATH), check_same_thread=False,
isolation_level=None) # autocommit
_global_conn.row_factory = sqlite3.Row
_global_conn.execute("PRAGMA journal_mode=WAL")
_global_conn.execute("PRAGMA foreign_keys=ON")
return _global_conn
def new_conn() -> sqlite3.Connection:
"""Åbn en frisk forbindelse til brug i tag_editor og dialogs."""
conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL") # bedre concurrent adgang conn.execute("PRAGMA foreign_keys=OFF") # FK checker forhindrer level_id gem
conn.execute("PRAGMA foreign_keys=ON") return conn
_local.conn = conn
return _local.conn
@contextmanager @contextmanager
def get_db(): def get_db():
"""Context manager der bruger app-forbindelsen i autocommit mode.
Hver statement committer med det samme — ingen eksplicit transaktion."""
conn = _get_conn() conn = _get_conn()
try: try:
yield conn yield conn
conn.commit()
except Exception: except Exception:
conn.rollback()
raise raise
def get_db_raw() -> sqlite3.Connection:
return _get_conn()
def init_db(): def init_db():
"""Opret alle tabeller hvis de ikke findes.""" """Opret alle tabeller hvis de ikke findes."""
conn = _get_conn() conn = _get_conn()
# Brug executescript direkte (ikke via context manager) da det auto-committer # executescript committer automatisk og nulstiller isolation_level
# Kør det direkte på den underliggende connection
conn.executescript(""" conn.executescript("""
CREATE TABLE IF NOT EXISTS libraries ( CREATE TABLE IF NOT EXISTS libraries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -148,23 +163,20 @@ def init_db():
CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id); CREATE INDEX IF NOT EXISTS idx_song_dances ON song_dances(song_id);
""") """)
# Kør migrations for ældre databaser (each separately) # executescript slår foreign_keys fra — genaktiver
migrations = [ conn.execute("PRAGMA foreign_keys=ON")
"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)
conn.commit()
except Exception:
pass
# Seed standard-niveauer — KUN hvis tabellen er tom # Tilføj db_version tabel hvis den ikke findes
conn.execute("""
CREATE TABLE IF NOT EXISTS db_version (
version INTEGER PRIMARY KEY
)
""")
# Kør versionsbaserede migrationer
_run_versioned_migrations(conn)
# Seed standard-niveauer
count = conn.execute("SELECT COUNT(*) FROM dance_levels").fetchone()[0] count = conn.execute("SELECT COUNT(*) FROM dance_levels").fetchone()[0]
if count == 0: if count == 0:
defaults = [ defaults = [
@@ -174,14 +186,49 @@ def init_db():
(4, "Erfaren", "For dedikerede dansere"), (4, "Erfaren", "For dedikerede dansere"),
(5, "Ekspert", "Konkurrenceniveau"), (5, "Ekspert", "Konkurrenceniveau"),
] ]
conn.executemany( for row in defaults:
conn.execute(
"INSERT OR IGNORE INTO dance_levels (sort_order, name, description) VALUES (?,?,?)", "INSERT OR IGNORE INTO dance_levels (sort_order, name, description) VALUES (?,?,?)",
defaults row
) )
conn.commit()
print(f"Dans-niveauer seedet: {len(defaults)} niveauer")
else: # ── Versionsbaserede migrationer ──────────────────────────────────────────────
print(f"Dans-niveauer: {count} niveauer i databasen") # Tilføj aldrig gamle — tilføj kun nye versioner nederst.
MIGRATIONS: dict[int, list[str]] = {
1: [
"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 ''",
],
# Eksempel på fremtidig migration:
# 2: ["ALTER TABLE songs ADD COLUMN mbid TEXT"],
}
def _run_versioned_migrations(conn):
"""Kør kun migrationer der ikke allerede er kørt vha. db_version tabel."""
row = conn.execute("SELECT version FROM db_version").fetchone()
current_version = row["version"] if row else 0
for version in sorted(MIGRATIONS.keys()):
if version <= current_version:
continue
for sql in MIGRATIONS[version]:
try:
conn.execute(sql)
except Exception:
pass # kolonnen eksisterer allerede
conn.execute(
"INSERT OR REPLACE INTO db_version (version) VALUES (?)", (version,)
)
@@ -282,30 +329,49 @@ def upsert_song(song_data: dict) -> str:
extra_tags_json, extra_tags_json,
)) ))
# Opdater danse hvis de er med i data # Opdater danse hvis de er med i data — bevar level_id og alternativer
if "dances" in song_data: if "dances" in song_data:
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,)) file_dances = []
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, 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"]: for dance in song_data["dances"]:
nm = dance.get("name", dance) if isinstance(dance, dict) else dance if isinstance(dance, dict):
if nm: file_dances.append(dance.get("name", ""))
_reg(nm) else:
except Exception: file_dances.append(dance)
pass file_dances = [d for d in file_dances if d]
# Hent eksisterende danse med level_id og alternativer
existing = conn.execute(
"SELECT id, dance_name, dance_order, level_id FROM song_dances "
"WHERE song_id=? ORDER BY dance_order",
(song_id,)
).fetchall()
existing_map = {r["dance_name"].lower(): r for r in existing}
# Slet danse der ikke længere er i filen
file_lower = [d.lower() for d in file_dances]
for row in existing:
if row["dance_name"].lower() not in file_lower:
conn.execute(
"DELETE FROM dance_alternatives WHERE song_dance_id=?", (row["id"],)
)
conn.execute("DELETE FROM song_dances WHERE id=?", (row["id"],))
# Tilføj eller opdater danse fra filen
for i, name in enumerate(file_dances, start=1):
ex = existing_map.get(name.lower())
if ex:
# Bevar level_id — opdater kun dance_order
conn.execute(
"UPDATE song_dances SET dance_order=? WHERE id=?",
(i, ex["id"])
)
else:
# Ny dans — ingen level_id endnu
conn.execute(
"INSERT INTO song_dances (song_id, dance_name, dance_order, level_id) "
"VALUES (?,?,?,NULL)",
(song_id, name, i)
)
return song_id return song_id
@@ -465,15 +531,41 @@ def clear_event_state():
# ── Dans-navne ordbog ───────────────────────────────────────────────────────── # ── Dans-navne ordbog ─────────────────────────────────────────────────────────
def get_dance_name_suggestions(prefix: str, limit: int = 20) -> list[str]: def get_dance_name_suggestions(prefix: str, limit: int = 20) -> list[str]:
"""Returnerer danse-navne der starter med prefix, sorteret efter popularitet.""" """Returnerer dans-navne der starter med prefix fra alle kendte sources,
sorteret efter popularitet. Inkluderer navne fra song_dances og dance_alternatives."""
with get_db() as conn: with get_db() as conn:
# Hent fra dance_names ordbog (primær kilde)
rows = conn.execute(""" rows = conn.execute("""
SELECT name FROM dance_names SELECT name, use_count FROM dance_names
WHERE name LIKE ? COLLATE NOCASE WHERE name LIKE ? COLLATE NOCASE
ORDER BY use_count DESC, name ORDER BY use_count DESC, name
LIMIT ? LIMIT ?
""", (f"{prefix}%", limit)).fetchall() """, (f"{prefix}%", limit)).fetchall()
return [r["name"] for r in rows] names = {r["name"]: r["use_count"] for r in rows}
# Supplér med navne direkte fra song_dances der ikke er i ordbogen
extra = conn.execute("""
SELECT DISTINCT dance_name as name FROM song_dances
WHERE dance_name LIKE ? COLLATE NOCASE
LIMIT ?
""", (f"{prefix}%", limit)).fetchall()
for r in extra:
if r["name"] not in names:
names[r["name"]] = 0
# Supplér med alternativ-danse
extra2 = conn.execute("""
SELECT DISTINCT alt_dance_name as name FROM dance_alternatives
WHERE alt_dance_name LIKE ? COLLATE NOCASE
LIMIT ?
""", (f"{prefix}%", limit)).fetchall()
for r in extra2:
if r["name"] not in names:
names[r["name"]] = 0
# Sorter: kendte navne med høj use_count først, derefter alfabetisk
return sorted(names.keys(),
key=lambda n: (-names[n], n.lower()))[:limit]
def register_dance_name(name: str, source: str = "local"): def register_dance_name(name: str, source: str = "local"):

View File

@@ -11,6 +11,7 @@ Sender signals til GUI:
from PyQt6.QtCore import QObject, pyqtSignal, QTimer from PyQt6.QtCore import QObject, pyqtSignal, QTimer
import random import random
import math
try: try:
import vlc import vlc
@@ -33,6 +34,7 @@ class Player(QObject):
self._duration: int = 0 self._duration: int = 0
self._demo_mode = False self._demo_mode = False
self._demo_stop_sec = 10 self._demo_stop_sec = 10
self._demo_fade_sec = 5
self._demo_fading = False self._demo_fading = False
self._volume = 78 self._volume = 78
@@ -78,10 +80,15 @@ class Player(QObject):
self._poll_timer.start() self._poll_timer.start()
self.state_changed.emit("playing") self.state_changed.emit("playing")
def play_demo(self, stop_at_sec: int = 10): def play_demo(self, stop_at_sec: int = 10, fade_sec: int = 5):
"""Afspil fra start og stop automatisk ved stop_at_sec med 2 sek fade-out.""" """
Afspil fra start, fade ud over fade_sec sekunder og stop.
Total afspilningstid = stop_at_sec + fade_sec.
fade_sec=0 giver ingen fade.
"""
self._demo_mode = True self._demo_mode = True
self._demo_stop_sec = stop_at_sec self._demo_stop_sec = stop_at_sec + fade_sec # total tid inkl. fade
self._demo_fade_sec = fade_sec
self._demo_fading = False self._demo_fading = False
if VLC_AVAILABLE and self._media_player: if VLC_AVAILABLE and self._media_player:
self._media_player.set_time(0) self._media_player.set_time(0)
@@ -156,18 +163,16 @@ class Player(QObject):
self.state_changed.emit("demo_ended") self.state_changed.emit("demo_ended")
return return
# Demo fade-out — de sidste 2 sekunder # Demo fade-out — de sidste _demo_fade_sec sekunder (0 = ingen fade)
FADE_SEC = 2.0 if self._demo_mode and VLC_AVAILABLE and self._media_player and self._demo_fade_sec > 0:
if self._demo_mode and VLC_AVAILABLE and self._media_player:
secs_left = self._demo_stop_sec - cur secs_left = self._demo_stop_sec - cur
if secs_left <= FADE_SEC and secs_left > 0: if secs_left <= self._demo_fade_sec and secs_left > 0:
# Fade fra fuld volumen til 0 over FADE_SEC sekunder fade_fraction = secs_left / self._demo_fade_sec # 1.0 → 0.0
fade_fraction = secs_left / FADE_SEC # 1.0 → 0.0 log_fraction = math.log10(1 + fade_fraction * 9) / math.log10(10)
faded_vol = int(self._volume * fade_fraction) faded_vol = int(self._volume * log_fraction)
self._media_player.audio_set_volume(max(0, faded_vol)) self._media_player.audio_set_volume(max(0, faded_vol))
self._demo_fading = True self._demo_fading = True
elif not self._demo_fading: elif not self._demo_fading:
# Ikke i fade-zone endnu — sørg for fuld volumen
self._media_player.audio_set_volume(self._volume) self._media_player.audio_set_volume(self._volume)
# VU-meter: brug VLC's audio-amplitude hvis tilgængelig, ellers simulér # VU-meter: brug VLC's audio-amplitude hvis tilgængelig, ellers simulér

View File

@@ -51,6 +51,7 @@ class LibraryPanel(QWidget):
super().__init__(parent) super().__init__(parent)
self._all_songs: list[dict] = [] self._all_songs: list[dict] = []
self._filtered: list[dict] = [] self._filtered: list[dict] = []
self._bpm_scan_running = False
self._search_timer = QTimer(self) self._search_timer = QTimer(self)
self._search_timer.setSingleShot(True) self._search_timer.setSingleShot(True)
self._search_timer.setInterval(150) self._search_timer.setInterval(150)
@@ -70,6 +71,12 @@ class LibraryPanel(QWidget):
header.addWidget(lbl) header.addWidget(lbl)
header.addStretch() header.addStretch()
self._btn_bpm_scan = QPushButton("♩ BPM alle")
self._btn_bpm_scan.setFixedHeight(24)
self._btn_bpm_scan.setToolTip("Analysér BPM på alle sange uden BPM (kører i baggrunden)")
self._btn_bpm_scan.clicked.connect(self._start_bulk_bpm_scan)
header.addWidget(self._btn_bpm_scan)
btn_manage = QPushButton("⚙ Mapper") btn_manage = QPushButton("⚙ Mapper")
btn_manage.setFixedHeight(24) btn_manage.setFixedHeight(24)
btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker") btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker")
@@ -172,7 +179,6 @@ class LibraryPanel(QWidget):
dance_levels = song.get("dance_levels", []) dance_levels = song.get("dance_levels", [])
missing = song.get("file_missing", False) missing = song.get("file_missing", False)
# Byg dans-streng med niveau hvis tilgængeligt
dance_parts = [] dance_parts = []
for i, d in enumerate(dances): for i, d in enumerate(dances):
lvl = dance_levels[i] if i < len(dance_levels) else "" lvl = dance_levels[i] if i < len(dance_levels) else ""
@@ -183,13 +189,97 @@ class LibraryPanel(QWidget):
bpm = song.get("bpm", 0) bpm = song.get("bpm", 0)
bpm_str = f"{bpm} BPM" if bpm else "? BPM" bpm_str = f"{bpm} BPM" if bpm else "? BPM"
line2 = f" {song.get('artist','')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}" line2 = f" {song.get('artist','')} · {bpm_str} · {song.get('file_format','').upper()}{dance_str}"
item = QListWidgetItem(f"{line1}\n{line2}")
row_widget = QWidget()
row_widget.setStyleSheet("background: transparent;")
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(2, 2, 2, 2)
row_layout.setSpacing(8)
lbl = QLabel(f"{line1}\n{line2}")
lbl.setWordWrap(False)
row_layout.addWidget(lbl, stretch=1)
btn_danse = QPushButton("Danse")
btn_danse.setFixedHeight(30)
btn_danse.setFixedWidth(70)
btn_danse.setToolTip("Rediger dans-tags")
btn_danse.setStyleSheet(
"QPushButton { background: #e8a020; color: #111; border-radius: 4px; "
"font-weight: bold; font-size: 12px; border: none; }"
"QPushButton:hover { background: #f0b030; }"
)
btn_danse.clicked.connect(lambda _, s=song: self.edit_tags_requested.emit(s))
row_layout.addWidget(btn_danse)
item = QListWidgetItem()
item.setData(Qt.ItemDataRole.UserRole, song) item.setData(Qt.ItemDataRole.UserRole, song)
if missing: row_widget.adjustSize()
item.setForeground(QColor("#5a6070")) hint = row_widget.sizeHint()
elif q and any(q in d.lower() for d in dances): hint.setHeight(max(hint.height(), 52))
item.setForeground(QColor("#e8a020")) item.setSizeHint(hint)
self._list.addItem(item) self._list.addItem(item)
self._list.setItemWidget(item, row_widget)
def _start_bulk_bpm_scan(self):
"""Start BPM-analyse på alle sange uden BPM i baggrundstråd med lav prioritet."""
if self._bpm_scan_running:
return
songs_without_bpm = [s for s in self._all_songs
if not s.get("bpm") and not s.get("file_missing")]
if not songs_without_bpm:
self._btn_bpm_scan.setText("♩ Alle har BPM")
return
self._bpm_scan_running = True
self._btn_bpm_scan.setText(f"♩ Scanner 0/{len(songs_without_bpm)}...")
self._btn_bpm_scan.setEnabled(False)
from PyQt6.QtCore import QThread, pyqtSignal as _sig
class BulkBpmWorker(QThread):
progress = _sig(int, int, str) # done, total, title
finished = _sig()
def __init__(self, songs):
super().__init__()
self._songs = songs
def run(self):
from local.tag_reader import analyze_and_save_bpm
total = len(self._songs)
for i, song in enumerate(self._songs, start=1):
if self.isInterruptionRequested():
break
try:
bpm = analyze_and_save_bpm(song["local_path"], song["id"])
if bpm:
song["bpm"] = int(round(bpm))
except Exception:
pass
self.progress.emit(i, total, song.get("title", ""))
self.finished.emit()
self._bulk_bpm_worker = BulkBpmWorker(songs_without_bpm)
def on_progress(done, total, title):
self._btn_bpm_scan.setText(f"{done}/{total}...")
# Opdater sangen i listen
for s in self._all_songs:
if s.get("title") == title and s.get("bpm"):
break
self._do_search()
def on_finished():
self._bpm_scan_running = False
self._btn_bpm_scan.setEnabled(True)
self._btn_bpm_scan.setText("♩ BPM alle")
self._do_search()
self._bulk_bpm_worker.progress.connect(on_progress)
self._bulk_bpm_worker.finished.connect(on_finished)
self._bulk_bpm_worker.start()
self._bulk_bpm_worker.setPriority(QThread.Priority.LowestPriority)
# ── Handlinger ──────────────────────────────────────────────────────────── # ── Handlinger ────────────────────────────────────────────────────────────

View File

@@ -26,7 +26,8 @@ class ProgressBar(QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._fraction = 0.0 self._fraction = 0.0
self._demo_fraction = 0.0 self._demo_fraction = 0.0 # hvor musikken stopper (blå)
self._demo_fade_fraction = 0.0 # hvor fade slutter (grå)
self.setFixedHeight(10) self.setFixedHeight(10)
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -34,8 +35,9 @@ class ProgressBar(QWidget):
self._fraction = max(0.0, min(1.0, f)) self._fraction = max(0.0, min(1.0, f))
self.update() self.update()
def set_demo_marker(self, f: float): def set_demo_marker(self, demo_f: float, fade_f: float = 0.0):
self._demo_fraction = max(0.0, min(1.0, f)) self._demo_fraction = max(0.0, min(1.0, demo_f))
self._demo_fade_fraction = max(0.0, min(1.0, fade_f))
self.update() self.update()
def paintEvent(self, event): def paintEvent(self, event):
@@ -46,6 +48,11 @@ class ProgressBar(QWidget):
fill_w = int(w * self._fraction) fill_w = int(w * self._fraction)
if fill_w > 0: if fill_w > 0:
p.fillRect(0, 0, fill_w, h, QColor("#e8a020")) p.fillRect(0, 0, fill_w, h, QColor("#e8a020"))
# Fade-slut markør (grå) — vises bag demo-markøren
if self._demo_fade_fraction > 0:
fx = int(w * self._demo_fade_fraction)
p.fillRect(fx - 1, 0, 2, h, QColor("#6a7080"))
# Demo-stop markør (blå)
if self._demo_fraction > 0: if self._demo_fraction > 0:
mx = int(w * self._demo_fraction) mx = int(w * self._demo_fraction)
p.fillRect(mx - 1, 0, 2, h, QColor("#3b8fd4")) p.fillRect(mx - 1, 0, 2, h, QColor("#3b8fd4"))
@@ -81,6 +88,7 @@ class MainWindow(QMainWindow):
self._settings = load_settings() self._settings = load_settings()
self._dark_theme = self._settings.get("dark_theme", True) self._dark_theme = self._settings.get("dark_theme", True)
self._demo_seconds = self._settings.get("demo_seconds", 10) self._demo_seconds = self._settings.get("demo_seconds", 10)
self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5)
self._connect_player_signals() self._connect_player_signals()
self._build_menu() self._build_menu()
@@ -306,12 +314,12 @@ class MainWindow(QMainWindow):
self._vol_slider = QSlider(Qt.Orientation.Horizontal) self._vol_slider = QSlider(Qt.Orientation.Horizontal)
self._vol_slider.setRange(0, 100) self._vol_slider.setRange(0, 100)
self._vol_slider.setValue(78) self._vol_slider.setValue(self._settings.get("volume", 78))
self._vol_slider.setFixedWidth(100) self._vol_slider.setFixedWidth(100)
self._vol_slider.valueChanged.connect(self._on_volume) self._vol_slider.valueChanged.connect(self._on_volume)
layout.addWidget(self._vol_slider) layout.addWidget(self._vol_slider)
self._lbl_vol = QLabel("78") self._lbl_vol = QLabel(str(self._settings.get("volume", 78)))
self._lbl_vol.setObjectName("vol_val") self._lbl_vol.setObjectName("vol_val")
layout.addWidget(self._lbl_vol) layout.addWidget(self._lbl_vol)
@@ -401,7 +409,7 @@ class MainWindow(QMainWindow):
except Exception as e: except Exception as e:
self._set_status(f"DB fejl: {e}") self._set_status(f"DB fejl: {e}")
print(f"DB init fejl: {e}") pass
def start_scan(self): def start_scan(self):
"""Start fuld scanning af alle biblioteker i baggrundstråd.""" """Start fuld scanning af alle biblioteker i baggrundstråd."""
@@ -463,7 +471,7 @@ class MainWindow(QMainWindow):
count = len(songs) count = len(songs)
self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000) self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000)
except Exception as e: except Exception as e:
print(f"Bibliotek reload fejl: {e}") pass
def add_library_path(self, path: str): def add_library_path(self, path: str):
try: try:
@@ -483,6 +491,7 @@ class MainWindow(QMainWindow):
if dialog.exec(): if dialog.exec():
self._settings = dialog.get_values() self._settings = dialog.get_values()
self._demo_seconds = self._settings.get("demo_seconds", 10) self._demo_seconds = self._settings.get("demo_seconds", 10)
self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5)
# Opdater tema hvis ændret # Opdater tema hvis ændret
new_dark = self._settings.get("dark_theme", True) new_dark = self._settings.get("dark_theme", True)
if new_dark != self._dark_theme: if new_dark != self._dark_theme:
@@ -498,7 +507,7 @@ class MainWindow(QMainWindow):
if hasattr(self, "_current_song") and self._current_song: if hasattr(self, "_current_song") and self._current_song:
dur = self._current_song.get("duration_sec", 0) dur = self._current_song.get("duration_sec", 0)
if dur > 0: if dur > 0:
self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0)) self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0), min((self._demo_seconds + self._demo_fade_seconds) / dur, 1.0))
self._set_status("Indstillinger gemt", 2000) self._set_status("Indstillinger gemt", 2000)
def _auto_login(self): def _auto_login(self):
@@ -562,7 +571,7 @@ class MainWindow(QMainWindow):
self._set_status(f"Synkroniseret {len(levels)} niveauer og {len(names)} dans-navne", 4000) self._set_status(f"Synkroniseret {len(levels)} niveauer og {len(names)} dans-navne", 4000)
except Exception as e: except Exception as e:
print(f"Dans-sync fejl: {e}") pass
def _go_offline(self): def _go_offline(self):
self._api_url = self._api_token = self._api_username = None self._api_url = self._api_token = self._api_username = None
@@ -744,7 +753,7 @@ class MainWindow(QMainWindow):
) )
if dur > 0: if dur > 0:
self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0)) self._progress.set_demo_marker(min(self._demo_seconds / dur, 1.0), min((self._demo_seconds + self._demo_fade_seconds) / dur, 1.0))
self._set_status(f"Indlæst: {song.get('title','')}", 3000) self._set_status(f"Indlæst: {song.get('title','')}", 3000)
@@ -787,7 +796,10 @@ class MainWindow(QMainWindow):
else: else:
self._demo_active = True self._demo_active = True
self._btn_demo.setChecked(True) self._btn_demo.setChecked(True)
self._player.play_demo(stop_at_sec=self._demo_seconds) self._player.play_demo(
stop_at_sec=self._demo_seconds,
fade_sec=self._demo_fade_seconds,
)
self._btn_play.setText("") self._btn_play.setText("")
def _prev_song(self): def _prev_song(self):
@@ -853,33 +865,34 @@ class MainWindow(QMainWindow):
self._load_song(next_song) self._load_song(next_song)
self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte") self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte")
else: else:
# Danseliste afsluttet — nulstil liste-markering og synkroniser
self._current_idx = -1
self._playlist_panel._current_idx = -1
self._playlist_panel._song_ended = False
self._playlist_panel._refresh()
self._sync_event_status_to_playlist()
self._lbl_title.setText("— Danseliste afsluttet —") self._lbl_title.setText("— Danseliste afsluttet —")
self._lbl_meta.setText("") self._lbl_meta.setText("")
self._lbl_dances.setText("") self._lbl_dances.setText("")
self._set_status("Danselisten er afsluttet") self._set_status("Danselisten er afsluttet")
def _sync_event_status_to_playlist(self): def _sync_event_status_to_playlist(self):
"""Gem event-fremgang i den aktive navngivne liste.""" """Gem event-fremgang (afspillet/sprunget over) til den navngivne liste."""
try: try:
from local.local_db import get_db pl_id = self._playlist_panel.get_named_playlist_id()
songs = self._playlist_panel.get_songs() if not pl_id:
statuses = self._playlist_panel.get_statuses()
with get_db() as conn:
# Find den aktive liste (ikke __aktiv__)
pl = conn.execute(
"SELECT id FROM playlists WHERE name != '__aktiv__' "
"ORDER BY created_at DESC LIMIT 1"
).fetchone()
if not pl:
return return
# Opdater status for hver sang i listen statuses = self._playlist_panel.get_statuses()
for i, (song, status) in enumerate(zip(songs, statuses)): from local.local_db import get_db
conn.execute(""" with get_db() as conn:
UPDATE playlist_songs SET status=? for position, status in enumerate(statuses, start=1):
WHERE playlist_id=? AND song_id=? conn.execute(
""", (status, pl["id"], song.get("id"))) "UPDATE playlist_songs SET status=? "
"WHERE playlist_id=? AND position=?",
(status, pl_id, position)
)
except Exception as e: except Exception as e:
print(f"Event-status sync fejl: {e}") pass
def _on_state_changed(self, state: str): def _on_state_changed(self, state: str):
if state == "playing": if state == "playing":
@@ -900,6 +913,9 @@ class MainWindow(QMainWindow):
def _on_volume(self, value: int): def _on_volume(self, value: int):
self._lbl_vol.setText(str(value)) self._lbl_vol.setText(str(value))
self._player.set_volume(value) self._player.set_volume(value)
from ui.settings_dialog import save_settings
self._settings["volume"] = value
save_settings(self._settings)
# ── Tema ────────────────────────────────────────────────────────────────── # ── Tema ──────────────────────────────────────────────────────────────────

View File

@@ -32,6 +32,7 @@ class PlaylistPanel(QWidget):
self._current_idx = -1 self._current_idx = -1
self._song_ended = False self._song_ended = False
self._active_playlist_id: int | None = None self._active_playlist_id: int | None = None
self._named_playlist_id: int | None = None # den indlæste/gemte navngivne liste
self._build_ui() self._build_ui()
self.setAcceptDrops(True) self.setAcceptDrops(True)
# Autogem-timer — venter 800ms efter sidst ændring # Autogem-timer — venter 800ms efter sidst ændring
@@ -229,7 +230,7 @@ class PlaylistPanel(QWidget):
from local.local_db import save_event_state from local.local_db import save_event_state
save_event_state(self._current_idx, self._statuses) save_event_state(self._current_idx, self._statuses)
except Exception as e: except Exception as e:
print(f"Event-state gem fejl: {e}") pass
def _trigger_event_state_save(self): def _trigger_event_state_save(self):
self._event_state_timer.start() self._event_state_timer.start()
@@ -250,9 +251,12 @@ class PlaylistPanel(QWidget):
self._refresh() self._refresh()
return True return True
except Exception as e: except Exception as e:
print(f"Event-state gendan fejl: {e}") pass
return False return False
def get_named_playlist_id(self) -> int | None:
return self._named_playlist_id
def next_playable_idx(self) -> int | None: def next_playable_idx(self) -> int | None:
"""Find første sang fra toppen der ikke er 'skipped' eller 'played'.""" """Find første sang fra toppen der ikke er 'skipped' eller 'played'."""
for i in range(len(self._songs)): for i in range(len(self._songs)):
@@ -286,7 +290,7 @@ class PlaylistPanel(QWidget):
self.playlist_changed.emit() self.playlist_changed.emit()
except Exception as e: except Exception as e:
self._lbl_autosave.setText(f"⚠ gemfejl") self._lbl_autosave.setText(f"⚠ gemfejl")
print(f"Autogem fejl: {e}") pass
def restore_active_playlist(self): def restore_active_playlist(self):
"""Indlæs den sidst aktive liste ved opstart.""" """Indlæs den sidst aktive liste ved opstart."""
@@ -324,7 +328,7 @@ class PlaylistPanel(QWidget):
self._lbl_autosave.setText("✓ gendannet") self._lbl_autosave.setText("✓ gendannet")
return True return True
except Exception as e: except Exception as e:
print(f"Gendan aktiv liste fejl: {e}") pass
return False return False
# ── Ny / Gem som / Hent ─────────────────────────────────────────────────── # ── Ny / Gem som / Hent ───────────────────────────────────────────────────
@@ -362,6 +366,7 @@ class PlaylistPanel(QWidget):
for i, song in enumerate(self._songs, start=1): for i, song in enumerate(self._songs, start=1):
if song.get("id"): if song.get("id"):
add_song_to_playlist(pl_id, song["id"], position=i) add_song_to_playlist(pl_id, song["id"], position=i)
self._named_playlist_id = pl_id
self._title_label.setText(f"DANSELISTE — {name.upper()}") self._title_label.setText(f"DANSELISTE — {name.upper()}")
self._lbl_autosave.setText(f"✓ gemt som \"{name}\"") self._lbl_autosave.setText(f"✓ gemt som \"{name}\"")
except Exception as e: except Exception as e:
@@ -400,11 +405,12 @@ class PlaylistPanel(QWidget):
from local.local_db import get_db from local.local_db import get_db
with get_db() as conn: with get_db() as conn:
songs_raw = conn.execute(""" songs_raw = conn.execute("""
SELECT s.*, ps.position FROM playlist_songs ps SELECT s.*, ps.position, ps.status FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id=? ORDER BY ps.position WHERE ps.playlist_id=? ORDER BY ps.position
""", (pl_id,)).fetchall() """, (pl_id,)).fetchall()
songs = [] songs = []
statuses = []
for row in songs_raw: for row in songs_raw:
dances = conn.execute( dances = conn.execute(
"SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order", "SELECT dance_name FROM song_dances WHERE song_id=? ORDER BY dance_order",
@@ -418,7 +424,16 @@ class PlaylistPanel(QWidget):
"file_missing": bool(row["file_missing"]), "file_missing": bool(row["file_missing"]),
"dances": [d["dance_name"] for d in dances], "dances": [d["dance_name"] for d in dances],
}) })
self.load_songs(songs, name=pl_name) statuses.append(row["status"] or "pending")
self._songs = songs
self._statuses = statuses
self._current_idx = -1
self._song_ended = False
self._named_playlist_id = pl_id
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}")
self._lbl_autosave.setText("✓ gendannet")
self._refresh()
self._trigger_autosave()
except Exception as e: except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke indlæse listen: {e}") QMessageBox.warning(self, "Fejl", f"Kunne ikke indlæse listen: {e}")

View File

@@ -13,19 +13,22 @@ from PyQt6.QtCore import Qt, QSettings
SETTINGS_KEY_THEME = "appearance/dark_theme" SETTINGS_KEY_THEME = "appearance/dark_theme"
SETTINGS_KEY_DEMO_SEC = "playback/demo_seconds" SETTINGS_KEY_DEMO_SEC = "playback/demo_seconds"
SETTINGS_KEY_MAIL_CLIENT = "mail/client" # "auto"|"thunderbird"|"outlook"|"mailto" SETTINGS_KEY_DEMO_FADE = "playback/demo_fade_seconds"
SETTINGS_KEY_VOLUME = "playback/volume"
SETTINGS_KEY_MAIL_CLIENT = "mail/client"
SETTINGS_KEY_MAIL_PATH = "mail/custom_path" SETTINGS_KEY_MAIL_PATH = "mail/custom_path"
SETTINGS_KEY_AUTO_LOGIN = "online/auto_login" SETTINGS_KEY_AUTO_LOGIN = "online/auto_login"
SETTINGS_KEY_USERNAME = "online/username" SETTINGS_KEY_USERNAME = "online/username"
SETTINGS_KEY_PASSWORD = "online/password" # gemt i klartekst — ikke ideelt, men funktionelt SETTINGS_KEY_PASSWORD = "online/password"
def load_settings() -> dict: def load_settings() -> dict:
"""Indlæs alle indstillinger med fornuftige standardværdier."""
s = QSettings("LineDance", "Player") s = QSettings("LineDance", "Player")
return { return {
"dark_theme": s.value(SETTINGS_KEY_THEME, True, type=bool), "dark_theme": s.value(SETTINGS_KEY_THEME, True, type=bool),
"demo_seconds": s.value(SETTINGS_KEY_DEMO_SEC, 10, type=int), "demo_seconds": s.value(SETTINGS_KEY_DEMO_SEC, 10, type=int),
"demo_fade_seconds": s.value(SETTINGS_KEY_DEMO_FADE, 5, type=int),
"volume": s.value(SETTINGS_KEY_VOLUME, 78, type=int),
"mail_client": s.value(SETTINGS_KEY_MAIL_CLIENT, "auto"), "mail_client": s.value(SETTINGS_KEY_MAIL_CLIENT, "auto"),
"mail_path": s.value(SETTINGS_KEY_MAIL_PATH, ""), "mail_path": s.value(SETTINGS_KEY_MAIL_PATH, ""),
"auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool), "auto_login": s.value(SETTINGS_KEY_AUTO_LOGIN, False, type=bool),
@@ -38,6 +41,8 @@ def save_settings(values: dict):
s = QSettings("LineDance", "Player") s = QSettings("LineDance", "Player")
s.setValue(SETTINGS_KEY_THEME, values.get("dark_theme", True)) 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_DEMO_SEC, values.get("demo_seconds", 10))
s.setValue(SETTINGS_KEY_DEMO_FADE, values.get("demo_fade_seconds", 5))
s.setValue(SETTINGS_KEY_VOLUME, values.get("volume", 78))
s.setValue(SETTINGS_KEY_MAIL_CLIENT, values.get("mail_client", "auto")) 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_MAIL_PATH, values.get("mail_path", ""))
s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False)) s.setValue(SETTINGS_KEY_AUTO_LOGIN, values.get("auto_login", False))
@@ -117,9 +122,21 @@ class SettingsDialog(QDialog):
self._spin_demo.setFixedWidth(140) self._spin_demo.setFixedWidth(140)
grp_layout.addRow("Forspil-længde:", self._spin_demo) grp_layout.addRow("Forspil-længde:", self._spin_demo)
self._spin_fade = QSpinBox()
self._spin_fade.setRange(0, 15)
self._spin_fade.setSuffix(" sekunder (0 = ingen fade)")
self._spin_fade.setFixedWidth(220)
self._spin_fade.setToolTip(
"Fade-out tilføjes til forspillets længde.\n"
"F.eks. 10 sek forspil + 5 sek fade = 15 sek total.\n"
"Sæt til 0 for ingen fade."
)
grp_layout.addRow("Fade-ud:", self._spin_fade)
note = QLabel( note = QLabel(
"Forspillet afspiller begyndelsen af sangen så arrangøren kan bekræfte\n" "Forspillet afspiller begyndelsen af sangen så arrangøren kan bekræfte\n"
"at det er den rigtige sang og dans inden eventet starter." "at det er den rigtige sang og dans inden eventet starter.\n"
"Fade-ud tilføjes oven i forspillets længde og fades logaritmisk."
) )
note.setObjectName("result_count") note.setObjectName("result_count")
note.setWordWrap(True) note.setWordWrap(True)
@@ -224,6 +241,7 @@ class SettingsDialog(QDialog):
v = self._values v = self._values
self._chk_dark.setChecked(v.get("dark_theme", True)) self._chk_dark.setChecked(v.get("dark_theme", True))
self._spin_demo.setValue(v.get("demo_seconds", 10)) self._spin_demo.setValue(v.get("demo_seconds", 10))
self._spin_fade.setValue(v.get("demo_fade_seconds", 5))
# Mail # Mail
client = v.get("mail_client", "auto") client = v.get("mail_client", "auto")
@@ -248,6 +266,7 @@ class SettingsDialog(QDialog):
values = { values = {
"dark_theme": self._chk_dark.isChecked(), "dark_theme": self._chk_dark.isChecked(),
"demo_seconds": self._spin_demo.value(), "demo_seconds": self._spin_demo.value(),
"demo_fade_seconds": self._spin_fade.value(),
"mail_client": self._mail_combo.currentData(), "mail_client": self._mail_combo.currentData(),
"mail_path": self._mail_path.text().strip(), "mail_path": self._mail_path.text().strip(),
"auto_login": self._chk_auto_login.isChecked(), "auto_login": self._chk_auto_login.isChecked(),

View File

@@ -1,237 +1,160 @@
""" """
tag_editor.py — Rediger danse og alternativ-danse med niveau og autoudfyld. tag_editor.py — Simpel og robust dans-tag editor.
Fire sektioner: Danse gemmes til MP3-filen via mutagen.
Mine danse | Fællesskabets danse Niveau og alternativ-danse gemmes til SQLite.
Mine alternativer | Fællesskabets alternativer
""" """
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QListWidget, QListWidgetItem, QFrame, QPushButton, QComboBox, QWidget, QMessageBox, QGroupBox,
QSplitter, QWidget, QMessageBox, QComboBox, QCompleter, QScrollArea, QFrame, QGridLayout,
QGridLayout, QGroupBox,
) )
from PyQt6.QtCore import Qt, QTimer, QStringListModel, pyqtSignal from PyQt6.QtCore import Qt, QTimer, QStringListModel
from PyQt6.QtGui import QColor from PyQt6.QtWidgets import QCompleter
class AutoCompleteLineEdit(QLineEdit): # ── Autoudfyld søgefelt ───────────────────────────────────────────────────────
"""QLineEdit med autoudfyld fra dans-navne databasen."""
def __init__(self, placeholder: str = "", parent=None): class AutoLineEdit(QLineEdit):
def __init__(self, placeholder="", parent=None):
super().__init__(parent) super().__init__(parent)
self.setPlaceholderText(placeholder) self.setPlaceholderText(placeholder)
self._completer_model = QStringListModel() self._model = QStringListModel()
self._completer = QCompleter(self._completer_model, self) comp = QCompleter(self._model, self)
self._completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) comp.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self._completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) comp.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self._completer.setMaxVisibleItems(12) comp.setMaxVisibleItems(10)
self.setCompleter(self._completer) self.setCompleter(comp)
self._timer = QTimer(self) t = QTimer(self)
self._timer.setSingleShot(True) t.setSingleShot(True)
self._timer.setInterval(150) t.setInterval(200)
self._timer.timeout.connect(self._update_suggestions) t.timeout.connect(self._suggest)
self.textChanged.connect(lambda _: self._timer.start()) self.textChanged.connect(lambda _: t.start())
self._timer = t
def _update_suggestions(self): def _suggest(self):
prefix = self.text().strip() prefix = self.text().strip()
if len(prefix) < 1: if not prefix:
return return
try: try:
from local.local_db import get_dance_name_suggestions from local.local_db import get_dance_name_suggestions
names = get_dance_name_suggestions(prefix, limit=20) self._model.setStringList(get_dance_name_suggestions(prefix))
self._completer_model.setStringList(names)
except Exception: except Exception:
pass pass
class DanceRow(QWidget): # ── Niveau dropdown ───────────────────────────────────────────────────────────
"""Én dans med navn og niveau-dropdown."""
removed = pyqtSignal()
def __init__(self, dance_name: str = "", level_id: int | None = None, def make_level_combo(levels: list, current_id=None) -> QComboBox:
levels: list = [], readonly: bool = False, parent=None): cb = QComboBox()
super().__init__(parent) cb.addItem("— intet niveau —", None)
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: for lvl in levels:
self._level_combo.addItem(lvl["name"], lvl["id"]) cb.addItem(lvl["name"], lvl["id"])
self._level_data.append(lvl["id"]) if current_id is not None:
if level_id is not None: for i in range(cb.count()):
for i, lid in enumerate(self._level_data): if cb.itemData(i) == current_id:
if lid == level_id: cb.setCurrentIndex(i)
self._level_combo.setCurrentIndex(i)
break break
self._level_combo.setFixedWidth(130) cb.setFixedWidth(130)
self._level_combo.setEnabled(not readonly) return cb
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): # ── Hoved-dialog ─────────────────────────────────────────────────────────────
"""É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): class TagEditorDialog(QDialog):
def __init__(self, song: dict, parent=None): def __init__(self, song: dict, parent=None):
super().__init__(parent) super().__init__(parent)
self._song = song self._song = song
self._levels = [] self._levels = []
self._my_dance_rows: list[DanceRow] = [] self._dances = [] # list of {name, level_id, db_id}
self._my_alt_rows: list[AltRow] = [] self._alts = [] # list of {name, level_id, note}
self.setWindowTitle(f"Rediger tags — {song.get('title','')}")
self.setMinimumSize(860, 620) self.setWindowTitle(f"Rediger tags — {song.get('title', '')}")
self.setMinimumSize(720, 500)
self.resize(820, 580)
self._load_levels() self._load_levels()
self._load_existing()
self._build_ui() self._build_ui()
self._load_data()
# ── Indlæsning ────────────────────────────────────────────────────────────
def _load_levels(self): def _load_levels(self):
try: try:
from local.local_db import get_dance_levels from local.local_db import get_dance_levels
self._levels = [dict(r) for r in get_dance_levels()] self._levels = [dict(r) for r in get_dance_levels()]
except Exception: except Exception as e:
pass # log fejl
self._levels = [] self._levels = []
def _load_existing(self):
"""Indlæs eksisterende danse og alternativer fra DB."""
try:
from local.local_db import new_conn
conn = new_conn()
song_id = self._song.get("id")
rows = conn.execute(
"SELECT id, dance_name, level_id FROM song_dances "
"WHERE song_id=? ORDER BY dance_order",
(song_id,)
).fetchall()
for row in rows:
for row in rows:
alts = conn.execute(
"SELECT alt_dance_name, level_id, note FROM dance_alternatives "
"WHERE song_dance_id=? AND source='local'",
(row["id"],)
).fetchall()
self._dances.append({
"name": row["dance_name"],
"level_id": row["level_id"],
"db_id": row["id"],
})
for alt in alts:
self._alts.append({
"name": alt["alt_dance_name"],
"level_id": alt["level_id"],
"note": alt["note"] or "",
})
conn.close()
except Exception as e:
pass # log fejl
# ── UI ────────────────────────────────────────────────────────────────────
def _build_ui(self): def _build_ui(self):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16) layout.setContentsMargins(12, 12, 12, 12)
layout.setSpacing(10) layout.setSpacing(8)
# ── Sang-info ───────────────────────────────────────────────────────── # Sang-info
info = QFrame() info = QFrame()
info.setObjectName("track_display") info.setObjectName("track_display")
info_layout = QHBoxLayout(info) il = QHBoxLayout(info)
info_layout.setContentsMargins(10, 8, 10, 8) il.setContentsMargins(10, 8, 10, 8)
title_col = QVBoxLayout() lbl_t = QLabel(self._song.get("title", ""))
lbl_title = QLabel(self._song.get("title", "")) lbl_t.setObjectName("track_title")
lbl_title.setObjectName("track_title") il.addWidget(lbl_t, stretch=1)
title_col.addWidget(lbl_title) fmt = self._song.get("file_format", "").lower()
meta = f"{self._song.get('artist','')} · {self._song.get('bpm',0)} BPM · {self._song.get('file_format','').upper()}" can_write = fmt in ("mp3", "flac", "ogg", "opus", "m4a")
lbl_meta = QLabel(meta) lbl_w = QLabel("✓ Danse skrives til filen" if can_write
lbl_meta.setObjectName("track_meta") else "⚠ Dette format understøtter ikke fil-skrivning")
title_col.addWidget(lbl_meta) lbl_w.setObjectName("result_count")
can_write = self._song.get("file_format","").lower() in ("mp3","flac","ogg","opus","m4a") il.addWidget(lbl_w)
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) layout.addWidget(info)
# ── Fire paneler i 2x2 grid ─────────────────────────────────────────── # To kolonner
grid = QWidget() cols = QHBoxLayout()
grid_layout = QGridLayout(grid) cols.setSpacing(12)
grid_layout.setSpacing(8) cols.addWidget(self._build_dances_panel())
cols.addWidget(self._build_alts_panel())
layout.addLayout(cols, stretch=1)
grid_layout.addWidget(self._build_my_dances_panel(), 0, 0) # Knapper
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 = QHBoxLayout()
btn_row.addStretch() btn_row.addStretch()
btn_cancel = QPushButton("Annuller") btn_cancel = QPushButton("Annuller")
@@ -243,202 +166,262 @@ class TagEditorDialog(QDialog):
btn_row.addWidget(btn_save) btn_row.addWidget(btn_save)
layout.addLayout(btn_row) layout.addLayout(btn_row)
# ── Mine danse ──────────────────────────────────────────────────────────── def _build_dances_panel(self) -> QGroupBox:
grp = QGroupBox("Danse")
def _build_my_dances_panel(self) -> QGroupBox:
grp = QGroupBox("Mine danse")
layout = QVBoxLayout(grp) layout = QVBoxLayout(grp)
layout.setSpacing(4)
self._my_dances_container = QVBoxLayout() # Scroll-område til eksisterende danse
layout.addLayout(self._my_dances_container) scroll = QScrollArea()
layout.addStretch() scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
container = QWidget()
self._dance_layout = QVBoxLayout(container)
self._dance_layout.setSpacing(4)
self._dance_layout.addStretch()
scroll.setWidget(container)
layout.addWidget(scroll, stretch=1)
# Udfyld med eksisterende
self._dance_rows = []
for d in self._dances:
self._add_dance_row(d["name"], d["level_id"])
# Tilføj-linje
add_row = QHBoxLayout()
self._new_dance = AutoLineEdit("Ny dans...", self)
self._new_dance.returnPressed.connect(self._on_add_dance)
add_row.addWidget(self._new_dance)
btn = QPushButton("+ Tilføj")
btn.setFixedWidth(70)
btn.clicked.connect(self._on_add_dance)
add_row.addWidget(btn)
layout.addLayout(add_row)
return grp
def _add_dance_row(self, name="", level_id=None):
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.setSpacing(4)
name_edit = AutoLineEdit("Dans...", self)
name_edit.setText(name)
row_layout.addWidget(name_edit, stretch=1)
level_cb = make_level_combo(self._levels, level_id)
row_layout.addWidget(level_cb)
btn_rm = QPushButton("")
btn_rm.setFixedSize(24, 24)
row_layout.addWidget(btn_rm)
# Indsæt FØR stretch
idx = self._dance_layout.count() - 1
self._dance_layout.insertWidget(idx, row_widget)
entry = {"widget": row_widget, "name": name_edit, "level": level_cb}
self._dance_rows.append(entry)
btn_rm.clicked.connect(lambda: self._remove_dance_row(entry))
def _remove_dance_row(self, entry):
self._dance_rows.remove(entry)
entry["widget"].deleteLater()
def _on_add_dance(self):
name = self._new_dance.text().strip()
if name:
self._add_dance_row(name)
self._new_dance.clear()
def _build_alts_panel(self) -> QGroupBox:
grp = QGroupBox("Alternativ-danse")
layout = QVBoxLayout(grp)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
container = QWidget()
self._alt_layout = QVBoxLayout(container)
self._alt_layout.setSpacing(4)
self._alt_layout.addStretch()
scroll.setWidget(container)
layout.addWidget(scroll, stretch=1)
self._alt_rows = []
for a in self._alts:
self._add_alt_row(a["name"], a["level_id"], a["note"])
add_row = QHBoxLayout() add_row = QHBoxLayout()
self._new_dance_input = AutoCompleteLineEdit("Ny dans...", self) self._new_alt = AutoLineEdit("Nyt alternativ...", self)
self._new_dance_input.returnPressed.connect(self._add_my_dance) self._new_alt.returnPressed.connect(self._on_add_alt)
add_row.addWidget(self._new_dance_input) add_row.addWidget(self._new_alt)
btn_add = QPushButton("+ Tilføj") btn = QPushButton("+ Tilføj")
btn_add.clicked.connect(self._add_my_dance) btn.setFixedWidth(70)
add_row.addWidget(btn_add) btn.clicked.connect(self._on_add_alt)
add_row.addWidget(btn)
layout.addLayout(add_row) layout.addLayout(add_row)
return grp return grp
def _add_my_dance(self, name: str = "", level_id=None): def _add_alt_row(self, name="", level_id=None, note=""):
n = name or self._new_dance_input.text().strip() row_widget = QWidget()
if not n: row_layout = QHBoxLayout(row_widget)
return row_layout.setContentsMargins(0, 0, 0, 0)
row = DanceRow(n, level_id, self._levels, readonly=False, parent=self) row_layout.setSpacing(4)
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): lbl = QLabel("")
self._my_dance_rows.remove(row) lbl.setObjectName("track_meta")
self._my_dances_container.removeWidget(row) row_layout.addWidget(lbl)
row.deleteLater()
# ── Fællesskabets danse ─────────────────────────────────────────────────── name_edit = AutoLineEdit("Dans...", self)
name_edit.setText(name)
row_layout.addWidget(name_edit, stretch=1)
def _build_community_dances_panel(self) -> QGroupBox: level_cb = make_level_combo(self._levels, level_id)
grp = QGroupBox("Fællesskabets danse") row_layout.addWidget(level_cb)
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 ───────────────────────────────────────────────────── note_edit = QLineEdit()
note_edit.setPlaceholderText("note...")
note_edit.setText(note)
note_edit.setFixedWidth(80)
row_layout.addWidget(note_edit)
def _build_my_alts_panel(self) -> QGroupBox: btn_rm = QPushButton("")
grp = QGroupBox("Mine alternativ-danse") btn_rm.setFixedSize(24, 24)
layout = QVBoxLayout(grp) row_layout.addWidget(btn_rm)
layout.setSpacing(4)
self._my_alts_container = QVBoxLayout()
layout.addLayout(self._my_alts_container)
layout.addStretch()
add_row = QHBoxLayout() idx = self._alt_layout.count() - 1
self._new_alt_input = AutoCompleteLineEdit("Alternativ dansenavn...", self) self._alt_layout.insertWidget(idx, row_widget)
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 = ""): entry = {"widget": row_widget, "name": name_edit,
n = name or self._new_alt_input.text().strip() "level": level_cb, "note": note_edit}
if not n: self._alt_rows.append(entry)
return btn_rm.clicked.connect(lambda: self._remove_alt_row(entry))
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): def _remove_alt_row(self, entry):
self._my_alt_rows.remove(row) self._alt_rows.remove(entry)
self._my_alts_container.removeWidget(row) entry["widget"].deleteLater()
row.deleteLater()
# ── Fællesskabets alternativer ──────────────────────────────────────────── def _on_add_alt(self):
name = self._new_alt.text().strip()
def _build_community_alts_panel(self) -> QGroupBox: if name:
grp = QGroupBox("Fællesskabets alternativ-danse") self._add_alt_row(name)
layout = QVBoxLayout(grp) self._new_alt.clear()
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 ─────────────────────────────────────────────────────────────────── # ── Gem ───────────────────────────────────────────────────────────────────
def _save(self): def _save(self):
import uuid
song_id = self._song.get("id") song_id = self._song.get("id")
local_path = self._song.get("local_path", "") local_path = self._song.get("local_path", "")
# Saml data fra UI
dances = []
for row in self._dance_rows:
name = row["name"].text().strip()
if name:
dances.append((name, row["level"].currentData()))
alts = []
for row in self._alt_rows:
name = row["name"].text().strip()
if name:
alts.append((name, row["level"].currentData(),
row["note"].text().strip()))
try: try:
from local.local_db import get_db, register_dance_name, add_alternative from local.local_db import new_conn
from local.tag_reader import write_dances, can_write_dances from local.tag_reader import write_dances, can_write_dances
import uuid
# Saml danse fra UI
dances = [(r.get_name(), r.get_level_id())
for r in self._my_dance_rows if r.get_name()]
dance_ids = [] conn = new_conn()
with get_db() as conn:
# Slet eksisterende danse og alternativer # Slet gammelt
old_dances = conn.execute( old = conn.execute(
"SELECT id FROM song_dances WHERE song_id=?", (song_id,) "SELECT id FROM song_dances WHERE song_id=?", (song_id,)
).fetchall() ).fetchall()
for od in old_dances: for o in old:
conn.execute("DELETE FROM dance_alternatives WHERE song_dance_id=?", (od["id"],)) conn.execute(
"DELETE FROM dance_alternatives WHERE song_dance_id=?",
(o["id"],)
)
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,)) conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
# Indsæt nye danse og hent IDs # Indsæt danse
for i, (name, level_id) in enumerate(dances, start=1): dance_ids = []
for i, (name, level_id) in enumerate(dances, 1):
conn.execute( conn.execute(
"INSERT INTO song_dances (song_id, dance_name, dance_order, level_id) " "INSERT INTO song_dances "
"VALUES (?,?,?,?)", "(song_id, dance_name, dance_order, level_id) VALUES (?,?,?,?)",
(song_id, name, i, level_id) (song_id, name, i, level_id)
) )
new_id = conn.execute( row = conn.execute(
"SELECT id FROM song_dances WHERE song_id=? AND dance_order=?", "SELECT id FROM song_dances "
(song_id, i) "WHERE song_id=? AND dance_order=?", (song_id, i)
).fetchone()["id"] ).fetchone()
dance_ids.append(new_id) dance_ids.append(row["id"])
register_dance_name(name)
# Indsæt alternativer knyttet til første dans # Opdater dance_names
if dance_ids and self._my_alt_rows: existing = conn.execute(
first_dance_id = dance_ids[0] "SELECT id FROM dance_names WHERE name=? COLLATE NOCASE",
for row in self._my_alt_rows: (name,)
name = row.get_name() ).fetchone()
if name: if existing:
import uuid as _uuid conn.execute(
conn.execute(""" "UPDATE dance_names SET use_count=use_count+1 WHERE id=?",
INSERT INTO dance_alternatives (existing["id"],)
(id, song_dance_id, alt_dance_name, level_id, note, source) )
VALUES (?,?,?,?,?,'local') else:
""", (str(_uuid.uuid4()), first_dance_id, conn.execute(
name, row.get_level_id(), row.get_note())) "INSERT INTO dance_names (name, source, use_count) "
register_dance_name(name) "VALUES (?,?,1)", (name, "local")
)
# Skriv til fil # Indsæt alternativer på første dans
if local_path and can_write_dances(local_path): if dance_ids and alts:
fid = dance_ids[0]
for alt_name, alt_level, alt_note in alts:
conn.execute(
"INSERT INTO dance_alternatives "
"(id, song_dance_id, alt_dance_name, level_id, note, source) "
"VALUES (?,?,?,?,?,'local')",
(str(uuid.uuid4()), fid, alt_name, alt_level, alt_note)
)
existing = conn.execute(
"SELECT id FROM dance_names WHERE name=? COLLATE NOCASE",
(alt_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)", (alt_name, "local")
)
conn.commit()
"SELECT COUNT(*) FROM song_dances WHERE song_id=?", (song_id,)
conn.close()
# Skriv danse til filen
if local_path:
from local.tag_reader import write_dances, can_write_dances
if can_write_dances(local_path):
dance_names = [n for n, _ in dances] dance_names = [n for n, _ in dances]
ok = write_dances(local_path, dance_names) if not write_dances(local_path, dance_names):
if not ok: QMessageBox.warning(
QMessageBox.warning(self, "Advarsel", self, "Advarsel",
"Tags gemt i database, men kunne ikke skrives til filen.") "Gemt i database, men kunne ikke skrive til filen."
)
self.accept() self.accept()
except Exception as e: except Exception as e:
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme tags: {e}") import traceback
traceback.print_exc()
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")