""" 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. """ import threading import time import logging from pathlib import Path from typing import Callable try: from watchdog.observers import Observer from watchdog.events import ( FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent, FileDeletedEvent, FileMovedEvent, ) WATCHDOG_AVAILABLE = True except ImportError: WATCHDOG_AVAILABLE = False print("Advarsel: watchdog ikke installeret — fil-overvågning deaktiveret") 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}") class LibraryWatcher: """ Styrer watchdog-observere for alle aktive musikbiblioteker. Én instans per applikation. """ def __init__(self, on_change: Callable | None = None): self.on_change = on_change self._observer: Observer | None = None 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 self._observer = Observer() libraries = get_libraries(active_only=True) 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._observer.stop() self._observer.join() 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 def remove_library(self, library_id: int): """Deaktiver bibliotek. Watchdog stopper automatisk ved næste restart.""" 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 — i baggrundstråde.""" for lib in get_libraries(active_only=True): path = Path(lib["path"]) if path.exists(): t = threading.Thread( target=self._full_scan_library, args=(lib["id"], str(path)), daemon=True ) t.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 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 = 5.0) -> bool: """Tjek om en sti er tilgængelig inden for timeout.""" import threading result = [False] def check(): try: 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: 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