From d4356e7337fa235aecb4d4d5fc5402fcd3a5b419 Mon Sep 17 00:00:00 2001 From: Carsten Kvist Date: Tue, 14 Apr 2026 15:05:54 +0200 Subject: [PATCH] Offline check ser ok ud. --- linedance-app/local/scanner.py | 40 ++++- linedance-app/ui/library_panel.py | 10 ++ linedance-app/ui/main_window.py | 3 +- linedance-app/ui/playlist_panel.py | 257 ++++++++++++++++++++++++++++- 4 files changed, 301 insertions(+), 9 deletions(-) diff --git a/linedance-app/local/scanner.py b/linedance-app/local/scanner.py index eb3b6422..a525c40b 100644 --- a/linedance-app/local/scanner.py +++ b/linedance-app/local/scanner.py @@ -50,10 +50,12 @@ def scan_library(library_id: int, library_path: str, db_path: str, # Byg indeks over kendte filer known = {} for row in conn.execute( - "SELECT local_path, file_modified_at FROM songs WHERE library_id=?", + "SELECT local_path, file_modified_at, file_missing FROM songs WHERE library_id=?", (library_id,) ).fetchall(): - known[row["local_path"]] = row["file_modified_at"] + # Sange markeret som manglende medtages ikke i known — de skal altid genscanes + if not row["file_missing"]: + known[row["local_path"]] = row["file_modified_at"] # Find alle musikfiler all_files = [] @@ -87,10 +89,44 @@ def scan_library(library_id: int, library_path: str, db_path: str, tags = read_tags(fp) extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False) + # Match 1: præcis sti-match existing = conn.execute( "SELECT id, bpm FROM songs WHERE local_path=?", (path_str,) ).fetchone() + # Match 2: titel+artist match — fil er flyttet eller var missing + if not existing: + title = tags.get("title", "") + artist = tags.get("artist", "") + if title: + # Prioritér file_missing=1 sange, men tag også sange med ugyldig sti + existing = conn.execute(""" + SELECT id, bpm FROM songs + WHERE title=? AND artist=? AND file_missing=1 + LIMIT 1 + """, (title, artist)).fetchone() + if not existing: + # Tjek om der er en sang med samme titel+artist men ugyldig sti + existing = conn.execute(""" + SELECT id, bpm, local_path FROM songs + WHERE title=? AND artist=? AND file_missing=0 + LIMIT 1 + """, (title, artist)).fetchone() + if existing: + from pathlib import Path as _Path + old_path = existing["local_path"] or "" + if old_path and not _Path(old_path).exists(): + pass # Sti er ugyldig — brug dette match + else: + existing = None # Sti er valid — det er en anden fil + + if existing: + # Opdater stien så den peger på den nye placering + conn.execute( + "UPDATE songs SET local_path=? WHERE id=?", + (path_str, existing["id"]) + ) + if existing: bpm = tags.get("bpm", 0) if not overwrite_bpm and existing["bpm"] and existing["bpm"] > 0: diff --git a/linedance-app/ui/library_panel.py b/linedance-app/ui/library_panel.py index e0cc42f2..8f34028f 100644 --- a/linedance-app/ui/library_panel.py +++ b/linedance-app/ui/library_panel.py @@ -99,6 +99,7 @@ class LibraryPanel(QWidget): song_selected = pyqtSignal(dict) add_to_playlist = pyqtSignal(dict) scan_requested = pyqtSignal() + sync_requested = pyqtSignal() edit_tags_requested = pyqtSignal(dict) send_mail_requested = pyqtSignal(dict) @@ -248,6 +249,15 @@ class LibraryPanel(QWidget): def _on_scan_clicked(self): self.scan_requested.emit() + def _on_sync_clicked(self): + self._btn_sync.setText("⇅ ...") + self._btn_sync.setEnabled(False) + self.sync_requested.emit() + QTimer.singleShot(3000, lambda: ( + self._btn_sync.setText("⇅ Sync"), + self._btn_sync.setEnabled(True), + )) + def set_scanning(self, scanning: bool, status_text: str = ""): pass # Status vises i statuslinjen diff --git a/linedance-app/ui/main_window.py b/linedance-app/ui/main_window.py index 9d6bdcd7..27316fa8 100644 --- a/linedance-app/ui/main_window.py +++ b/linedance-app/ui/main_window.py @@ -364,6 +364,7 @@ class MainWindow(QMainWindow): self._library_panel.song_selected.connect(self._on_library_song_selected) self._library_panel.add_to_playlist.connect(self._add_song_to_playlist) self._library_panel.scan_requested.connect(self.start_scan) + self._library_panel.sync_requested.connect(self._manual_sync) self._library_panel.edit_tags_requested.connect(self._open_tag_editor) self._library_panel.send_mail_requested.connect(self._send_mail) @@ -563,7 +564,7 @@ class MainWindow(QMainWindow): except Exception: pass - QTimer.singleShot(30000, self.start_background_scan) + QTimer.singleShot(5000, self.start_background_scan) def start_background_scan(self): """Start scanning af alle aktive biblioteker i baggrunden.""" diff --git a/linedance-app/ui/playlist_panel.py b/linedance-app/ui/playlist_panel.py index 3150acb6..fcd36098 100644 --- a/linedance-app/ui/playlist_panel.py +++ b/linedance-app/ui/playlist_panel.py @@ -5,13 +5,31 @@ playlist_panel.py — Danseliste med Ny/Gem/Hent knapper, autogem og event-overb from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, QHBoxLayout, QPushButton, QMenu, QAbstractItemView, - QMessageBox, + QMessageBox, QStyledItemDelegate, ) -from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray -from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent +from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QByteArray, QRect +from PyQt6.QtGui import QColor, QDragEnterEvent, QDropEvent, QPainter -ACTIVE_PLAYLIST_NAME = "__aktiv__" # fast navn til autogem-listen +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): @@ -89,6 +107,12 @@ class PlaylistPanel(QWidget): 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("") @@ -133,6 +157,7 @@ class PlaylistPanel(QWidget): # ── 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) @@ -176,6 +201,7 @@ class PlaylistPanel(QWidget): self._song_ended = False if name: self._title_label.setText(f"DANSELISTE — {name.upper()}") + self._check_availability(self._songs) self._refresh() self._trigger_autosave() @@ -444,6 +470,7 @@ class PlaylistPanel(QWidget): 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() @@ -875,6 +902,98 @@ class PlaylistPanel(QWidget): 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(): + song["availability"] = "green" + 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 — eksisterer og tilgængeligt + if path and Path(path).exists(): + song["availability"] = "green" + song["file_missing"] = False + # Opdater songs tabellen + conn.execute( + "UPDATE songs SET file_missing=0, local_path=? WHERE id=?", + (path, song["id"]) + ) + continue + + # Forsøg auto-match via titel+artist + title = song.get("title", "") + artist = song.get("artist", "") + 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["availability"] = "green" + song["file_missing"] = False + # Opdater playlist_songs til at pege på den fundne sang + if self._named_playlist_id: + conn.execute(""" + UPDATE playlist_songs SET song_id=? + WHERE playlist_id=? AND song_id=( + SELECT id FROM songs + WHERE title=? AND artist=? + LIMIT 1 + ) + """, (match["id"], self._named_playlist_id, title, artist)) + 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): @@ -885,6 +1004,9 @@ class PlaylistPanel(QWidget): 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() @@ -892,15 +1014,20 @@ class PlaylistPanel(QWidget): act_unplay = menu.addAction("↺ Sæt til ikke afspillet") act_played = menu.addAction("✓ Sæt til afspillet") menu.addSeparator() - # Dans-valg act_dance = menu.addAction("💃 Vælg dans...") - # Workshop toggle 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) @@ -928,6 +1055,10 @@ class PlaylistPanel(QWidget): 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) @@ -935,6 +1066,111 @@ class PlaylistPanel(QWidget): 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: {song.get('title','')} – {song.get('artist','')}") + 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): @@ -969,10 +1205,19 @@ class PlaylistPanel(QWidget): active = dances[0] if dances else "ingen dans tagget" 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}. {song.get('title','—')}{ws_tag}\n" f" {song.get('artist','')} · {active}") 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)