Homelabtoolkit v1
This commit is contained in:
@@ -0,0 +1,625 @@
|
||||
"""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"}
|
||||
Reference in New Issue
Block a user