Bedre mappehåndtering
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user