259 lines
8.9 KiB
Python
259 lines
8.9 KiB
Python
"""
|
|
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.
|
|
|
|
Tre operationer:
|
|
1. Nye filer → indsæt i SQLite
|
|
2. Ændrede filer → opdater SQLite (baseret på fil-timestamp)
|
|
3. Forsvundne → marker som missing i SQLite
|
|
"""
|
|
logger.info(f"Fuld scan starter: {library_path}")
|
|
base = Path(library_path)
|
|
|
|
# Hvad SQLite kender til
|
|
known = get_all_song_paths_for_library(library_id)
|
|
|
|
# Hvad der faktisk er på disk
|
|
found_paths = set()
|
|
processed = 0
|
|
errors = 0
|
|
|
|
for file_path in base.rglob("*"):
|
|
if not file_path.is_file() or not is_supported(file_path):
|
|
continue
|
|
|
|
path_str = str(file_path)
|
|
found_paths.add(path_str)
|
|
disk_modified = get_file_modified_at(file_path)
|
|
|
|
# Ny fil eller ændret siden sidst
|
|
if path_str not in known or known[path_str] != disk_modified:
|
|
try:
|
|
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"
|
|
)
|
|
|
|
|
|
# ── 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
|