Version 1
This commit is contained in:
33
linedance-app/app_logger.py
Normal file
33
linedance-app/app_logger.py
Normal 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")
|
||||
@@ -64,7 +64,7 @@ exe = EXE(
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False, # UPX kan give problemer med PyQt6 DLL-filer
|
||||
console=True, # Vis fejlbeskeder
|
||||
console=False, # Ingen konsol-vindue
|
||||
disable_windowed_traceback=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -17,36 +17,51 @@ from pathlib import Path
|
||||
DB_PATH = Path.home() / ".linedance" / "local.db"
|
||||
|
||||
_local = threading.local()
|
||||
_global_conn: sqlite3.Connection | None = None
|
||||
|
||||
|
||||
def _get_conn() -> sqlite3.Connection:
|
||||
"""Returnerer en thread-lokal forbindelse."""
|
||||
if not hasattr(_local, "conn") or _local.conn is None:
|
||||
"""Returnerer en global forbindelse i autocommit mode."""
|
||||
global _global_conn
|
||||
if _global_conn is None:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL") # bedre concurrent adgang
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
_local.conn = conn
|
||||
return _local.conn
|
||||
_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.execute("PRAGMA foreign_keys=OFF") # FK checker forhindrer level_id gem
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
"""Context manager der bruger app-forbindelsen i autocommit mode.
|
||||
Hver statement committer med det samme — ingen eksplicit transaktion."""
|
||||
conn = _get_conn()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def get_db_raw() -> sqlite3.Connection:
|
||||
return _get_conn()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Opret alle tabeller hvis de ikke findes."""
|
||||
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("""
|
||||
CREATE TABLE IF NOT EXISTS libraries (
|
||||
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);
|
||||
""")
|
||||
|
||||
# Kør migrations for ældre databaser (each separately)
|
||||
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)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
# executescript slår foreign_keys fra — genaktiver
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
# 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]
|
||||
if count == 0:
|
||||
defaults = [
|
||||
@@ -174,14 +186,49 @@ def init_db():
|
||||
(4, "Erfaren", "For dedikerede dansere"),
|
||||
(5, "Ekspert", "Konkurrenceniveau"),
|
||||
]
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO dance_levels (sort_order, name, description) VALUES (?,?,?)",
|
||||
defaults
|
||||
for row in defaults:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO dance_levels (sort_order, name, description) VALUES (?,?,?)",
|
||||
row
|
||||
)
|
||||
|
||||
|
||||
# ── Versionsbaserede migrationer ──────────────────────────────────────────────
|
||||
# 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,)
|
||||
)
|
||||
conn.commit()
|
||||
print(f"Dans-niveauer seedet: {len(defaults)} niveauer")
|
||||
else:
|
||||
print(f"Dans-niveauer: {count} niveauer i databasen")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -282,30 +329,49 @@ def upsert_song(song_data: dict) -> str:
|
||||
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:
|
||||
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
|
||||
for i, dance in enumerate(song_data["dances"], start=1):
|
||||
# dance kan være str eller dict med {name, level_id}
|
||||
file_dances = []
|
||||
for dance in song_data["dances"]:
|
||||
if isinstance(dance, dict):
|
||||
name = dance.get("name", "")
|
||||
level_id = dance.get("level_id")
|
||||
file_dances.append(dance.get("name", ""))
|
||||
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"]:
|
||||
nm = dance.get("name", dance) if isinstance(dance, dict) else dance
|
||||
if nm:
|
||||
_reg(nm)
|
||||
except Exception:
|
||||
pass
|
||||
file_dances.append(dance)
|
||||
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
|
||||
|
||||
@@ -465,15 +531,41 @@ def clear_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."""
|
||||
"""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:
|
||||
# Hent fra dance_names ordbog (primær kilde)
|
||||
rows = conn.execute("""
|
||||
SELECT name FROM dance_names
|
||||
SELECT name, use_count 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]
|
||||
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"):
|
||||
|
||||
Binary file not shown.
@@ -11,6 +11,7 @@ Sender signals til GUI:
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal, QTimer
|
||||
import random
|
||||
import math
|
||||
|
||||
try:
|
||||
import vlc
|
||||
@@ -33,6 +34,7 @@ class Player(QObject):
|
||||
self._duration: int = 0
|
||||
self._demo_mode = False
|
||||
self._demo_stop_sec = 10
|
||||
self._demo_fade_sec = 5
|
||||
self._demo_fading = False
|
||||
self._volume = 78
|
||||
|
||||
@@ -78,10 +80,15 @@ class Player(QObject):
|
||||
self._poll_timer.start()
|
||||
self.state_changed.emit("playing")
|
||||
|
||||
def play_demo(self, stop_at_sec: int = 10):
|
||||
"""Afspil fra start og stop automatisk ved stop_at_sec med 2 sek fade-out."""
|
||||
def play_demo(self, stop_at_sec: int = 10, fade_sec: int = 5):
|
||||
"""
|
||||
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_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
|
||||
if VLC_AVAILABLE and self._media_player:
|
||||
self._media_player.set_time(0)
|
||||
@@ -156,18 +163,16 @@ class Player(QObject):
|
||||
self.state_changed.emit("demo_ended")
|
||||
return
|
||||
|
||||
# Demo fade-out — de sidste 2 sekunder
|
||||
FADE_SEC = 2.0
|
||||
if self._demo_mode and VLC_AVAILABLE and self._media_player:
|
||||
# Demo fade-out — de sidste _demo_fade_sec sekunder (0 = ingen fade)
|
||||
if self._demo_mode and VLC_AVAILABLE and self._media_player and self._demo_fade_sec > 0:
|
||||
secs_left = self._demo_stop_sec - cur
|
||||
if secs_left <= FADE_SEC and secs_left > 0:
|
||||
# Fade fra fuld volumen til 0 over FADE_SEC sekunder
|
||||
fade_fraction = secs_left / FADE_SEC # 1.0 → 0.0
|
||||
faded_vol = int(self._volume * fade_fraction)
|
||||
if secs_left <= self._demo_fade_sec and secs_left > 0:
|
||||
fade_fraction = secs_left / self._demo_fade_sec # 1.0 → 0.0
|
||||
log_fraction = math.log10(1 + fade_fraction * 9) / math.log10(10)
|
||||
faded_vol = int(self._volume * log_fraction)
|
||||
self._media_player.audio_set_volume(max(0, faded_vol))
|
||||
self._demo_fading = True
|
||||
elif not self._demo_fading:
|
||||
# Ikke i fade-zone endnu — sørg for fuld volumen
|
||||
self._media_player.audio_set_volume(self._volume)
|
||||
|
||||
# VU-meter: brug VLC's audio-amplitude hvis tilgængelig, ellers simulér
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -41,9 +41,9 @@ 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)
|
||||
|
||||
@@ -51,6 +51,7 @@ class LibraryPanel(QWidget):
|
||||
super().__init__(parent)
|
||||
self._all_songs: list[dict] = []
|
||||
self._filtered: list[dict] = []
|
||||
self._bpm_scan_running = False
|
||||
self._search_timer = QTimer(self)
|
||||
self._search_timer.setSingleShot(True)
|
||||
self._search_timer.setInterval(150)
|
||||
@@ -70,6 +71,12 @@ class LibraryPanel(QWidget):
|
||||
header.addWidget(lbl)
|
||||
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.setFixedHeight(24)
|
||||
btn_manage.setToolTip("Tilføj, fjern og scan musikbiblioteker")
|
||||
@@ -172,7 +179,6 @@ class LibraryPanel(QWidget):
|
||||
dance_levels = song.get("dance_levels", [])
|
||||
missing = song.get("file_missing", False)
|
||||
|
||||
# Byg dans-streng med niveau hvis tilgængeligt
|
||||
dance_parts = []
|
||||
for i, d in enumerate(dances):
|
||||
lvl = dance_levels[i] if i < len(dance_levels) else ""
|
||||
@@ -183,13 +189,97 @@ class LibraryPanel(QWidget):
|
||||
bpm = song.get("bpm", 0)
|
||||
bpm_str = f"{bpm} BPM" if bpm else "? BPM"
|
||||
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)
|
||||
if missing:
|
||||
item.setForeground(QColor("#5a6070"))
|
||||
elif q and any(q in d.lower() for d in dances):
|
||||
item.setForeground(QColor("#e8a020"))
|
||||
row_widget.adjustSize()
|
||||
hint = row_widget.sizeHint()
|
||||
hint.setHeight(max(hint.height(), 52))
|
||||
item.setSizeHint(hint)
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ class ProgressBar(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
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.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
@@ -34,8 +35,9 @@ class ProgressBar(QWidget):
|
||||
self._fraction = max(0.0, min(1.0, f))
|
||||
self.update()
|
||||
|
||||
def set_demo_marker(self, f: float):
|
||||
self._demo_fraction = max(0.0, min(1.0, f))
|
||||
def set_demo_marker(self, demo_f: float, fade_f: float = 0.0):
|
||||
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()
|
||||
|
||||
def paintEvent(self, event):
|
||||
@@ -46,6 +48,11 @@ class ProgressBar(QWidget):
|
||||
fill_w = int(w * self._fraction)
|
||||
if fill_w > 0:
|
||||
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:
|
||||
mx = int(w * self._demo_fraction)
|
||||
p.fillRect(mx - 1, 0, 2, h, QColor("#3b8fd4"))
|
||||
@@ -81,6 +88,7 @@ class MainWindow(QMainWindow):
|
||||
self._settings = load_settings()
|
||||
self._dark_theme = self._settings.get("dark_theme", True)
|
||||
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._build_menu()
|
||||
@@ -306,12 +314,12 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
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.valueChanged.connect(self._on_volume)
|
||||
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")
|
||||
layout.addWidget(self._lbl_vol)
|
||||
|
||||
@@ -401,7 +409,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
except Exception as e:
|
||||
self._set_status(f"DB fejl: {e}")
|
||||
print(f"DB init fejl: {e}")
|
||||
pass
|
||||
|
||||
def start_scan(self):
|
||||
"""Start fuld scanning af alle biblioteker i baggrundstråd."""
|
||||
@@ -463,7 +471,7 @@ class MainWindow(QMainWindow):
|
||||
count = len(songs)
|
||||
self._set_status(f"Bibliotek: {count} sang{'e' if count != 1 else ''}", 3000)
|
||||
except Exception as e:
|
||||
print(f"Bibliotek reload fejl: {e}")
|
||||
pass
|
||||
|
||||
def add_library_path(self, path: str):
|
||||
try:
|
||||
@@ -483,6 +491,7 @@ class MainWindow(QMainWindow):
|
||||
if dialog.exec():
|
||||
self._settings = dialog.get_values()
|
||||
self._demo_seconds = self._settings.get("demo_seconds", 10)
|
||||
self._demo_fade_seconds = self._settings.get("demo_fade_seconds", 5)
|
||||
# Opdater tema hvis ændret
|
||||
new_dark = self._settings.get("dark_theme", True)
|
||||
if new_dark != self._dark_theme:
|
||||
@@ -498,7 +507,7 @@ class MainWindow(QMainWindow):
|
||||
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._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)
|
||||
|
||||
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)
|
||||
except Exception as e:
|
||||
print(f"Dans-sync fejl: {e}")
|
||||
pass
|
||||
|
||||
def _go_offline(self):
|
||||
self._api_url = self._api_token = self._api_username = None
|
||||
@@ -744,7 +753,7 @@ class MainWindow(QMainWindow):
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -787,7 +796,10 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
self._demo_active = 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("⏸")
|
||||
|
||||
def _prev_song(self):
|
||||
@@ -853,33 +865,34 @@ class MainWindow(QMainWindow):
|
||||
self._load_song(next_song)
|
||||
self._set_status(f"Klar: {next_song.get('title','')} — tryk ▶ for at starte")
|
||||
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_meta.setText("")
|
||||
self._lbl_dances.setText("")
|
||||
self._set_status("Danselisten er afsluttet")
|
||||
|
||||
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:
|
||||
from local.local_db import get_db
|
||||
songs = self._playlist_panel.get_songs()
|
||||
pl_id = self._playlist_panel.get_named_playlist_id()
|
||||
if not pl_id:
|
||||
return
|
||||
statuses = self._playlist_panel.get_statuses()
|
||||
from local.local_db import get_db
|
||||
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
|
||||
# Opdater status for hver sang i listen
|
||||
for i, (song, status) in enumerate(zip(songs, statuses)):
|
||||
conn.execute("""
|
||||
UPDATE playlist_songs SET status=?
|
||||
WHERE playlist_id=? AND song_id=?
|
||||
""", (status, pl["id"], song.get("id")))
|
||||
for position, status in enumerate(statuses, start=1):
|
||||
conn.execute(
|
||||
"UPDATE playlist_songs SET status=? "
|
||||
"WHERE playlist_id=? AND position=?",
|
||||
(status, pl_id, position)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Event-status sync fejl: {e}")
|
||||
pass
|
||||
|
||||
def _on_state_changed(self, state: str):
|
||||
if state == "playing":
|
||||
@@ -900,6 +913,9 @@ class MainWindow(QMainWindow):
|
||||
def _on_volume(self, value: int):
|
||||
self._lbl_vol.setText(str(value))
|
||||
self._player.set_volume(value)
|
||||
from ui.settings_dialog import save_settings
|
||||
self._settings["volume"] = value
|
||||
save_settings(self._settings)
|
||||
|
||||
# ── Tema ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ class PlaylistPanel(QWidget):
|
||||
self._current_idx = -1
|
||||
self._song_ended = False
|
||||
self._active_playlist_id: int | None = None
|
||||
self._named_playlist_id: int | None = None # den indlæste/gemte navngivne liste
|
||||
self._build_ui()
|
||||
self.setAcceptDrops(True)
|
||||
# Autogem-timer — venter 800ms efter sidst ændring
|
||||
@@ -229,7 +230,7 @@ class PlaylistPanel(QWidget):
|
||||
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}")
|
||||
pass
|
||||
|
||||
def _trigger_event_state_save(self):
|
||||
self._event_state_timer.start()
|
||||
@@ -250,9 +251,12 @@ class PlaylistPanel(QWidget):
|
||||
self._refresh()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Event-state gendan fejl: {e}")
|
||||
pass
|
||||
return False
|
||||
|
||||
def get_named_playlist_id(self) -> int | None:
|
||||
return self._named_playlist_id
|
||||
|
||||
def next_playable_idx(self) -> int | None:
|
||||
"""Find første sang fra toppen der ikke er 'skipped' eller 'played'."""
|
||||
for i in range(len(self._songs)):
|
||||
@@ -286,7 +290,7 @@ class PlaylistPanel(QWidget):
|
||||
self.playlist_changed.emit()
|
||||
except Exception as e:
|
||||
self._lbl_autosave.setText(f"⚠ gemfejl")
|
||||
print(f"Autogem fejl: {e}")
|
||||
pass
|
||||
|
||||
def restore_active_playlist(self):
|
||||
"""Indlæs den sidst aktive liste ved opstart."""
|
||||
@@ -324,7 +328,7 @@ class PlaylistPanel(QWidget):
|
||||
self._lbl_autosave.setText("✓ gendannet")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Gendan aktiv liste fejl: {e}")
|
||||
pass
|
||||
return False
|
||||
|
||||
# ── Ny / Gem som / Hent ───────────────────────────────────────────────────
|
||||
@@ -362,6 +366,7 @@ class PlaylistPanel(QWidget):
|
||||
for i, song in enumerate(self._songs, start=1):
|
||||
if song.get("id"):
|
||||
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._lbl_autosave.setText(f"✓ gemt som \"{name}\"")
|
||||
except Exception as e:
|
||||
@@ -400,11 +405,12 @@ class PlaylistPanel(QWidget):
|
||||
from local.local_db import get_db
|
||||
with get_db() as conn:
|
||||
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
|
||||
WHERE ps.playlist_id=? ORDER BY ps.position
|
||||
""", (pl_id,)).fetchall()
|
||||
songs = []
|
||||
statuses = []
|
||||
for row in songs_raw:
|
||||
dances = conn.execute(
|
||||
"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"]),
|
||||
"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:
|
||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke indlæse listen: {e}")
|
||||
|
||||
|
||||
@@ -13,36 +13,41 @@ 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_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_AUTO_LOGIN = "online/auto_login"
|
||||
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:
|
||||
"""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, ""),
|
||||
"dark_theme": s.value(SETTINGS_KEY_THEME, True, type=bool),
|
||||
"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_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", ""))
|
||||
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_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_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):
|
||||
@@ -117,9 +122,21 @@ class SettingsDialog(QDialog):
|
||||
self._spin_demo.setFixedWidth(140)
|
||||
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(
|
||||
"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.setWordWrap(True)
|
||||
@@ -224,6 +241,7 @@ class SettingsDialog(QDialog):
|
||||
v = self._values
|
||||
self._chk_dark.setChecked(v.get("dark_theme", True))
|
||||
self._spin_demo.setValue(v.get("demo_seconds", 10))
|
||||
self._spin_fade.setValue(v.get("demo_fade_seconds", 5))
|
||||
|
||||
# Mail
|
||||
client = v.get("mail_client", "auto")
|
||||
@@ -246,13 +264,14 @@ class SettingsDialog(QDialog):
|
||||
|
||||
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(),
|
||||
"dark_theme": self._chk_dark.isChecked(),
|
||||
"demo_seconds": self._spin_demo.value(),
|
||||
"demo_fade_seconds": self._spin_fade.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
|
||||
|
||||
@@ -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:
|
||||
Mine danse | Fællesskabets danse
|
||||
Mine alternativer | Fællesskabets alternativer
|
||||
Danse gemmes til MP3-filen via mutagen.
|
||||
Niveau og alternativ-danse gemmes til SQLite.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QListWidget, QListWidgetItem, QFrame,
|
||||
QSplitter, QWidget, QMessageBox, QComboBox, QCompleter,
|
||||
QGridLayout, QGroupBox,
|
||||
QPushButton, QComboBox, QWidget, QMessageBox, QGroupBox,
|
||||
QScrollArea, QFrame, QGridLayout,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, QStringListModel, pyqtSignal
|
||||
from PyQt6.QtGui import QColor
|
||||
from PyQt6.QtCore import Qt, QTimer, QStringListModel
|
||||
from PyQt6.QtWidgets import QCompleter
|
||||
|
||||
|
||||
class AutoCompleteLineEdit(QLineEdit):
|
||||
"""QLineEdit med autoudfyld fra dans-navne databasen."""
|
||||
# ── Autoudfyld søgefelt ───────────────────────────────────────────────────────
|
||||
|
||||
def __init__(self, placeholder: str = "", parent=None):
|
||||
class AutoLineEdit(QLineEdit):
|
||||
def __init__(self, placeholder="", 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())
|
||||
self._model = QStringListModel()
|
||||
comp = QCompleter(self._model, self)
|
||||
comp.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||
comp.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
|
||||
comp.setMaxVisibleItems(10)
|
||||
self.setCompleter(comp)
|
||||
t = QTimer(self)
|
||||
t.setSingleShot(True)
|
||||
t.setInterval(200)
|
||||
t.timeout.connect(self._suggest)
|
||||
self.textChanged.connect(lambda _: t.start())
|
||||
self._timer = t
|
||||
|
||||
def _update_suggestions(self):
|
||||
def _suggest(self):
|
||||
prefix = self.text().strip()
|
||||
if len(prefix) < 1:
|
||||
if not prefix:
|
||||
return
|
||||
try:
|
||||
from local.local_db import get_dance_name_suggestions
|
||||
names = get_dance_name_suggestions(prefix, limit=20)
|
||||
self._completer_model.setStringList(names)
|
||||
self._model.setStringList(get_dance_name_suggestions(prefix))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class DanceRow(QWidget):
|
||||
"""Én dans med navn og niveau-dropdown."""
|
||||
removed = pyqtSignal()
|
||||
# ── Niveau dropdown ───────────────────────────────────────────────────────────
|
||||
|
||||
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()
|
||||
def make_level_combo(levels: list, current_id=None) -> QComboBox:
|
||||
cb = QComboBox()
|
||||
cb.addItem("— intet niveau —", None)
|
||||
for lvl in levels:
|
||||
cb.addItem(lvl["name"], lvl["id"])
|
||||
if current_id is not None:
|
||||
for i in range(cb.count()):
|
||||
if cb.itemData(i) == current_id:
|
||||
cb.setCurrentIndex(i)
|
||||
break
|
||||
cb.setFixedWidth(130)
|
||||
return cb
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
# ── Hoved-dialog ─────────────────────────────────────────────────────────────
|
||||
|
||||
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._song = song
|
||||
self._levels = []
|
||||
self._dances = [] # list of {name, level_id, db_id}
|
||||
self._alts = [] # list of {name, level_id, note}
|
||||
|
||||
self.setWindowTitle(f"Rediger tags — {song.get('title', '')}")
|
||||
self.setMinimumSize(720, 500)
|
||||
self.resize(820, 580)
|
||||
|
||||
self._load_levels()
|
||||
self._load_existing()
|
||||
self._build_ui()
|
||||
self._load_data()
|
||||
|
||||
# ── Indlæsning ────────────────────────────────────────────────────────────
|
||||
|
||||
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:
|
||||
except Exception as e:
|
||||
pass # log fejl
|
||||
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):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# ── Sang-info ─────────────────────────────────────────────────────────
|
||||
# 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)
|
||||
il = QHBoxLayout(info)
|
||||
il.setContentsMargins(10, 8, 10, 8)
|
||||
lbl_t = QLabel(self._song.get("title", "—"))
|
||||
lbl_t.setObjectName("track_title")
|
||||
il.addWidget(lbl_t, stretch=1)
|
||||
fmt = self._song.get("file_format", "").lower()
|
||||
can_write = fmt in ("mp3", "flac", "ogg", "opus", "m4a")
|
||||
lbl_w = QLabel("✓ Danse skrives til filen" if can_write
|
||||
else "⚠ Dette format understøtter ikke fil-skrivning")
|
||||
lbl_w.setObjectName("result_count")
|
||||
il.addWidget(lbl_w)
|
||||
layout.addWidget(info)
|
||||
|
||||
# ── Fire paneler i 2x2 grid ───────────────────────────────────────────
|
||||
grid = QWidget()
|
||||
grid_layout = QGridLayout(grid)
|
||||
grid_layout.setSpacing(8)
|
||||
# To kolonner
|
||||
cols = QHBoxLayout()
|
||||
cols.setSpacing(12)
|
||||
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)
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
# Knapper
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
btn_cancel = QPushButton("Annuller")
|
||||
@@ -243,202 +166,262 @@ class TagEditorDialog(QDialog):
|
||||
btn_row.addWidget(btn_save)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
# ── Mine danse ────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_my_dances_panel(self) -> QGroupBox:
|
||||
grp = QGroupBox("Mine danse")
|
||||
def _build_dances_panel(self) -> QGroupBox:
|
||||
grp = QGroupBox("Danse")
|
||||
layout = QVBoxLayout(grp)
|
||||
layout.setSpacing(4)
|
||||
|
||||
self._my_dances_container = QVBoxLayout()
|
||||
layout.addLayout(self._my_dances_container)
|
||||
layout.addStretch()
|
||||
# Scroll-område til eksisterende danse
|
||||
scroll = QScrollArea()
|
||||
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()
|
||||
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)
|
||||
self._new_alt = AutoLineEdit("Nyt alternativ...", self)
|
||||
self._new_alt.returnPressed.connect(self._on_add_alt)
|
||||
add_row.addWidget(self._new_alt)
|
||||
btn = QPushButton("+ Tilføj")
|
||||
btn.setFixedWidth(70)
|
||||
btn.clicked.connect(self._on_add_alt)
|
||||
add_row.addWidget(btn)
|
||||
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 _add_alt_row(self, name="", level_id=None, note=""):
|
||||
row_widget = QWidget()
|
||||
row_layout = QHBoxLayout(row_widget)
|
||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
row_layout.setSpacing(4)
|
||||
|
||||
def _remove_dance_row(self, row: DanceRow):
|
||||
self._my_dance_rows.remove(row)
|
||||
self._my_dances_container.removeWidget(row)
|
||||
row.deleteLater()
|
||||
lbl = QLabel("→")
|
||||
lbl.setObjectName("track_meta")
|
||||
row_layout.addWidget(lbl)
|
||||
|
||||
# ── 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:
|
||||
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
|
||||
level_cb = make_level_combo(self._levels, level_id)
|
||||
row_layout.addWidget(level_cb)
|
||||
|
||||
# ── 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:
|
||||
grp = QGroupBox("Mine alternativ-danse")
|
||||
layout = QVBoxLayout(grp)
|
||||
layout.setSpacing(4)
|
||||
self._my_alts_container = QVBoxLayout()
|
||||
layout.addLayout(self._my_alts_container)
|
||||
layout.addStretch()
|
||||
btn_rm = QPushButton("✕")
|
||||
btn_rm.setFixedSize(24, 24)
|
||||
row_layout.addWidget(btn_rm)
|
||||
|
||||
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
|
||||
idx = self._alt_layout.count() - 1
|
||||
self._alt_layout.insertWidget(idx, row_widget)
|
||||
|
||||
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()
|
||||
entry = {"widget": row_widget, "name": name_edit,
|
||||
"level": level_cb, "note": note_edit}
|
||||
self._alt_rows.append(entry)
|
||||
btn_rm.clicked.connect(lambda: self._remove_alt_row(entry))
|
||||
|
||||
def _remove_alt_row(self, row: AltRow):
|
||||
self._my_alt_rows.remove(row)
|
||||
self._my_alts_container.removeWidget(row)
|
||||
row.deleteLater()
|
||||
def _remove_alt_row(self, entry):
|
||||
self._alt_rows.remove(entry)
|
||||
entry["widget"].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}")
|
||||
def _on_add_alt(self):
|
||||
name = self._new_alt.text().strip()
|
||||
if name:
|
||||
self._add_alt_row(name)
|
||||
self._new_alt.clear()
|
||||
|
||||
# ── Gem ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _save(self):
|
||||
song_id = self._song.get("id")
|
||||
import uuid
|
||||
song_id = self._song.get("id")
|
||||
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:
|
||||
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
|
||||
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()]
|
||||
|
||||
conn = new_conn()
|
||||
|
||||
# Slet gammelt
|
||||
old = conn.execute(
|
||||
"SELECT id FROM song_dances WHERE song_id=?", (song_id,)
|
||||
).fetchall()
|
||||
for o in old:
|
||||
conn.execute(
|
||||
"DELETE FROM dance_alternatives WHERE song_dance_id=?",
|
||||
(o["id"],)
|
||||
)
|
||||
conn.execute("DELETE FROM song_dances WHERE song_id=?", (song_id,))
|
||||
|
||||
# Indsæt danse
|
||||
dance_ids = []
|
||||
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,))
|
||||
for i, (name, level_id) in enumerate(dances, 1):
|
||||
conn.execute(
|
||||
"INSERT INTO song_dances "
|
||||
"(song_id, dance_name, dance_order, level_id) VALUES (?,?,?,?)",
|
||||
(song_id, name, i, level_id)
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM song_dances "
|
||||
"WHERE song_id=? AND dance_order=?", (song_id, i)
|
||||
).fetchone()
|
||||
dance_ids.append(row["id"])
|
||||
|
||||
# Indsæt nye danse og hent IDs
|
||||
for i, (name, level_id) in enumerate(dances, start=1):
|
||||
# Opdater dance_names
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM dance_names WHERE name=? COLLATE NOCASE",
|
||||
(name,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"INSERT INTO song_dances (song_id, dance_name, dance_order, level_id) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(song_id, name, i, level_id)
|
||||
"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, "local")
|
||||
)
|
||||
new_id = conn.execute(
|
||||
"SELECT id FROM song_dances WHERE song_id=? AND dance_order=?",
|
||||
(song_id, i)
|
||||
).fetchone()["id"]
|
||||
dance_ids.append(new_id)
|
||||
register_dance_name(name)
|
||||
|
||||
# Indsæt alternativer knyttet til første dans
|
||||
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:
|
||||
import uuid as _uuid
|
||||
conn.execute("""
|
||||
INSERT INTO dance_alternatives
|
||||
(id, song_dance_id, alt_dance_name, level_id, note, source)
|
||||
VALUES (?,?,?,?,?,'local')
|
||||
""", (str(_uuid.uuid4()), first_dance_id,
|
||||
name, row.get_level_id(), row.get_note()))
|
||||
register_dance_name(name)
|
||||
# Indsæt alternativer på første dans
|
||||
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")
|
||||
)
|
||||
|
||||
# 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.")
|
||||
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]
|
||||
if not write_dances(local_path, dance_names):
|
||||
QMessageBox.warning(
|
||||
self, "Advarsel",
|
||||
"Gemt i database, men kunne ikke skrive til filen."
|
||||
)
|
||||
|
||||
self.accept()
|
||||
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user