230 lines
9.2 KiB
Python
230 lines
9.2 KiB
Python
"""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
|