feedBack/tests/test_scraper_options.py
Byron Gamatos f5c9c34291
library: scraper options — per-source + per-field auto-apply, review-queue order (R1) (#726)
* library: scraper options — per-source + per-field auto-apply toggles, review-queue order (R1)

Grows the Settings→Library "Metadata matching" card into the full
scraper-options panel — not everybody needs the same things out of a
scraper:

- Sources: enrich_src_musicbrainz gates the background matcher (phase 2;
  identity hashes still stamp, and manual Fix-match/search stays
  available — same contract as the master toggle). enrich_src_caa gates
  the Cover Art Archive fetch (phase 3). Rows skipped by an off toggle
  stay unevaluated, so re-enabling picks them up on the next pass —
  nothing is permanently forfeited.
- Auto-apply fields: enrich_apply_names/year/genres filter what an
  AUTOMATIC match may canonicalize (_enrich_field_filter, applied on all
  three automatic paths: cache copy, mbid/isrc exact keys, text auto).
  MusicBrainz ids always stamp — they're identity, not display; the art
  fetch and future re-matching need them. A match the user confirms in
  the review modal applies in full. enrich_apply_art gates the art fetch
  alongside the CAA source toggle (two axes, one behaviour today —
  future art sources slot in without re-teaching the panel).
- Review queue order: enrich_review_order = missing_first (default,
  today's behaviour) | artist | recent, read by GET /api/enrichment/review;
  unknown stored values degrade to the default.
- Settings card: Sources / Auto-apply / Review-queue-order groups wired
  in match-review.js; the master toggle is relabelled "Match songs
  automatically" so it doesn't read the same as the new MusicBrainz
  source toggle. No tailwind rebuild needed — every class was already
  scanned from core source.

Tests: tests/test_scraper_options.py (9) — settings validation,
MB-source-off stamps-without-matching + re-enable, per-field stripping
on auto matches with ids preserved, review-accept full-apply despite
toggles, CAA gating on both axes, review-order modes incl. the
unknown-value fallback. Full-suite failure set A/B-identical to the
base (39 env/pre-existing).

Stacked on feat/enrichment-art (#715) — merge that first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* library: per-field auto-apply honours "nothing forfeited" (backfill + no partial seeding)

The R1 per-FIELD auto-apply toggles settled a `matched` row with the
disabled fields stripped, but enrichment_pending() never revisits an
unchanged-hash matched row — so re-enabling a field never backfilled it,
and enrichment_cache_lookup() (which gates only on mb_recording_id) could
seed sibling charts with the stripped blanks. This broke the same
"nothing is permanently forfeited" contract the source (mb_on) and art
(art_on) toggles already keep.

Fix: persist an `apply_mask` marker (sorted blocked apply-keys) on every
AUTOMATIC match:
- migration: additive `apply_mask TEXT` column (idempotent ALTER).
- enrichment_pending(allowed_keys=...): re-queues a `matched` row whose
  apply_mask names a field that is now re-enabled → backfill on re-enable,
  converges (a fully-applied row is never re-queued).
- enrichment_cache_lookup: only fully-applied donors (apply_mask empty/NULL)
  may seed siblings; a partial row is skipped and the sibling falls through
  to its own re-filtered match.
- _enrich_apply_mask()/_enrich_blocked_apply_keys() helpers; threaded through
  _enrich_one → apply_enrichment_match. Review/manual writers leave it NULL
  (a confirmed pick applies in full).

Tests: re-enable-backfills-and-converges; partial row is not a cache donor
(fully-applied one is). 13 scraper-options tests pass; 170 enrichment/
settings tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:57:42 +02:00

334 lines
14 KiB
Python

"""Tests for the R1 scraper options: per-source toggles (MusicBrainz /
Cover Art Archive), per-field auto-apply toggles (names / year / genres /
cover art), and the review-queue order preference.
Same no-network contract as the P8/R3 suites: both transports are faked
over their single seams (`_mb_http_get`, `_caa_http_get`) and the network
flag is only force-enabled where a test needs the pipeline to run.
"""
import importlib
import io as _io
import sys
import pytest
from fastapi.testclient import TestClient
from PIL import Image
@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:
conn.close()
sys.modules.pop("server", None)
@pytest.fixture()
def client(server):
return TestClient(server.app)
class FakeMB:
"""Canned MusicBrainz over the `_mb_http_get` seam (the P8 fixture)."""
def __init__(self):
self.calls = []
self.search_response = {"recordings": []}
def __call__(self, path, params):
self.calls.append((path, dict(params)))
if path == "recording":
return self.search_response
raise AssertionError(f"unexpected MB path {path!r}")
@pytest.fixture()
def mb(server, monkeypatch):
fake = FakeMB()
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def _put(server, fn, title="Thunderstruck (v2)", artist="ACDC", album="",
duration=292, year="1990", mtime=0):
server.meta_db.put(fn, mtime, 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}],
}
# ── settings keys ─────────────────────────────────────────────────────────────
BOOL_KEYS = ("enrich_src_musicbrainz", "enrich_src_caa", "enrich_apply_names",
"enrich_apply_year", "enrich_apply_genres", "enrich_apply_art")
def test_scraper_option_keys_validate_and_persist(client):
for key in BOOL_KEYS:
bad = client.post("/api/settings", json={key: "yes"}).json()
assert "error" in bad
client.post("/api/settings", json={key: False})
assert client.get("/api/settings").json()[key] is False
assert "error" in client.post(
"/api/settings", json={"enrich_review_order": "random"}).json()
assert "error" in client.post(
"/api/settings", json={"enrich_review_order": 42}).json()
client.post("/api/settings", json={"enrich_review_order": "artist"})
assert client.get("/api/settings").json()["enrich_review_order"] == "artist"
def test_defaults_present(server):
d = server._default_settings()
for key in BOOL_KEYS:
assert d[key] is True
assert d["enrich_review_order"] == "missing_first"
# ── per-source: MusicBrainz ───────────────────────────────────────────────────
def test_musicbrainz_source_off_stamps_without_matching(server, mb, client):
client.post("/api/settings", json={"enrich_src_musicbrainz": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
assert mb.calls == []
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "unscanned" # hash stamped, no match
# Re-enabling picks the same row up on the next pass.
client.post("/api/settings", json={"enrich_src_musicbrainz": True})
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
# ── per-field auto-apply ──────────────────────────────────────────────────────
def test_field_toggles_strip_auto_applied_fields(server, mb, client):
client.post("/api/settings", json={"enrich_apply_year": False,
"enrich_apply_genres": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_artist"] == "AC/DC"
assert row["canon_title"] == "Thunderstruck"
assert row["canon_album"] == "The Razors Edge"
assert row["canon_year"] is None # toggled off
assert row["genres"] == [] # toggled off
# Identity ids always stamp — the art fetch and re-matching need them.
assert row["mb_recording_id"] == "rec-1"
assert row["mb_release_id"] == "rel-1"
def test_names_toggle_keeps_ids_and_other_fields(server, mb, client):
client.post("/api/settings", json={"enrich_apply_names": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_artist"] is None
assert row["canon_title"] is None
assert row["canon_album"] is None
assert row["canon_year"] == "1990"
assert row["genres"] == ["hard rock"]
assert row["mb_recording_id"] == "rec-1"
def test_review_accept_applies_all_fields_despite_toggles(server, mb, client):
"""A match the USER confirms is their intent — the auto-apply toggles
gate only what happens without them."""
client.post("/api/settings", json={"enrich_apply_names": False,
"enrich_apply_year": False,
"enrich_apply_genres": False})
# Partial artist agreement → review tier (candidates stored unfiltered).
_put(server, "a.sloppak", artist="AC/DC ft Nobody")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "review"
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["canon_artist"] == "AC/DC"
assert row["canon_year"] == "1990"
assert row["genres"] == ["hard rock"]
# ── per-field: nothing-forfeited (re-enable backfills, no partial seeding) ─────
def test_reenabling_field_backfills_matched_row(server, mb, client):
"""A field toggled OFF strips the value AND records it in apply_mask, so
turning the field back on re-queues the (unchanged-hash) matched row and
backfills — the same "nothing forfeited" contract the source/art toggles
keep."""
client.post("/api/settings", json={"enrich_apply_year": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_year"] is None # suppressed
assert row["apply_mask"] == "enrich_apply_year" # …and remembered
# Re-enable → next pass re-queues and backfills the year (hash unchanged).
client.post("/api/settings", json={"enrich_apply_year": True})
server._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_year"] == "1990" # backfilled
assert row["apply_mask"] in (None, "") # fully applied now
# Converged: a fully-applied row is not re-queued again.
assert server.meta_db.enrichment_pending(
allowed_keys=frozenset(server._ENRICH_APPLY_FIELDS)) == []
def test_partial_match_is_not_a_cache_donor(server):
"""A row that suppressed a display field must not seed a sibling chart of
the same recording with its blank — enrichment_cache_lookup skips it, so
the sibling falls through to its own (correctly-filtered) match. A fully-
applied row IS a donor."""
h = server.meta_db.enrichment_content_hash("ACDC", "Thunderstruck (v2)", "", 292)
# Partial donor: matched with the year suppressed (apply_mask set).
server.meta_db.apply_enrichment_match(
"a.sloppak", h, "matched", source="text", score=1.0,
cand={"recording_id": "rec-1", "artist": "AC/DC", "title": "Thunderstruck"},
apply_mask="enrich_apply_year")
assert server.meta_db.enrichment_cache_lookup(
h, exclude_filename="b.sloppak") is None
# Fully-applied donor (no apply_mask): offered, with its year intact.
server.meta_db.apply_enrichment_match(
"c.sloppak", h, "matched", source="text", score=1.0,
cand={"recording_id": "rec-1", "artist": "AC/DC",
"title": "Thunderstruck", "year": "1990"})
donor = server.meta_db.enrichment_cache_lookup(h, exclude_filename="b.sloppak")
assert donor is not None and donor["year"] == "1990"
# ── per-source / per-field: cover art ─────────────────────────────────────────
def png_bytes(color=(200, 30, 30)):
buf = _io.BytesIO()
Image.new("RGB", (4, 4), color).save(buf, "PNG")
return buf.getvalue()
def make_sloppak(server, name, title="Song", artist="Artist"):
d = server.DLC_DIR / name
d.mkdir(parents=True)
(d / "manifest.yaml").write_text(
f"title: {title}\nartist: {artist}\nduration: 100\n"
"arrangements: []\nstems: []\n", encoding="utf-8")
server.meta_db.put(name, 0, 0, {
"title": title, "artist": artist, "album": "", "year": "",
"duration": 100, "arrangements": [{"name": "Lead", "index": 0}],
})
return d
def _match_row(server, fn, release_id="rel-1"):
song = server.meta_db.enrichment_song_row(fn)
h = server.meta_db.enrichment_content_hash(
song["artist"], song["title"], song["album"], song["duration"])
server.meta_db.apply_enrichment_match(
fn, h, "matched", source="text", score=1.0,
cand={"recording_id": "rec-1", "release_id": release_id,
"title": song["title"], "artist": song["artist"]})
@pytest.fixture()
def caa(server, monkeypatch):
calls = []
art = {"rel-1": png_bytes((60, 60, 200))}
def fake(release_id):
calls.append(release_id)
return art.get(release_id)
fake.calls = calls
monkeypatch.setattr(server, "_caa_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
return fake
def test_caa_source_toggle_gates_art_fetch(server, client, caa):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak")
client.post("/api/settings", json={"enrich_src_caa": False})
server._background_enrich()
assert caa.calls == []
row = server.meta_db.get_enrichment("a.sloppak")
assert row["art_state"] is None # not forfeited, just skipped
# Re-enable → the same row is picked up.
client.post("/api/settings", json={"enrich_src_caa": True})
server._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
def test_apply_art_toggle_gates_art_fetch(server, client, caa):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak")
client.post("/api/settings", json={"enrich_apply_art": False})
server._background_enrich()
assert caa.calls == []
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
# ── review-queue order ────────────────────────────────────────────────────────
def _review_row(server, fn):
server.meta_db.apply_enrichment_match(
fn, "h", "review", source="text", score=0.7,
candidates=[{"recording_id": "rec-1", "title": "T", "artist": "A"}])
def _review_filenames(client):
return [s["filename"] for s in
client.get("/api/enrichment/review").json()["songs"]]
def test_review_queue_order_setting(server, client):
# a: complete, artist Alpha, oldest; b: missing album+year, artist Zeta,
# middle; c: missing year, artist Mid, newest — the three orders differ.
_put(server, "a.sloppak", title="Song A", artist="Alpha",
album="Full", year="1990", mtime=100)
_put(server, "b.sloppak", title="Song B", artist="Zeta",
album="", year="", mtime=200)
_put(server, "c.sloppak", title="Song C", artist="Mid",
album="Full", year="", mtime=300)
for fn in ("a.sloppak", "b.sloppak", "c.sloppak"):
_review_row(server, fn)
# Default: missing-data-first.
assert _review_filenames(client) == ["b.sloppak", "c.sloppak", "a.sloppak"]
client.post("/api/settings", json={"enrich_review_order": "artist"})
assert _review_filenames(client) == ["a.sloppak", "c.sloppak", "b.sloppak"]
client.post("/api/settings", json={"enrich_review_order": "recent"})
assert _review_filenames(client) == ["c.sloppak", "b.sloppak", "a.sloppak"]
# An unknown stored value degrades to the default order, never an error.
assert server.meta_db.enrichment_review_queue(order="bogus")[0]["filename"] == "b.sloppak"