1509 lines
64 KiB
Python
1509 lines
64 KiB
Python
"""
|
||
playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overblik.
|
||
"""
|
||
|
||
import sys as _sys
|
||
from pathlib import Path as _Path
|
||
|
||
|
||
def _is_local_path(path: str) -> bool:
|
||
"""Returnerer True hvis stien er på et lokalt/USB-drev, False hvis netværk."""
|
||
try:
|
||
if _sys.platform == "win32":
|
||
import ctypes
|
||
drive = path[:3]
|
||
# GetDriveType: 2=Removable, 3=Fixed, 4=Remote(netværk), 5=CDROM, 6=RAMdisk
|
||
dtype = ctypes.windll.kernel32.GetDriveTypeW(drive)
|
||
return dtype not in (4,) # 4 = netværksdrev
|
||
else:
|
||
# Linux/Mac — tjek /proc/mounts
|
||
NETWORK_FS = {
|
||
"nfs", "nfs4", "cifs", "smb", "smb2", "smb3",
|
||
"fuse.sshfs", "fuse.gvfsd-fuse", "fuse.s3fs",
|
||
"davfs", "ncpfs", "afs", "glusterfs", "fuse.glusterfs",
|
||
}
|
||
try:
|
||
with open("/proc/mounts") as f:
|
||
mounts = []
|
||
for line in f:
|
||
parts = line.split()
|
||
if len(parts) >= 3:
|
||
mounts.append((parts[1], parts[2]))
|
||
# Find længste matchende mount-punkt
|
||
mounts.sort(key=lambda x: len(x[0]), reverse=True)
|
||
for mount_point, fs_type in mounts:
|
||
if path.startswith(mount_point):
|
||
return fs_type not in NETWORK_FS
|
||
except Exception:
|
||
pass
|
||
return True # Antag lokal
|
||
except Exception:
|
||
return True # Antag lokal ved fejl
|
||
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QWidget, QVBoxLayout, QListWidget, QListWidgetItem,
|
||
QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView,
|
||
QMessageBox, QStyledItemDelegate,
|
||
)
|
||
from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray, QRect
|
||
from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent, QPainter
|
||
|
||
|
||
ACTIVE_PLAYLIST_NAME = "__aktiv__"
|
||
|
||
|
||
class AvailabilityDelegate(QStyledItemDelegate):
|
||
"""Tegner en farvet dot til højre for hvert playlist-item."""
|
||
def paint(self, painter, option, index):
|
||
super().paint(painter, option, index)
|
||
color = index.data(Qt.ItemDataRole.UserRole + 1)
|
||
if not color:
|
||
return
|
||
painter.save()
|
||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||
painter.setBrush(QColor(color))
|
||
painter.setPen(Qt.PenStyle.NoPen)
|
||
r = option.rect
|
||
cx = r.right() - 10
|
||
cy = r.center().y()
|
||
painter.drawEllipse(cx - 5, cy - 5, 10, 10)
|
||
painter.restore()
|
||
|
||
|
||
class PlaylistPanel(QWidget):
|
||
song_selected = pyqtSignal(int)
|
||
status_changed = pyqtSignal(int, str)
|
||
song_dropped = pyqtSignal(dict)
|
||
playlist_changed = pyqtSignal()
|
||
event_started = pyqtSignal()
|
||
next_song_ready = pyqtSignal(dict) # udsendes når næste sang ændres — main_window indlæser den # udsendes af Start event — main_window indlæser første sang # udsendes ved enhver ændring → trigger autogem
|
||
sync_requested = pyqtSignal() # bed main_window om at køre sync (efter sletning)
|
||
|
||
STATUS_ICON = {"pending": " ", "playing": " ▶ ", "played": " ✓ ", "skipped": " — ", "next": " ▷ "}
|
||
STATUS_COLOR = {"pending": "#5a6070", "playing": "#e8a020", "played": "#2ecc71", "skipped": "#e74c3c", "next": "#3b8fd4"}
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self._songs: list[dict] = []
|
||
self._statuses: list[str] = []
|
||
self._current_idx = -1
|
||
self._song_ended = False
|
||
self._active_playlist_id: str | None = None
|
||
self._named_playlist_id: str | None = None # den indlæste/gemte navngivne liste
|
||
self._build_ui()
|
||
self.setAcceptDrops(True)
|
||
# Autogem-timer — venter 800ms efter sidst ændring
|
||
self._autosave_timer = QTimer(self)
|
||
self._autosave_timer.setSingleShot(True)
|
||
self._autosave_timer.setInterval(800)
|
||
self._autosave_timer.timeout.connect(self._autosave)
|
||
# Event-state gem — hurtig, kritisk for genopstart efter strømsvigt
|
||
self._event_state_timer = QTimer(self)
|
||
self._event_state_timer.setSingleShot(True)
|
||
self._event_state_timer.setInterval(300)
|
||
self._event_state_timer.timeout.connect(self._save_event_state)
|
||
|
||
def _build_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(0)
|
||
|
||
# ── Header med titel ──────────────────────────────────────────────────
|
||
header = QHBoxLayout()
|
||
header.setContentsMargins(10, 6, 10, 6)
|
||
self._title_label = QLabel("DANSELISTE")
|
||
self._title_label.setObjectName("section_title")
|
||
header.addWidget(self._title_label)
|
||
layout.addLayout(header)
|
||
|
||
# ── Ny / Gem / Hent knapper ───────────────────────────────────────────
|
||
toolbar = QHBoxLayout()
|
||
toolbar.setContentsMargins(8, 2, 8, 4)
|
||
toolbar.setSpacing(4)
|
||
|
||
btn_new = QPushButton("✚ Ny")
|
||
btn_new.setFixedHeight(26)
|
||
btn_new.setToolTip("Opret en ny tom danseliste")
|
||
btn_new.clicked.connect(self._new_playlist)
|
||
toolbar.addWidget(btn_new)
|
||
|
||
btn_save = QPushButton("💾 Gem som...")
|
||
btn_save.setFixedHeight(26)
|
||
btn_save.setToolTip("Gem aktuel liste med et navn")
|
||
btn_save.clicked.connect(self._save_as)
|
||
toolbar.addWidget(btn_save)
|
||
|
||
self._btn_save_current = QPushButton("💾 Gem")
|
||
self._btn_save_current.setFixedHeight(26)
|
||
self._btn_save_current.setToolTip("Gem ændringer til den indlæste liste")
|
||
self._btn_save_current.clicked.connect(self._save_current)
|
||
self._btn_save_current.setEnabled(False)
|
||
toolbar.addWidget(self._btn_save_current)
|
||
|
||
btn_load = QPushButton("📂 Hent...")
|
||
btn_load.setFixedHeight(26)
|
||
btn_load.setToolTip("Hent en tidligere gemt danseliste")
|
||
btn_load.clicked.connect(self._load_dialog)
|
||
toolbar.addWidget(btn_load)
|
||
|
||
self._btn_offline_check = QPushButton("⬤ Offline tjek")
|
||
self._btn_offline_check.setFixedHeight(26)
|
||
self._btn_offline_check.setToolTip("Kontrollér om alle sange er tilgængelige på denne maskine")
|
||
self._btn_offline_check.clicked.connect(self._check_offline)
|
||
toolbar.addWidget(self._btn_offline_check)
|
||
|
||
toolbar.addStretch()
|
||
|
||
self._lbl_autosave = QLabel("")
|
||
self._lbl_autosave.setObjectName("result_count")
|
||
toolbar.addWidget(self._lbl_autosave)
|
||
|
||
layout.addLayout(toolbar)
|
||
|
||
# ── Event-kontrol ─────────────────────────────────────────────────────
|
||
ctrl = QHBoxLayout()
|
||
ctrl.setContentsMargins(8, 2, 8, 4)
|
||
ctrl.setSpacing(6)
|
||
|
||
self._btn_start = QPushButton("▶ START EVENT")
|
||
self._btn_start.setFixedHeight(28)
|
||
self._btn_start.setToolTip("Nulstil alle statusser og gør klar til event")
|
||
self._btn_start.clicked.connect(self._start_event)
|
||
ctrl.addWidget(self._btn_start)
|
||
ctrl.addStretch()
|
||
|
||
self._lbl_progress = QLabel("0 / 0")
|
||
self._lbl_progress.setObjectName("result_count")
|
||
ctrl.addWidget(self._lbl_progress)
|
||
|
||
btn_info = QPushButton("ℹ")
|
||
btn_info.setFixedSize(24, 28)
|
||
btn_info.setToolTip("Danseliste-info: samlet tid, pause-tid m.m.")
|
||
btn_info.clicked.connect(self._show_playlist_info)
|
||
ctrl.addWidget(btn_info)
|
||
|
||
layout.addLayout(ctrl)
|
||
|
||
# Pause-tid per dans (skjult men kan vises via info)
|
||
try:
|
||
from ui.settings_dialog import load_settings
|
||
s = load_settings()
|
||
self._pause_seconds = s.get("between_seconds", 60)
|
||
self._workshop_seconds = s.get("workshop_minutes", 10) * 60
|
||
except Exception:
|
||
self._pause_seconds = 60
|
||
self._workshop_seconds = 600
|
||
|
||
# ── Liste ─────────────────────────────────────────────────────────────
|
||
self._list = QListWidget()
|
||
self._list.setItemDelegate(AvailabilityDelegate(self._list))
|
||
self._list.setObjectName("playlist_list")
|
||
self._list.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
|
||
self._list.setDefaultDropAction(Qt.DropAction.MoveAction)
|
||
self._list.setAcceptDrops(True)
|
||
self._list.itemDoubleClicked.connect(self._on_double_click)
|
||
self._list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||
self._list.customContextMenuRequested.connect(self._show_context_menu)
|
||
self._list.model().rowsMoved.connect(self._on_rows_moved)
|
||
layout.addWidget(self._list)
|
||
|
||
# ── Drag & drop ───────────────────────────────────────────────────────────
|
||
|
||
def dragEnterEvent(self, event: QDragEnterEvent):
|
||
if event.mimeData().hasFormat("application/x-linedance-song"):
|
||
event.acceptProposedAction()
|
||
else:
|
||
event.ignore()
|
||
|
||
def dropEvent(self, event: QDropEvent):
|
||
mime = event.mimeData()
|
||
if mime.hasFormat("application/x-linedance-song"):
|
||
import json
|
||
song = json.loads(mime.data("application/x-linedance-song").data().decode())
|
||
self._append_song(song)
|
||
self.song_dropped.emit(song)
|
||
event.acceptProposedAction()
|
||
|
||
def _append_song(self, song: dict):
|
||
self._songs.append(song)
|
||
self._statuses.append("pending")
|
||
self._refresh()
|
||
self._trigger_autosave()
|
||
|
||
# ── Data API ──────────────────────────────────────────────────────────────
|
||
|
||
def load_songs(self, songs: list[dict], reset_statuses: bool = True, name: str = ""):
|
||
self._songs = list(songs)
|
||
if reset_statuses:
|
||
self._statuses = ["pending"] * len(songs)
|
||
self._current_idx = -1
|
||
self._song_ended = False
|
||
if name:
|
||
self._title_label.setText(f"DANSELISTE — {name.upper()}")
|
||
self._check_availability(self._songs)
|
||
self._refresh()
|
||
self._trigger_autosave()
|
||
|
||
def set_current(self, idx: int, song_ended: bool = False):
|
||
self._current_idx = idx
|
||
self._song_ended = song_ended
|
||
if 0 <= idx < len(self._statuses) and not song_ended:
|
||
self._statuses[idx] = "playing"
|
||
self._refresh()
|
||
self._scroll_to(idx)
|
||
|
||
def mark_played(self, idx: int):
|
||
if 0 <= idx < len(self._statuses):
|
||
self._statuses[idx] = "played"
|
||
self._refresh()
|
||
self._trigger_autosave()
|
||
self._trigger_event_state_save()
|
||
|
||
def set_next_ready(self, idx: int):
|
||
"""Sæt næste sang klar — uden at overskrive skipped/played statusser."""
|
||
# Nulstil forrige current hvis den stadig er playing
|
||
old = self._current_idx
|
||
if old != idx and 0 <= old < len(self._statuses):
|
||
if self._statuses[old] == "playing":
|
||
self._statuses[old] = "pending"
|
||
self._current_idx = idx
|
||
self._song_ended = False
|
||
# Ændr KUN status hvis den er pending — rør ikke skipped/played
|
||
if 0 <= idx < len(self._statuses):
|
||
if self._statuses[idx] not in ("skipped", "played"):
|
||
self._statuses[idx] = "pending"
|
||
self._refresh()
|
||
self._scroll_to(idx)
|
||
|
||
def get_song(self, idx: int) -> dict | None:
|
||
return self._songs[idx] if 0 <= idx < len(self._songs) else None
|
||
|
||
def get_songs(self) -> list[dict]:
|
||
return list(self._songs)
|
||
|
||
def get_statuses(self) -> list[str]:
|
||
return list(self._statuses)
|
||
|
||
def count(self) -> int:
|
||
return len(self._songs)
|
||
|
||
def set_playlist_name(self, name: str):
|
||
self._title_label.setText(f"DANSELISTE — {name.upper()}")
|
||
|
||
# ── Drag-flytning ─────────────────────────────────────────────────────────
|
||
|
||
def _on_rows_moved(self, parent, start, end, dest, dest_row):
|
||
"""Opdater _songs og _statuses når en sang flyttes via drag."""
|
||
# Husk hvilken sang der er aktiv
|
||
current_song_id = None
|
||
if 0 <= self._current_idx < len(self._songs):
|
||
current_song_id = self._songs[self._current_idx].get("id")
|
||
|
||
new_songs = []
|
||
new_statuses = []
|
||
for i in range(self._list.count()):
|
||
old_idx = self._list.item(i).data(Qt.ItemDataRole.UserRole)
|
||
if old_idx is not None and 0 <= old_idx < len(self._songs):
|
||
new_songs.append(self._songs[old_idx])
|
||
new_statuses.append(self._statuses[old_idx])
|
||
self._songs = new_songs
|
||
self._statuses = new_statuses
|
||
|
||
# Gendan current_idx til den sang der stadig spiller
|
||
if current_song_id:
|
||
for i, s in enumerate(self._songs):
|
||
if s.get("id") == current_song_id:
|
||
self._current_idx = i
|
||
break
|
||
else:
|
||
self._current_idx = -1
|
||
|
||
self._song_ended = False
|
||
self._refresh()
|
||
self._trigger_autosave()
|
||
# Emit IKKE next_song_ready — afspilning fortsætter uforstyrret
|
||
|
||
# ── Event-state ───────────────────────────────────────────────────────────
|
||
|
||
def _save_event_state(self):
|
||
"""Gem current_idx og statuses — overlever strømsvigt."""
|
||
try:
|
||
from local.local_db import save_event_state
|
||
save_event_state(self._current_idx, self._statuses)
|
||
except Exception as e:
|
||
pass
|
||
|
||
def _trigger_event_state_save(self):
|
||
self._event_state_timer.start()
|
||
|
||
def restore_event_state(self) -> bool:
|
||
"""Gendan gemt event-fremgang. Returnerer True hvis gendannet."""
|
||
try:
|
||
from local.local_db import load_event_state
|
||
result = load_event_state()
|
||
if not result:
|
||
return False
|
||
idx, statuses = result
|
||
if len(statuses) != len(self._songs):
|
||
return False # listen er ændret siden sidst
|
||
self._statuses = statuses
|
||
self._current_idx = idx
|
||
self._song_ended = False
|
||
self._refresh()
|
||
return True
|
||
except Exception as e:
|
||
pass
|
||
return False
|
||
|
||
def get_named_playlist_id(self) -> str | None:
|
||
return self._named_playlist_id
|
||
|
||
def next_playable_idx(self) -> int | None:
|
||
"""Find første sang fra toppen der ikke er afspillet, sprunget over eller i gang."""
|
||
for i in range(len(self._songs)):
|
||
if self._statuses[i] not in ("skipped", "played", "playing"):
|
||
if i == self._current_idx and not self._song_ended:
|
||
continue
|
||
return i
|
||
return None
|
||
|
||
# ── Autogem ───────────────────────────────────────────────────────────────
|
||
|
||
def _trigger_autosave(self):
|
||
"""Start/nulstil debounce-timer — gemmer 800ms efter sidst ændring."""
|
||
self._autosave_timer.start()
|
||
self._lbl_autosave.setText("● ikke gemt")
|
||
|
||
def _autosave(self):
|
||
"""Gem til '__aktiv__' OG til den navngivne liste hvis der er én."""
|
||
try:
|
||
from local.local_db import get_db, create_playlist, add_song_to_playlist
|
||
with get_db() as conn:
|
||
conn.execute(
|
||
"DELETE FROM playlists WHERE name=?", (ACTIVE_PLAYLIST_NAME,)
|
||
)
|
||
pl_id = create_playlist(ACTIVE_PLAYLIST_NAME)
|
||
self._active_playlist_id = pl_id
|
||
for i, song in enumerate(self._songs, start=1):
|
||
if song.get("id"):
|
||
add_song_to_playlist(pl_id, song["id"], position=i)
|
||
|
||
# Gem også til den navngivne liste
|
||
if self._named_playlist_id:
|
||
with get_db() as conn:
|
||
conn.execute(
|
||
"DELETE FROM playlist_songs WHERE playlist_id=?",
|
||
(self._named_playlist_id,)
|
||
)
|
||
for i, song in enumerate(self._songs, start=1):
|
||
if song.get("id"):
|
||
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
|
||
import uuid as _uuid
|
||
conn.execute(
|
||
"INSERT INTO playlist_songs "
|
||
"(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override) "
|
||
"VALUES (?,?,?,?,?,?,?,?)",
|
||
(str(_uuid.uuid4()), self._named_playlist_id,
|
||
song["id"], song.get("file_id"),
|
||
i, status,
|
||
1 if song.get("is_workshop") else 0,
|
||
song.get("active_dance") or "")
|
||
)
|
||
|
||
self._lbl_autosave.setText("✓ gemt")
|
||
self.playlist_changed.emit()
|
||
except Exception as e:
|
||
self._lbl_autosave.setText("⚠ gemfejl")
|
||
|
||
def _save_named_playlist_id(self, pl_id: str | None):
|
||
"""Gem hvilken navngiven liste der er aktiv — til brug ved næste opstart."""
|
||
from PyQt6.QtCore import QSettings
|
||
s = QSettings("LineDance", "Player")
|
||
if pl_id:
|
||
s.setValue("session/named_playlist_id", pl_id)
|
||
else:
|
||
s.remove("session/named_playlist_id")
|
||
|
||
def restore_active_playlist(self):
|
||
"""Gendan senest aktive navngivne liste med event-fremgang ved opstart."""
|
||
try:
|
||
from PyQt6.QtCore import QSettings
|
||
s = QSettings("LineDance", "Player")
|
||
pl_id = s.value("session/named_playlist_id", None, type=str)
|
||
if not pl_id:
|
||
return False
|
||
|
||
import sqlite3
|
||
from local.local_db import DB_PATH
|
||
conn = sqlite3.connect(str(DB_PATH))
|
||
conn.row_factory = sqlite3.Row
|
||
|
||
# Verificer at listen stadig eksisterer
|
||
pl = conn.execute(
|
||
"SELECT id, name FROM playlists WHERE id=?", (pl_id,)
|
||
).fetchone()
|
||
if not pl:
|
||
conn.close()
|
||
return False
|
||
|
||
# Hent sange med status, workshop og dans-override
|
||
# JOIN songs — sangen er altid i songs tabellen (oprettet ved pull med file_missing=1)
|
||
# file_missing betyder bare at filen ikke er på denne maskine
|
||
songs_raw = conn.execute("""
|
||
SELECT s.id, s.title, s.artist, s.album,
|
||
s.bpm, s.duration_sec,
|
||
ps.file_id,
|
||
f.local_path, f.file_format,
|
||
COALESCE(f.file_missing, 1) as file_missing,
|
||
ps.position, ps.status, ps.is_workshop, ps.dance_override
|
||
FROM playlist_songs ps
|
||
JOIN songs s ON s.id = ps.song_id
|
||
LEFT JOIN files f ON f.id = ps.file_id
|
||
WHERE ps.playlist_id=? ORDER BY ps.position
|
||
""", (pl_id,)).fetchall()
|
||
|
||
songs = []
|
||
statuses = []
|
||
for row in songs_raw:
|
||
dances = conn.execute("""
|
||
SELECT d.name FROM song_dances sd
|
||
JOIN dances d ON d.id = sd.dance_id
|
||
WHERE sd.song_id=? ORDER BY sd.dance_order
|
||
""", (row["id"],)).fetchall()
|
||
dance_names = [d["name"] for d in dances]
|
||
override = row["dance_override"] or ""
|
||
active_dance = override if override else (dance_names[0] if dance_names else "")
|
||
|
||
local_path = row["local_path"] or ""
|
||
file_missing = bool(row["file_missing"])
|
||
|
||
# Forsøg at finde en anden fil lokalt hvis den specifikke mangler
|
||
if file_missing or not local_path:
|
||
match = conn.execute(
|
||
"SELECT f.local_path FROM files f "
|
||
"WHERE f.song_id=? AND f.file_missing=0 LIMIT 1",
|
||
(row["id"],)
|
||
).fetchone()
|
||
if match:
|
||
local_path = match["local_path"]
|
||
file_missing = False
|
||
|
||
songs.append({
|
||
"id": row["id"],
|
||
"title": row["title"],
|
||
"artist": row["artist"],
|
||
"album": row["album"] or "",
|
||
"bpm": row["bpm"] or 0,
|
||
"duration_sec": row["duration_sec"] or 0,
|
||
"file_id": row["file_id"] if "file_id" in row.keys() else None,
|
||
"local_path": local_path,
|
||
"file_format": row["file_format"] or "",
|
||
"file_missing": file_missing,
|
||
"dances": dance_names,
|
||
"active_dance": active_dance,
|
||
"alt_dance": row["alt_dance_override"] if "alt_dance_override" in row.keys() else "",
|
||
"is_workshop": bool(row["is_workshop"]),
|
||
})
|
||
statuses.append(row["status"] or "pending")
|
||
conn.close()
|
||
|
||
if songs:
|
||
self._songs = songs
|
||
# Rens "playing" — må kun være én ad gangen
|
||
self._statuses = [
|
||
"pending" if s == "playing" else s
|
||
for s in statuses
|
||
]
|
||
self._named_playlist_id = pl_id
|
||
self._current_idx = -1
|
||
self._song_ended = False
|
||
self._btn_save_current.setEnabled(True)
|
||
self._btn_save_current.setToolTip(f"Gem ændringer til '{pl['name']}'")
|
||
self._title_label.setText(f"DANSELISTE — {pl['name'].upper()}")
|
||
self._lbl_autosave.setText("✓ gendannet")
|
||
self._check_availability(self._songs)
|
||
|
||
# Find næste uafspillede
|
||
ni = self.next_playable_idx()
|
||
if ni is not None:
|
||
self._current_idx = ni
|
||
|
||
self._refresh()
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
# ── Ny / Gem som / Hent ───────────────────────────────────────────────────
|
||
|
||
def _new_playlist(self):
|
||
if self._songs:
|
||
reply = QMessageBox.question(
|
||
self, "Ny danseliste",
|
||
"Ryd den aktuelle liste og start forfra?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
self._songs = []
|
||
self._statuses = []
|
||
self._current_idx = -1
|
||
self._song_ended = False
|
||
self._named_playlist_id = None
|
||
self._btn_save_current.setEnabled(False)
|
||
self._btn_save_current.setToolTip("Gem ændringer til den indlæste liste")
|
||
self._save_named_playlist_id(None)
|
||
self._title_label.setText("DANSELISTE — NY")
|
||
self._refresh()
|
||
self._trigger_autosave()
|
||
|
||
def _save_as(self):
|
||
if not self._songs:
|
||
QMessageBox.information(self, "Gem", "Danselisten er tom.")
|
||
return
|
||
from ui.playlist_browser import PlaylistBrowserDialog
|
||
current_name = self._title_label.text().replace("DANSELISTE — ", "").replace("DANSELISTE", "").strip()
|
||
dialog = PlaylistBrowserDialog(
|
||
mode="save",
|
||
current_songs=self._songs,
|
||
current_name=current_name,
|
||
parent=self.window()
|
||
)
|
||
def on_saved(pl_id, name):
|
||
self._named_playlist_id = pl_id
|
||
self._title_label.setText(f"DANSELISTE — {name.upper()}")
|
||
self._lbl_autosave.setText(f"✓ gemt som \"{name}\"")
|
||
self._btn_save_current.setEnabled(True)
|
||
self._btn_save_current.setToolTip(f"Gem ændringer til '{name}'")
|
||
self._save_named_playlist_id(pl_id)
|
||
dialog.playlist_selected.connect(on_saved)
|
||
dialog.sync_requested.connect(self._request_sync)
|
||
dialog.exec()
|
||
|
||
def _save_current(self):
|
||
"""Gem ændringer tilbage til den aktuelt indlæste navngivne liste."""
|
||
if not self._named_playlist_id:
|
||
return
|
||
if not self._songs:
|
||
QMessageBox.information(self, "Gem", "Danselisten er tom.")
|
||
return
|
||
try:
|
||
from local.local_db import get_db
|
||
with get_db() as conn:
|
||
conn.execute(
|
||
"DELETE FROM playlist_songs WHERE playlist_id=?",
|
||
(self._named_playlist_id,)
|
||
)
|
||
for i, song in enumerate(self._songs, start=1):
|
||
if song.get("id"):
|
||
status = self._statuses[i-1] if i-1 < len(self._statuses) else "pending"
|
||
import uuid as _uuid
|
||
conn.execute(
|
||
"INSERT INTO playlist_songs "
|
||
"(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override) "
|
||
"VALUES (?,?,?,?,?,?,?,?)",
|
||
(str(_uuid.uuid4()), self._named_playlist_id,
|
||
song["id"], song.get("file_id"),
|
||
i, status,
|
||
1 if song.get("is_workshop") else 0,
|
||
song.get("active_dance") or "")
|
||
)
|
||
self._lbl_autosave.setText("✓ gemt")
|
||
|
||
# Push til server hvis linket med edit-rettighed
|
||
if getattr(self, "_can_edit_server", False):
|
||
from local.local_db import get_db as _gdb
|
||
with _gdb() as c:
|
||
meta = c.execute(
|
||
"SELECT api_project_id FROM playlists WHERE id=?",
|
||
(self._named_playlist_id,)
|
||
).fetchone()
|
||
if meta and meta["api_project_id"]:
|
||
self._push_linked_playlist(
|
||
self._named_playlist_id, meta["api_project_id"]
|
||
)
|
||
self._lbl_autosave.setText("✓ gemt og synkroniseret")
|
||
|
||
except Exception as e:
|
||
QMessageBox.warning(self, "Fejl", f"Kunne ikke gemme: {e}")
|
||
|
||
def _request_sync(self):
|
||
"""Bobl sync-anmodning op til main_window."""
|
||
self.sync_requested.emit()
|
||
|
||
def _load_dialog(self):
|
||
from ui.playlist_browser import PlaylistBrowserDialog
|
||
dialog = PlaylistBrowserDialog(mode="load", parent=self.window())
|
||
dialog.playlist_selected.connect(self._load_playlist_by_id)
|
||
dialog.sync_requested.connect(self._request_sync)
|
||
dialog.exec()
|
||
|
||
def _load_playlist_by_id(self, pl_id: str, pl_name: str):
|
||
try:
|
||
from local.local_db import get_db
|
||
|
||
# Tjek om listen er linket til serveren — pull først
|
||
with get_db() as conn:
|
||
pl_meta = conn.execute(
|
||
"SELECT api_project_id, is_linked, server_permission "
|
||
"FROM playlists WHERE id=?", (pl_id,)
|
||
).fetchone()
|
||
|
||
if pl_meta and pl_meta["is_linked"] and pl_meta["api_project_id"]:
|
||
self._pull_linked_playlist(pl_id, pl_meta["api_project_id"])
|
||
# Opdater gem-knap baseret på rettighed
|
||
perm = pl_meta["server_permission"] or "view"
|
||
self._named_playlist_id = pl_id
|
||
self._can_edit_server = (perm == "edit")
|
||
else:
|
||
self._can_edit_server = False
|
||
with get_db() as conn:
|
||
# JOIN songs — sangen er altid i songs tabellen (oprettet ved pull med file_missing=1)
|
||
songs_raw = conn.execute("""
|
||
SELECT s.id, s.title, s.artist, s.album,
|
||
s.bpm, s.duration_sec,
|
||
ps.file_id,
|
||
f.local_path, f.file_format,
|
||
COALESCE(f.file_missing, 1) as file_missing,
|
||
ps.position, ps.status, ps.is_workshop, ps.dance_override
|
||
FROM playlist_songs ps
|
||
JOIN songs s ON s.id = ps.song_id
|
||
LEFT JOIN files f ON f.id = ps.file_id
|
||
WHERE ps.playlist_id=? ORDER BY ps.position
|
||
""", (pl_id,)).fetchall()
|
||
songs = []
|
||
statuses = []
|
||
repaired = 0
|
||
for row in songs_raw:
|
||
dances = conn.execute("""
|
||
SELECT d.name FROM song_dances sd
|
||
JOIN dances d ON d.id = sd.dance_id
|
||
WHERE sd.song_id=? ORDER BY sd.dance_order
|
||
""", (row["id"],)).fetchall()
|
||
dance_names = [d["name"] for d in dances]
|
||
override = row["dance_override"] or ""
|
||
active_dance = override if override else (dance_names[0] if dance_names else "")
|
||
|
||
local_path = row["local_path"] or ""
|
||
file_missing = bool(row["file_missing"])
|
||
|
||
# Forsøg at finde en anden fil lokalt hvis den specifikke mangler
|
||
if file_missing or not local_path:
|
||
match = conn.execute(
|
||
"SELECT f.local_path FROM files f "
|
||
"WHERE f.song_id=? AND f.file_missing=0 LIMIT 1",
|
||
(row["id"],)
|
||
).fetchone()
|
||
if match:
|
||
local_path = match["local_path"]
|
||
file_missing = False
|
||
repaired += 1
|
||
|
||
songs.append({
|
||
"id": row["id"],
|
||
"title": row["title"],
|
||
"artist": row["artist"],
|
||
"album": row["album"] or "",
|
||
"bpm": row["bpm"] or 0,
|
||
"duration_sec": row["duration_sec"] or 0,
|
||
"file_id": row["file_id"] if "file_id" in row.keys() else None,
|
||
"local_path": local_path,
|
||
"file_format": row["file_format"] or "",
|
||
"file_missing": file_missing,
|
||
"dances": dance_names,
|
||
"active_dance": active_dance,
|
||
"is_workshop": bool(row["is_workshop"]),
|
||
})
|
||
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
|
||
|
||
# Vis link-indikator i titlen
|
||
is_linked = pl_meta and pl_meta["is_linked"]
|
||
perm = pl_meta["server_permission"] if is_linked else "edit"
|
||
link_icon = " 🔗" if is_linked else ""
|
||
self._title_label.setText(f"DANSELISTE — {pl_name.upper()}{link_icon}")
|
||
|
||
status_txt = f"✓ indlæst — {repaired} sange fundet lokalt" if repaired else "✓ indlæst"
|
||
if is_linked:
|
||
status_txt += f" ({perm})"
|
||
self._lbl_autosave.setText(status_txt)
|
||
|
||
# Gem-knap: deaktiver hvis view-only linket liste
|
||
can_save = not is_linked or perm == "edit"
|
||
self._btn_save_current.setEnabled(can_save)
|
||
self._btn_save_current.setToolTip(
|
||
f"Gem ændringer til '{pl_name}'" if can_save
|
||
else "Du har kun læse-adgang til denne delte liste"
|
||
)
|
||
self._save_named_playlist_id(pl_id)
|
||
self._refresh()
|
||
self._trigger_autosave()
|
||
except Exception as e:
|
||
QMessageBox.warning(self, "Fejl", f"Kunne ikke indlæse listen: {e}")
|
||
|
||
# ── Start event ───────────────────────────────────────────────────────────
|
||
|
||
def _show_playlist_info(self):
|
||
"""Åbn/luk det flydende danseliste-info vindue."""
|
||
try:
|
||
if hasattr(self, "_info_window") and self._info_window \
|
||
and self._info_window.isVisible():
|
||
self._info_window.close()
|
||
self._info_window = None
|
||
return
|
||
except RuntimeError:
|
||
self._info_window = None
|
||
|
||
# Opdater defaults fra indstillinger ved åbning
|
||
try:
|
||
from ui.settings_dialog import load_settings
|
||
s = load_settings()
|
||
self._pause_seconds = s.get("between_seconds", 60)
|
||
self._workshop_seconds = s.get("workshop_minutes", 10) * 60
|
||
except Exception:
|
||
pass
|
||
|
||
from ui.playlist_info_dialog import PlaylistInfoWindow
|
||
from PyQt6.QtWidgets import QApplication
|
||
from PyQt6.QtCore import QSettings
|
||
main = self.window()
|
||
self._info_window = PlaylistInfoWindow(self, parent=main)
|
||
QApplication.instance().aboutToQuit.connect(self._info_window.close)
|
||
|
||
# Gendan gemt position eller placer ved siden af hovedvindue
|
||
s = QSettings("LineDance", "Player")
|
||
pos = s.value("window/info_pos")
|
||
if pos:
|
||
self._info_window.move(pos)
|
||
elif main:
|
||
geo = main.geometry()
|
||
self._info_window.move(geo.right() + 10, geo.top())
|
||
|
||
# Sikr at vinduet er på skærmen
|
||
screen = QApplication.primaryScreen().availableGeometry()
|
||
w_geo = self._info_window.frameGeometry()
|
||
x = max(0, min(w_geo.x(), screen.right() - w_geo.width()))
|
||
y = max(0, min(w_geo.y(), screen.bottom() - w_geo.height()))
|
||
self._info_window.move(x, y)
|
||
self._info_window.show()
|
||
|
||
def _change_dance(self, idx: int, song: dict):
|
||
"""Lad brugeren vælge/skrive hvilken dans der vises for dette nummer."""
|
||
from ui.dance_picker_dialog import DancePickerDialog
|
||
dances = song.get("dances", [])
|
||
current = song.get("active_dance", "")
|
||
if not current:
|
||
current = dances[0] if dances else ""
|
||
current_choreo = song.get("active_choreo", "")
|
||
|
||
# Afgør om valget er permanent eller midlertidigt
|
||
# Permanent: ingen dans tagget, eller valgt dans er ikke i de taggede
|
||
# Midlertidig: sangen har flere danse og brugeren vælger en af dem
|
||
|
||
dlg = DancePickerDialog(
|
||
current_dance=current,
|
||
song_title=song.get("title", ""),
|
||
existing_dances=dances,
|
||
parent=self.window()
|
||
)
|
||
if dlg.exec():
|
||
chosen = dlg.get_dance()
|
||
# Dans-valg i playlisten er altid midlertidigt — kun dance_override
|
||
song["active_dance"] = chosen # tom streng = ingen dans
|
||
self._refresh()
|
||
self._sync_dance_to_db(idx, song)
|
||
|
||
def _change_alt_dance(self, idx: int, song: dict):
|
||
"""Lad brugeren vælge alternativ dans til denne sang i playlisten."""
|
||
from ui.alt_dance_picker_dialog import AltDancePickerDialog
|
||
dlg = AltDancePickerDialog(song, parent=self.window())
|
||
if dlg.exec():
|
||
if dlg.was_cleared():
|
||
chosen = ""
|
||
else:
|
||
chosen = dlg.get_dance()
|
||
rating = dlg.get_rating()
|
||
song["alt_dance"] = chosen
|
||
self._refresh()
|
||
# Gem alt_dance_override på playlist_songs
|
||
self._sync_alt_dance_to_db(idx, song, chosen)
|
||
# Gem rating hvis givet
|
||
if chosen and rating is not None:
|
||
self._save_alt_dance_rating(song, chosen, rating)
|
||
|
||
def _sync_alt_dance_to_db(self, idx: int, song: dict, alt_dance: str):
|
||
"""Gem alt_dance_override til playlist_songs."""
|
||
if not self._named_playlist_id:
|
||
return
|
||
try:
|
||
from local.local_db import get_db
|
||
with get_db() as conn:
|
||
conn.execute(
|
||
"UPDATE playlist_songs SET alt_dance_override=? "
|
||
"WHERE playlist_id=? AND position=?",
|
||
(alt_dance, self._named_playlist_id, idx + 1)
|
||
)
|
||
except Exception as e:
|
||
import logging
|
||
logging.getLogger(__name__).warning(f"alt_dance_to_db fejl: {e}")
|
||
|
||
def _save_alt_dance_rating(self, song: dict, dance_name: str, rating: int):
|
||
"""Gem brugerens rating på en alternativ-dans."""
|
||
import uuid
|
||
song_id = song.get("id", "")
|
||
try:
|
||
from local.local_db import get_db
|
||
with get_db() as conn:
|
||
# Find dance_id
|
||
dance_row = conn.execute(
|
||
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
|
||
(dance_name,)
|
||
).fetchone()
|
||
if not dance_row:
|
||
return
|
||
dance_id = dance_row["id"]
|
||
# Opdater eller indsæt rating
|
||
existing = conn.execute(
|
||
"SELECT id FROM song_alt_dances WHERE song_id=? AND dance_id=?",
|
||
(song_id, dance_id)
|
||
).fetchone()
|
||
if existing:
|
||
conn.execute(
|
||
"UPDATE song_alt_dances SET user_rating=? WHERE song_id=? AND dance_id=?",
|
||
(rating, song_id, dance_id)
|
||
)
|
||
else:
|
||
conn.execute(
|
||
"INSERT INTO song_alt_dances (id, song_id, dance_id, user_rating) VALUES (?,?,?,?)",
|
||
(str(uuid.uuid4()), song_id, dance_id, rating)
|
||
)
|
||
except Exception as e:
|
||
import logging
|
||
logging.getLogger(__name__).warning(f"save_alt_dance_rating fejl: {e}")
|
||
|
||
def _sync_dance_to_db(self, idx: int, song: dict):
|
||
"""Gem dance_override til playlist_songs (midlertidigt valg)."""
|
||
import logging
|
||
_log = logging.getLogger(__name__)
|
||
if not self._named_playlist_id:
|
||
_log.warning("_sync_dance_to_db: ingen named_playlist_id")
|
||
return
|
||
try:
|
||
from local.local_db import get_db
|
||
dance_val = song.get("active_dance") or ""
|
||
with get_db() as conn:
|
||
rows_affected = conn.execute(
|
||
"UPDATE playlist_songs SET dance_override=? "
|
||
"WHERE playlist_id=? AND position=?",
|
||
(dance_val, self._named_playlist_id, idx + 1)
|
||
).rowcount
|
||
_log.info(f"dance_override='{dance_val}' gemt på position {idx+1}, {rows_affected} rækker")
|
||
except Exception as e:
|
||
import logging
|
||
logging.getLogger(__name__).warning(f"_sync_dance_to_db fejl: {e}")
|
||
|
||
def _save_dance_permanently(self, idx: int, song: dict, dance_name: str, choreo: str = ""):
|
||
"""
|
||
Gem dans permanent på sangen:
|
||
1. song_dances tabellen
|
||
2. ID3-tag i filen (hvis tilgængelig)
|
||
3. Opdater sang-dict så listen vises korrekt
|
||
"""
|
||
import uuid
|
||
song_id = song.get("id", "")
|
||
local_path = song.get("local_path", "")
|
||
|
||
try:
|
||
from local.local_db import get_db
|
||
with get_db() as conn:
|
||
# Find eller opret dans
|
||
dance_row = conn.execute(
|
||
"SELECT id FROM dances WHERE name=? COLLATE NOCASE LIMIT 1",
|
||
(dance_name,)
|
||
).fetchone()
|
||
if dance_row:
|
||
dance_id = dance_row["id"]
|
||
if choreo:
|
||
conn.execute(
|
||
"UPDATE dances SET choreographer=? WHERE id=? AND choreographer=''",
|
||
(choreo, dance_id)
|
||
)
|
||
else:
|
||
cur = conn.execute(
|
||
"INSERT INTO dances (name, choreographer) VALUES (?,?)",
|
||
(dance_name, choreo or "")
|
||
)
|
||
dance_id = cur.lastrowid
|
||
|
||
# Tilføj til song_dances
|
||
existing = conn.execute(
|
||
"SELECT id FROM song_dances WHERE song_id=? AND dance_id=?",
|
||
(song_id, dance_id)
|
||
).fetchone()
|
||
if not existing:
|
||
# Find næste dance_order
|
||
max_order = conn.execute(
|
||
"SELECT MAX(dance_order) FROM song_dances WHERE song_id=?",
|
||
(song_id,)
|
||
).fetchone()[0] or 0
|
||
conn.execute(
|
||
"INSERT INTO song_dances (id, song_id, dance_id, dance_order) VALUES (?,?,?,?)",
|
||
(str(uuid.uuid4()), song_id, dance_id, max_order + 1)
|
||
)
|
||
|
||
# Opdater sang-dict
|
||
dances = song.get("dances", [])
|
||
if dance_name not in dances:
|
||
dances.append(dance_name)
|
||
song["dances"] = dances
|
||
song["active_dance"] = dance_name
|
||
|
||
# Gem i ID3-tag hvis filen er tilgængelig
|
||
if local_path:
|
||
try:
|
||
from local.tag_reader import write_dance_to_file
|
||
write_dance_to_file(local_path, dances)
|
||
except Exception:
|
||
pass
|
||
|
||
# Opdater også dance_override på listen
|
||
self._sync_dance_to_db(idx, song)
|
||
|
||
import logging
|
||
logging.getLogger(__name__).info(
|
||
f"Dans gemt permanent: '{dance_name}' → '{song.get('title','?')}'"
|
||
)
|
||
|
||
except Exception as e:
|
||
import logging
|
||
logging.getLogger(__name__).warning(f"Kunne ikke gemme dans permanent: {e}")
|
||
|
||
def _sync_ws_to_db(self, idx: int, song: dict):
|
||
"""Gem is_workshop til playlist_songs — både navngiven og aktiv liste."""
|
||
pl_ids = []
|
||
if self._named_playlist_id:
|
||
pl_ids.append(self._named_playlist_id)
|
||
if self._active_playlist_id:
|
||
pl_ids.append(self._active_playlist_id)
|
||
if not pl_ids:
|
||
return
|
||
try:
|
||
from local.local_db import get_db
|
||
with get_db() as conn:
|
||
for pl_id in pl_ids:
|
||
conn.execute(
|
||
"UPDATE playlist_songs SET is_workshop=? "
|
||
"WHERE playlist_id=? AND position=?",
|
||
(1 if song.get("is_workshop") else 0, pl_id, idx + 1)
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
def _pull_linked_playlist(self, pl_id: str, server_id: str):
|
||
"""Hent seneste version af en linket liste fra serveren — i baggrundstråd."""
|
||
import threading
|
||
import uuid
|
||
|
||
def _do_pull():
|
||
try:
|
||
from ui.settings_dialog import load_settings
|
||
from local.local_db import DB_PATH
|
||
import sqlite3, urllib.request, json
|
||
|
||
s = load_settings()
|
||
server_url = s.get("server_url", "").rstrip("/")
|
||
mw = self.window()
|
||
token = getattr(mw, "_api_token", None)
|
||
if not token or not server_url:
|
||
return
|
||
|
||
req = urllib.request.Request(
|
||
f"{server_url}/sharing/playlists/{server_id}",
|
||
headers={"Authorization": f"Bearer {token}"}
|
||
)
|
||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||
pl_data = json.loads(resp.read())
|
||
|
||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||
conn.row_factory = sqlite3.Row
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.execute("DELETE FROM playlist_songs WHERE playlist_id=?", (pl_id,))
|
||
|
||
position = 1
|
||
for song_data in pl_data.get("songs", []):
|
||
title = song_data.get("title", "")
|
||
artist = song_data.get("artist", "")
|
||
if not title:
|
||
continue
|
||
# Find sang via titel+artist
|
||
local = conn.execute(
|
||
"SELECT s.id FROM songs s "
|
||
"JOIN files f ON f.song_id = s.id AND f.file_missing=0 "
|
||
"WHERE s.title=? AND s.artist=? LIMIT 1",
|
||
(title, artist)
|
||
).fetchone()
|
||
if not local:
|
||
# Sang mangler lokalt — opret som missing
|
||
local = conn.execute(
|
||
"SELECT id FROM songs WHERE title=? AND artist=? LIMIT 1",
|
||
(title, artist)
|
||
).fetchone()
|
||
if not local:
|
||
new_id = str(uuid.uuid4())
|
||
conn.execute(
|
||
"INSERT INTO songs (id, title, artist) VALUES (?,?,?)",
|
||
(new_id, title, artist)
|
||
)
|
||
song_id = new_id
|
||
else:
|
||
song_id = local["id"]
|
||
|
||
# Find fil
|
||
file_row = conn.execute(
|
||
"SELECT id FROM files WHERE song_id=? AND file_missing=0 LIMIT 1",
|
||
(song_id,)
|
||
).fetchone()
|
||
file_id = file_row["id"] if file_row else None
|
||
|
||
conn.execute(
|
||
"INSERT INTO playlist_songs "
|
||
"(id, playlist_id, song_id, file_id, position, status, is_workshop, dance_override) "
|
||
"VALUES (?,?,?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), pl_id, song_id, file_id,
|
||
position, song_data.get("status", "pending"),
|
||
1 if song_data.get("is_workshop") else 0,
|
||
song_data.get("dance_override") or "")
|
||
)
|
||
position += 1
|
||
|
||
conn.commit()
|
||
conn.close()
|
||
except Exception:
|
||
pass # Offline — brug lokalt cachet version
|
||
|
||
threading.Thread(target=_do_pull, daemon=True).start()
|
||
|
||
def _push_linked_playlist(self, pl_id: str, server_id: str):
|
||
"""Push ændringer til server for en linket liste."""
|
||
try:
|
||
from ui.settings_dialog import load_settings
|
||
from local.local_db import DB_PATH
|
||
s = load_settings()
|
||
server_url = s.get("server_url", "").rstrip("/")
|
||
mw = self.window()
|
||
token = getattr(mw, "_api_token", None)
|
||
if not token or not server_url:
|
||
return
|
||
|
||
import sqlite3, json, urllib.request
|
||
conn = sqlite3.connect(str(DB_PATH))
|
||
conn.row_factory = sqlite3.Row
|
||
songs = []
|
||
for ps in conn.execute(
|
||
"SELECT s.title, s.artist, ps.position, ps.status, "
|
||
"ps.is_workshop, ps.dance_override "
|
||
"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.append({
|
||
"title": ps["title"],
|
||
"artist": ps["artist"],
|
||
"position": ps["position"],
|
||
"status": ps["status"] or "pending",
|
||
"is_workshop": bool(ps["is_workshop"]),
|
||
"dance_override": ps["dance_override"] or "",
|
||
})
|
||
conn.close()
|
||
|
||
data = json.dumps({"songs": songs}).encode()
|
||
req = urllib.request.Request(
|
||
f"{server_url}/sharing/playlists/{server_id}/songs",
|
||
data=data,
|
||
headers={
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {token}",
|
||
},
|
||
method="PUT"
|
||
)
|
||
urllib.request.urlopen(req, timeout=8)
|
||
except Exception as e:
|
||
pass
|
||
|
||
def _on_pause_changed(self, seconds: int):
|
||
self._pause_seconds = seconds
|
||
|
||
def _start_event(self):
|
||
if not self._songs:
|
||
return
|
||
reply = QMessageBox.question(
|
||
self, "Start event",
|
||
"Dette nulstiller alle statusser i danselisten.\nFortsæt?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
self._statuses = ["pending"] * len(self._songs)
|
||
self._current_idx = -1
|
||
self._song_ended = False
|
||
try:
|
||
from local.local_db import clear_event_state
|
||
clear_event_state()
|
||
except Exception:
|
||
pass
|
||
self._refresh()
|
||
self._scroll_to(0)
|
||
# Sæt første sang klar
|
||
ni = self.next_playable_idx()
|
||
if ni is not None:
|
||
self._current_idx = ni
|
||
self._refresh()
|
||
self.next_song_ready.emit(self._songs[ni])
|
||
self.event_started.emit()
|
||
|
||
# ── Tilgængelighed ────────────────────────────────────────────────────────
|
||
|
||
def _check_availability(self, songs: list[dict]) -> list[dict]:
|
||
"""Tjek om sange er tilgængelige lokalt — forsøger auto-match ved gule."""
|
||
from pathlib import Path
|
||
from local.local_db import get_db
|
||
|
||
try:
|
||
with get_db() as conn:
|
||
for song in songs:
|
||
path = song.get("local_path", "")
|
||
if path and Path(path).exists():
|
||
# Grøn = lokal, Gul = netværk men tilgængeligt
|
||
song["availability"] = "green" if _is_local_path(path) else "yellow"
|
||
continue
|
||
|
||
# Forsøg auto-match via titel+artist
|
||
title = song.get("title", "")
|
||
artist = song.get("artist", "")
|
||
try:
|
||
match = conn.execute("""
|
||
SELECT id, local_path FROM songs
|
||
WHERE title=? AND artist=? AND file_missing=0
|
||
AND local_path IS NOT NULL AND local_path != ''
|
||
LIMIT 1
|
||
""", (title, artist)).fetchone()
|
||
if match and Path(match["local_path"]).exists():
|
||
song["local_path"] = match["local_path"]
|
||
song["id"] = match["id"]
|
||
song["file_missing"] = False
|
||
song["availability"] = "green"
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
song["availability"] = "yellow" if not path else "red"
|
||
except Exception:
|
||
# DB fejl — sæt alle til yellow
|
||
for song in songs:
|
||
song.setdefault("availability", "yellow")
|
||
return songs
|
||
|
||
def check_offline_availability(self):
|
||
"""Gennemgå alle sange og forsøg auto-match for gule/røde."""
|
||
from pathlib import Path
|
||
from local.local_db import get_db
|
||
|
||
with get_db() as conn:
|
||
for song in self._songs:
|
||
path = song.get("local_path", "")
|
||
# Grøn = lokal, Gul = netværk men tilgængeligt
|
||
if path and Path(path).exists():
|
||
song["availability"] = "green" if _is_local_path(path) else "yellow"
|
||
song["file_missing"] = False
|
||
# Opdater files tabellen
|
||
conn.execute(
|
||
"UPDATE files SET file_missing=0 WHERE local_path=?",
|
||
(path,)
|
||
)
|
||
continue
|
||
|
||
# Forsøg auto-match via titel+artist i files tabellen
|
||
title = song.get("title", "")
|
||
artist = song.get("artist", "")
|
||
match = conn.execute("""
|
||
SELECT f.id as file_id, f.local_path, s.id as song_id
|
||
FROM files f
|
||
JOIN songs s ON s.id = f.song_id
|
||
WHERE s.title=? AND s.artist=? AND f.file_missing=0
|
||
AND f.local_path IS NOT NULL AND f.local_path != ''
|
||
LIMIT 1
|
||
""", (title, artist)).fetchone()
|
||
|
||
if match and Path(match["local_path"]).exists():
|
||
song["local_path"] = match["local_path"]
|
||
song["file_id"] = match["file_id"]
|
||
song["availability"] = "green" if _is_local_path(match["local_path"]) else "yellow"
|
||
song["file_missing"] = False
|
||
# Opdater playlist_songs til at pege på den fundne fil
|
||
if self._named_playlist_id:
|
||
conn.execute(
|
||
"UPDATE playlist_songs SET file_id=? "
|
||
"WHERE playlist_id=? AND song_id=?",
|
||
(match["file_id"], self._named_playlist_id, song["id"])
|
||
)
|
||
else:
|
||
song["availability"] = "red"
|
||
|
||
self._refresh()
|
||
green_count = sum(1 for s in self._songs if s.get("availability") == "green")
|
||
return green_count, len(self._songs)
|
||
|
||
# ── Højreklik ─────────────────────────────────────────────────────────────
|
||
|
||
def _show_context_menu(self, pos):
|
||
item = self._list.itemAt(pos)
|
||
if not item:
|
||
return
|
||
idx = item.data(Qt.ItemDataRole.UserRole)
|
||
if idx is None:
|
||
return
|
||
song = self._songs[idx] if 0 <= idx < len(self._songs) else None
|
||
avail = song.get("availability", "yellow") if song else "yellow"
|
||
all_green = all(s.get("availability", "yellow") == "green" for s in self._songs)
|
||
|
||
menu = QMenu(self)
|
||
act_play = menu.addAction("▶ Afspil denne")
|
||
menu.addSeparator()
|
||
act_skip = menu.addAction("— Spring over")
|
||
act_unplay = menu.addAction("↺ Sæt til ikke afspillet")
|
||
act_played = menu.addAction("✓ Sæt til afspillet")
|
||
menu.addSeparator()
|
||
act_dance = menu.addAction("💃 Vælg dans...")
|
||
act_alt_dance = menu.addAction("💃 Vælg alternativ dans...")
|
||
is_ws = song.get("is_workshop", False) if song else False
|
||
act_ws = menu.addAction("🎓 Fjern workshop" if is_ws else "🎓 Markér som workshop")
|
||
menu.addSeparator()
|
||
act_dance_info = menu.addAction("ℹ Dans-info...")
|
||
menu.addSeparator()
|
||
# Offline tilgængelighed
|
||
act_check = menu.addAction("🔍 Kontrollér offline afvikling")
|
||
act_check.setEnabled(not all_green)
|
||
act_manual = menu.addAction("📂 Manuel match af fil...")
|
||
act_manual.setEnabled(avail != "green")
|
||
menu.addSeparator()
|
||
act_remove = menu.addAction("✕ Fjern fra liste")
|
||
|
||
action = menu.exec(self._list.mapToGlobal(pos))
|
||
if action == act_play:
|
||
self.song_selected.emit(idx)
|
||
elif action == act_skip:
|
||
self._statuses[idx] = "skipped"
|
||
self.status_changed.emit(idx, "skipped")
|
||
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
|
||
elif action == act_unplay:
|
||
self._statuses[idx] = "pending"
|
||
self.status_changed.emit(idx, "pending")
|
||
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
|
||
elif action == act_played:
|
||
self._statuses[idx] = "played"
|
||
self.status_changed.emit(idx, "played")
|
||
self._refresh(); self._trigger_autosave(); self._trigger_event_state_save()
|
||
elif action == act_dance and song:
|
||
self._change_dance(idx, song)
|
||
elif action == act_alt_dance and song:
|
||
self._change_alt_dance(idx, song)
|
||
elif action == act_ws and song:
|
||
song["is_workshop"] = not song.get("is_workshop", False)
|
||
self._sync_ws_to_db(idx, song)
|
||
self._refresh()
|
||
self.playlist_changed.emit()
|
||
elif action == act_dance_info:
|
||
if song:
|
||
from ui.dance_info_dialog import DanceInfoDialog
|
||
dlg = DanceInfoDialog(song, parent=self.window())
|
||
dlg.exec()
|
||
elif action == act_check:
|
||
self._check_offline()
|
||
elif action == act_manual and song:
|
||
self._manual_match(idx, song)
|
||
elif action == act_remove:
|
||
self._songs.pop(idx)
|
||
self._statuses.pop(idx)
|
||
if self._current_idx >= idx:
|
||
self._current_idx = max(-1, self._current_idx - 1)
|
||
self._refresh(); self._trigger_autosave()
|
||
|
||
def _check_offline(self):
|
||
"""Kør offline tjek og vis resultat."""
|
||
green, total = self.check_offline_availability()
|
||
red = sum(1 for s in self._songs if s.get("availability") == "red")
|
||
if red == 0:
|
||
self._set_status_msg(f"✓ Alle {total} sange er tilgængelige lokalt", 4000)
|
||
else:
|
||
self._set_status_msg(
|
||
f"⚠ {green}/{total} tilgængelige — {red} ikke fundet (rød markering)", 6000
|
||
)
|
||
|
||
def _set_status_msg(self, msg: str, ms: int = 3000):
|
||
"""Vis besked i playlist-titlen kortvarigt."""
|
||
old = self._title_label.text()
|
||
self._title_label.setText(msg)
|
||
QTimer.singleShot(ms, lambda: self._title_label.setText(old))
|
||
|
||
def _manual_match(self, idx: int, song: dict):
|
||
"""Søg i biblioteket og lad brugeren vælge den rigtige sang."""
|
||
from PyQt6.QtWidgets import (
|
||
QDialog, QVBoxLayout, QHBoxLayout, QLineEdit,
|
||
QListWidget, QListWidgetItem, QPushButton, QLabel
|
||
)
|
||
from local.local_db import get_db
|
||
from pathlib import Path
|
||
|
||
dlg = QDialog(self.window())
|
||
dlg.setWindowTitle(f"Manuel match: {song.get('title','?')}")
|
||
dlg.setMinimumSize(500, 400)
|
||
layout = QVBoxLayout(dlg)
|
||
layout.setContentsMargins(12, 12, 12, 12)
|
||
layout.setSpacing(8)
|
||
|
||
lbl = QLabel(f"Søg i biblioteket efter en sang til: <b>{song.get('title','')} – {song.get('artist','')}</b>")
|
||
lbl.setWordWrap(True)
|
||
layout.addWidget(lbl)
|
||
|
||
search = QLineEdit()
|
||
search.setPlaceholderText("Søg på titel eller artist...")
|
||
search.setText(song.get("title", ""))
|
||
layout.addWidget(search)
|
||
|
||
lst = QListWidget()
|
||
layout.addWidget(lst)
|
||
|
||
def _load_results(text):
|
||
lst.clear()
|
||
try:
|
||
with get_db() as conn:
|
||
rows = conn.execute("""
|
||
SELECT id, title, artist, local_path, file_format
|
||
FROM songs
|
||
WHERE file_missing=0
|
||
AND (title LIKE ? OR artist LIKE ?)
|
||
ORDER BY title LIMIT 100
|
||
""", (f"%{text}%", f"%{text}%")).fetchall()
|
||
for row in rows:
|
||
label = f"{row['title']} · {row['artist']} [{row['file_format']}]"
|
||
item = QListWidgetItem(label)
|
||
item.setData(Qt.ItemDataRole.UserRole, dict(row))
|
||
lst.addItem(item)
|
||
except Exception:
|
||
pass
|
||
|
||
search.textChanged.connect(_load_results)
|
||
_load_results(search.text())
|
||
|
||
btn_row = QHBoxLayout()
|
||
btn_ok = QPushButton("✓ Brug valgt sang")
|
||
btn_ok.setEnabled(False)
|
||
btn_cancel = QPushButton("Annuller")
|
||
btn_row.addWidget(btn_ok)
|
||
btn_row.addWidget(btn_cancel)
|
||
layout.addLayout(btn_row)
|
||
|
||
lst.itemSelectionChanged.connect(lambda: btn_ok.setEnabled(bool(lst.currentItem())))
|
||
btn_cancel.clicked.connect(dlg.reject)
|
||
|
||
def _accept():
|
||
item = lst.currentItem()
|
||
if not item:
|
||
return
|
||
chosen = item.data(Qt.ItemDataRole.UserRole)
|
||
path = chosen.get("local_path", "")
|
||
if not path or not Path(path).exists():
|
||
return
|
||
song["local_path"] = path
|
||
song["availability"] = "green"
|
||
song["file_missing"] = False
|
||
# Opdater DB
|
||
try:
|
||
with get_db() as conn:
|
||
conn.execute(
|
||
"UPDATE songs SET local_path=?, file_missing=0 WHERE id=?",
|
||
(path, song.get("id", ""))
|
||
)
|
||
except Exception:
|
||
pass
|
||
self._refresh()
|
||
dlg.accept()
|
||
|
||
btn_ok.clicked.connect(_accept)
|
||
lst.itemDoubleClicked.connect(lambda _: _accept())
|
||
dlg.exec()
|
||
|
||
# ── Render ────────────────────────────────────────────────────────────────
|
||
|
||
def _refresh(self):
|
||
self._list.clear()
|
||
played = sum(1 for s in self._statuses if s == "played")
|
||
self._lbl_progress.setText(f"{played} / {len(self._songs)} afspillet")
|
||
|
||
# Find næste uafspillede til blå markering — aldrig samme som current
|
||
next_idx = None
|
||
if self._current_idx >= 0 and not self._song_ended:
|
||
# Sang spiller — vis næste som blå
|
||
next_idx = self.next_playable_idx()
|
||
elif self._current_idx == -1 or self._song_ended:
|
||
# Ingen sang spiller — vis første som blå
|
||
next_idx = self.next_playable_idx()
|
||
|
||
for i, song in enumerate(self._songs):
|
||
is_current = (i == self._current_idx and not self._song_ended)
|
||
is_next = (i == next_idx and not is_current)
|
||
if is_current:
|
||
status = "playing"
|
||
elif is_next:
|
||
status = "next"
|
||
else:
|
||
status = self._statuses[i]
|
||
icon = self.STATUS_ICON.get(status, " ")
|
||
|
||
# Dans er primær tekst, sang er sekundær
|
||
active = song.get("active_dance", "")
|
||
if not active:
|
||
dances = song.get("dances", [])
|
||
active = dances[0] if dances else "— ingen dans —"
|
||
ws_tag = " 🎓" if song.get("is_workshop") else ""
|
||
|
||
# Tilgængeligheds-dot til højre — kun hvis tjekket (ikke yellow)
|
||
avail = song.get("availability", "yellow")
|
||
avail_color = {"green": "#27ae60", "red": "#e74c3c"}.get(avail, None)
|
||
avail_tip = {"green": "Tilgængelig lokalt", "red": "Ikke fundet lokalt"}.get(avail, "")
|
||
|
||
text = (f"{i+1:>2}. {active}{ws_tag}\n"
|
||
f" {song.get('title','—')} · {song.get('artist','')}")
|
||
item = QListWidgetItem(f"{icon} {text}")
|
||
item.setData(Qt.ItemDataRole.UserRole, i)
|
||
item.setData(Qt.ItemDataRole.UserRole + 1, avail_color)
|
||
if avail_tip:
|
||
item.setToolTip(avail_tip)
|
||
|
||
if status == "playing":
|
||
item.setForeground(QColor(self.STATUS_COLOR["playing"]))
|
||
f = item.font(); f.setBold(True); item.setFont(f)
|
||
elif status == "next":
|
||
item.setForeground(QColor(self.STATUS_COLOR["next"]))
|
||
f = item.font(); f.setBold(True); item.setFont(f)
|
||
elif status == "played":
|
||
item.setForeground(QColor("#2ecc71"))
|
||
elif status == "skipped":
|
||
item.setForeground(QColor("#e74c3c"))
|
||
else:
|
||
item.setForeground(QColor("#9aa0b0"))
|
||
self._list.addItem(item)
|
||
|
||
def _scroll_to(self, idx: int):
|
||
if 0 <= idx < self._list.count():
|
||
self._list.scrollToItem(
|
||
self._list.item(idx), QListWidget.ScrollHint.PositionAtCenter)
|
||
|
||
def _on_double_click(self, item: QListWidgetItem):
|
||
idx = item.data(Qt.ItemDataRole.UserRole)
|
||
if idx is not None:
|
||
self.song_selected.emit(idx) |