""" 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 og start overvågning af det med det samme.""" library_id = add_library(path) if self._observer and self._running: handler = MusicLibraryHandler(library_id, self.on_change) self._observer.schedule(handler, path, recursive=True) logger.info(f"Tilføjet bibliotek: {path}") # Scan det nye bibliotek i baggrunden threading.Thread( target=self._full_scan_library, args=(library_id, path), daemon=True, ).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.""" for lib in get_libraries(active_only=True): path = Path(lib["path"]) if path.exists(): self._full_scan_library(lib["id"], str(path)) def _full_scan_library(self, library_id: int, library_path: str): """ Sammenligner filer på disk med SQLite og synkroniserer forskelle. Håndterer utilgængelige mapper og symlinks sikkert. """ logger.info(f"Fuld scan starter: {library_path}") base = Path(library_path) # Tjek at mappen faktisk er tilgængelig — med timeout if not self._path_accessible(base): logger.warning(f"Bibliotek ikke tilgængeligt (timeout eller ingen adgang): {library_path}") return known = get_all_song_paths_for_library(library_id) found_paths = set() processed = 0 errors = 0 import os for dirpath, dirnames, filenames in os.walk( str(base), followlinks=False, onerror=lambda e: logger.warning(f"Adgang nægtet: {e}") ): for filename in filenames: file_path = Path(dirpath) / filename try: if not is_supported(file_path): continue path_str = str(file_path) found_paths.add(path_str) disk_modified = get_file_modified_at(file_path) if path_str not in known or known[path_str] != disk_modified: tags = read_tags(file_path) tags["library_id"] = library_id upsert_song(tags) processed += 1 if self.on_change: self.on_change("upserted", path_str, None) except Exception as e: logger.error(f"Scan-fejl for {file_path}: {e}") errors += 1 # Marker forsvundne filer missing_count = 0 for known_path in known: if known_path not in found_paths: mark_song_missing(known_path) missing_count += 1 if self.on_change: self.on_change("deleted", known_path, None) update_library_scan_time(library_id) logger.info( f"Scan færdig: {library_path} — " f"{processed} opdateret, {missing_count} mangler, {errors} fejl" ) 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