"""Tests for the metadata-enrichment plumbing (P7): the song_enrichment cache table + identity hashing + the queue/lifecycle around the (for now) no-op matcher. The text matcher itself is the next slice; these tests pin the contracts it will inherit: rename-survivable idempotent hashing, manual rows never auto-reset, never purged on rescan, purged on explicit delete.""" import importlib import enrichment import sys import pytest from fastapi.testclient import TestClient @pytest.fixture() def server(tmp_path, monkeypatch, isolate_logging): monkeypatch.setenv("CONFIG_DIR", str(tmp_path)) monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1") sys.modules.pop("server", None) srv = importlib.import_module("server") try: yield srv finally: conn = getattr(getattr(srv, "meta_db", None), "conn", None) if conn is not None: getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)() conn.close() sys.modules.pop("server", None) @pytest.fixture() def client(server): return TestClient(server.app) def _put(server, fn, title="Song", artist="Artist", album="", duration=100): server.meta_db.put(fn, 0, 0, { "title": title, "artist": artist, "album": album, "duration": duration, "arrangements": [{"name": "Lead", "index": 0}], }) # ── identity hash ───────────────────────────────────────────────────────────── def test_hash_is_rename_survivable_and_normalized(server): h = server.meta_db.enrichment_content_hash # keyed on metadata, not the filename → a renamed pack keeps its hash assert h("Artist", "Song", "Album", 100) == h("Artist", "Song", "Album", 100) # case + whitespace folded; duration rounded to whole seconds assert h(" artist ", "SONG", "album", 100.4) == h("Artist", "Song", "Album", 100) # a real identity change means a different hash assert h("Artist", "Song", "Album", 100) != h("Artist", "Other", "Album", 100) assert h("Artist", "Song", "Album", None) == h("Artist", "Song", "Album", 0) # ── queue selection ─────────────────────────────────────────────────────────── def test_pending_covers_new_unscanned_and_changed(server): _put(server, "a.archive") assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"] # stubbed → still unscanned → still pending (the matcher hasn't run) enrichment._background_enrich() assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"] # a matched row with the CURRENT hash is settled… h = server.meta_db.enrichment_content_hash("Artist", "Song", "", 100) with server.meta_db._lock: server.meta_db.conn.execute( "UPDATE song_enrichment SET match_state = 'matched', content_hash = ? " "WHERE filename = 'a.archive'", (h,)) server.meta_db.conn.commit() assert server.meta_db.enrichment_pending() == [] # …until the song's identity changes, which re-queues it _put(server, "a.archive", title="Song (Remastered)") assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"] def test_hash_change_resets_matched_but_never_manual(server): _put(server, "a.archive") _put(server, "b.archive", title="Other") enrichment._background_enrich() with server.meta_db._lock: server.meta_db.conn.execute( "UPDATE song_enrichment SET match_state = 'matched' WHERE filename = 'a.archive'") server.meta_db.conn.execute( "UPDATE song_enrichment SET match_state = 'manual' WHERE filename = 'b.archive'") server.meta_db.conn.commit() # identity edits… _put(server, "a.archive", title="Song v2") _put(server, "b.archive", title="Other v2") enrichment._background_enrich() a = server.meta_db.get_enrichment("a.archive") b = server.meta_db.get_enrichment("b.archive") # …drop a stale MATCH back to unscanned with the fresh hash assert a["match_state"] == "unscanned" assert a["content_hash"] == server.meta_db.enrichment_content_hash("Artist", "Song v2", "", 100) # …but a MANUAL pin survives untouched (state AND hash) assert b["match_state"] == "manual" assert b["content_hash"] == server.meta_db.enrichment_content_hash("Artist", "Other", "", 100) def test_failed_rows_not_requeued_by_pending(server): _put(server, "a.archive") enrichment._background_enrich() with server.meta_db._lock: server.meta_db.conn.execute( "UPDATE song_enrichment SET match_state = 'failed' WHERE filename = 'a.archive'") server.meta_db.conn.commit() # backoff/retry policy belongs to the matcher slice, not the queue walk assert server.meta_db.enrichment_pending() == [] # ── worker pass ─────────────────────────────────────────────────────────────── def test_enrich_pass_stamps_every_song(server): for i in range(5): _put(server, f"s{i}.archive", title=f"Song {i}") enrichment._background_enrich() for i in range(5): row = server.meta_db.get_enrichment(f"s{i}.archive") assert row is not None assert row["match_state"] == "unscanned" assert row["content_hash"] == server.meta_db.enrichment_content_hash( "Artist", f"Song {i}", "", 100) # ── lifecycle: rescan survival vs explicit delete ───────────────────────────── def test_rescan_never_purges_enrichment(server): _put(server, "a.archive") enrichment._background_enrich() server.meta_db.delete_missing(set()) # file vanished from a scan snapshot assert server.meta_db.get_enrichment("a.archive") is not None # row survives # …and is invisible in the read-time-filtered counts assert server.meta_db.enrichment_state_counts() == {} _put(server, "a.archive") # the file comes back assert server.meta_db.enrichment_state_counts() == {"unscanned": 1} def test_status_endpoint_counts(client, server): _put(server, "a.archive") _put(server, "b.archive", title="Other") enrichment._background_enrich() body = client.get("/api/enrichment/status").json() assert body["states"] == {"unscanned": 2} assert body["total_songs"] == 2 assert body["running"] is False assert body["processed"] == 2 def test_art_cache_dir_created(server): d = enrichment._enrichment_art_dir() assert d.is_dir() assert d.name == "art_cache" # ── Refresh Metadata batch: per-tile states, progress, Stop ─────────────────── def test_states_for_returns_only_known_filenames(server): _put(server, "a.archive") enrichment._background_enrich() got = server.meta_db.enrichment_states_for(["a.archive", "nope.archive"]) assert got == {"a.archive": "unscanned"} # unknown filename absent assert server.meta_db.enrichment_states_for([]) == {} def test_states_endpoint(client, server): _put(server, "a.archive") _put(server, "b.archive", title="Other") enrichment._background_enrich() body = client.post("/api/enrichment/states", json={"filenames": ["a.archive", "zzz.missing"]}).json() assert body["states"] == {"a.archive": "unscanned"} assert body["running"] is False assert body["current"] is None def test_status_exposes_progress_fields(client, server): _put(server, "a.archive") enrichment._background_enrich() body = client.get("/api/enrichment/status").json() for k in ("total", "matched", "current", "cancelling"): assert k in body assert body["cancelling"] is False def test_cancel_is_noop_when_idle(client, server): body = client.post("/api/enrichment/cancel").json() assert body == {"ok": True, "was_running": False} # A no-op must not arm the flag (which would then poison the next pass). assert enrichment._enrich_cancel.is_set() is False def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch): for i in range(4): _put(server, f"s{i}.archive", title=f"Song {i}") # Force the matcher path on (the test env is offline by default) and stub the # per-song matcher so nothing touches the network — it just trips Stop after # the first song, exactly as the /cancel route would mid-pass. monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True) calls = [] def fake_enrich_one(row, **_kw): calls.append(row["filename"]) enrichment._enrich_cancel.set() monkeypatch.setattr(enrichment, "_enrich_one", fake_enrich_one) enrichment._enrich_cancel.clear() enrichment._background_enrich() # The loop checks cancel BEFORE each song, so exactly one is processed before # it breaks — not the whole 4-row queue. assert calls == ["s0.archive"] assert enrichment._enrich_status["total"] == 4 assert enrichment._enrich_status["matched"] == 1 def test_rematch_requeues_visible_but_skips_manual(server, client): _put(server, "a.archive") # will be 'matched' _put(server, "b.archive", title="Other") # will be 'failed' _put(server, "c.archive", title="Pinned") # will be 'manual' — untouchable enrichment._background_enrich() with server.meta_db._lock: server.meta_db.conn.execute( "UPDATE song_enrichment SET match_state='matched' WHERE filename='a.archive'") server.meta_db.conn.execute( "UPDATE song_enrichment SET match_state='failed' WHERE filename='b.archive'") server.meta_db.conn.execute( "UPDATE song_enrichment SET match_state='manual' WHERE filename='c.archive'") server.meta_db.conn.commit() body = client.post("/api/enrichment/rematch", json={ "filenames": ["a.archive", "b.archive", "c.archive", "nope.archive"]}).json() # A per-view refresh re-runs everything shown EXCEPT the manual pin (and an # unknown filename); matched + failed are both re-queued. assert set(body["queued"]) == {"a.archive", "b.archive"} assert body["count"] == 2 server._join_background_db_threads() assert server.meta_db.get_enrichment("a.archive")["match_state"] == "unscanned" assert server.meta_db.get_enrichment("b.archive")["match_state"] == "unscanned" assert server.meta_db.get_enrichment("c.archive")["match_state"] == "manual" # ── filename-derived artist/title fallback (blank-artist packs) ─────────────── def test_filename_artist_title_parse(server): f = enrichment._artist_title_from_filename assert f("CDLC/0 - City Pop/Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak") == \ {"artist": "Tatsuro Yamashita", "title": "Ride On Time"} assert f("Anri_Windy-Summer_v1_p.feedpak") == {"artist": "Anri", "title": "Windy Summer"} # a trailing "(440Hz)" retune tag is stripped before parsing assert f("Cindy_Watashitachi-o-Shinjite-Ite_v1_p (440Hz).feedpak") == \ {"artist": "Cindy", "title": "Watashitachi o Shinjite Ite"} # doesn't fit the convention → no guess assert f("nounderscore.feedpak") is None def test_blank_artist_seeds_match_from_filename(server, monkeypatch): server.meta_db.put("Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak", 0, 0, { "title": "Tatsuro-Yamashita_Ride-On-Time_v1_p", "artist": "", "album": "", "duration": 240, "arrangements": [{"name": "Bass", "index": 0}]}) monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True) monkeypatch.setattr(enrichment, "_manifest_exact_ids", lambda fn: {}) seen = {} def fake_search(artist, title, limit=8): seen["artist"], seen["title"] = artist, title return [] monkeypatch.setattr(enrichment, "_mb_search_recordings", fake_search) row = next(r for r in server.meta_db.enrichment_pending() if r["filename"].startswith("Tatsuro")) enrichment._enrich_one(row) # the blank pack artist was replaced by the filename-derived identity for # the search (this is exactly what rescues the 'failed' pile) assert seen == {"artist": "Tatsuro Yamashita", "title": "Ride On Time"} def test_present_artist_is_not_overridden_by_filename(server, monkeypatch): server.meta_db.put("Weird-Filename_x_y.feedpak", 0, 0, { "title": "Real Title", "artist": "Real Artist", "album": "", "duration": 100, "arrangements": [{"name": "Lead", "index": 0}]}) monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True) monkeypatch.setattr(enrichment, "_manifest_exact_ids", lambda fn: {}) seen = {} def fake_search(artist, title, limit=8): seen["artist"], seen["title"] = artist, title return [] monkeypatch.setattr(enrichment, "_mb_search_recordings", fake_search) row = next(r for r in server.meta_db.enrichment_pending() if r["filename"].startswith("Weird")) enrichment._enrich_one(row) # a pack that DOES carry an artist keeps it — the filename is never consulted assert seen == {"artist": "Real Artist", "title": "Real Title"} def test_kick_clears_a_stale_cancel(server): # A cancelled-then-rekicked pass must start clean: _kick_enrich clears the # flag so the fresh pass isn't aborted the instant it checks. enrichment._enrich_cancel.set() enrichment._kick_enrich() server._join_background_db_threads() assert enrichment._enrich_cancel.is_set() is False