Næste version
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -232,6 +232,25 @@ MIGRATIONS: dict[int, list[str]] = {
|
||||
SELECT DISTINCT dance_name, level_id, 'local'
|
||||
FROM song_dances WHERE dance_name IS NOT NULL AND dance_name != ''""",
|
||||
],
|
||||
3: [
|
||||
"ALTER TABLE playlists ADD COLUMN tags TEXT NOT NULL DEFAULT ''",
|
||||
],
|
||||
4: [
|
||||
"ALTER TABLE dances ADD COLUMN choreographer TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE dances ADD COLUMN video_url TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE dances ADD COLUMN stepsheet_url TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE dances ADD COLUMN notes TEXT NOT NULL DEFAULT ''",
|
||||
],
|
||||
5: [
|
||||
# Workshop-markering på sang+dans kombination (ikke dans alene)
|
||||
"""ALTER TABLE song_dances ADD COLUMN is_workshop INTEGER NOT NULL DEFAULT 0""",
|
||||
"""ALTER TABLE song_alt_dances ADD COLUMN is_workshop INTEGER NOT NULL DEFAULT 0""",
|
||||
],
|
||||
6: [
|
||||
# Workshop og dans-valg på selve playlist-sangen
|
||||
"""ALTER TABLE playlist_songs ADD COLUMN is_workshop INTEGER NOT NULL DEFAULT 0""",
|
||||
"""ALTER TABLE playlist_songs ADD COLUMN dance_override TEXT NOT NULL DEFAULT ''""",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -283,11 +302,12 @@ def get_libraries(active_only: bool = True) -> list[sqlite3.Row]:
|
||||
|
||||
def remove_library(library_id: int):
|
||||
with get_db() as conn:
|
||||
# Marker sange som manglende
|
||||
# Marker sange som manglende og løsriv dem fra biblioteket
|
||||
conn.execute(
|
||||
"UPDATE songs SET file_missing=1 WHERE library_id=?", (library_id,)
|
||||
"UPDATE songs SET file_missing=1, library_id=NULL WHERE library_id=?",
|
||||
(library_id,)
|
||||
)
|
||||
# Slet biblioteket helt
|
||||
# Nu kan biblioteket slettes uden FK-konflikt
|
||||
conn.execute("DELETE FROM libraries WHERE id=?", (library_id,))
|
||||
|
||||
|
||||
@@ -452,20 +472,70 @@ def get_all_song_paths_for_library(library_id: int) -> dict[str, str]:
|
||||
|
||||
# ── Afspilningslister ─────────────────────────────────────────────────────────
|
||||
|
||||
def create_playlist(name: str, description: str = "") -> int:
|
||||
def create_playlist(name: str, description: str = "", tags: str = "") -> int:
|
||||
with get_db() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO playlists (name, description) VALUES (?,?)",
|
||||
(name, description)
|
||||
"INSERT INTO playlists (name, description, tags) VALUES (?,?,?)",
|
||||
(name, description, tags)
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_playlists() -> list[sqlite3.Row]:
|
||||
def update_playlist_tags(playlist_id: int, tags: str):
|
||||
with get_db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM playlists ORDER BY created_at DESC"
|
||||
conn.execute(
|
||||
"UPDATE playlists SET tags=? WHERE id=?",
|
||||
(tags, playlist_id)
|
||||
)
|
||||
|
||||
|
||||
def get_all_playlist_tags() -> list[str]:
|
||||
"""Returnerer alle unikke tags på tværs af alle playlists, sorteret alfabetisk."""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT tags FROM playlists WHERE tags != '' AND name != ?",
|
||||
("__aktiv__",)
|
||||
).fetchall()
|
||||
tags = set()
|
||||
for row in rows:
|
||||
for tag in row["tags"].split(","):
|
||||
t = tag.strip().lower()
|
||||
if t:
|
||||
tags.add(t)
|
||||
return sorted(tags)
|
||||
|
||||
|
||||
def get_playlists(tag_filter: str | None = None) -> list[sqlite3.Row]:
|
||||
"""Hent alle navngivne playlists med sang-antal. Filtrer på tag hvis angivet."""
|
||||
with get_db() as conn:
|
||||
if tag_filter:
|
||||
rows = conn.execute("""
|
||||
SELECT p.*, COUNT(ps.id) as song_count
|
||||
FROM playlists p
|
||||
LEFT JOIN playlist_songs ps ON ps.playlist_id = p.id
|
||||
WHERE p.name != ? AND (
|
||||
p.tags LIKE ? OR p.tags LIKE ? OR
|
||||
p.tags LIKE ? OR p.tags = ?
|
||||
)
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC
|
||||
""", (
|
||||
"__aktiv__",
|
||||
f"{tag_filter},%",
|
||||
f"%, {tag_filter},%",
|
||||
f"%, {tag_filter}",
|
||||
tag_filter,
|
||||
)).fetchall()
|
||||
else:
|
||||
rows = conn.execute("""
|
||||
SELECT p.*, COUNT(ps.id) as song_count
|
||||
FROM playlists p
|
||||
LEFT JOIN playlist_songs ps ON ps.playlist_id = p.id
|
||||
WHERE p.name != ?
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC
|
||||
""", ("__aktiv__",)).fetchall()
|
||||
return rows
|
||||
|
||||
|
||||
def add_song_to_playlist(playlist_id: int, song_id: str, position: int | None = None) -> int:
|
||||
@@ -554,6 +624,29 @@ def clear_event_state():
|
||||
|
||||
# ── Dans-entitet funktioner ───────────────────────────────────────────────────
|
||||
|
||||
def get_dance(dance_id: int) -> sqlite3.Row | None:
|
||||
with get_db() as conn:
|
||||
return conn.execute(
|
||||
"SELECT * FROM dances WHERE id=?", (dance_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def update_dance_info(dance_id: int, choreographer: str = "",
|
||||
video_url: str = "", stepsheet_url: str = "",
|
||||
notes: str = ""):
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
UPDATE dances SET
|
||||
choreographer = ?,
|
||||
video_url = ?,
|
||||
stepsheet_url = ?,
|
||||
notes = ?
|
||||
WHERE id = ?
|
||||
""", (choreographer.strip(), video_url.strip(),
|
||||
stepsheet_url.strip(), notes.strip(), dance_id))
|
||||
|
||||
|
||||
|
||||
def get_or_create_dance(name: str, level_id: int | None,
|
||||
conn=None) -> int:
|
||||
"""Find eller opret en dans (name + level_id kombination).
|
||||
@@ -605,11 +698,12 @@ def get_dance_suggestions(prefix: str, limit: int = 20) -> list[dict]:
|
||||
|
||||
|
||||
def get_dances_for_song(song_id: str) -> list[dict]:
|
||||
"""Hent hoveddanse for en sang med niveau-info."""
|
||||
"""Hent hoveddanse for en sang med niveau-info og workshop-flag."""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT d.id as dance_id, d.name, d.level_id,
|
||||
dl.name as level_name, sd.dance_order, sd.id as song_dance_id
|
||||
dl.name as level_name, sd.dance_order,
|
||||
sd.id as song_dance_id, sd.is_workshop
|
||||
FROM song_dances sd
|
||||
JOIN dances d ON d.id = sd.dance_id
|
||||
LEFT JOIN dance_levels dl ON dl.id = d.level_id
|
||||
|
||||
Reference in New Issue
Block a user