From bdb1f5915a25078e9e5a3c4c6f23aa033e4238cc Mon Sep 17 00:00:00 2001 From: Carsten Kvist Date: Sun, 12 Apr 2026 13:42:05 +0200 Subject: [PATCH] =?UTF-8?q?Bedre=20mappeh=C3=A5ndtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- linedance-app/local/file_watcher.py | 409 ++-------------------------- linedance-app/local/scanner.py | 165 +++++++++++ linedance-app/ui/library_manager.py | 375 +++++++++++++------------ linedance-app/ui/library_panel.py | 12 +- linedance-app/ui/main_window.py | 10 +- linedance-app/ui/scan_worker.py | 85 +++--- 6 files changed, 417 insertions(+), 639 deletions(-) create mode 100644 linedance-app/local/scanner.py diff --git a/linedance-app/local/file_watcher.py b/linedance-app/local/file_watcher.py index 110c3b06..e83354fb 100644 --- a/linedance-app/local/file_watcher.py +++ b/linedance-app/local/file_watcher.py @@ -1,411 +1,34 @@ """ -file_watcher.py — Overvåger musikbiblioteker og holder SQLite opdateret. - -Bruger watchdog til at reagere på fil-ændringer i realtid. -Kører fuld scan ved opstart for at fange ændringer lavet mens appen var lukket. +file_watcher.py — Minimal fil-watcher. +Scanning håndteres af scanner.py / ScanWorker. """ - -import threading -import time -import logging -from pathlib import Path -from typing import Callable - -try: - import sys - if sys.platform == "win32": - # WindowsApiObserver opretter en tråd per undermappe og løber tør - # for Windows handles ved store biblioteker — brug polling i stedet - from watchdog.observers.polling import PollingObserver as Observer - else: - from watchdog.observers import Observer - from watchdog.events import ( - FileSystemEventHandler, - FileCreatedEvent, - FileModifiedEvent, - FileDeletedEvent, - FileMovedEvent, - ) - WATCHDOG_AVAILABLE = True -except ImportError: - WATCHDOG_AVAILABLE = False - -from local.tag_reader import is_supported, read_tags, get_file_modified_at -from local.local_db import ( - get_libraries, add_library, remove_library, - upsert_song, mark_song_missing, - get_all_song_paths_for_library, update_library_scan_time, -) - -logger = logging.getLogger(__name__) - - -class MusicLibraryHandler(FileSystemEventHandler): - """ - Reagerer på ændringer i et musikbibliotek. - Kører i watchdog's baggrundstråd — DB-operationer er thread-safe via WAL. - """ - - def __init__(self, library_id: int, on_change: Callable | None = None): - self.library_id = library_id - self.on_change = on_change # valgfrit callback til GUI-opdatering - self._debounce: dict[str, float] = {} - self._debounce_lock = threading.Lock() - - def _debounced(self, path: str) -> bool: - """ - Forhindrer at samme fil behandles flere gange på kort tid. - Nogle programmer gemmer filer i flere trin (temp-fil → rename). - """ - now = time.time() - with self._debounce_lock: - last = self._debounce.get(path, 0) - if now - last < 1.5: # 1.5 sekunder cooldown - return False - self._debounce[path] = now - return True - - def on_created(self, event): - if event.is_directory or not is_supported(event.src_path): - return - if self._debounced(event.src_path): - self._process_file(event.src_path) - - def on_modified(self, event): - if event.is_directory or not is_supported(event.src_path): - return - if self._debounced(event.src_path): - self._process_file(event.src_path) - - def on_deleted(self, event): - if event.is_directory or not is_supported(event.src_path): - return - logger.info(f"Fil slettet: {event.src_path}") - mark_song_missing(event.src_path) - if self.on_change: - self.on_change("deleted", event.src_path, None) - - def on_moved(self, event): - if event.is_directory: - return - # Behandl som slet + opret - if is_supported(event.src_path): - mark_song_missing(event.src_path) - if is_supported(event.dest_path): - if self._debounced(event.dest_path): - self._process_file(event.dest_path) - - def _process_file(self, path: str): - """Læs tags og gem i SQLite.""" - try: - logger.debug(f"Høster tags fra: {path}") - tags = read_tags(path) - tags["library_id"] = self.library_id - song_id = upsert_song(tags) - logger.info(f"Opdateret: {Path(path).name} ({len(tags.get('dances', []))} danse)") - if self.on_change: - self.on_change("upserted", path, song_id) - except Exception as e: - logger.error(f"Fejl ved behandling af {path}: {e}") - +# Fil-watcher er deaktiveret — scanning sker via ScanWorker i library_manager +# Denne fil beholdes til fremtidig live-overvågning class LibraryWatcher: - """ - Styrer watchdog-observere for alle aktive musikbiblioteker. - Én instans per applikation. - """ - - def __init__(self, on_change: Callable | None = None): + def __init__(self, on_change=None): self.on_change = on_change - self._observer: Observer | None = None - self._running = False + self._running = False def start(self): - """Start overvågning af alle aktive biblioteker + kør fuld scan.""" - if not WATCHDOG_AVAILABLE: - logger.warning("watchdog ikke tilgængelig — starter kun fuld scan") - self._full_scan_all() - return - - import sqlite3 - from local.local_db import DB_PATH - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - libraries = conn.execute( - "SELECT id, path FROM libraries WHERE is_active=1" - ).fetchall() - conn.close() - - if not libraries: - logger.info("Ingen biblioteker — springer watchdog over") - self._running = True - return - - # Brug længere poll-interval på Windows for at spare handles - import sys - poll_interval = 30 if sys.platform == "win32" else 5 - self._observer = Observer(timeout=poll_interval) - - for lib in libraries: - path = Path(lib["path"]) - if not path.exists(): - logger.warning(f"Bibliotek findes ikke: {path}") - continue - handler = MusicLibraryHandler(lib["id"], self.on_change) - self._observer.schedule(handler, str(path), recursive=True) - logger.info(f"Overvåger: {path}") - - self._observer.start() self._running = True - # Fuld scan i baggrundstråd så GUI ikke blokeres - threading.Thread(target=self._full_scan_all, daemon=True).start() - def stop(self): - if self._observer and self._running: - self._running = False - # Stop i baggrundstråd så GUI ikke hænger - obs = self._observer - self._observer = None - def _stop(): - try: - obs.stop() - obs.join(timeout=3) - except Exception: - pass - import threading - threading.Thread(target=_stop, daemon=True).start() + self._running = False def add_library(self, path: str) -> int: - """Tilføj et nyt bibliotek — alt kører i baggrundstråd.""" - library_id = add_library(path) - - def _bg(lib_id, lib_path): - # Registrer watchdog — kan blokere lidt på Windows med mange filer - if self._observer and self._running: - try: - handler = MusicLibraryHandler(lib_id, self.on_change) - self._observer.schedule(handler, lib_path, recursive=True) - except Exception as e: - logger.error(f"Watchdog schedule fejl: {e}") - - # Scan - self._full_scan_library(lib_id, lib_path) - - t = threading.Thread(target=_bg, args=(library_id, path), daemon=True) - t.start() - return library_id + from local.local_db import add_library + return add_library(path) def remove_library(self, library_id: int): - """Deaktiver bibliotek. Watchdog stopper automatisk ved næste restart.""" + from local.local_db import remove_library remove_library(library_id) - # Genstart observer for at fjerne watch (watchdog understøtter ikke unschedule by id) - if self._observer and self._running: - self._observer.unschedule_all() - self._reschedule_all() - - def _reschedule_all(self): - """Genplanlæg alle aktive biblioteker på observeren.""" - for lib in get_libraries(active_only=True): - path = Path(lib["path"]) - if path.exists(): - handler = MusicLibraryHandler(lib["id"], self.on_change) - self._observer.schedule(handler, str(path), recursive=True) - - def _full_scan_all(self): - """Kør fuld scan på alle aktive biblioteker — én ad gangen i baggrundstråd.""" - def _run(): - try: - import sqlite3 - from local.local_db import DB_PATH - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - libs = conn.execute( - "SELECT id, path FROM libraries WHERE is_active=1" - ).fetchall() - conn.close() - for lib in libs: - lib_path = Path(lib["path"]) - if lib_path.exists(): - self._full_scan_library(lib["id"], str(lib_path)) - except Exception as e: - logger.error(f"_full_scan_all fejl: {e}") - - # Kør i én baggrundstråd — undgår Windows handle-udmattelse - threading.Thread(target=_run, daemon=True).start() - - def _full_scan_library(self, library_id: int, library_path: str): - """Scan ét bibliotek med sin egen SQLite-forbindelse — blokerer aldrig GUI.""" - import sqlite3, uuid, json, os - from local.local_db import DB_PATH - from local.tag_reader import read_tags, is_supported, get_file_modified_at - - logger.info(f"Fuld scan starter: {library_path}") - base = Path(library_path) - - if not self._path_accessible(base): - logger.warning(f"Bibliotek ikke tilgængeligt: {library_path}") - return - - try: - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - - # Hent kendte stier og modified-tider - known = { - row["local_path"]: row["file_modified_at"] - for row in conn.execute( - "SELECT local_path, file_modified_at FROM songs WHERE library_id=?", - (library_id,) - ).fetchall() - } - - found_paths = set() - processed = 0 - - for dirpath, _, filenames in os.walk(str(base), followlinks=False): - for filename in filenames: - file_path = Path(dirpath) / filename - if not is_supported(file_path): - continue - path_str = str(file_path) - found_paths.add(path_str) - try: - disk_modified = get_file_modified_at(file_path) - if path_str in known and known[path_str] == disk_modified: - continue # uændret — skip - - tags = read_tags(file_path) - extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False) - existing = conn.execute( - "SELECT id FROM songs WHERE local_path=?", (path_str,) - ).fetchone() - - if existing: - song_id = existing["id"] - conn.execute(""" - UPDATE songs SET library_id=?, title=?, artist=?, - album=?, bpm=?, duration_sec=?, file_format=?, - file_modified_at=?, file_missing=0, extra_tags=? - WHERE id=? - """, (library_id, tags.get("title",""), tags.get("artist",""), - tags.get("album",""), tags.get("bpm",0), - tags.get("duration_sec",0), tags.get("file_format",""), - disk_modified, extra, song_id)) - else: - song_id = str(uuid.uuid4()) - conn.execute(""" - INSERT OR IGNORE INTO songs (id, library_id, local_path, title, - artist, album, bpm, duration_sec, file_format, - file_modified_at, extra_tags) - VALUES (?,?,?,?,?,?,?,?,?,?,?) - """, (song_id, library_id, path_str, - tags.get("title",""), tags.get("artist",""), - tags.get("album",""), tags.get("bpm",0), - tags.get("duration_sec",0), tags.get("file_format",""), - disk_modified, extra)) - - # Danse fra fil — merge med eksisterende niveau - file_dances = tags.get("dances", []) - if file_dances: - existing_dances = { - row["name"].lower(): row - for row in conn.execute(""" - SELECT d.id, d.name, sd.dance_order - FROM song_dances sd JOIN dances d ON d.id=sd.dance_id - WHERE sd.song_id=? - """, (song_id,)).fetchall() - } - for i, dance_name in enumerate(file_dances, 1): - if not dance_name: - continue - name_lower = dance_name.lower() - if name_lower in existing_dances: - conn.execute( - "UPDATE song_dances SET dance_order=? " - "WHERE song_id=? AND dance_id=?", - (i, song_id, existing_dances[name_lower]["id"]) - ) - else: - # Opret dans uden niveau - d = conn.execute( - "SELECT id FROM dances WHERE name=? COLLATE NOCASE " - "AND level_id IS NULL", (dance_name,) - ).fetchone() - if not d: - conn.execute( - "INSERT INTO dances (name, level_id, source) " - "VALUES (?,NULL,'local')", (dance_name,) - ) - d = conn.execute( - "SELECT id FROM dances WHERE name=? COLLATE NOCASE " - "AND level_id IS NULL", (dance_name,) - ).fetchone() - conn.execute( - "INSERT OR IGNORE INTO song_dances " - "(song_id, dance_id, dance_order) VALUES (?,?,?)", - (song_id, d["id"], i) - ) - - conn.commit() - processed += 1 - if self.on_change: - self.on_change("upserted", path_str, song_id) - - except Exception as e: - logger.error(f"Scan-fejl for {file_path}: {e}") - - # Markér forsvundne filer - for known_path in known: - if known_path not in found_paths: - conn.execute( - "UPDATE songs SET file_missing=1 WHERE local_path=?", - (known_path,) - ) - conn.commit() - if self.on_change: - self.on_change("deleted", known_path, None) - - conn.execute( - "UPDATE libraries SET last_full_scan=datetime('now') WHERE id=?", - (library_id,) - ) - conn.commit() - conn.close() - logger.info(f"Scan færdig: {library_path} — {processed} opdateret") - - except Exception as e: - logger.error(f"Scan fejl: {library_path}: {e}") - - def _path_accessible(self, path: Path, timeout_sec: float = 8.0) -> bool: - """Tjek om en sti er tilgængelig inden for timeout.""" - result = [False] - def check(): - try: - import os, sys - p = str(path) - if sys.platform == "win32" and (p.startswith("\\\\") or p.startswith("//")): - # UNC-stier på Windows — brug listdir der fungerer bedre - os.listdir(p) - result[0] = True - else: - result[0] = path.exists() and path.is_dir() - except Exception: - result[0] = False - t = threading.Thread(target=check, daemon=True) - t.start() - t.join(timeout=timeout_sec) - return result[0] -# ── Singleton til brug i appen ──────────────────────────────────────────────── +_watcher_instance = None -_watcher: LibraryWatcher | None = None - - -def get_watcher(on_change: Callable | None = None) -> LibraryWatcher: - """Returnerer den globale LibraryWatcher-instans.""" - global _watcher - if _watcher is None: - _watcher = LibraryWatcher(on_change=on_change) - return _watcher +def get_watcher(on_change=None) -> LibraryWatcher: + global _watcher_instance + if _watcher_instance is None: + _watcher_instance = LibraryWatcher(on_change=on_change) + return _watcher_instance diff --git a/linedance-app/local/scanner.py b/linedance-app/local/scanner.py new file mode 100644 index 00000000..b97f327b --- /dev/null +++ b/linedance-app/local/scanner.py @@ -0,0 +1,165 @@ +""" +scanner.py — Scanning af musikbiblioteker i baggrunden. + +Kører som en separat subprocess der scanner ét bibliotek ad gangen +og rapporterer fremgang via stdout JSON-linjer. + +Kan også importeres direkte og bruges via ScanWorker QThread. +""" +import os +import sys +import json +import sqlite3 +import uuid +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +SUPPORTED = {'.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aiff', '.wma'} + + +def is_supported(path: Path) -> bool: + return path.suffix.lower() in SUPPORTED + + +def get_file_mtime(path: Path) -> str: + try: + return str(os.path.getmtime(str(path))) + except Exception: + return "" + + +def scan_library(library_id: int, library_path: str, db_path: str, + overwrite_bpm: bool = False, + progress_callback=None): + """ + Scan ét bibliotek og upsert sange til SQLite. + progress_callback(done, total, current_file) kaldes løbende. + """ + from local.tag_reader import read_tags + + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + + base = Path(library_path) + if not base.exists(): + conn.close() + return 0 + + # Byg indeks over kendte filer + known = {} + for row in conn.execute( + "SELECT local_path, file_modified_at FROM songs WHERE library_id=?", + (library_id,) + ).fetchall(): + known[row["local_path"]] = row["file_modified_at"] + + # Find alle musikfiler + all_files = [] + for dirpath, _, filenames in os.walk(str(base), followlinks=False): + for fn in filenames: + fp = Path(dirpath) / fn + if is_supported(fp): + all_files.append(fp) + + total = len(all_files) + done = 0 + + for fp in all_files: + path_str = str(fp) + mtime = get_file_mtime(fp) + + if progress_callback: + progress_callback(done, total, fp.name) + + # Spring over hvis ikke ændret + if path_str in known and known[path_str] == mtime: + done += 1 + continue + + try: + tags = read_tags(fp) + extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False) + + existing = conn.execute( + "SELECT id, bpm FROM songs WHERE local_path=?", (path_str,) + ).fetchone() + + if existing: + bpm = tags.get("bpm", 0) + if not overwrite_bpm and existing["bpm"] and existing["bpm"] > 0: + bpm = existing["bpm"] # behold eksisterende BPM + conn.execute(""" + UPDATE songs SET + library_id=?, title=?, artist=?, album=?, + bpm=?, duration_sec=?, file_format=?, + file_modified_at=?, file_missing=0, extra_tags=? + WHERE id=? + """, (library_id, tags.get("title",""), tags.get("artist",""), + tags.get("album",""), bpm, tags.get("duration_sec",0), + tags.get("file_format",""), mtime, extra, existing["id"])) + else: + song_id = str(uuid.uuid4()) + conn.execute(""" + INSERT OR IGNORE INTO songs + (id, library_id, local_path, title, artist, album, + bpm, duration_sec, file_format, file_modified_at, extra_tags) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + """, (song_id, library_id, path_str, + tags.get("title",""), tags.get("artist",""), + tags.get("album",""), tags.get("bpm",0), + tags.get("duration_sec",0), tags.get("file_format",""), + mtime, extra)) + + conn.commit() + except Exception as e: + logger.warning(f"Scan fejl {fp.name}: {e}") + + done += 1 + + # Marker manglende filer + for path_str in known: + if not Path(path_str).exists(): + conn.execute( + "UPDATE songs SET file_missing=1 WHERE local_path=?", (path_str,) + ) + conn.commit() + + conn.execute( + "UPDATE libraries SET last_full_scan=datetime('now') WHERE id=?", + (library_id,) + ) + conn.commit() + conn.close() + return done + + +# ── Subprocess entry point ───────────────────────────────────────────────────── + +if __name__ == "__main__": + """ + Kørsel som subprocess: + python scanner.py + Rapporterer JSON-linjer til stdout: {"done":N,"total":M,"file":"..."} + """ + if len(sys.argv) < 4: + sys.exit(1) + + lib_id = int(sys.argv[1]) + lib_path = sys.argv[2] + db_path = sys.argv[3] + + # Tilføj app-mappen til path så local.tag_reader kan importeres + app_dir = str(Path(__file__).parent.parent) + if app_dir not in sys.path: + sys.path.insert(0, app_dir) + + def report(done, total, filename): + print(json.dumps({"done": done, "total": total, "file": filename}), + flush=True) + + count = scan_library(lib_id, lib_path, db_path, + progress_callback=report) + print(json.dumps({"done": count, "total": count, "finished": True}), + flush=True) diff --git a/linedance-app/ui/library_manager.py b/linedance-app/ui/library_manager.py index 8b4fc86f..dfd0e7e0 100644 --- a/linedance-app/ui/library_manager.py +++ b/linedance-app/ui/library_manager.py @@ -1,147 +1,114 @@ """ -library_manager.py — Dialog til at administrere musikbiblioteker med BPM-scanning. +library_manager.py — Håndter musikmapper. +Tilføj/fjern mapper. Scan KUN ved eksplicit knap-tryk. """ +import sqlite3 +from pathlib import Path from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QLabel, QWidget, - QPushButton, QListWidget, QListWidgetItem, QMessageBox, - QFrame, QSizePolicy, + QPushButton, QFrame, QMessageBox, QProgressBar, ) -from PyQt6.QtCore import Qt, pyqtSignal, QThread -from PyQt6.QtGui import QColor - - -class BpmScanWorker(QThread): - progress = pyqtSignal(int, int) # done, total - finished = pyqtSignal(int) # antal scannet - - def __init__(self, library_id: int, scan_all: bool = False): - super().__init__() - self._library_id = library_id - self._scan_all = scan_all # False = kun manglende, True = alle - - def run(self): - import sqlite3 - from local.local_db import DB_PATH - from local.tag_reader import analyze_bpm - - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - - if self._scan_all: - songs = conn.execute( - "SELECT id, local_path FROM songs WHERE library_id=? AND file_missing=0", - (self._library_id,) - ).fetchall() - else: - songs = conn.execute( - "SELECT id, local_path FROM songs " - "WHERE library_id=? AND file_missing=0 AND (bpm IS NULL OR bpm=0)", - (self._library_id,) - ).fetchall() - - total = len(songs) - done = 0 - for song in songs: - if self.isInterruptionRequested(): - break - try: - bpm = analyze_bpm(song["local_path"]) - if bpm: - conn.execute( - "UPDATE songs SET bpm=? WHERE id=?", - (int(round(bpm)), song["id"]) - ) - conn.commit() - except Exception: - pass - done += 1 - self.progress.emit(done, total) - - conn.close() - self.finished.emit(done) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer class LibraryManagerDialog(QDialog): - library_removed = pyqtSignal(int) + libraries_changed = pyqtSignal() # signal til main_window om at genindlæse - def __init__(self, parent=None): + def __init__(self, db_path: str, parent=None): super().__init__(parent) - self.setWindowTitle("Administrer musikbiblioteker") - self.setMinimumWidth(580) - self.setMinimumHeight(360) - self._bpm_workers = {} # library_id → BpmScanWorker + self._db_path = db_path + self._workers = {} # library_id → ScanWorker + self._scanning = False + + self.setWindowTitle("Musikmapper") + self.setMinimumWidth(600) + self.setMinimumHeight(300) self._build_ui() self._load() + # ── 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) - lbl = QLabel("Aktive musikbiblioteker:") - lbl.setObjectName("track_meta") + lbl = QLabel("Tilføj eller fjern musikmapper. Scan starter kun ved klik på knappen.") + lbl.setObjectName("result_count") + lbl.setWordWrap(True) layout.addWidget(lbl) self._libs_layout = QVBoxLayout() self._libs_layout.setSpacing(6) layout.addLayout(self._libs_layout) + layout.addStretch() - note = QLabel( - "Når du fjerner et bibliotek, slettes det fra overvågningen.\n" - "Sangene forbliver i databasen men markeres som manglende (⚠)." - ) - note.setObjectName("result_count") - note.setWordWrap(True) - layout.addWidget(note) + # Global status + self._lbl_status = QLabel("") + self._lbl_status.setObjectName("result_count") + self._lbl_status.hide() + layout.addWidget(self._lbl_status) + self._progress = QProgressBar() + self._progress.hide() + layout.addWidget(self._progress) + + # Knap-række btn_row = QHBoxLayout() - btn_add = QPushButton("+ Tilføj mappe") + btn_add = QPushButton("+ Tilføj mappe") btn_add.clicked.connect(self._add_folder) btn_row.addWidget(btn_add) btn_row.addStretch() btn_close = QPushButton("Luk") - btn_close.clicked.connect(self.accept) + btn_close.clicked.connect(self._on_close) btn_row.addWidget(btn_close) layout.addLayout(btn_row) def _load(self): + """Indlæs biblioteker fra DB og vis dem — ingen scanning.""" while self._libs_layout.count(): item = self._libs_layout.takeAt(0) if item.widget(): item.widget().deleteLater() try: - import sqlite3 - from local.local_db import DB_PATH, get_libraries - libs = get_libraries(active_only=True) + conn = sqlite3.connect(self._db_path) + conn.row_factory = sqlite3.Row + libs = conn.execute( + "SELECT id, path, last_full_scan FROM libraries WHERE is_active=1 ORDER BY path" + ).fetchall() + total_songs = {} for lib in libs: - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - count = conn.execute( + cnt = conn.execute( "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0", (lib["id"],) ).fetchone()[0] - missing_bpm = conn.execute( - "SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0 " - "AND (bpm IS NULL OR bpm=0)", - (lib["id"],) - ).fetchone()[0] - conn.close() - lib_dict = dict(lib) - lib_dict["_count"] = count - lib_dict["_missing_bpm"] = missing_bpm - self._libs_layout.addWidget(self._make_lib_row(lib_dict)) + total_songs[lib["id"]] = cnt + conn.close() + + for lib in libs: + self._libs_layout.addWidget( + self._make_lib_row(dict(lib), total_songs[lib["id"]]) + ) + + if not libs: + lbl = QLabel("Ingen musikmapper tilføjet endnu.") + lbl.setObjectName("result_count") + self._libs_layout.addWidget(lbl) except Exception as e: - lbl = QLabel(f"Fejl: {e}") + lbl = QLabel(f"Fejl ved indlæsning: {e}") self._libs_layout.addWidget(lbl) - def _make_lib_row(self, lib: dict) -> QFrame: - from pathlib import Path - lib_id = lib["id"] - path = lib["path"] - exists = Path(path).exists() + def _make_lib_row(self, lib: dict, song_count: int) -> QFrame: + lib_id = lib["id"] + path = lib["path"] + exists = Path(path).exists() + last = lib.get("last_full_scan") or "aldrig" + if isinstance(last, str) and len(last) > 16: + last = last[:16] + scanning = lib_id in self._workers frame = QFrame() frame.setObjectName("track_display") @@ -149,56 +116,43 @@ class LibraryManagerDialog(QDialog): vbox.setContentsMargins(10, 8, 10, 8) vbox.setSpacing(4) - # Sti + scan-info - last_scan = lib.get("last_full_scan") or "aldrig" - if isinstance(last_scan, str) and len(last_scan) > 10: - last_scan = last_scan[:10] - total = lib.get("_count", 0) - missing_bpm = lib.get("_missing_bpm", 0) - + # Sti lbl_path = QLabel(("⚠ " if not exists else "") + path) lbl_path.setObjectName("track_title" if exists else "result_count") vbox.addWidget(lbl_path) - lbl_info = QLabel( - f" {total} sange · senest scannet: {last_scan} · " - f"{missing_bpm} uden BPM" - ) + # Info + lbl_info = QLabel(f" {song_count} sange · senest scannet: {last}") lbl_info.setObjectName("result_count") vbox.addWidget(lbl_info) - # Statuslinje til BPM-fremgang - lbl_status = QLabel("") - lbl_status.setObjectName("result_count") - lbl_status.hide() - vbox.addWidget(lbl_status) + # Scan-status label + lbl_scan = QLabel("") + lbl_scan.setObjectName("result_count") + lbl_scan.hide() + vbox.addWidget(lbl_scan) - # Knap-række + # Knapper btn_row = QHBoxLayout() btn_row.setSpacing(6) - btn_scan = QPushButton("⟳ Fil-scan") + btn_scan = QPushButton("⟳ Scan nye filer") btn_scan.setFixedHeight(30) - btn_scan.setToolTip("Scan for nye og ændrede filer") - btn_scan.clicked.connect(lambda _, lid=lib_id, p=path: self._scan_files(lid, p)) + btn_scan.setEnabled(exists and not scanning) + btn_scan.clicked.connect( + lambda _, lid=lib_id, p=path, b=btn_scan, s=lbl_scan: + self._start_scan(lid, p, False, b, s) + ) btn_row.addWidget(btn_scan) - btn_bpm = QPushButton(f"♩ BPM manglende ({missing_bpm})") - btn_bpm.setFixedHeight(30) - btn_bpm.setToolTip("Analysér BPM på sange der mangler det") - btn_bpm.setEnabled(missing_bpm > 0) - btn_bpm.clicked.connect( - lambda _, lid=lib_id, b=btn_bpm, s=lbl_status: self._start_bpm(lid, False, b, s) + btn_scan_all = QPushButton("⟳ Scan alle filer") + btn_scan_all.setFixedHeight(30) + btn_scan_all.setEnabled(exists and not scanning) + btn_scan_all.clicked.connect( + lambda _, lid=lib_id, p=path, b=btn_scan_all, s=lbl_scan: + self._start_scan(lid, p, True, b, s) ) - btn_row.addWidget(btn_bpm) - - btn_bpm_all = QPushButton("♩ BPM alle") - btn_bpm_all.setFixedHeight(30) - btn_bpm_all.setToolTip("Genanalysér BPM på alle sange (overskriver eksisterende)") - btn_bpm_all.clicked.connect( - lambda _, lid=lib_id, b=btn_bpm_all, s=lbl_status: self._start_bpm(lid, True, b, s) - ) - btn_row.addWidget(btn_bpm_all) + btn_row.addWidget(btn_scan_all) btn_row.addStretch() @@ -210,70 +164,111 @@ class LibraryManagerDialog(QDialog): vbox.addLayout(btn_row) return frame - def _scan_files(self, library_id: int, path: str): - mw = self.parent() - if hasattr(mw, "_watcher") and mw._watcher: - mw._watcher._full_scan_library(library_id, path) - from PyQt6.QtCore import QTimer - QTimer.singleShot(1000, self._load) - - def _start_bpm(self, library_id: int, scan_all: bool, - btn: QPushButton, lbl_status: QLabel): - if library_id in self._bpm_workers: - return # allerede i gang - - worker = BpmScanWorker(library_id, scan_all=scan_all) - - def on_progress(done, total): - lbl_status.setText(f"♩ {done}/{total} analyseret...") - lbl_status.show() - btn.setEnabled(False) - - def on_finished(count): - lbl_status.setText(f"✓ {count} sange analyseret") - btn.setEnabled(True) - self._bpm_workers.pop(library_id, None) - # Opdater UI og bibliotek - from PyQt6.QtCore import QTimer - QTimer.singleShot(500, self._load) - mw = self.parent() - if hasattr(mw, "_reload_library"): - QTimer.singleShot(600, mw._reload_library) - - worker.progress.connect(on_progress) - worker.finished.connect(on_finished) - self._bpm_workers[library_id] = worker - worker.start() - worker.setPriority(QThread.Priority.LowestPriority) + # ── Tilføj / fjern ──────────────────────────────────────────────────────── def _add_folder(self): from PyQt6.QtWidgets import QFileDialog folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe") - if folder: - mw = self.parent() - if hasattr(mw, "add_library_path"): - mw.add_library_path(folder) - from PyQt6.QtCore import QTimer - QTimer.singleShot(800, self._load) + if not folder: + return + try: + conn = sqlite3.connect(self._db_path) + # Tjek om mappen allerede er tilføjet + existing = conn.execute( + "SELECT id FROM libraries WHERE path=?", (folder,) + ).fetchone() + if existing: + QMessageBox.information(self, "Allerede tilføjet", + "Denne mappe er allerede i listen.") + conn.close() + return + conn.execute( + "INSERT INTO libraries (path, is_active) VALUES (?, 1)", (folder,) + ) + conn.commit() + conn.close() + self._load() + self.libraries_changed.emit() + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke tilføje: {e}") def _remove_library(self, lib: dict): reply = QMessageBox.question( - self, "Fjern bibliotek", - f"Fjern overvågningen af:\n{lib['path']}\n\n" - "Sange forbliver i databasen men markeres som manglende.", + self, "Fjern mappe", + f"Fjern:\n{lib['path']}\n\n" + "Alle sange fra denne mappe slettes også fra databasen.\n" + "Dans-tags og playlister bevares.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) - if reply == QMessageBox.StandardButton.Yes: - try: - mw = self.parent() - if hasattr(mw, "_watcher") and mw._watcher: - mw._watcher.remove_library(lib["id"]) - else: - from local.local_db import remove_library - remove_library(lib["id"]) - self.library_removed.emit(lib["id"]) - if hasattr(mw, "_reload_library"): - mw._reload_library() - self._load() - except Exception as e: - QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}") + if reply != QMessageBox.StandardButton.Yes: + return + + # Stop evt. scanning på dette bibliotek + if lib["id"] in self._workers: + self._workers[lib["id"]].cancel() + self._workers.pop(lib["id"], None) + + try: + conn = sqlite3.connect(self._db_path) + # Slet sange der tilhører dette bibliotek + conn.execute("DELETE FROM songs WHERE library_id=?", (lib["id"],)) + # Deaktiver biblioteket + conn.execute("UPDATE libraries SET is_active=0 WHERE id=?", (lib["id"],)) + conn.commit() + conn.close() + self._load() + self.libraries_changed.emit() + except Exception as e: + QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {e}") + + # ── Scanning ────────────────────────────────────────────────────────────── + + def _start_scan(self, library_id: int, path: str, + scan_all: bool, btn: QPushButton, lbl: QLabel): + if library_id in self._workers: + return + + from local.local_db import DB_PATH + from ui.scan_worker import ScanWorker + + worker = ScanWorker(library_id, path, str(DB_PATH), + overwrite_bpm=scan_all) + + def on_progress(done, total, filename): + if total > 0: + lbl.setText(f"⟳ {done}/{total} — {filename}") + lbl.show() + btn.setEnabled(False) + + def on_finished(count, lib_path): + lbl.setText(f"✓ {count} sange scannet") + btn.setEnabled(True) + self._workers.pop(library_id, None) + QTimer.singleShot(300, self._load) + self.libraries_changed.emit() + + def on_error(msg): + lbl.setText(f"⚠ Fejl: {msg}") + lbl.show() + btn.setEnabled(True) + self._workers.pop(library_id, None) + + worker.progress.connect(on_progress) + worker.finished.connect(on_finished) + worker.error.connect(on_error) + self._workers[library_id] = worker + worker.setPriority(QThread.Priority.LowestPriority) + worker.start() + + # ── Luk ─────────────────────────────────────────────────────────────────── + + def _on_close(self): + # Stop alle aktive scannere + for worker in self._workers.values(): + worker.cancel() + self.accept() + + def closeEvent(self, event): + for worker in self._workers.values(): + worker.cancel() + event.accept() diff --git a/linedance-app/ui/library_panel.py b/linedance-app/ui/library_panel.py index 295cfb8f..e1b3c742 100644 --- a/linedance-app/ui/library_panel.py +++ b/linedance-app/ui/library_panel.py @@ -378,10 +378,18 @@ class LibraryPanel(QWidget): def _manage_libraries(self): from ui.library_manager import LibraryManagerDialog - dialog = LibraryManagerDialog(parent=self.window()) - dialog.library_removed.connect(lambda _: self.scan_requested.emit()) + from local.local_db import DB_PATH + dialog = LibraryManagerDialog(db_path=str(DB_PATH), parent=self.window()) + dialog.libraries_changed.connect(self._on_libraries_changed) dialog.exec() + def _on_libraries_changed(self): + """Kald reload på main_window når biblioteker ændres.""" + mw = self.window() + if hasattr(mw, "_reload_library"): + from PyQt6.QtCore import QTimer + QTimer.singleShot(500, mw._reload_library) + def _add_folder(self): from PyQt6.QtWidgets import QFileDialog folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe") diff --git a/linedance-app/ui/main_window.py b/linedance-app/ui/main_window.py index d6faa719..efc7a03b 100644 --- a/linedance-app/ui/main_window.py +++ b/linedance-app/ui/main_window.py @@ -496,7 +496,7 @@ class MainWindow(QMainWindow): ) def _post_init(self): - """Kør efter DB er initialiseret — gendan state og start watcher.""" + """Kør efter DB er initialiseret — gendan state.""" try: restored = self._playlist_panel.restore_active_playlist() if restored: @@ -512,8 +512,12 @@ class MainWindow(QMainWindow): ) except Exception: pass - QTimer.singleShot(5000, self._start_watcher) - QTimer.singleShot(60000, self.start_scan) + + # Periodisk reload af bibliotek hvert 10. sekund — fanger ny-scannede sange + self._auto_reload_timer = QTimer(self) + self._auto_reload_timer.setInterval(10000) + self._auto_reload_timer.timeout.connect(self._reload_library) + self._auto_reload_timer.start() def add_library_path(self, path: str): try: diff --git a/linedance-app/ui/scan_worker.py b/linedance-app/ui/scan_worker.py index 13ae61ba..2b09d37a 100644 --- a/linedance-app/ui/scan_worker.py +++ b/linedance-app/ui/scan_worker.py @@ -1,64 +1,47 @@ """ -scan_worker.py — Kører fuld biblioteks-scanning i en baggrundstråd -så GUI ikke fryser. +scan_worker.py — QThread der scanner biblioteker i baggrunden. +Rapporterer fremgang via signals uden at blokere GUI. """ - from PyQt6.QtCore import QThread, pyqtSignal class ScanWorker(QThread): - """ - Kører _full_scan_all() i en baggrundstråd. - Sender status-opdateringer undervejs. - """ - status_update = pyqtSignal(str) # løbende statusbeskeder - scan_done = pyqtSignal(int) # antal behandlede filer + progress = pyqtSignal(int, int, str) # done, total, filename + finished = pyqtSignal(int, str) # antal, library_path + error = pyqtSignal(str) - def __init__(self, watcher, parent=None): - super().__init__(parent) - self._watcher = watcher - self._total = 0 + def __init__(self, library_id: int, library_path: str, + db_path: str, overwrite_bpm: bool = False): + super().__init__() + self._library_id = library_id + self._library_path = library_path + self._db_path = db_path + self._overwrite_bpm = overwrite_bpm + self._cancelled = False + + def cancel(self): + self._cancelled = True + self.requestInterruption() def run(self): try: - from local.local_db import get_libraries - from local.tag_reader import is_supported - import os - libraries = get_libraries(active_only=True) + from local.scanner import scan_library - if not libraries: - self.status_update.emit("Ingen biblioteker konfigureret") - self.scan_done.emit(0) - return - - total_processed = 0 - for lib in libraries: - from pathlib import Path - path = Path(lib["path"]) - name = path.name - - if not path.exists(): - self.status_update.emit(f"⚠ Mappe ikke fundet: {path}") - continue - - self.status_update.emit(f"Scanner: {name}...") - - # Tæl filer med os.walk — håndterer permission-fejl sikkert - count = 0 - for dirpath, _, filenames in os.walk(str(path), followlinks=False): - for f in filenames: - if is_supported(f): - count += 1 - - self.status_update.emit(f"Scanner: {name} ({count} filer)...") - - # Kør scanning - self._watcher._full_scan_library(lib["id"], str(path)) - total_processed += count - - self.status_update.emit(f"Scan færdig — {total_processed} filer gennemgået") - self.scan_done.emit(total_processed) + def on_progress(done, total, filename): + if self.isInterruptionRequested(): + raise InterruptedError() + self.progress.emit(done, total, filename) + count = scan_library( + self._library_id, + self._library_path, + self._db_path, + overwrite_bpm=self._overwrite_bpm, + progress_callback=on_progress, + ) + if not self._cancelled: + self.finished.emit(count, self._library_path) + except InterruptedError: + pass except Exception as e: - self.status_update.emit(f"Scan fejl: {e}") - self.scan_done.emit(0) + self.error.emit(str(e))