Næste version

This commit is contained in:
2026-04-12 10:25:41 +02:00
parent b678787236
commit 57f3c913b4
18 changed files with 2690 additions and 458 deletions

View File

@@ -148,21 +148,98 @@ class LibraryWatcher:
self._running = False
def add_library(self, path: str) -> int:
"""Tilføj et nyt bibliotek og start overvågning af det med det samme."""
"""Tilføj et nyt bibliotek — scanner i baggrundstråd med egen DB-forbindelse."""
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()
# Scan i baggrundstråd med daemon=True så den ikke blokerer programlukning
def _scan_in_background(lib_id, lib_path):
try:
import sqlite3
from local.local_db import DB_PATH, is_supported, get_file_modified_at
from local.tag_reader import read_tags
import os
# Åbn egen forbindelse — deler ikke med GUI-tråden
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
base = Path(lib_path)
if not self._path_accessible(base):
conn.close()
return
known = {
row["local_path"]: row["file_modified_at"]
for row in conn.execute(
"SELECT local_path, file_modified_at FROM songs WHERE library_id=?",
(lib_id,)
).fetchall()
}
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)
disk_modified = get_file_modified_at(file_path)
if path_str not in known or known[path_str] != disk_modified:
try:
tags = read_tags(file_path)
tags["library_id"] = lib_id
# Upsert via direkte SQL på denne forbindelse
import uuid, json
existing = conn.execute(
"SELECT id FROM songs WHERE local_path=?",
(path_str,)
).fetchone()
extra = json.dumps(tags.get("extra_tags", {}), ensure_ascii=False)
if existing:
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=?
""", (lib_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, existing["id"]))
song_id = existing["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, lib_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))
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: {file_path}: {e}")
conn.execute(
"UPDATE libraries SET last_full_scan=datetime('now') WHERE id=?",
(lib_id,)
)
conn.commit()
conn.close()
logger.info(f"Bibliotek scannet: {lib_path}{processed} filer")
except Exception as e:
logger.error(f"Baggrunds-scan fejl: {e}")
t = threading.Thread(target=_scan_in_background, args=(library_id, path), daemon=True)
t.start()
return library_id
def remove_library(self, library_id: int):
@@ -182,69 +259,159 @@ class LibraryWatcher:
self._observer.schedule(handler, str(path), recursive=True)
def _full_scan_all(self):
"""Kør fuld scan på alle aktive biblioteker."""
"""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():
self._full_scan_library(lib["id"], str(path))
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):
"""
Sammenligner filer på disk med SQLite og synkroniserer forskelle.
Håndterer utilgængelige mapper og symlinks sikkert.
"""
"""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)
# 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}")
logger.warning(f"Bibliotek ikke tilgængeligt: {library_path}")
return
known = get_all_song_paths_for_library(library_id)
found_paths = set()
processed = 0
errors = 0
try:
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
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:
# 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)
disk_modified = get_file_modified_at(file_path)
try:
disk_modified = get_file_modified_at(file_path)
if path_str in known and known[path_str] == disk_modified:
continue # uændret — skip
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)
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, None)
except Exception as e:
logger.error(f"Scan-fejl for {file_path}: {e}")
errors += 1
self.on_change("upserted", path_str, song_id)
# 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)
except Exception as e:
logger.error(f"Scan-fejl for {file_path}: {e}")
update_library_scan_time(library_id)
logger.info(
f"Scan færdig: {library_path}"
f"{processed} opdateret, {missing_count} mangler, {errors} fejl"
)
# 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."""