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