feedBack/tests/test_mb_match.py
ChrisBeWithYou 74cd08f765
library: MusicBrainz text matching + Match-Review UI (P8) (#710)
* library: MusicBrainz text matching + Match-Review UI — P8

Replaces the enrichment plumbing's no-op matcher (P7) with the real
pipeline, per the library-metadata design: a wrong match is worse than a
slow one, so medium confidence goes to a human review queue and never
straight to canonical values.

- lib/mb_match.py (new, pure — no network/DB/server imports): denoise
  (author credits, (440Hz)/(Live)/(No Lead)/(v2) parentheticals,
  diacritics/punctuation, ACDC / AC DC / AC/DC folding via compacted
  token equality), token-set similarity, scoring with year/duration
  corroboration bonuses, tier classification (auto needs combined
  >= 0.95 AND per-field floors — a perfect-title cover by the wrong
  artist, or a chart with no artist, can never auto-match), Lucene
  query building, MusicBrainz response normalization.
- Matcher precedence in _enrich_one: content-hash cache copy (another
  chart of the same recording matches with no network) -> manifest
  mbid (tier 0) / isrc (tier 1) exact keys, feature-detected and
  strictly shape-validated, read-only -> text search tiers
  (auto / review / failed).
- Lifecycle: review rows store their ranked candidate list (JSON) and
  write NO canonical fields until a human accepts; failed rows retry on
  an exponential backoff (1 h doubling, 7 d cap) via the attempts
  column; user-rejected rows never auto-retry; an identity edit
  re-queues anything and resets the backoff; never-overwrite-manual is
  enforced inside the single writer (apply_enrichment_match) so no call
  path can forget it.
- Network: _mb_http_get is the one transport seam — throttled to
  <= 1 req/s through P7's _enrich_throttle, identified with a real
  User-Agent from VERSION, and a 503 pauses the whole pass without
  burning attempts. Offline guard: no sockets under
  FEEDBACK_ENRICH_OFFLINE or FEEDBACK_SKIP_STARTUP_TASKS, so pytest can
  never reach MusicBrainz; the pass still stamps identity hashes
  (two-phase), which is why every P7 test passes unchanged.
- Routes: GET /api/enrichment/review, POST
  /api/enrichment/review/{filename}/accept|reject|pick, GET
  /api/enrichment/search (throttled manual-search proxy). All four are
  demo-mode blocked.
- Match facet: match= CSV accepted by /api/library AND
  /api/library/stats (the A-Z rail's letter counts stay lockstep with
  the grid) — review / matched (incl. manual) / unmatched / pending,
  the same EXISTS idiom as the mastery facet.
- UI: static/v3/match-review.js (new, self-contained) — an ambient
  "N to review" chip beside the song count (rendered only when
  non-zero; silent on success, no toasts), and a review drawer on the
  filter-drawer slide idiom (Escape + focus trap; row click accepts,
  "Not a match" rejects, "Search instead" is the fix-match escape
  hatch). songs.js gets the chip mount, a Match filter section, and
  session-only match state; also fixes the latent applySavedPrefs bug
  where restored filters dropped the mastery key, which made the
  filter drawer throw for anyone with saved prefs.
- static/tailwind.min.css regenerated (scripts/build-tailwind.sh) for
  the new utility classes; conflicts with sibling PRs resolve by
  re-running the script.

Nothing is ever written to pack files — canonical values live only in
the song_enrichment display cache. Cover art caching and acoustic
fingerprinting are follow-up slices.

22 pure unit tests + 19 server tests (fake transport injected over the
_mb_http_get seam) + demo-mode route cases; full-suite failure set
A/B-identical with the change stashed vs applied.

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

* library: match-review modal + configurable auto-apply confidence (P8 R0)

Follow-up to the initial P8 commit, folding in the first round of tester
feedback on the review surface and the matcher's knobs:

- Review GUI is a centred MODAL now, not a sidebar — one chart at a
  time (the scraper-review model from media-server / emulation-frontend
  apps): the chart's current metadata with explicit amber
  "Missing: album / year / cover art" chips (art detected via the art
  request failing), candidates each carrying "Adds: year - genres -
  ISRC" / "Shows as: ACDC -> AC/DC" per-field chips, and Skip /
  Not a match / Search instead / Use selected with prev-next + arrow-key
  navigation. Chip + window API surface unchanged, so songs.js needed no
  edits for the rework.
- Auto-apply confidence is a SETTING: default drops 0.95 -> 0.90
  (mb_match.AUTO_MIN; classify() takes an auto_min override). The
  per-field floors are untouched and threshold-independent — a
  perfect-title cover by the wrong artist still can't auto-match at any
  setting. New validated settings keys: enrich_enabled (bool) +
  enrich_auto_threshold (0.5–1.01; >1.0 = "Always review", since a
  capped score can equal exactly 1.0). Read once per pass; disabling
  gates only the BACKGROUND matcher — manual search/fix stays available.
- Settings -> Library -> "Metadata matching" card: enable toggle,
  confidence select (85 / 90 / 95 / Always review), a Match Now button
  (new POST /api/enrichment/kick, single-flight like every other kick,
  demo-mode blocked), and a live status line fed by the same fetch as
  the review chip. Markup in index.html per the v3 settings pattern,
  wired by match-review.js, null-guarded so v2 no-ops.
- Review queue orders missing-data charts first — confirming those has
  the most to gain; complete charts only stand to be re-labelled.

Tests: threshold moves the auto/review boundary via settings; the
enable toggle gates matching but not the manual proxy; settings
validation; kick route; queue ordering; classify(auto_min=...) floors.
Full-suite failure set byte-identical to the pre-change baseline.
tailwind.min.css regenerated for the modal's utility classes.

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

* fix(library): lock MusicBrainz throttle across sleep + de-dup enrich queue (PR #710 review)

Hold a module-level lock across _enrich_throttle's read/sleep/write so the
background daemon and threadpooled sync search route serialize outbound MB
requests instead of bursting past the 1 req/s limit. De-dup the enrich queue
by filename so a changed-hash failed row isn't processed twice per pass.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:47:55 +02:00

228 lines
9.3 KiB
Python

"""Unit tests for lib/mb_match.py — the pure text-matching engine (P8).
No network, no database, no server import: denoise/tokenize, similarity,
scoring + tier classification, Lucene query building, and MusicBrainz
response parsing are all exercised as plain functions.
"""
import mb_match as m
# ── denoise / tokenize ────────────────────────────────────────────────────────
def test_denoise_lowercases_and_strips_punct_and_diacritics():
assert m.denoise("Motörhead") == "motorhead"
assert m.denoise("Beyoncé!!") == "beyonce"
assert m.denoise("Guns N' Roses") == "guns n roses"
assert m.denoise(" Weird spacing ") == "weird spacing"
def test_denoise_strips_noise_parentheticals():
# The design's explicit list: author suffixes + (440Hz)/(Live)/(No Lead)/(v2).
assert m.denoise("Thunderstruck (440Hz)") == "thunderstruck"
assert m.denoise("Thunderstruck (Live)") == "thunderstruck"
assert m.denoise("Thunderstruck (No Lead)") == "thunderstruck"
assert m.denoise("Thunderstruck (v2)") == "thunderstruck"
assert m.denoise("Thunderstruck [Remastered 2012]") == "thunderstruck"
assert m.denoise("One (Live at Wembley)") == "one"
def test_denoise_strips_author_credits():
assert m.denoise("Back in Black (by SomeCharter)") == "back in black"
assert m.denoise("Back in Black (charted by X99)") == "back in black"
assert m.denoise("Back in Black - by SomeCharter") == "back in black"
def test_denoise_keeps_meaningful_parentheticals():
# A parenthetical with no noise term survives (both sides get the same
# treatment, so symmetric content still matches).
assert m.denoise("Doin' It (All for My Baby)") == "doin it all for my baby"
def test_denoise_leading_the_is_artist_only():
assert m.denoise("The Beatles", strip_leading_the=True) == "beatles"
# Titles keep their "The" — never strip it there.
assert m.denoise("The Trooper") == "the trooper"
def test_ampersand_folds_to_and():
assert m.similarity("Angus & Julia Stone", "Angus and Julia Stone", artist=True) == 1.0
# ── similarity ────────────────────────────────────────────────────────────────
def test_similarity_exact_and_empty():
assert m.similarity("Back in Black", "Back In Black!") == 1.0
assert m.similarity("", "Anything") == 0.0
assert m.similarity(None, None) == 0.0
def test_similarity_folds_spelling_drift_via_compaction():
# The headline case: ACDC / AC DC / AC/DC all name the same artist.
assert m.similarity("ACDC", "AC/DC", artist=True) == 1.0
assert m.similarity("AC DC", "ACDC", artist=True) == 1.0
assert m.similarity("Greenday", "Green Day", artist=True) == 1.0
def test_similarity_partial_overlap():
s = m.similarity("Highway to Hell", "Highway Hell")
assert 0.7 < s < 1.0
assert m.similarity("Back in Black", "Paint It Black") < 0.5
# ── scoring + tiers ───────────────────────────────────────────────────────────
SONG = {"artist": "ACDC", "title": "Thunderstruck (v2)", "album": "The Razors Edge",
"year": "1990", "duration": 292}
def test_score_exact_match_is_high():
cand = {"artist": "AC/DC", "title": "Thunderstruck", "year": "1990", "duration": 292}
s = m.score_candidate(SONG, cand)
assert s == 1.0
assert m.classify(SONG, cand, s) == "auto"
def test_score_cover_never_auto():
# Perfect title, wrong artist (a cover) — must not auto-match.
cand = {"artist": "Some Cover Band", "title": "Thunderstruck"}
s = m.score_candidate(SONG, cand)
assert m.classify(SONG, cand, s) != "auto"
def test_missing_artist_caps_at_review():
song = {"artist": "", "title": "Thunderstruck", "duration": 292}
cand = {"artist": "AC/DC", "title": "Thunderstruck", "duration": 292}
s = m.score_candidate(song, cand)
# artist half scores 0 → combined ≤ 0.55 + bonuses → review at best.
assert m.classify(song, cand, s) != "auto"
def test_year_and_duration_corroborate():
# Fuzzy title so the base sits below the 1.0 cap and bonuses are visible.
base = {"artist": "AC/DC", "title": "Thunderstruck Thunder"}
plain = m.score_candidate(SONG, base)
with_year = m.score_candidate(SONG, dict(base, year="1990"))
with_dur = m.score_candidate(SONG, dict(base, duration=290))
assert with_year > plain
assert with_dur > plain
def test_fuzzy_title_with_corroboration_lands_review_or_auto():
cand = {"artist": "AC/DC", "title": "Thunderstruck Thunder"}
s = m.score_candidate(SONG, cand)
assert m.classify(SONG, cand, s) in ("review", "auto")
def test_unrelated_is_none():
cand = {"artist": "Norah Jones", "title": "Sunrise"}
s = m.score_candidate(SONG, cand)
assert m.classify(SONG, cand, s) == "none"
def test_classify_auto_min_override():
# The host's "auto-apply confidence" setting: a perfect match autos at
# any real threshold, and "Always review" (>1.0) sends even it to review.
cand = {"artist": "AC/DC", "title": "Thunderstruck", "year": "1990", "duration": 292}
s = m.score_candidate(SONG, cand)
assert s == 1.0
assert m.classify(SONG, cand, s, auto_min=0.9) == "auto"
assert m.classify(SONG, cand, s, auto_min=1.01) == "review"
# The per-field floors are independent of the threshold: a wrong-artist
# cover stays non-auto even at a permissive auto_min.
cover = {"artist": "Some Cover Band", "title": "Thunderstruck"}
cs = m.score_candidate(SONG, cover)
assert m.classify(SONG, cover, cs, auto_min=0.5) != "auto"
def test_rank_candidates_orders_by_our_score():
cands = [
{"recording_id": "b", "artist": "Someone Else", "title": "Thunderstruck", "mb_score": 100},
{"recording_id": "a", "artist": "AC/DC", "title": "Thunderstruck", "mb_score": 90},
]
ranked = m.rank_candidates(SONG, cands)
assert [c["recording_id"] for c in ranked] == ["a", "b"]
assert all("score" in c for c in ranked)
# ── query building ────────────────────────────────────────────────────────────
def test_build_recording_query_denoises_and_quotes():
q = m.build_recording_query("ACDC", 'Thunderstruck (v2)')
assert q == 'recording:"thunderstruck" AND artist:"acdc"'
def test_build_recording_query_escapes_and_handles_missing_artist():
q = m.build_recording_query("", 'Say "Hello"')
# Quotes are punct-stripped by denoise, so nothing to escape here — but
# the artist clause must be absent entirely.
assert q.startswith('recording:"')
assert "artist:" not in q
# ── MusicBrainz response parsing ──────────────────────────────────────────────
MB_DOC = {
"id": "rec-123",
"score": 98,
"title": "Thunderstruck",
"length": 292773,
"isrcs": ["AUAP09000045"],
"artist-credit": [
{"name": "AC/DC", "joinphrase": "",
"artist": {"id": "art-1", "name": "AC/DC", "sort-name": "AC/DC"}},
],
"releases": [
{"id": "rel-compilation", "title": "Greatest Hits", "status": "Official",
"date": "2005-01-01", "release-group": {"primary-type": "Compilation"}},
{"id": "rel-album", "title": "The Razors Edge", "status": "Official",
"date": "1990-09-24", "release-group": {"primary-type": "Album"}},
{"id": "rel-boot", "title": "Bootleg", "status": "Bootleg",
"date": "1989-01-01", "release-group": {"primary-type": "Album"}},
],
"tags": [{"name": "hard rock", "count": 10}, {"name": "rock", "count": 4}],
}
def test_parse_recording_doc_normalizes():
c = m.parse_recording_doc(MB_DOC)
assert c["recording_id"] == "rec-123"
assert c["title"] == "Thunderstruck"
assert c["artist"] == "AC/DC"
assert c["artist_id"] == "art-1"
# Official Album beats the compilation and the bootleg.
assert c["album"] == "The Razors Edge"
assert c["release_id"] == "rel-album"
assert c["year"] == "1990"
assert c["duration"] == 293
assert c["isrc"] == "AUAP09000045"
assert c["genres"] == ["hard rock", "rock"]
assert c["mb_score"] == 98
def test_parse_recording_doc_joined_artist_credit():
doc = dict(MB_DOC)
doc["artist-credit"] = [
{"name": "Queen", "joinphrase": " & ",
"artist": {"id": "q", "name": "Queen", "sort-name": "Queen"}},
{"name": "David Bowie",
"artist": {"id": "b", "name": "David Bowie", "sort-name": "Bowie, David"}},
]
c = m.parse_recording_doc(doc)
assert c["artist"] == "Queen & David Bowie"
assert c["artist_id"] == "q"
def test_parse_recording_doc_rejects_malformed():
assert m.parse_recording_doc({}) is None
assert m.parse_recording_doc({"id": "x"}) is None
assert m.parse_recording_doc(None) is None
def test_parse_search_response():
body = {"recordings": [MB_DOC, {"bogus": True}]}
cands = m.parse_search_response(body)
assert len(cands) == 1
assert m.parse_search_response({}) == []
assert m.parse_search_response(None) == []