mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-25 14:21:21 +00:00
* refactor(server): move the metadata-enrichment subsystem into lib/enrichment.py (R3)
MusicBrainz / Cover-Art-Archive / AcoustID transport, the match-scorer glue, and
the background enrichment worker (~930 lines, 61 defs) leave server.py as one
cohesive unit. Bodies are verbatim; the only changes are seam reads:
meta_db / config_dir / sloppak_cache_dir / art_cache_dir -> appstate.<slot>
_song_pack_art_exists / _art_override_paths (stay in server.py for the art +
delete routes) -> appstate.<callable> (new seam slots, injected by reference)
_env_flag -> env_compat.env_flag_compat (the existing identical helper)
_artist_title_from_filename -> imported from metadata_db (its home)
the User-Agent VERSION lookup: Path(__file__).parent ->
Path(__file__).resolve().parents[1] (lib/enrichment.py -> app root)
server.py drives the worker through the module (import enrichment; the routes +
scan lifecycle call enrichment.X). Tests that faked the network on `server`
(_mb_http_get, _enrich_network_enabled, _caa_http_get, ...) now patch the same
names on `enrichment` — the module attribute is resolved at call time, so one
setattr reaches both the routes and the worker's internal callers. Acyclic:
enrichment imports appstate/appconfig/dlc_paths/metadata_db/mb_match/
acoustid_match/sloppak/loosefolder, never server.
server.py: 6,917 -> 5,988 (-929).
Verified: pyflakes clean (bar the pre-existing File/safe_join/tuning_name/ET);
route table IDENTICAL (143); full pytest 2400 passed (140 enrichment/art cases
incl the offline-safety + transport-error-pauses-pass contracts that fake the
network); test_packaging 44 passed (enrichment.py resolves under lib/); eslint 0.
Boot smoke: /api/enrichment/status + POST /kick serve from the new module.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: reset enrichment worker state between tests (CodeRabbit)
lib/enrichment.py now owns the worker, and it stays imported for the whole
session while the `server` fixtures pop-and-reimport `server` — so the cancel
Event / status dict / caches would leak across tests, and a stale `_enrich_cancel`
could short-circuit a later direct `_background_enrich()`. An autouse conftest
fixture clears that process-global state before each test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: tighten the enrichment-reset fixture (CodeRabbit)
Narrow the import guard to ImportError (not blind Exception, BLE001), and stop
clearing _caa_index_locks — it's guarded by _caa_index_locks_guard, so an
unlocked clear() would race a still-alive worker, and its per-release mutexes
carry no test state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
633 lines
28 KiB
Python
633 lines
28 KiB
Python
"""Server-level tests for the P8 MusicBrainz matcher + Match-Review flow.
|
|
|
|
The HTTP transport is a fake installed over `enrichment._mb_http_get` — the ONE
|
|
seam enrichment uses to reach the network — so nothing here ever opens a
|
|
socket. The offline default is itself under test: without explicitly
|
|
enabling the network flag, a pass must skip matching entirely (pytest can
|
|
never hit MusicBrainz, whatever a test triggers).
|
|
"""
|
|
|
|
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 / "config"))
|
|
dlc = tmp_path / "dlc"
|
|
dlc.mkdir()
|
|
monkeypatch.setenv("DLC_DIR", str(dlc))
|
|
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)
|
|
|
|
|
|
class FakeMB:
|
|
"""Canned MusicBrainz: records every call, serves per-path responses."""
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.search_response = {"recordings": []}
|
|
self.recording_lookups = {} # mbid → recording doc
|
|
self.isrc_lookups = {} # isrc → {"recordings": [...]}
|
|
self.raise_transport = False
|
|
|
|
def __call__(self, path, params):
|
|
if self.raise_transport:
|
|
raise enrichment.EnrichTransportError("fake network down")
|
|
self.calls.append((path, dict(params)))
|
|
if path == "recording":
|
|
return self.search_response
|
|
if path.startswith("recording/"):
|
|
return self.recording_lookups.get(path.split("/", 1)[1])
|
|
if path.startswith("isrc/"):
|
|
return self.isrc_lookups.get(path.split("/", 1)[1])
|
|
raise AssertionError(f"unexpected MB path {path!r}")
|
|
|
|
@property
|
|
def search_calls(self):
|
|
return [c for c in self.calls if c[0] == "recording"]
|
|
|
|
|
|
@pytest.fixture()
|
|
def mb(server, monkeypatch):
|
|
"""Install the fake transport AND enable the network flag (the test env
|
|
disables it by default — see test_offline_default_skips_matching)."""
|
|
fake = FakeMB()
|
|
fake._srv = server
|
|
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
|
|
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
|
return fake
|
|
|
|
|
|
def _put(server, fn, title="Thunderstruck (v2)", artist="ACDC", album="",
|
|
duration=292, year="1990"):
|
|
server.meta_db.put(fn, 0, 0, {
|
|
"title": title, "artist": artist, "album": album, "year": year,
|
|
"duration": duration, "arrangements": [{"name": "Lead", "index": 0}],
|
|
})
|
|
|
|
|
|
def mb_doc(rid="rec-1", title="Thunderstruck", artist="AC/DC", artist_id="art-1",
|
|
album="The Razors Edge", date="1990-09-24", length_ms=292000, score=100):
|
|
return {
|
|
"id": rid, "score": score, "title": title, "length": length_ms,
|
|
"isrcs": ["AUAP09000045"],
|
|
"artist-credit": [{"name": artist, "artist": {
|
|
"id": artist_id, "name": artist, "sort-name": artist}}],
|
|
"releases": [{"id": "rel-1", "title": album, "status": "Official",
|
|
"date": date, "release-group": {"primary-type": "Album"}}],
|
|
"tags": [{"name": "hard rock", "count": 7}],
|
|
}
|
|
|
|
|
|
# ── strict-then-loose search fallback ────────────────────────────────────────
|
|
|
|
def test_search_falls_back_to_loose_when_strict_is_empty(server, monkeypatch):
|
|
"""The strict field-phrase query misses a non-Latin-primary artist; the
|
|
loose retry (no field scoping) searches aliases and finds it."""
|
|
calls = []
|
|
|
|
def _routed(path, params):
|
|
q = params.get("query", "")
|
|
calls.append(q)
|
|
if q.startswith("recording:"): # strict phrase → nothing
|
|
return {"recordings": []}
|
|
return {"recordings": [mb_doc(rid="rec-x", title="Telephone Number")]}
|
|
|
|
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
|
|
cands = enrichment._mb_search_recordings("Junko Ohashi", "Telephone Number")
|
|
assert len(cands) == 1
|
|
assert len(calls) == 2 # strict first, then the loose retry
|
|
assert calls[0].startswith("recording:") # strict is the field-phrase form
|
|
assert "artist:" not in calls[1] and '"' not in calls[1] # loose retry
|
|
|
|
|
|
def test_search_does_not_retry_when_strict_hits(server, monkeypatch):
|
|
"""A strict hit must not spend a second (throttled) request on the loose
|
|
query."""
|
|
calls = []
|
|
|
|
def _routed(path, params):
|
|
calls.append(params.get("query", ""))
|
|
return {"recordings": [mb_doc()]}
|
|
|
|
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
|
|
cands = enrichment._mb_search_recordings("AC/DC", "Thunderstruck")
|
|
assert len(cands) == 1
|
|
assert len(calls) == 1
|
|
|
|
|
|
# ── alias-aware scoring (non-Latin-primary artists) ──────────────────────────
|
|
|
|
_AID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
|
|
|
|
|
def test_artist_aliases_fetched_and_cached(server, monkeypatch):
|
|
calls = []
|
|
|
|
def fake(path, params):
|
|
calls.append(path)
|
|
return {"sort-name": "Ohashi, Junko",
|
|
"aliases": [{"name": "Junko Ohashi"}, {"name": "大橋 純子"}]}
|
|
|
|
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
|
|
names = enrichment._mb_artist_aliases(_AID)
|
|
assert "Junko Ohashi" in names and "Ohashi, Junko" in names
|
|
enrichment._mb_artist_aliases(_AID) # cached → no second request
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_artist_aliases_rejects_bad_id(server, monkeypatch):
|
|
def boom(path, params):
|
|
raise AssertionError("must not fetch for a non-UUID id")
|
|
|
|
monkeypatch.setattr(enrichment, "_mb_http_get", boom)
|
|
assert enrichment._mb_artist_aliases("not-a-uuid") == []
|
|
|
|
|
|
def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
|
|
# A pack whose (romanized) artist MB stores under a Japanese primary name.
|
|
_put(server, "x.sloppak", title="Telephone Number", artist="Junko Ohashi")
|
|
|
|
def _routed(path, params):
|
|
if path.startswith("artist/"): # alias lookup
|
|
return {"sort-name": "Ohashi, Junko",
|
|
"aliases": [{"name": "Junko Ohashi"}]}
|
|
q = params.get("query", "")
|
|
if q.startswith("recording:"): # strict phrase → nothing
|
|
return {"recordings": []}
|
|
return {"recordings": [mb_doc(rid="rec-jp", title="Telephone Number",
|
|
artist="大橋純子", artist_id=_AID)]} # loose hit
|
|
|
|
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
|
|
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
|
enrichment._background_enrich()
|
|
row = server.meta_db.get_enrichment("x.sloppak")
|
|
# The romanized alias lifts the artist over the auto floor → auto-confirmed.
|
|
assert row["match_state"] == "matched"
|
|
assert row["mb_recording_id"] == "rec-jp"
|
|
|
|
|
|
# ── per-song field locks respected by the auto-matcher ───────────────────────
|
|
|
|
def test_locked_field_not_canonicalized_by_auto_match(server, monkeypatch):
|
|
_put(server, "x.sloppak") # title "Thunderstruck (v2)", artist "ACDC"
|
|
server.meta_db.set_song_override("x.sloppak", "artist", locked=True)
|
|
monkeypatch.setattr(enrichment, "_mb_http_get",
|
|
lambda path, params: {"recordings": [mb_doc()]})
|
|
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
|
|
enrichment._background_enrich()
|
|
row = server.meta_db.get_enrichment("x.sloppak")
|
|
assert row["match_state"] == "matched" # still matches (identity applies)…
|
|
assert row["canon_artist"] is None # …but the LOCKED artist isn't canonicalized
|
|
assert row["canon_title"] == "Thunderstruck" # unlocked display fields still apply
|
|
assert row["mb_recording_id"] # identity keys still stored (art needs them)
|
|
|
|
|
|
# ── offline safety (the pytest-never-hits-network contract) ──────────────────
|
|
|
|
def test_offline_default_skips_matching(server, monkeypatch):
|
|
"""Under the test env (FEEDBACK_SKIP_STARTUP_TASKS) a pass stamps hashes
|
|
but never matches — even with a transport installed."""
|
|
fake = FakeMB()
|
|
fake._srv = server
|
|
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
|
|
_put(server, "a.sloppak")
|
|
enrichment._background_enrich()
|
|
assert fake.calls == []
|
|
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "unscanned"
|
|
|
|
|
|
def test_real_transport_refuses_when_offline(server):
|
|
"""_mb_http_get itself raises (before any socket) when the network is
|
|
disabled — defence in depth under pytest."""
|
|
with pytest.raises(enrichment.EnrichTransportError):
|
|
enrichment._mb_http_get("recording", {"query": "x"})
|
|
|
|
|
|
def test_transport_error_pauses_pass_without_burning_attempts(server, mb):
|
|
_put(server, "a.sloppak")
|
|
_put(server, "b.sloppak", title="Other Song")
|
|
mb.raise_transport = True
|
|
enrichment._background_enrich()
|
|
for fn in ("a.sloppak", "b.sloppak"):
|
|
row = server.meta_db.get_enrichment(fn)
|
|
assert row["match_state"] == "unscanned"
|
|
assert row["attempts"] == 0
|
|
# Network comes back → the next kick matches both.
|
|
mb.raise_transport = False
|
|
mb.search_response = {"recordings": [mb_doc()]}
|
|
enrichment._background_enrich()
|
|
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
|
|
|
|
|
|
# ── text tiers ────────────────────────────────────────────────────────────────
|
|
|
|
def test_high_confidence_auto_matches_and_settles(server, mb):
|
|
_put(server, "a.sloppak")
|
|
mb.search_response = {"recordings": [mb_doc()]}
|
|
enrichment._background_enrich()
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["match_state"] == "matched"
|
|
assert row["match_source"] == "text"
|
|
assert row["match_score"] >= 0.95
|
|
assert row["mb_recording_id"] == "rec-1"
|
|
assert row["canon_artist"] == "AC/DC"
|
|
assert row["canon_title"] == "Thunderstruck"
|
|
assert row["canon_album"] == "The Razors Edge"
|
|
assert row["canon_year"] == "1990"
|
|
assert row["genres"] == ["hard rock"]
|
|
# Settled: another pass makes NO further network calls…
|
|
n = len(mb.calls)
|
|
enrichment._background_enrich()
|
|
assert len(mb.calls) == n
|
|
# …until the identity changes, which re-matches.
|
|
_put(server, "a.sloppak", title="Back in Black")
|
|
mb.search_response = {"recordings": [mb_doc(rid="rec-2", title="Back in Black",
|
|
album="Back in Black", date="1980-07-25")]}
|
|
enrichment._background_enrich()
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["mb_recording_id"] == "rec-2"
|
|
|
|
|
|
def test_medium_confidence_goes_to_review_not_canonical(server, mb):
|
|
# Partial artist agreement → medium confidence.
|
|
_put(server, "a.sloppak", artist="AC/DC ft Nobody")
|
|
mb.search_response = {"recordings": [mb_doc()]}
|
|
enrichment._background_enrich()
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["match_state"] == "review"
|
|
assert row["match_source"] == "text"
|
|
# Review before auto-canonicalize: NO canonical values written yet.
|
|
assert row["canon_artist"] is None
|
|
assert row["mb_recording_id"] is None
|
|
assert row["candidates"] and row["candidates"][0]["recording_id"] == "rec-1"
|
|
# A review row is settled while its identity is unchanged — no re-query.
|
|
n = len(mb.calls)
|
|
enrichment._background_enrich()
|
|
assert len(mb.calls) == n
|
|
|
|
|
|
def test_low_confidence_fails_with_backoff(server, mb):
|
|
_put(server, "a.sloppak")
|
|
mb.search_response = {"recordings": [mb_doc(rid="rec-x", title="Sunrise",
|
|
artist="Norah Jones")]}
|
|
enrichment._background_enrich()
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["match_state"] == "failed"
|
|
assert row["attempts"] == 1
|
|
assert row["last_attempt_at"] is not None
|
|
# Immediately after, the backoff hasn't elapsed → no retry, no network.
|
|
n = len(mb.calls)
|
|
enrichment._background_enrich()
|
|
assert len(mb.calls) == n
|
|
assert server.meta_db.get_enrichment("a.sloppak")["attempts"] == 1
|
|
# Rewind the clock two hours → eligible again, attempts increments.
|
|
with server.meta_db._lock:
|
|
server.meta_db.conn.execute(
|
|
"UPDATE song_enrichment SET last_attempt_at = last_attempt_at - 7200")
|
|
server.meta_db.conn.commit()
|
|
enrichment._background_enrich()
|
|
assert len(mb.calls) == n + 1
|
|
assert server.meta_db.get_enrichment("a.sloppak")["attempts"] == 2
|
|
|
|
|
|
def test_no_results_fails(server, mb):
|
|
_put(server, "a.sloppak")
|
|
mb.search_response = {"recordings": []}
|
|
enrichment._background_enrich()
|
|
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "failed"
|
|
|
|
|
|
# ── the content-hash match cache ──────────────────────────────────────────────
|
|
|
|
def test_cache_hit_copies_match_without_network(server, mb):
|
|
_put(server, "a.sloppak")
|
|
_put(server, "b.sloppak") # identical identity → same content_hash
|
|
mb.search_response = {"recordings": [mb_doc()]}
|
|
enrichment._background_enrich()
|
|
assert len(mb.search_calls) == 1 # ONE search covered both charts
|
|
a = server.meta_db.get_enrichment("a.sloppak")
|
|
b = server.meta_db.get_enrichment("b.sloppak")
|
|
assert a["match_state"] == b["match_state"] == "matched"
|
|
assert {a["match_source"], b["match_source"]} == {"text", "cache"}
|
|
assert a["mb_recording_id"] == b["mb_recording_id"] == "rec-1"
|
|
|
|
|
|
# ── exact keys from the manifest (tier 0 / tier 1) ────────────────────────────
|
|
|
|
def _write_sloppak_manifest(server, name, extra_yaml=""):
|
|
d = server.DLC_DIR / name
|
|
d.mkdir(parents=True)
|
|
(d / "manifest.yaml").write_text(
|
|
"title: Thunderstruck\nartist: AC/DC\nduration: 292\n"
|
|
"arrangements: []\nstems: []\n" + extra_yaml,
|
|
encoding="utf-8")
|
|
|
|
|
|
def test_manifest_mbid_tier0(server, mb):
|
|
mbid = "12345678-abcd-4ef0-9876-0123456789ab"
|
|
_write_sloppak_manifest(server, "a.sloppak", f"mbid: {mbid}\n")
|
|
_put(server, "a.sloppak")
|
|
mb.recording_lookups[mbid] = mb_doc(rid=mbid)
|
|
enrichment._background_enrich()
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["match_state"] == "matched"
|
|
assert row["match_source"] == "mbid"
|
|
assert row["match_score"] == 1.0
|
|
assert row["mb_recording_id"] == mbid
|
|
assert mb.search_calls == [] # trusted key — no text search
|
|
|
|
|
|
def test_manifest_isrc_tier1(server, mb):
|
|
_write_sloppak_manifest(server, "a.sloppak", "isrc: AUAP09000045\n")
|
|
_put(server, "a.sloppak")
|
|
mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
|
|
enrichment._background_enrich()
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["match_state"] == "matched"
|
|
assert row["match_source"] == "isrc"
|
|
assert mb.search_calls == []
|
|
|
|
|
|
def test_manifest_isrc_display_hyphens_stripped(server, mb):
|
|
"""Spec 1.14.0: the hyphenated display form (AU-AP0-90-00045) is
|
|
presentation only — the reader strips separators, so a hand-authored
|
|
manifest still hits the exact tier with the bare 12-char code."""
|
|
_write_sloppak_manifest(server, "a.sloppak", "isrc: AU-AP0-90-00045\n")
|
|
_put(server, "a.sloppak")
|
|
mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
|
|
enrichment._background_enrich()
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["match_state"] == "matched"
|
|
assert row["match_source"] == "isrc"
|
|
assert mb.search_calls == []
|
|
|
|
|
|
def test_bad_manifest_mbid_falls_through_to_text(server, mb):
|
|
mbid = "12345678-abcd-4ef0-9876-0123456789ab"
|
|
_write_sloppak_manifest(server, "a.sloppak", f"mbid: {mbid}\n")
|
|
_put(server, "a.sloppak")
|
|
mb.recording_lookups.clear() # lookup 404s (typo'd manifest)
|
|
mb.search_response = {"recordings": [mb_doc()]}
|
|
enrichment._background_enrich()
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["match_state"] == "matched"
|
|
assert row["match_source"] == "text"
|
|
|
|
|
|
# ── manual is sacred ──────────────────────────────────────────────────────────
|
|
|
|
def test_manual_never_overwritten_by_matcher(server, mb):
|
|
_put(server, "a.sloppak")
|
|
server.meta_db.set_enrichment_manual(
|
|
"a.sloppak", {"recording_id": "user-pick", "title": "Thunderstruck",
|
|
"artist": "AC/DC"}, source="search")
|
|
mb.search_response = {"recordings": [mb_doc(rid="machine-pick")]}
|
|
enrichment._background_enrich()
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["match_state"] == "manual"
|
|
assert row["mb_recording_id"] == "user-pick"
|
|
# The writer refuses machine writes onto manual outright.
|
|
ok = server.meta_db.apply_enrichment_match(
|
|
"a.sloppak", row["content_hash"], "matched", source="text", score=1.0,
|
|
cand={"recording_id": "machine-pick", "title": "X"})
|
|
assert ok is False
|
|
|
|
|
|
# ── review routes ─────────────────────────────────────────────────────────────
|
|
|
|
def _seed_review(server, mb, fn="a.sloppak", title="Thunderstruck (v2)"):
|
|
# Distinct raw titles give distinct content hashes (else the match cache
|
|
# legitimately copies an earlier row instead of running the text tiers).
|
|
_put(server, fn, title=title, artist="AC/DC ft Nobody")
|
|
mb.search_response = {"recordings": [mb_doc()]}
|
|
enrichment._background_enrich()
|
|
assert server.meta_db.get_enrichment(fn)["match_state"] == "review"
|
|
|
|
|
|
def test_review_queue_route(server, mb, client):
|
|
_seed_review(server, mb)
|
|
body = client.get("/api/enrichment/review").json()
|
|
assert body["total_review"] == 1
|
|
assert body["songs"][0]["filename"] == "a.sloppak"
|
|
assert body["songs"][0]["candidates"][0]["recording_id"] == "rec-1"
|
|
|
|
|
|
def test_review_accept_route(server, mb, client):
|
|
_seed_review(server, mb)
|
|
r = client.post("/api/enrichment/review/a.sloppak/accept",
|
|
json={"recording_id": "rec-1"})
|
|
assert r.status_code == 200
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["match_state"] == "manual"
|
|
assert row["match_source"] == "review"
|
|
assert row["canon_artist"] == "AC/DC"
|
|
assert client.get("/api/enrichment/review").json()["total_review"] == 0
|
|
# Accepting a candidate that isn't in the stored list → 404.
|
|
_seed_review(server, mb, fn="b.sloppak", title="Thunderstruck (Live)")
|
|
r = client.post("/api/enrichment/review/b.sloppak/accept",
|
|
json={"recording_id": "nope"})
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_review_reject_route_never_retries(server, mb, client):
|
|
_seed_review(server, mb)
|
|
r = client.post("/api/enrichment/review/a.sloppak/reject")
|
|
assert r.status_code == 200
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["match_state"] == "failed"
|
|
assert row["match_source"] == "rejected"
|
|
# Rejected rows are excluded from the retry backoff forever…
|
|
n = len(mb.calls)
|
|
enrichment._background_enrich()
|
|
assert len(mb.calls) == n
|
|
# …but an identity edit re-queues (the user fixed the metadata).
|
|
_put(server, "a.sloppak", artist="AC/DC")
|
|
enrichment._background_enrich()
|
|
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
|
|
# Rejecting a manual row is refused.
|
|
r = client.post("/api/enrichment/review/a.sloppak/reject")
|
|
assert r.status_code == 200 # matched → rejectable
|
|
client.post("/api/enrichment/review/a.sloppak/pick",
|
|
json={"candidate": {"recording_id": "rec-9", "title": "T"}})
|
|
r = client.post("/api/enrichment/review/a.sloppak/reject")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_pick_route_fix_match(server, mb, client):
|
|
_put(server, "a.sloppak")
|
|
r = client.post("/api/enrichment/review/a.sloppak/pick", json={"candidate": {
|
|
"recording_id": "rec-77", "title": "Thunderstruck", "artist": "AC/DC",
|
|
"album": "The Razors Edge", "year": "1990", "genres": ["hard rock"],
|
|
"junk_key": "dropped"}})
|
|
assert r.status_code == 200
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["match_state"] == "manual"
|
|
assert row["match_source"] == "search"
|
|
assert row["mb_recording_id"] == "rec-77"
|
|
# Malformed candidate → 400; unknown song → 404.
|
|
assert client.post("/api/enrichment/review/a.sloppak/pick",
|
|
json={"candidate": {"title": "no id"}}).status_code == 400
|
|
assert client.post("/api/enrichment/review/ghost.sloppak/pick",
|
|
json={"candidate": {"recording_id": "x", "title": "t"}}
|
|
).status_code == 404
|
|
|
|
|
|
def test_search_proxy(server, mb, client, monkeypatch):
|
|
_put(server, "a.sloppak")
|
|
mb.search_response = {"recordings": [mb_doc()]}
|
|
assert client.get("/api/enrichment/search").status_code == 400
|
|
body = client.get("/api/enrichment/search",
|
|
params={"title": "Thunderstruck", "artist": "AC/DC",
|
|
"filename": "a.sloppak"}).json()
|
|
assert body["candidates"][0]["recording_id"] == "rec-1"
|
|
assert body["candidates"][0]["score"] > 0.9
|
|
# Transport failure surfaces as 503, not a 500.
|
|
def _down(path, params):
|
|
raise enrichment.EnrichTransportError("down")
|
|
monkeypatch.setattr(enrichment, "_mb_http_get", _down)
|
|
r = client.get("/api/enrichment/search", params={"title": "x"})
|
|
assert r.status_code == 503
|
|
|
|
|
|
# ── the match facet on /api/library + /api/library/stats ─────────────────────
|
|
|
|
def test_match_facet_filters_grid_and_stats(server, mb, client, monkeypatch):
|
|
_put(server, "auto.sloppak") # → matched
|
|
_put(server, "rev.sloppak", title="Revsong", artist="AC/DC ft Nobody") # → review
|
|
_put(server, "fail.sloppak", title="Failsong", artist="Zzz") # → failed
|
|
_put(server, "pend.sloppak", title="Pendsong", artist="Yyy") # stays unscanned
|
|
|
|
def _routed(path, params):
|
|
q = params.get("query", "")
|
|
if "thunderstruck" in q:
|
|
return {"recordings": [mb_doc()]}
|
|
if "revsong" in q:
|
|
return {"recordings": [mb_doc(rid="rec-r", title="Revsong")]}
|
|
return {"recordings": []}
|
|
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
|
|
enrichment._background_enrich()
|
|
# Pendsong got failed by the pass (no results); reset it to unscanned to
|
|
# represent the not-yet-scanned band.
|
|
with server.meta_db._lock:
|
|
server.meta_db.conn.execute(
|
|
"UPDATE song_enrichment SET match_state='unscanned', attempts=0, "
|
|
"last_attempt_at=NULL WHERE filename='pend.sloppak'")
|
|
server.meta_db.conn.commit()
|
|
|
|
def names(match):
|
|
return sorted(s["filename"] for s in client.get(
|
|
"/api/library", params={"match": match, "size": 50}).json()["songs"])
|
|
|
|
assert names("review") == ["rev.sloppak"]
|
|
assert names("matched") == ["auto.sloppak"]
|
|
assert names("unmatched") == ["fail.sloppak"]
|
|
assert names("pending") == ["pend.sloppak"]
|
|
assert names("review,matched") == ["auto.sloppak", "rev.sloppak"]
|
|
# Stats agree with the grid (the rail's lockstep contract).
|
|
total = client.get("/api/library/stats",
|
|
params={"match": "review"}).json()["total_songs"]
|
|
assert total == 1
|
|
# Unknown values are ignored → unfiltered.
|
|
assert len(names("bogus")) == 4
|
|
|
|
|
|
def test_status_counts_by_state(server, mb, client):
|
|
_seed_review(server, mb)
|
|
states = client.get("/api/enrichment/status").json()["states"]
|
|
assert states.get("review") == 1
|
|
|
|
|
|
# ── settings: enable toggle + auto-apply confidence ───────────────────────────
|
|
|
|
def test_auto_threshold_setting_moves_the_auto_review_boundary(server, mb, client):
|
|
# artist exact (1.0) + title 4/5 token overlap (0.8) and NO year/duration
|
|
# corroboration → combined exactly 0.90.
|
|
mb.search_response = {"recordings": [
|
|
mb_doc(title="Highway Hell", artist="AC/DC", date="", length_ms=None)]}
|
|
client.post("/api/settings", json={"enrich_auto_threshold": 0.95})
|
|
_put(server, "a.sloppak", title="Highway to Hell", artist="AC/DC",
|
|
year="", duration=0)
|
|
enrichment._background_enrich()
|
|
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "review"
|
|
# Lower the bar to the default 0.90 → an identity edit re-queues, and the
|
|
# same 0.90-scored candidate now auto-applies.
|
|
client.post("/api/settings", json={"enrich_auto_threshold": 0.9})
|
|
_put(server, "a.sloppak", title="Highway to Hell", artist="AC/DC",
|
|
year="", duration=0, album="Different Album")
|
|
enrichment._background_enrich()
|
|
row = server.meta_db.get_enrichment("a.sloppak")
|
|
assert row["match_state"] == "matched"
|
|
assert abs(row["match_score"] - 0.9) < 1e-6
|
|
|
|
|
|
def test_enrich_enabled_setting_gates_background_matching(server, mb, client):
|
|
client.post("/api/settings", json={"enrich_enabled": False})
|
|
_put(server, "a.sloppak")
|
|
mb.search_response = {"recordings": [mb_doc()]}
|
|
enrichment._background_enrich()
|
|
assert mb.calls == []
|
|
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "unscanned"
|
|
# Manual search/fix stays available while the background matcher is off.
|
|
r = client.get("/api/enrichment/search", params={"title": "Thunderstruck"})
|
|
assert r.status_code == 200
|
|
# Re-enable → the next pass matches.
|
|
client.post("/api/settings", json={"enrich_enabled": True})
|
|
enrichment._background_enrich()
|
|
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
|
|
|
|
|
|
def test_settings_validation(server, client):
|
|
assert "error" in client.post(
|
|
"/api/settings", json={"enrich_enabled": "yes"}).json()
|
|
assert "error" in client.post(
|
|
"/api/settings", json={"enrich_auto_threshold": "high"}).json()
|
|
assert "error" in client.post(
|
|
"/api/settings", json={"enrich_auto_threshold": 2.5}).json()
|
|
ok = client.post("/api/settings", json={"enrich_auto_threshold": 1.01}).json()
|
|
assert "error" not in ok
|
|
assert client.get("/api/settings").json()["enrich_auto_threshold"] == 1.01
|
|
|
|
|
|
def test_kick_route(server, mb, client):
|
|
import time as _t
|
|
body = client.post("/api/enrichment/kick").json()
|
|
assert "started" in body
|
|
# Let the kicked pass settle so its daemon thread can't bleed into the
|
|
# fixture teardown (the DB connection closes there).
|
|
for _ in range(200):
|
|
if not client.get("/api/enrichment/status").json()["running"]:
|
|
break
|
|
_t.sleep(0.02)
|
|
|
|
|
|
def test_review_queue_orders_missing_data_first(server, mb, client):
|
|
# Complete chart first alphabetically, incomplete second — the queue must
|
|
# surface the incomplete (missing album + year) one first anyway.
|
|
_seed_review(server, mb, fn="aa.sloppak", title="Thunderstruck (v2)")
|
|
_put(server, "zz.sloppak", title="Thunderstruck (Live)",
|
|
artist="AC/DC ft Nobody", album="", year="")
|
|
enrichment._background_enrich()
|
|
assert server.meta_db.get_enrichment("zz.sloppak")["match_state"] == "review"
|
|
songs = client.get("/api/enrichment/review").json()["songs"]
|
|
assert [s["filename"] for s in songs] == ["zz.sloppak", "aa.sloppak"]
|