626 lines
26 KiB
Python
626 lines
26 KiB
Python
"""Music-library scanning, MusicBrainz enrichment, and completeness logic.
|
|||
|
|
|
||
|
|
Design rules (per the feature spec):
|
||
|
|
* Disk is only walked by an explicit background job, never on page render.
|
||
|
|
* The UI reads exclusively from the database.
|
||
|
|
* Jobs write progress to ``library_scan_runs`` so the UI can poll.
|
||
|
|
* Manual user decisions (ignore / mark owned / mark missing) are never
|
||
|
|
overwritten by a metadata refresh.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
import os
|
||
|
|
import threading
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from . import db, musicbrainz
|
||
|
|
from .music_covers import AUDIO_EXTENSIONS, MUSIC_ROOT, _first_tag, _load_audio, clean_name, clean_track_number, extract_year
|
||
|
|
from .text_normalize import (
|
||
|
|
MISSING,
|
||
|
|
OWNED,
|
||
|
|
classify_release,
|
||
|
|
is_various_artists,
|
||
|
|
normalize_artist,
|
||
|
|
normalize_title,
|
||
|
|
)
|
||
|
|
|
||
|
|
logger = logging.getLogger("homelabtoolkit.music_library")
|
||
|
|
|
||
|
|
_OWNED_STATUSES = (OWNED, "probably_owned")
|
||
|
|
_BATCH_SIZE = 200
|
||
|
|
|
||
|
|
# Only one job of each kind runs at a time (single process).
|
||
|
|
_running: set[str] = set()
|
||
|
|
_running_lock = threading.Lock()
|
||
|
|
|
||
|
|
|
||
|
|
# ── job guards ────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
|
||
|
|
def _try_acquire(kind: str) -> bool:
|
||
|
|
with _running_lock:
|
||
|
|
if kind in _running:
|
||
|
|
return False
|
||
|
|
_running.add(kind)
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def _release(kind: str) -> None:
|
||
|
|
with _running_lock:
|
||
|
|
_running.discard(kind)
|
||
|
|
|
||
|
|
|
||
|
|
def _is_running(kind: str) -> bool:
|
||
|
|
with _running_lock:
|
||
|
|
return kind in _running
|
||
|
|
|
||
|
|
|
||
|
|
# ── scan run bookkeeping ──────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
|
||
|
|
def _create_run(kind: str) -> int:
|
||
|
|
with db.connect() as conn:
|
||
|
|
cur = conn.execute(
|
||
|
|
"INSERT INTO library_scan_runs(kind, status, started_at) VALUES(?, 'running', ?)",
|
||
|
|
(kind, db.now_iso()),
|
||
|
|
)
|
||
|
|
return cur.lastrowid
|
||
|
|
|
||
|
|
|
||
|
|
def _update_run(run_id: int, **fields) -> None:
|
||
|
|
if not fields:
|
||
|
|
return
|
||
|
|
cols = ", ".join(f"{k}=?" for k in fields)
|
||
|
|
with db.connect() as conn:
|
||
|
|
conn.execute(f"UPDATE library_scan_runs SET {cols} WHERE id=?", (*fields.values(), run_id))
|
||
|
|
|
||
|
|
|
||
|
|
def _finish_run(run_id: int, status: str, **fields) -> None:
|
||
|
|
_update_run(run_id, status=status, completed_at=db.now_iso(), **fields)
|
||
|
|
|
||
|
|
|
||
|
|
# ── tag reading ───────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
|
||
|
|
def _read_tags(path: Path) -> dict | None:
|
||
|
|
audio = _load_audio(path)
|
||
|
|
if audio is None:
|
||
|
|
return None
|
||
|
|
album_artist = _first_tag(audio, ["albumartist", "album artist"])
|
||
|
|
track_artist = _first_tag(audio, ["artist", "albumartist"])
|
||
|
|
artist = album_artist or track_artist or clean_name(path.parent.parent.name)
|
||
|
|
album = _first_tag(audio, ["album"]) or clean_name(path.parent.name)
|
||
|
|
title = _first_tag(audio, ["title"]) or path.stem
|
||
|
|
track_no = clean_track_number(audio.get("tracknumber"))
|
||
|
|
disc_no = clean_track_number(audio.get("discnumber"))
|
||
|
|
return {
|
||
|
|
"artist": artist,
|
||
|
|
"album": album,
|
||
|
|
"title": title,
|
||
|
|
"track_number": int(track_no) if track_no else None,
|
||
|
|
"disc_number": int(disc_no) if disc_no else None,
|
||
|
|
"year": int(extract_year(_first_tag(audio, ["date", "originaldate", "year"])) or 0),
|
||
|
|
"artist_mbid": _first_tag(audio, ["musicbrainz_artistid"]),
|
||
|
|
"album_mbid": _first_tag(audio, ["musicbrainz_releasegroupid", "musicbrainz_albumid"]),
|
||
|
|
"track_mbid": _first_tag(audio, ["musicbrainz_trackid"]),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _iter_audio_files(root: Path):
|
||
|
|
"""Yield audio file paths lazily so we never hold the library in memory."""
|
||
|
|
for dirpath, _dirnames, filenames in os.walk(root):
|
||
|
|
for name in filenames:
|
||
|
|
if Path(name).suffix.lower() in AUDIO_EXTENSIONS:
|
||
|
|
yield Path(dirpath) / name
|
||
|
|
|
||
|
|
|
||
|
|
# ── upserts (in-run caches keep artist/album lookups cheap) ───────────────────
|
||
|
|
|
||
|
|
|
||
|
|
def _get_artist_id(conn, cache: dict, meta: dict) -> int:
|
||
|
|
name = meta["artist"]
|
||
|
|
norm = normalize_artist(name)
|
||
|
|
if norm in cache:
|
||
|
|
return cache[norm]
|
||
|
|
now = db.now_iso()
|
||
|
|
various = 1 if is_various_artists(name) else 0
|
||
|
|
row = conn.execute("SELECT id FROM library_artists WHERE name_normalized=?", (norm,)).fetchone()
|
||
|
|
if row:
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE library_artists SET is_active=1, is_various=?, updated_at=?, mbid=COALESCE(mbid, ?) WHERE id=?",
|
||
|
|
(various, now, meta.get("artist_mbid"), row["id"]),
|
||
|
|
)
|
||
|
|
artist_id = row["id"]
|
||
|
|
else:
|
||
|
|
cur = conn.execute(
|
||
|
|
"INSERT INTO library_artists(name, name_normalized, mbid, is_various, is_active, created_at, updated_at) "
|
||
|
|
"VALUES(?,?,?,?,1,?,?)",
|
||
|
|
(name, norm, meta.get("artist_mbid"), various, now, now),
|
||
|
|
)
|
||
|
|
artist_id = cur.lastrowid
|
||
|
|
cache[norm] = artist_id
|
||
|
|
return artist_id
|
||
|
|
|
||
|
|
|
||
|
|
def _get_album_id(conn, cache: dict, artist_id: int, meta: dict) -> int:
|
||
|
|
title = meta["album"]
|
||
|
|
norm = normalize_title(title)
|
||
|
|
year = meta.get("year") or 0
|
||
|
|
key = (artist_id, norm, year)
|
||
|
|
if key in cache:
|
||
|
|
return cache[key]
|
||
|
|
now = db.now_iso()
|
||
|
|
row = conn.execute(
|
||
|
|
"SELECT id FROM library_albums WHERE artist_id=? AND title_normalized=? AND year=?",
|
||
|
|
(artist_id, norm, year),
|
||
|
|
).fetchone()
|
||
|
|
if row:
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE library_albums SET is_active=1, updated_at=?, mbid=COALESCE(mbid, ?) WHERE id=?",
|
||
|
|
(now, meta.get("album_mbid"), row["id"]),
|
||
|
|
)
|
||
|
|
album_id = row["id"]
|
||
|
|
else:
|
||
|
|
cur = conn.execute(
|
||
|
|
"INSERT INTO library_albums(artist_id, title, title_normalized, year, mbid, is_active, created_at, updated_at) "
|
||
|
|
"VALUES(?,?,?,?,?,1,?,?)",
|
||
|
|
(artist_id, title, norm, year, meta.get("album_mbid"), now, now),
|
||
|
|
)
|
||
|
|
album_id = cur.lastrowid
|
||
|
|
cache[key] = album_id
|
||
|
|
return album_id
|
||
|
|
|
||
|
|
|
||
|
|
def _upsert_track(conn, album_id: int, meta: dict, path: Path, size: int, mtime: float, run_id: int) -> None:
|
||
|
|
now = db.now_iso()
|
||
|
|
conn.execute(
|
||
|
|
"""
|
||
|
|
INSERT INTO library_tracks(album_id, title, track_number, disc_number, file_path, file_mtime,
|
||
|
|
file_size, mbid, is_active, last_seen_scan_id, created_at, updated_at)
|
||
|
|
VALUES(?,?,?,?,?,?,?,?,1,?,?,?)
|
||
|
|
ON CONFLICT(file_path) DO UPDATE SET
|
||
|
|
album_id=excluded.album_id, title=excluded.title, track_number=excluded.track_number,
|
||
|
|
disc_number=excluded.disc_number, file_mtime=excluded.file_mtime, file_size=excluded.file_size,
|
||
|
|
mbid=excluded.mbid, is_active=1, last_seen_scan_id=excluded.last_seen_scan_id, updated_at=excluded.updated_at
|
||
|
|
""",
|
||
|
|
(
|
||
|
|
album_id, meta["title"], meta["track_number"], meta["disc_number"], str(path), mtime,
|
||
|
|
size, meta.get("track_mbid"), run_id, now, now,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ── scanner ───────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
|
||
|
|
def run_scan(root: Path | None = None) -> dict:
|
||
|
|
"""Walk the library and upsert artists/albums/tracks. Unchanged files
|
||
|
|
(same path + size + mtime) are skipped without reading tags. Files that
|
||
|
|
vanished are marked inactive, never hard-deleted."""
|
||
|
|
root = Path(root) if root else MUSIC_ROOT
|
||
|
|
run_id = _create_run("scan")
|
||
|
|
logger.info("Scan %d started: %s", run_id, root)
|
||
|
|
|
||
|
|
if not root.exists():
|
||
|
|
_finish_run(run_id, "failed", error_message=f"Music root not found: {root}")
|
||
|
|
logger.warning("Scan %d aborted: root missing", run_id)
|
||
|
|
return {"run_id": run_id, "status": "failed"}
|
||
|
|
|
||
|
|
files_scanned = 0
|
||
|
|
try:
|
||
|
|
with db.connect() as conn:
|
||
|
|
artist_cache: dict = {}
|
||
|
|
album_cache: dict = {}
|
||
|
|
batch = 0
|
||
|
|
for path in _iter_audio_files(root):
|
||
|
|
try:
|
||
|
|
stat = path.stat()
|
||
|
|
except OSError:
|
||
|
|
continue
|
||
|
|
size, mtime = stat.st_size, stat.st_mtime
|
||
|
|
existing = conn.execute(
|
||
|
|
"SELECT id, file_size, file_mtime FROM library_tracks WHERE file_path=?",
|
||
|
|
(str(path),),
|
||
|
|
).fetchone()
|
||
|
|
if existing and existing["file_size"] == size and abs((existing["file_mtime"] or 0) - mtime) < 1:
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE library_tracks SET is_active=1, last_seen_scan_id=? WHERE id=?",
|
||
|
|
(run_id, existing["id"]),
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
meta = _read_tags(path)
|
||
|
|
if meta is not None:
|
||
|
|
artist_id = _get_artist_id(conn, artist_cache, meta)
|
||
|
|
album_id = _get_album_id(conn, album_cache, artist_id, meta)
|
||
|
|
_upsert_track(conn, album_id, meta, path, size, mtime, run_id)
|
||
|
|
files_scanned += 1
|
||
|
|
batch += 1
|
||
|
|
if batch >= _BATCH_SIZE:
|
||
|
|
conn.commit()
|
||
|
|
batch = 0
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE library_scan_runs SET files_scanned=?, progress=? WHERE id=?",
|
||
|
|
(files_scanned, f"Scanned {files_scanned} files", run_id),
|
||
|
|
)
|
||
|
|
conn.commit()
|
||
|
|
conn.commit()
|
||
|
|
|
||
|
|
# Mark vanished files inactive, then cascade activity up.
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE library_tracks SET is_active=0 WHERE COALESCE(last_seen_scan_id, -1) != ? AND is_active=1",
|
||
|
|
(run_id,),
|
||
|
|
)
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE library_albums SET is_active = "
|
||
|
|
"CASE WHEN EXISTS(SELECT 1 FROM library_tracks t WHERE t.album_id=library_albums.id AND t.is_active=1) "
|
||
|
|
"THEN 1 ELSE 0 END"
|
||
|
|
)
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE library_artists SET is_active = "
|
||
|
|
"CASE WHEN EXISTS(SELECT 1 FROM library_albums al WHERE al.artist_id=library_artists.id AND al.is_active=1) "
|
||
|
|
"THEN 1 ELSE 0 END"
|
||
|
|
)
|
||
|
|
artists_found = conn.execute("SELECT COUNT(*) c FROM library_artists WHERE is_active=1").fetchone()["c"]
|
||
|
|
albums_found = conn.execute("SELECT COUNT(*) c FROM library_albums WHERE is_active=1").fetchone()["c"]
|
||
|
|
conn.commit()
|
||
|
|
|
||
|
|
_finish_run(
|
||
|
|
run_id, "completed",
|
||
|
|
files_scanned=files_scanned, albums_found=albums_found, artists_found=artists_found,
|
||
|
|
progress="Done",
|
||
|
|
)
|
||
|
|
logger.info("Scan %d completed: %d files, %d artists, %d albums", run_id, files_scanned, artists_found, albums_found)
|
||
|
|
return {"run_id": run_id, "status": "completed", "files_scanned": files_scanned}
|
||
|
|
except Exception as exc: # pragma: no cover - defensive
|
||
|
|
logger.exception("Scan %d failed", run_id)
|
||
|
|
_finish_run(run_id, "failed", files_scanned=files_scanned, error_message=str(exc))
|
||
|
|
return {"run_id": run_id, "status": "failed", "error": str(exc)}
|
||
|
|
|
||
|
|
|
||
|
|
# ── completeness ──────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
|
||
|
|
def _local_albums_for_artist(conn, artist_id: int) -> list[dict]:
|
||
|
|
rows = conn.execute(
|
||
|
|
"SELECT id, title, title_normalized, year, mbid FROM library_albums WHERE artist_id=? AND is_active=1",
|
||
|
|
(artist_id,),
|
||
|
|
).fetchall()
|
||
|
|
return [dict(r) for r in rows]
|
||
|
|
|
||
|
|
|
||
|
|
def recompute_completeness(conn, artist_id: int) -> None:
|
||
|
|
"""Rebuild completeness rows for one artist from stored external releases and
|
||
|
|
local albums. Manual decisions (manual_override=1) are preserved."""
|
||
|
|
local_albums = _local_albums_for_artist(conn, artist_id)
|
||
|
|
releases = conn.execute(
|
||
|
|
"SELECT mb_release_group_mbid, title, first_release_year, primary_type, secondary_types "
|
||
|
|
"FROM external_releases WHERE artist_id=?",
|
||
|
|
(artist_id,),
|
||
|
|
).fetchall()
|
||
|
|
|
||
|
|
existing = {
|
||
|
|
r["release_group_mbid"]: dict(r)
|
||
|
|
for r in conn.execute(
|
||
|
|
"SELECT release_group_mbid, manual_override FROM collection_completeness WHERE artist_id=?",
|
||
|
|
(artist_id,),
|
||
|
|
).fetchall()
|
||
|
|
}
|
||
|
|
|
||
|
|
qualifying_mbids: list[str] = []
|
||
|
|
now = db.now_iso()
|
||
|
|
for rel in releases:
|
||
|
|
secondary = json.loads(rel["secondary_types"] or "[]")
|
||
|
|
if not musicbrainz.is_official_album(rel["primary_type"], secondary):
|
||
|
|
continue
|
||
|
|
mbid = rel["mb_release_group_mbid"]
|
||
|
|
qualifying_mbids.append(mbid)
|
||
|
|
|
||
|
|
prior = existing.get(mbid)
|
||
|
|
if prior and prior["manual_override"]:
|
||
|
|
# Keep the user's decision; only refresh descriptive fields.
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE collection_completeness SET title=?, year=?, source='musicbrainz', updated_at=? "
|
||
|
|
"WHERE artist_id=? AND release_group_mbid=?",
|
||
|
|
(rel["title"], rel["first_release_year"] or 0, now, artist_id, mbid),
|
||
|
|
)
|
||
|
|
continue
|
||
|
|
|
||
|
|
status, confidence, reason, local_id = classify_release(
|
||
|
|
rel["title"], rel["first_release_year"], mbid, local_albums
|
||
|
|
)
|
||
|
|
conn.execute(
|
||
|
|
"""
|
||
|
|
INSERT INTO collection_completeness(artist_id, release_group_mbid, local_album_id, title, year,
|
||
|
|
status, confidence, reason, source, manual_override, updated_at)
|
||
|
|
VALUES(?,?,?,?,?,?,?,?, 'musicbrainz', 0, ?)
|
||
|
|
ON CONFLICT(artist_id, release_group_mbid) DO UPDATE SET
|
||
|
|
local_album_id=excluded.local_album_id, title=excluded.title, year=excluded.year,
|
||
|
|
status=excluded.status, confidence=excluded.confidence, reason=excluded.reason,
|
||
|
|
source='musicbrainz', updated_at=excluded.updated_at
|
||
|
|
WHERE collection_completeness.manual_override=0
|
||
|
|
""",
|
||
|
|
(artist_id, mbid, local_id, rel["title"], rel["first_release_year"] or 0,
|
||
|
|
status, confidence, reason, now),
|
||
|
|
)
|
||
|
|
|
||
|
|
# Drop non-manual rows that are no longer qualifying (e.g. filter changes).
|
||
|
|
placeholders = ",".join("?" for _ in qualifying_mbids) or "''"
|
||
|
|
conn.execute(
|
||
|
|
f"DELETE FROM collection_completeness WHERE artist_id=? AND manual_override=0 "
|
||
|
|
f"AND release_group_mbid NOT IN ({placeholders})",
|
||
|
|
(artist_id, *qualifying_mbids),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ── metadata refresh job (per-artist MusicBrainz lookups) ─────────────────────
|
||
|
|
|
||
|
|
|
||
|
|
def run_metadata_refresh() -> dict:
|
||
|
|
run_id = _create_run("metadata")
|
||
|
|
logger.info("Metadata refresh %d started", run_id)
|
||
|
|
processed = 0
|
||
|
|
try:
|
||
|
|
with db.connect() as conn:
|
||
|
|
artists = conn.execute(
|
||
|
|
"SELECT id, name, mbid, is_various FROM library_artists WHERE is_active=1 ORDER BY name"
|
||
|
|
).fetchall()
|
||
|
|
total = len(artists)
|
||
|
|
|
||
|
|
for artist in artists:
|
||
|
|
artist_id = artist["id"]
|
||
|
|
if artist["is_various"]:
|
||
|
|
with db.connect() as conn:
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO external_artist_matches(artist_id, status, checked_at) VALUES(?, 'skipped', ?) "
|
||
|
|
"ON CONFLICT(artist_id) DO UPDATE SET status='skipped', checked_at=excluded.checked_at",
|
||
|
|
(artist_id, db.now_iso()),
|
||
|
|
)
|
||
|
|
processed += 1
|
||
|
|
continue
|
||
|
|
|
||
|
|
match = musicbrainz.search_artist(artist["name"])
|
||
|
|
with db.connect() as conn:
|
||
|
|
if not match or not match.get("mbid"):
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO external_artist_matches(artist_id, status, checked_at) VALUES(?, 'not_found', ?) "
|
||
|
|
"ON CONFLICT(artist_id) DO UPDATE SET status='not_found', checked_at=excluded.checked_at",
|
||
|
|
(artist_id, db.now_iso()),
|
||
|
|
)
|
||
|
|
processed += 1
|
||
|
|
_bump_metadata_progress(run_id, processed, total)
|
||
|
|
continue
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO external_artist_matches(artist_id, mb_artist_mbid, mb_artist_name, confidence, status, checked_at) "
|
||
|
|
"VALUES(?,?,?,?, 'matched', ?) "
|
||
|
|
"ON CONFLICT(artist_id) DO UPDATE SET mb_artist_mbid=excluded.mb_artist_mbid, "
|
||
|
|
"mb_artist_name=excluded.mb_artist_name, confidence=excluded.confidence, status='matched', checked_at=excluded.checked_at",
|
||
|
|
(artist_id, match["mbid"], match["name"], match["confidence"], db.now_iso()),
|
||
|
|
)
|
||
|
|
|
||
|
|
release_groups = musicbrainz.fetch_release_groups(match["mbid"])
|
||
|
|
with db.connect() as conn:
|
||
|
|
for rg in release_groups:
|
||
|
|
if not rg.get("mbid"):
|
||
|
|
continue
|
||
|
|
conn.execute(
|
||
|
|
"""
|
||
|
|
INSERT INTO external_releases(artist_id, mb_release_group_mbid, title, title_normalized,
|
||
|
|
first_release_year, primary_type, secondary_types, fetched_at)
|
||
|
|
VALUES(?,?,?,?,?,?,?,?)
|
||
|
|
ON CONFLICT(artist_id, mb_release_group_mbid) DO UPDATE SET
|
||
|
|
title=excluded.title, title_normalized=excluded.title_normalized,
|
||
|
|
first_release_year=excluded.first_release_year, primary_type=excluded.primary_type,
|
||
|
|
secondary_types=excluded.secondary_types, fetched_at=excluded.fetched_at
|
||
|
|
""",
|
||
|
|
(
|
||
|
|
artist_id, rg["mbid"], rg["title"], normalize_title(rg["title"]),
|
||
|
|
rg["first_release_year"], rg["primary_type"], json.dumps(rg["secondary_types"]),
|
||
|
|
db.now_iso(),
|
||
|
|
),
|
||
|
|
)
|
||
|
|
recompute_completeness(conn, artist_id)
|
||
|
|
|
||
|
|
processed += 1
|
||
|
|
_bump_metadata_progress(run_id, processed, total)
|
||
|
|
|
||
|
|
_finish_run(run_id, "completed", artists_found=processed, progress=f"Checked {processed} artists")
|
||
|
|
logger.info("Metadata refresh %d completed: %d artists", run_id, processed)
|
||
|
|
return {"run_id": run_id, "status": "completed", "artists": processed}
|
||
|
|
except Exception as exc: # pragma: no cover - defensive
|
||
|
|
logger.exception("Metadata refresh %d failed", run_id)
|
||
|
|
_finish_run(run_id, "failed", error_message=str(exc))
|
||
|
|
return {"run_id": run_id, "status": "failed", "error": str(exc)}
|
||
|
|
|
||
|
|
|
||
|
|
def _bump_metadata_progress(run_id: int, processed: int, total: int) -> None:
|
||
|
|
_update_run(run_id, artists_found=processed, progress=f"Checked {processed}/{total} artists")
|
||
|
|
|
||
|
|
|
||
|
|
# ── job launchers ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
|
||
|
|
def start_scan_job(root: Path | None = None) -> dict:
|
||
|
|
if not _try_acquire("scan"):
|
||
|
|
return {"started": False, "reason": "A scan is already running."}
|
||
|
|
|
||
|
|
def _worker():
|
||
|
|
try:
|
||
|
|
run_scan(root)
|
||
|
|
finally:
|
||
|
|
_release("scan")
|
||
|
|
|
||
|
|
threading.Thread(target=_worker, name="scan_music_collection", daemon=True).start()
|
||
|
|
return {"started": True}
|
||
|
|
|
||
|
|
|
||
|
|
def start_metadata_job() -> dict:
|
||
|
|
if not _try_acquire("metadata"):
|
||
|
|
return {"started": False, "reason": "A metadata refresh is already running."}
|
||
|
|
|
||
|
|
def _worker():
|
||
|
|
try:
|
||
|
|
run_metadata_refresh()
|
||
|
|
finally:
|
||
|
|
_release("metadata")
|
||
|
|
|
||
|
|
threading.Thread(target=_worker, name="refresh_music_metadata", daemon=True).start()
|
||
|
|
return {"started": True}
|
||
|
|
|
||
|
|
|
||
|
|
# ── read-side queries (UI; database only) ─────────────────────────────────────
|
||
|
|
|
||
|
|
|
||
|
|
def _latest_run(conn, kind: str) -> dict | None:
|
||
|
|
row = conn.execute(
|
||
|
|
"SELECT * FROM library_scan_runs WHERE kind=? ORDER BY id DESC LIMIT 1", (kind,)
|
||
|
|
).fetchone()
|
||
|
|
return dict(row) if row else None
|
||
|
|
|
||
|
|
|
||
|
|
def get_status() -> dict:
|
||
|
|
with db.connect() as conn:
|
||
|
|
scan = _latest_run(conn, "scan")
|
||
|
|
metadata = _latest_run(conn, "metadata")
|
||
|
|
return {
|
||
|
|
"scan": scan,
|
||
|
|
"metadata": metadata,
|
||
|
|
"scan_running": _is_running("scan"),
|
||
|
|
"metadata_running": _is_running("metadata"),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def get_overview() -> dict:
|
||
|
|
with db.connect() as conn:
|
||
|
|
scan = _latest_run(conn, "scan")
|
||
|
|
metadata = _latest_run(conn, "metadata")
|
||
|
|
totals = conn.execute(
|
||
|
|
"""
|
||
|
|
SELECT
|
||
|
|
SUM(CASE WHEN status IN ('owned','probably_owned') THEN 1 ELSE 0 END) AS owned,
|
||
|
|
SUM(CASE WHEN status='missing' THEN 1 ELSE 0 END) AS missing,
|
||
|
|
SUM(CASE WHEN status='uncertain' THEN 1 ELSE 0 END) AS uncertain,
|
||
|
|
SUM(CASE WHEN status='ignored' THEN 1 ELSE 0 END) AS ignored
|
||
|
|
FROM collection_completeness c
|
||
|
|
JOIN library_artists a ON a.id=c.artist_id AND a.is_active=1
|
||
|
|
"""
|
||
|
|
).fetchone()
|
||
|
|
artist_count = conn.execute("SELECT COUNT(*) c FROM library_artists WHERE is_active=1").fetchone()["c"]
|
||
|
|
album_count = conn.execute("SELECT COUNT(*) c FROM library_albums WHERE is_active=1").fetchone()["c"]
|
||
|
|
|
||
|
|
owned = totals["owned"] or 0
|
||
|
|
missing = totals["missing"] or 0
|
||
|
|
uncertain = totals["uncertain"] or 0
|
||
|
|
ignored = totals["ignored"] or 0
|
||
|
|
denom = owned + missing + uncertain
|
||
|
|
completeness = round(owned / denom * 100, 1) if denom else 0.0
|
||
|
|
return {
|
||
|
|
"last_scan": scan,
|
||
|
|
"last_metadata": metadata,
|
||
|
|
"scan_running": _is_running("scan"),
|
||
|
|
"metadata_running": _is_running("metadata"),
|
||
|
|
"owned": owned,
|
||
|
|
"missing": missing,
|
||
|
|
"uncertain": uncertain,
|
||
|
|
"ignored": ignored,
|
||
|
|
"completeness": completeness,
|
||
|
|
"library_artists": artist_count,
|
||
|
|
"library_albums": album_count,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def get_artists_completeness(search: str = "") -> list[dict]:
|
||
|
|
where = "WHERE a.is_active=1"
|
||
|
|
params: list = []
|
||
|
|
if search.strip():
|
||
|
|
where += " AND a.name LIKE ?"
|
||
|
|
params.append(f"%{search.strip()}%")
|
||
|
|
with db.connect() as conn:
|
||
|
|
rows = conn.execute(
|
||
|
|
f"""
|
||
|
|
SELECT a.id, a.name,
|
||
|
|
SUM(CASE WHEN c.status IN ('owned','probably_owned') THEN 1 ELSE 0 END) AS owned,
|
||
|
|
SUM(CASE WHEN c.status='missing' THEN 1 ELSE 0 END) AS missing,
|
||
|
|
SUM(CASE WHEN c.status='uncertain' THEN 1 ELSE 0 END) AS uncertain,
|
||
|
|
SUM(CASE WHEN c.status='ignored' THEN 1 ELSE 0 END) AS ignored,
|
||
|
|
COUNT(c.id) AS total
|
||
|
|
FROM library_artists a
|
||
|
|
JOIN collection_completeness c ON c.artist_id=a.id
|
||
|
|
{where}
|
||
|
|
GROUP BY a.id
|
||
|
|
HAVING total > 0
|
||
|
|
ORDER BY missing DESC, a.name COLLATE NOCASE
|
||
|
|
""",
|
||
|
|
params,
|
||
|
|
).fetchall()
|
||
|
|
result = []
|
||
|
|
for r in rows:
|
||
|
|
owned, missing, uncertain = r["owned"] or 0, r["missing"] or 0, r["uncertain"] or 0
|
||
|
|
denom = owned + missing + uncertain
|
||
|
|
result.append(
|
||
|
|
{
|
||
|
|
"id": r["id"],
|
||
|
|
"name": r["name"],
|
||
|
|
"owned": owned,
|
||
|
|
"missing": missing,
|
||
|
|
"uncertain": uncertain,
|
||
|
|
"ignored": r["ignored"] or 0,
|
||
|
|
"completeness": round(owned / denom * 100, 1) if denom else 0.0,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def get_artist_albums(artist_id: int) -> dict:
|
||
|
|
with db.connect() as conn:
|
||
|
|
artist = conn.execute("SELECT id, name FROM library_artists WHERE id=?", (artist_id,)).fetchone()
|
||
|
|
rows = conn.execute(
|
||
|
|
"SELECT id, release_group_mbid, title, year, status, confidence, reason, source, manual_override "
|
||
|
|
"FROM collection_completeness WHERE artist_id=? ORDER BY year, title COLLATE NOCASE",
|
||
|
|
(artist_id,),
|
||
|
|
).fetchall()
|
||
|
|
return {
|
||
|
|
"artist": dict(artist) if artist else None,
|
||
|
|
"albums": [dict(r) for r in rows],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def set_album_decision(completeness_id: int, action: str) -> dict:
|
||
|
|
action = (action or "").lower()
|
||
|
|
valid = {"ignore", "owned", "missing", "reset"}
|
||
|
|
if action not in valid:
|
||
|
|
raise ValueError(f"Unknown action: {action}")
|
||
|
|
|
||
|
|
now = db.now_iso()
|
||
|
|
with db.connect() as conn:
|
||
|
|
row = conn.execute(
|
||
|
|
"SELECT id, artist_id FROM collection_completeness WHERE id=?", (completeness_id,)
|
||
|
|
).fetchone()
|
||
|
|
if not row:
|
||
|
|
raise LookupError("Completeness row not found.")
|
||
|
|
|
||
|
|
if action == "ignore":
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE collection_completeness SET status='ignored', manual_override=1, reason='Manually ignored', updated_at=? WHERE id=?",
|
||
|
|
(now, completeness_id),
|
||
|
|
)
|
||
|
|
elif action == "owned":
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE collection_completeness SET status='owned', confidence=1.0, manual_override=1, reason='Manually marked owned', updated_at=? WHERE id=?",
|
||
|
|
(now, completeness_id),
|
||
|
|
)
|
||
|
|
elif action == "missing":
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE collection_completeness SET status='missing', confidence=0, manual_override=1, reason='Manually marked missing', updated_at=? WHERE id=?",
|
||
|
|
(now, completeness_id),
|
||
|
|
)
|
||
|
|
elif action == "reset":
|
||
|
|
conn.execute(
|
||
|
|
"UPDATE collection_completeness SET manual_override=0, updated_at=? WHERE id=?",
|
||
|
|
(now, completeness_id),
|
||
|
|
)
|
||
|
|
recompute_completeness(conn, row["artist_id"])
|
||
|
|
return {"status": "ok"}
|