"""Tests for the Music Collection Completeness feature. Covers normalisation, fuzzy matching, scan upserts, deleted-file handling, completeness statuses, and that manual ignore decisions survive a recompute. Scanning uses a fake tag reader so we don't need real audio files with embedded metadata — the scanner's database behaviour is what's under test. """ from pathlib import Path import pytest from services import db from services import music_library as ml from services import text_normalize as tn @pytest.fixture def temp_db(tmp_path, monkeypatch): monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db") db.init_db() yield def _fake_tags(path: Path) -> dict: return { "artist": path.parent.parent.name, "album": path.parent.name, "title": path.stem, "track_number": 1, "disc_number": 1, "year": 1997, "artist_mbid": None, "album_mbid": None, "track_mbid": None, } def _make_track(root: Path, artist: str, album: str, name: str) -> Path: folder = root / artist / album folder.mkdir(parents=True, exist_ok=True) f = folder / name f.write_bytes(b"\x00" * 64) return f # ── normalisation ───────────────────────────────────────────────────────────── def test_normalize_strips_editions_but_keeps_original(): assert tn.normalize_title("OK Computer (Deluxe Edition)") == tn.normalize_title("OK Computer") assert tn.normalize_title("The Bends - 2009 Remaster") == "the bends" assert tn.normalize_title("In Rainbows [Remastered]") == "in rainbows" def test_normalize_artist_drops_leading_the(): assert tn.normalize_artist("The Beatles") == "beatles" assert tn.normalize_artist("AC/DC") == "ac dc" def test_is_various_artists(): assert tn.is_various_artists("Various Artists") assert tn.is_various_artists("VA") assert not tn.is_various_artists("Radiohead") # ── fuzzy matching / classification ─────────────────────────────────────────── def _local(title, year=1997, mbid=None, _id=1): return {"id": _id, "title": title, "title_normalized": tn.normalize_title(title), "year": year, "mbid": mbid} def test_classify_mbid_match(): locals_ = [_local("Whatever", mbid="mbid-123")] status, conf, reason, lid = tn.classify_release("Different Title", 2000, "mbid-123", locals_) assert status == tn.OWNED and conf == 1.0 and lid == 1 def test_classify_title_and_year(): locals_ = [_local("OK Computer", 1997)] status, *_ = tn.classify_release("OK Computer", 1997, None, locals_) assert status == tn.OWNED # within a year still counts as owned status2, *_ = tn.classify_release("OK Computer", 1998, None, locals_) assert status2 == tn.OWNED def test_classify_fuzzy_and_missing(): locals_ = [_local("OK Computer", 1997)] status, conf, *_ = tn.classify_release("OK Komputer", None, None, locals_) assert status == tn.PROBABLY_OWNED status2, *_ = tn.classify_release("Kid A", 2000, None, locals_) assert status2 == tn.MISSING # ── scan upserts + deleted handling ─────────────────────────────────────────── def test_scan_upserts_and_skips_unchanged(temp_db, tmp_path, monkeypatch): monkeypatch.setattr(ml, "_read_tags", _fake_tags) root = tmp_path / "music" _make_track(root, "Radiohead", "OK Computer", "01 - Airbag.mp3") _make_track(root, "Radiohead", "OK Computer", "02 - Paranoid Android.mp3") _make_track(root, "Portishead", "Dummy", "01 - Mysterons.flac") result = ml.run_scan(root) assert result["status"] == "completed" with db.connect() as conn: artists = conn.execute("SELECT COUNT(*) c FROM library_artists WHERE is_active=1").fetchone()["c"] albums = conn.execute("SELECT COUNT(*) c FROM library_albums WHERE is_active=1").fetchone()["c"] tracks = conn.execute("SELECT COUNT(*) c FROM library_tracks WHERE is_active=1").fetchone()["c"] assert (artists, albums, tracks) == (2, 2, 3) # Re-scan unchanged: counts stable, no duplicate rows. ml.run_scan(root) with db.connect() as conn: tracks = conn.execute("SELECT COUNT(*) c FROM library_tracks").fetchone()["c"] assert tracks == 3 def test_deleted_file_marked_inactive(temp_db, tmp_path, monkeypatch): monkeypatch.setattr(ml, "_read_tags", _fake_tags) root = tmp_path / "music" f1 = _make_track(root, "Radiohead", "OK Computer", "01 - Airbag.mp3") _make_track(root, "Radiohead", "OK Computer", "02 - Paranoid Android.mp3") ml.run_scan(root) f1.unlink() ml.run_scan(root) with db.connect() as conn: gone = conn.execute("SELECT is_active FROM library_tracks WHERE file_path=?", (str(f1),)).fetchone() active_tracks = conn.execute("SELECT COUNT(*) c FROM library_tracks WHERE is_active=1").fetchone()["c"] # album still active because one track remains album_active = conn.execute("SELECT is_active FROM library_albums LIMIT 1").fetchone()["is_active"] assert gone["is_active"] == 0 assert active_tracks == 1 assert album_active == 1 def test_deleted_album_deactivates_album_and_artist(temp_db, tmp_path, monkeypatch): monkeypatch.setattr(ml, "_read_tags", _fake_tags) root = tmp_path / "music" f = _make_track(root, "Solo", "Only Album", "01 - Track.mp3") ml.run_scan(root) f.unlink() ml.run_scan(root) with db.connect() as conn: album_active = conn.execute("SELECT is_active FROM library_albums LIMIT 1").fetchone()["is_active"] artist_active = conn.execute("SELECT is_active FROM library_artists LIMIT 1").fetchone()["is_active"] assert album_active == 0 assert artist_active == 0 # ── completeness statuses + manual decisions ────────────────────────────────── def _seed_artist_with_release(year_local=1997): """Insert one artist, one local album (OK Computer), and three external release groups (owned, missing, live-excluded). Returns artist id.""" with db.connect() as conn: cur = conn.execute( "INSERT INTO library_artists(name, name_normalized, is_active, created_at, updated_at) VALUES('Radiohead','radiohead',1,'','')" ) artist_id = cur.lastrowid conn.execute( "INSERT INTO library_albums(artist_id, title, title_normalized, year, is_active, created_at, updated_at) VALUES(?,?,?,?,1,'','')", (artist_id, "OK Computer", tn.normalize_title("OK Computer"), year_local), ) for mbid, title, yr, prim, sec in [ ("rg-ok", "OK Computer", 1997, "Album", "[]"), ("rg-kida", "Kid A", 2000, "Album", "[]"), ("rg-live", "I Might Be Wrong: Live Recordings", 2001, "Album", '["Live"]'), ]: 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(?,?,?,?,?,?,?,'')", (artist_id, mbid, title, tn.normalize_title(title), yr, prim, sec), ) ml.recompute_completeness(conn, artist_id) return artist_id def test_completeness_statuses(temp_db): artist_id = _seed_artist_with_release() with db.connect() as conn: rows = { r["title"]: r for r in conn.execute( "SELECT title, status FROM collection_completeness WHERE artist_id=?", (artist_id,) ).fetchall() } assert rows["OK Computer"]["status"] == tn.OWNED assert rows["Kid A"]["status"] == tn.MISSING # The live album is filtered out — no completeness row created. assert "I Might Be Wrong: Live Recordings" not in rows def test_manual_ignore_survives_recompute(temp_db): artist_id = _seed_artist_with_release() with db.connect() as conn: kid = conn.execute( "SELECT id FROM collection_completeness WHERE artist_id=? AND title='Kid A'", (artist_id,) ).fetchone() ml.set_album_decision(kid["id"], "ignore") # A metadata refresh recomputes — the manual decision must persist. with db.connect() as conn: ml.recompute_completeness(conn, artist_id) row = conn.execute("SELECT status, manual_override FROM collection_completeness WHERE id=?", (kid["id"],)).fetchone() assert row["status"] == "ignored" assert row["manual_override"] == 1 def test_reset_decision_recomputes(temp_db): artist_id = _seed_artist_with_release() with db.connect() as conn: kid = conn.execute( "SELECT id FROM collection_completeness WHERE artist_id=? AND title='Kid A'", (artist_id,) ).fetchone() ml.set_album_decision(kid["id"], "owned") ml.set_album_decision(kid["id"], "reset") with db.connect() as conn: row = conn.execute("SELECT status, manual_override FROM collection_completeness WHERE id=?", (kid["id"],)).fetchone() assert row["manual_override"] == 0 assert row["status"] == tn.MISSING # Kid A is not in the local library