Files
embycovers/tests/test_music_library.py
T

230 lines
9.2 KiB
Python
Raw Normal View History

2026-06-08 00:01:55 +12:00
"""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