Bedre mappehåndtering
This commit is contained in:
@@ -1,411 +1,34 @@
|
|||||||
"""
|
"""
|
||||||
file_watcher.py — Overvåger musikbiblioteker og holder SQLite opdateret.
|
file_watcher.py — Minimal fil-watcher.
|
||||||
|
Scanning håndteres af scanner.py / ScanWorker.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
# Fil-watcher er deaktiveret — scanning sker via ScanWorker i library_manager
|
||||||
import threading
|
# Denne fil beholdes til fremtidig live-overvågning
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
class LibraryWatcher:
|
class LibraryWatcher:
|
||||||
"""
|
def __init__(self, on_change=None):
|
||||||
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.on_change = on_change
|
||||||
self._observer: Observer | None = None
|
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
||||||
def start(self):
|
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
|
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):
|
def stop(self):
|
||||||
if self._observer and self._running:
|
|
||||||
self._running = False
|
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()
|
|
||||||
|
|
||||||
def add_library(self, path: str) -> int:
|
def add_library(self, path: str) -> int:
|
||||||
"""Tilføj et nyt bibliotek — alt kører i baggrundstråd."""
|
from local.local_db import add_library
|
||||||
library_id = add_library(path)
|
return 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):
|
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)
|
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=None) -> LibraryWatcher:
|
||||||
|
global _watcher_instance
|
||||||
|
if _watcher_instance is None:
|
||||||
def get_watcher(on_change: Callable | None = None) -> LibraryWatcher:
|
_watcher_instance = LibraryWatcher(on_change=on_change)
|
||||||
"""Returnerer den globale LibraryWatcher-instans."""
|
return _watcher_instance
|
||||||
global _watcher
|
|
||||||
if _watcher is None:
|
|
||||||
_watcher = LibraryWatcher(on_change=on_change)
|
|
||||||
return _watcher
|
|
||||||
|
|||||||
165
linedance-app/local/scanner.py
Normal file
165
linedance-app/local/scanner.py
Normal file
@@ -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 <library_id> <library_path> <db_path>
|
||||||
|
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)
|
||||||
@@ -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 (
|
from PyQt6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QWidget,
|
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QWidget,
|
||||||
QPushButton, QListWidget, QListWidgetItem, QMessageBox,
|
QPushButton, QFrame, QMessageBox, QProgressBar,
|
||||||
QFrame, QSizePolicy,
|
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, pyqtSignal, QThread
|
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class LibraryManagerDialog(QDialog):
|
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)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Administrer musikbiblioteker")
|
self._db_path = db_path
|
||||||
self.setMinimumWidth(580)
|
self._workers = {} # library_id → ScanWorker
|
||||||
self.setMinimumHeight(360)
|
self._scanning = False
|
||||||
self._bpm_workers = {} # library_id → BpmScanWorker
|
|
||||||
|
self.setWindowTitle("Musikmapper")
|
||||||
|
self.setMinimumWidth(600)
|
||||||
|
self.setMinimumHeight(300)
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
|
# ── UI ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _build_ui(self):
|
def _build_ui(self):
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(16, 16, 16, 16)
|
layout.setContentsMargins(12, 12, 12, 12)
|
||||||
layout.setSpacing(10)
|
layout.setSpacing(8)
|
||||||
|
|
||||||
lbl = QLabel("Aktive musikbiblioteker:")
|
lbl = QLabel("Tilføj eller fjern musikmapper. Scan starter kun ved klik på knappen.")
|
||||||
lbl.setObjectName("track_meta")
|
lbl.setObjectName("result_count")
|
||||||
|
lbl.setWordWrap(True)
|
||||||
layout.addWidget(lbl)
|
layout.addWidget(lbl)
|
||||||
|
|
||||||
self._libs_layout = QVBoxLayout()
|
self._libs_layout = QVBoxLayout()
|
||||||
self._libs_layout.setSpacing(6)
|
self._libs_layout.setSpacing(6)
|
||||||
layout.addLayout(self._libs_layout)
|
layout.addLayout(self._libs_layout)
|
||||||
|
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
|
|
||||||
note = QLabel(
|
# Global status
|
||||||
"Når du fjerner et bibliotek, slettes det fra overvågningen.\n"
|
self._lbl_status = QLabel("")
|
||||||
"Sangene forbliver i databasen men markeres som manglende (⚠)."
|
self._lbl_status.setObjectName("result_count")
|
||||||
)
|
self._lbl_status.hide()
|
||||||
note.setObjectName("result_count")
|
layout.addWidget(self._lbl_status)
|
||||||
note.setWordWrap(True)
|
|
||||||
layout.addWidget(note)
|
|
||||||
|
|
||||||
|
self._progress = QProgressBar()
|
||||||
|
self._progress.hide()
|
||||||
|
layout.addWidget(self._progress)
|
||||||
|
|
||||||
|
# Knap-række
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_add = QPushButton("+ Tilføj mappe")
|
btn_add = QPushButton("+ Tilføj mappe")
|
||||||
btn_add.clicked.connect(self._add_folder)
|
btn_add.clicked.connect(self._add_folder)
|
||||||
btn_row.addWidget(btn_add)
|
btn_row.addWidget(btn_add)
|
||||||
btn_row.addStretch()
|
btn_row.addStretch()
|
||||||
btn_close = QPushButton("Luk")
|
btn_close = QPushButton("Luk")
|
||||||
btn_close.clicked.connect(self.accept)
|
btn_close.clicked.connect(self._on_close)
|
||||||
btn_row.addWidget(btn_close)
|
btn_row.addWidget(btn_close)
|
||||||
layout.addLayout(btn_row)
|
layout.addLayout(btn_row)
|
||||||
|
|
||||||
def _load(self):
|
def _load(self):
|
||||||
|
"""Indlæs biblioteker fra DB og vis dem — ingen scanning."""
|
||||||
while self._libs_layout.count():
|
while self._libs_layout.count():
|
||||||
item = self._libs_layout.takeAt(0)
|
item = self._libs_layout.takeAt(0)
|
||||||
if item.widget():
|
if item.widget():
|
||||||
item.widget().deleteLater()
|
item.widget().deleteLater()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
conn = sqlite3.connect(self._db_path)
|
||||||
from local.local_db import DB_PATH, get_libraries
|
|
||||||
libs = get_libraries(active_only=True)
|
|
||||||
for lib in libs:
|
|
||||||
conn = sqlite3.connect(str(DB_PATH))
|
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
count = conn.execute(
|
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:
|
||||||
|
cnt = conn.execute(
|
||||||
"SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0",
|
"SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0",
|
||||||
(lib["id"],)
|
(lib["id"],)
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
missing_bpm = conn.execute(
|
total_songs[lib["id"]] = cnt
|
||||||
"SELECT COUNT(*) FROM songs WHERE library_id=? AND file_missing=0 "
|
|
||||||
"AND (bpm IS NULL OR bpm=0)",
|
|
||||||
(lib["id"],)
|
|
||||||
).fetchone()[0]
|
|
||||||
conn.close()
|
conn.close()
|
||||||
lib_dict = dict(lib)
|
|
||||||
lib_dict["_count"] = count
|
for lib in libs:
|
||||||
lib_dict["_missing_bpm"] = missing_bpm
|
self._libs_layout.addWidget(
|
||||||
self._libs_layout.addWidget(self._make_lib_row(lib_dict))
|
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:
|
except Exception as e:
|
||||||
lbl = QLabel(f"Fejl: {e}")
|
lbl = QLabel(f"Fejl ved indlæsning: {e}")
|
||||||
self._libs_layout.addWidget(lbl)
|
self._libs_layout.addWidget(lbl)
|
||||||
|
|
||||||
def _make_lib_row(self, lib: dict) -> QFrame:
|
def _make_lib_row(self, lib: dict, song_count: int) -> QFrame:
|
||||||
from pathlib import Path
|
|
||||||
lib_id = lib["id"]
|
lib_id = lib["id"]
|
||||||
path = lib["path"]
|
path = lib["path"]
|
||||||
exists = Path(path).exists()
|
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 = QFrame()
|
||||||
frame.setObjectName("track_display")
|
frame.setObjectName("track_display")
|
||||||
@@ -149,56 +116,43 @@ class LibraryManagerDialog(QDialog):
|
|||||||
vbox.setContentsMargins(10, 8, 10, 8)
|
vbox.setContentsMargins(10, 8, 10, 8)
|
||||||
vbox.setSpacing(4)
|
vbox.setSpacing(4)
|
||||||
|
|
||||||
# Sti + scan-info
|
# Sti
|
||||||
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)
|
|
||||||
|
|
||||||
lbl_path = QLabel(("⚠ " if not exists else "") + path)
|
lbl_path = QLabel(("⚠ " if not exists else "") + path)
|
||||||
lbl_path.setObjectName("track_title" if exists else "result_count")
|
lbl_path.setObjectName("track_title" if exists else "result_count")
|
||||||
vbox.addWidget(lbl_path)
|
vbox.addWidget(lbl_path)
|
||||||
|
|
||||||
lbl_info = QLabel(
|
# Info
|
||||||
f" {total} sange · senest scannet: {last_scan} · "
|
lbl_info = QLabel(f" {song_count} sange · senest scannet: {last}")
|
||||||
f"{missing_bpm} uden BPM"
|
|
||||||
)
|
|
||||||
lbl_info.setObjectName("result_count")
|
lbl_info.setObjectName("result_count")
|
||||||
vbox.addWidget(lbl_info)
|
vbox.addWidget(lbl_info)
|
||||||
|
|
||||||
# Statuslinje til BPM-fremgang
|
# Scan-status label
|
||||||
lbl_status = QLabel("")
|
lbl_scan = QLabel("")
|
||||||
lbl_status.setObjectName("result_count")
|
lbl_scan.setObjectName("result_count")
|
||||||
lbl_status.hide()
|
lbl_scan.hide()
|
||||||
vbox.addWidget(lbl_status)
|
vbox.addWidget(lbl_scan)
|
||||||
|
|
||||||
# Knap-række
|
# Knapper
|
||||||
btn_row = QHBoxLayout()
|
btn_row = QHBoxLayout()
|
||||||
btn_row.setSpacing(6)
|
btn_row.setSpacing(6)
|
||||||
|
|
||||||
btn_scan = QPushButton("⟳ Fil-scan")
|
btn_scan = QPushButton("⟳ Scan nye filer")
|
||||||
btn_scan.setFixedHeight(30)
|
btn_scan.setFixedHeight(30)
|
||||||
btn_scan.setToolTip("Scan for nye og ændrede filer")
|
btn_scan.setEnabled(exists and not scanning)
|
||||||
btn_scan.clicked.connect(lambda _, lid=lib_id, p=path: self._scan_files(lid, p))
|
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_row.addWidget(btn_scan)
|
||||||
|
|
||||||
btn_bpm = QPushButton(f"♩ BPM manglende ({missing_bpm})")
|
btn_scan_all = QPushButton("⟳ Scan alle filer")
|
||||||
btn_bpm.setFixedHeight(30)
|
btn_scan_all.setFixedHeight(30)
|
||||||
btn_bpm.setToolTip("Analysér BPM på sange der mangler det")
|
btn_scan_all.setEnabled(exists and not scanning)
|
||||||
btn_bpm.setEnabled(missing_bpm > 0)
|
btn_scan_all.clicked.connect(
|
||||||
btn_bpm.clicked.connect(
|
lambda _, lid=lib_id, p=path, b=btn_scan_all, s=lbl_scan:
|
||||||
lambda _, lid=lib_id, b=btn_bpm, s=lbl_status: self._start_bpm(lid, False, b, s)
|
self._start_scan(lid, p, True, b, s)
|
||||||
)
|
)
|
||||||
btn_row.addWidget(btn_bpm)
|
btn_row.addWidget(btn_scan_all)
|
||||||
|
|
||||||
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.addStretch()
|
btn_row.addStretch()
|
||||||
|
|
||||||
@@ -210,70 +164,111 @@ class LibraryManagerDialog(QDialog):
|
|||||||
vbox.addLayout(btn_row)
|
vbox.addLayout(btn_row)
|
||||||
return frame
|
return frame
|
||||||
|
|
||||||
def _scan_files(self, library_id: int, path: str):
|
# ── Tilføj / fjern ────────────────────────────────────────────────────────
|
||||||
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)
|
|
||||||
|
|
||||||
def _add_folder(self):
|
def _add_folder(self):
|
||||||
from PyQt6.QtWidgets import QFileDialog
|
from PyQt6.QtWidgets import QFileDialog
|
||||||
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
||||||
if folder:
|
if not folder:
|
||||||
mw = self.parent()
|
return
|
||||||
if hasattr(mw, "add_library_path"):
|
try:
|
||||||
mw.add_library_path(folder)
|
conn = sqlite3.connect(self._db_path)
|
||||||
from PyQt6.QtCore import QTimer
|
# Tjek om mappen allerede er tilføjet
|
||||||
QTimer.singleShot(800, self._load)
|
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):
|
def _remove_library(self, lib: dict):
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, "Fjern bibliotek",
|
self, "Fjern mappe",
|
||||||
f"Fjern overvågningen af:\n{lib['path']}\n\n"
|
f"Fjern:\n{lib['path']}\n\n"
|
||||||
"Sange forbliver i databasen men markeres som manglende.",
|
"Alle sange fra denne mappe slettes også fra databasen.\n"
|
||||||
|
"Dans-tags og playlister bevares.",
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
)
|
)
|
||||||
if reply == QMessageBox.StandardButton.Yes:
|
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:
|
try:
|
||||||
mw = self.parent()
|
conn = sqlite3.connect(self._db_path)
|
||||||
if hasattr(mw, "_watcher") and mw._watcher:
|
# Slet sange der tilhører dette bibliotek
|
||||||
mw._watcher.remove_library(lib["id"])
|
conn.execute("DELETE FROM songs WHERE library_id=?", (lib["id"],))
|
||||||
else:
|
# Deaktiver biblioteket
|
||||||
from local.local_db import remove_library
|
conn.execute("UPDATE libraries SET is_active=0 WHERE id=?", (lib["id"],))
|
||||||
remove_library(lib["id"])
|
conn.commit()
|
||||||
self.library_removed.emit(lib["id"])
|
conn.close()
|
||||||
if hasattr(mw, "_reload_library"):
|
|
||||||
mw._reload_library()
|
|
||||||
self._load()
|
self._load()
|
||||||
|
self.libraries_changed.emit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.warning(self, "Fejl", f"Kunne ikke fjerne: {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()
|
||||||
|
|||||||
@@ -378,10 +378,18 @@ class LibraryPanel(QWidget):
|
|||||||
|
|
||||||
def _manage_libraries(self):
|
def _manage_libraries(self):
|
||||||
from ui.library_manager import LibraryManagerDialog
|
from ui.library_manager import LibraryManagerDialog
|
||||||
dialog = LibraryManagerDialog(parent=self.window())
|
from local.local_db import DB_PATH
|
||||||
dialog.library_removed.connect(lambda _: self.scan_requested.emit())
|
dialog = LibraryManagerDialog(db_path=str(DB_PATH), parent=self.window())
|
||||||
|
dialog.libraries_changed.connect(self._on_libraries_changed)
|
||||||
dialog.exec()
|
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):
|
def _add_folder(self):
|
||||||
from PyQt6.QtWidgets import QFileDialog
|
from PyQt6.QtWidgets import QFileDialog
|
||||||
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
folder = QFileDialog.getExistingDirectory(self, "Vælg musikmappe")
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _post_init(self):
|
def _post_init(self):
|
||||||
"""Kør efter DB er initialiseret — gendan state og start watcher."""
|
"""Kør efter DB er initialiseret — gendan state."""
|
||||||
try:
|
try:
|
||||||
restored = self._playlist_panel.restore_active_playlist()
|
restored = self._playlist_panel.restore_active_playlist()
|
||||||
if restored:
|
if restored:
|
||||||
@@ -512,8 +512,12 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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):
|
def add_library_path(self, path: str):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,64 +1,47 @@
|
|||||||
"""
|
"""
|
||||||
scan_worker.py — Kører fuld biblioteks-scanning i en baggrundstråd
|
scan_worker.py — QThread der scanner biblioteker i baggrunden.
|
||||||
så GUI ikke fryser.
|
Rapporterer fremgang via signals uden at blokere GUI.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
|
|
||||||
class ScanWorker(QThread):
|
class ScanWorker(QThread):
|
||||||
"""
|
progress = pyqtSignal(int, int, str) # done, total, filename
|
||||||
Kører _full_scan_all() i en baggrundstråd.
|
finished = pyqtSignal(int, str) # antal, library_path
|
||||||
Sender status-opdateringer undervejs.
|
error = pyqtSignal(str)
|
||||||
"""
|
|
||||||
status_update = pyqtSignal(str) # løbende statusbeskeder
|
|
||||||
scan_done = pyqtSignal(int) # antal behandlede filer
|
|
||||||
|
|
||||||
def __init__(self, watcher, parent=None):
|
def __init__(self, library_id: int, library_path: str,
|
||||||
super().__init__(parent)
|
db_path: str, overwrite_bpm: bool = False):
|
||||||
self._watcher = watcher
|
super().__init__()
|
||||||
self._total = 0
|
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):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
from local.local_db import get_libraries
|
from local.scanner import scan_library
|
||||||
from local.tag_reader import is_supported
|
|
||||||
import os
|
|
||||||
libraries = get_libraries(active_only=True)
|
|
||||||
|
|
||||||
if not libraries:
|
def on_progress(done, total, filename):
|
||||||
self.status_update.emit("Ingen biblioteker konfigureret")
|
if self.isInterruptionRequested():
|
||||||
self.scan_done.emit(0)
|
raise InterruptedError()
|
||||||
return
|
self.progress.emit(done, total, filename)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
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:
|
except Exception as e:
|
||||||
self.status_update.emit(f"Scan fejl: {e}")
|
self.error.emit(str(e))
|
||||||
self.scan_done.emit(0)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user