mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 11:49:28 +00:00
fix(enrichment): rank the canonical studio take over live/comp versions (#758)
* fix(enrichment): rank the canonical studio take over live/comp versions A flat MusicBrainz /recording text search ties every take of a song at the same score, so "AC/DC — Highway to Hell" returns a wall of live bootlegs and compilations with the 1979 studio version buried (or below the fetch limit). - build_recording_query: drop live-ONLY recordings (`-secondarytype:Live`). Compilations are deliberately kept — they REUSE the studio recording, so filtering them cuts the very recording we want (verified against MB). - _best_release / parse_recording_doc: pick the canonical studio album (primary Album, no Live/Compilation/Remix/... secondary type) for the displayed album/year, and expose a `studio` flag. - rank_candidates: since the combined score caps at 1.0 (perfect text match ties), break ties on the studio flag and — when the caller knows the audio length — on duration proximity, so the studio take wins over live/extended cuts. The studio distinction is intentionally NOT scored (a live take is still the right SONG), only re-ordered. - /api/enrichment/search: accept an optional `duration` param so a caller that has the audio but no library row (the editor's create modal) can pass the master-track length for the duration tiebreak. Verified end-to-end against live MusicBrainz: AC/DC "Highway to Hell" now returns the 1979 studio recording at #1 with the correct album + year. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enrichment): official releases outrank unofficial studio albums _best_release sorted (clean, status_ok, date), so an UNofficial bootleg Album outranked an official Single/EP/comp — regressing canonical album/year and seeding cover-art from a bootleg for single-only songs. Order status_ok before clean: official first, then prefer a clean studio album among the official releases (still surfaces the studio album over an official live/comp album). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enrichment): keep live recordings for genuinely-live charts build_recording_query unconditionally added -secondarytype:Live, but denoise() strips a '(Live at …)' qualifier from the query — so a chart that IS a live take had its only correct recording filtered out (both background enrichment and manual search). Skip the live filter when the source title carries a parenthetical live marker; a bare title word ('Live and Let Die') still filters. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(enrichment): drop the studio tiebreak when the chart is a live take Follow-through on keeping live recordings for live charts: rank_candidates still ranked the studio take ahead of a tied live one, so a live chart would auto-match the studio recording. Skip the studio tiebreak when the source title has a live marker — duration proximity + score then pick the right live version. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a86abadb14
commit
a65d8cfa13
+77
-10
@@ -39,6 +39,14 @@ DURATION_BONUS_LOOSE = 0.025 # …within 15s
|
|||||||
_DURATION_TIGHT = 5
|
_DURATION_TIGHT = 5
|
||||||
_DURATION_LOOSE = 15
|
_DURATION_LOOSE = 15
|
||||||
|
|
||||||
|
# Release-group secondary types that mark a NON-canonical release (a live album,
|
||||||
|
# a greatest-hits comp, a remix/DJ set, …). Used both to pick the canonical
|
||||||
|
# studio album for display and to reward studio recordings in ranking.
|
||||||
|
_SECONDARY_SKIP = {
|
||||||
|
"live", "compilation", "remix", "dj-mix", "mixtape/street",
|
||||||
|
"demo", "interview", "audiobook", "spokenword",
|
||||||
|
}
|
||||||
|
|
||||||
# ── Denoise ───────────────────────────────────────────────────────────────────
|
# ── Denoise ───────────────────────────────────────────────────────────────────
|
||||||
# A parenthetical/bracketed group is dropped when it contains any of these
|
# A parenthetical/bracketed group is dropped when it contains any of these
|
||||||
# noise terms as a whole word (chart-variant markers, tuning/pitch notes,
|
# noise terms as a whole word (chart-variant markers, tuning/pitch notes,
|
||||||
@@ -154,6 +162,10 @@ def score_candidate(song: dict, cand: dict) -> float:
|
|||||||
score += DURATION_BONUS
|
score += DURATION_BONUS
|
||||||
elif diff <= _DURATION_LOOSE:
|
elif diff <= _DURATION_LOOSE:
|
||||||
score += DURATION_BONUS_LOOSE
|
score += DURATION_BONUS_LOOSE
|
||||||
|
# NB: the studio-vs-live distinction is deliberately NOT scored here — a live
|
||||||
|
# take is still the RIGHT SONG (same title/artist), so it must not change the
|
||||||
|
# auto/review confidence. Canonical-version preference lives in the RANK sort
|
||||||
|
# (rank_candidates) instead, where it only reorders same-song candidates.
|
||||||
return min(score, 1.0)
|
return min(score, 1.0)
|
||||||
|
|
||||||
|
|
||||||
@@ -179,15 +191,34 @@ def classify(song: dict, cand: dict, score: float, auto_min: float | None = None
|
|||||||
|
|
||||||
|
|
||||||
def rank_candidates(song: dict, candidates: list[dict]) -> list[dict]:
|
def rank_candidates(song: dict, candidates: list[dict]) -> list[dict]:
|
||||||
"""Score every candidate against the song and return them sorted by our
|
"""Score every candidate against the song and return them sorted best-first.
|
||||||
score (MusicBrainz's own search score is only a tiebreak). Each returned
|
The combined `score` caps at 1.0, so a perfect-text-match query (every "AC/DC
|
||||||
dict is a copy carrying `score` (rounded — it's displayed and stored)."""
|
Highway to Hell" recording) ties at the top — there the studio flag and, when
|
||||||
|
the caller knows the audio length, the duration match break the tie so the
|
||||||
|
canonical studio take wins over live/promo/extended cuts. Each returned dict
|
||||||
|
is a copy carrying `score` (rounded — it's displayed and stored)."""
|
||||||
|
sd = _duration_int(song.get("duration"))
|
||||||
|
# For a chart that IS a live take (build_recording_query keeps live
|
||||||
|
# recordings for these) the studio take is the WRONG recording, so drop the
|
||||||
|
# studio tiebreak — duration proximity + text/mb score then pick the right
|
||||||
|
# live version instead of auto-matching the studio one.
|
||||||
|
prefer_studio = not _LIVE_GROUP_RE.search(str(song.get("title") or ""))
|
||||||
|
|
||||||
|
def _dur_diff(c):
|
||||||
|
cd = _duration_int(c.get("duration"))
|
||||||
|
return abs(sd - cd) if (sd and cd) else 10 ** 6
|
||||||
|
|
||||||
ranked = []
|
ranked = []
|
||||||
for cand in candidates or []:
|
for cand in candidates or []:
|
||||||
c = dict(cand)
|
c = dict(cand)
|
||||||
c["score"] = round(score_candidate(song, cand), 4)
|
c["score"] = round(score_candidate(song, cand), 4)
|
||||||
ranked.append(c)
|
ranked.append(c)
|
||||||
ranked.sort(key=lambda c: (c["score"], c.get("mb_score") or 0), reverse=True)
|
ranked.sort(
|
||||||
|
key=lambda c: (c["score"],
|
||||||
|
(1 if c.get("studio") else 0) if prefer_studio else 0,
|
||||||
|
-_dur_diff(c), # closest to the audio length
|
||||||
|
c.get("mb_score") or 0),
|
||||||
|
reverse=True)
|
||||||
return ranked
|
return ranked
|
||||||
|
|
||||||
|
|
||||||
@@ -198,6 +229,11 @@ def _lucene_escape_phrase(s: str) -> str:
|
|||||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
|
||||||
|
|
||||||
|
# A parenthetical/bracketed "(Live …)" marker — the live signal denoise() strips
|
||||||
|
# from the title. Mirrors _NOISE_GROUP_RE but for the `live` term only.
|
||||||
|
_LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def build_recording_query(artist, title) -> str:
|
def build_recording_query(artist, title) -> str:
|
||||||
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
|
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
|
||||||
the noise we strip (author credits, "(Live)", "(v2)") would otherwise
|
the noise we strip (author credits, "(Live)", "(v2)") would otherwise
|
||||||
@@ -209,7 +245,22 @@ def build_recording_query(artist, title) -> str:
|
|||||||
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
|
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
|
||||||
if a:
|
if a:
|
||||||
parts.append('artist:"%s"' % _lucene_escape_phrase(a))
|
parts.append('artist:"%s"' % _lucene_escape_phrase(a))
|
||||||
return " AND ".join(parts)
|
q = " AND ".join(parts)
|
||||||
|
# Drop live-ONLY recordings (bootlegs, live albums) — the canonical studio
|
||||||
|
# take is never tagged Live, and this is the single biggest source of junk in
|
||||||
|
# a flat recording search. Compilations are deliberately NOT excluded: they
|
||||||
|
# REUSE the studio recording, so filtering them would drop the very recording
|
||||||
|
# we want (verified against MusicBrainz — `-secondarytype:Compilation` cut the
|
||||||
|
# AC/DC studio "Highway to Hell" recording entirely).
|
||||||
|
#
|
||||||
|
# EXCEPT when the source chart is itself a live take: denoise() strips the
|
||||||
|
# "(Live at …)" qualifier from the query, so filtering Live would leave the
|
||||||
|
# genuinely-live chart with NO correct recording. Only a parenthetical marker
|
||||||
|
# counts — a bare title word ("Live and Let Die") is a real word, not a live
|
||||||
|
# tag — mirroring what denoise removes.
|
||||||
|
if q and not _LIVE_GROUP_RE.search(str(title or "")):
|
||||||
|
q += " AND -secondarytype:Live"
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
||||||
@@ -226,19 +277,33 @@ def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
|||||||
return name, str(artist.get("id", "") or ""), str(artist.get("sort-name", "") or "")
|
return name, str(artist.get("id", "") or ""), str(artist.get("sort-name", "") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_clean_studio_album(rg: dict) -> bool:
|
||||||
|
"""A release-group that is a primary-type Album with NO non-canonical
|
||||||
|
secondary type (Live / Compilation / Remix / …) — i.e. a studio album."""
|
||||||
|
if str(rg.get("primary-type", "")).lower() != "album":
|
||||||
|
return False
|
||||||
|
secs = {str(s).lower() for s in (rg.get("secondary-types") or [])}
|
||||||
|
return not (secs & _SECONDARY_SKIP)
|
||||||
|
|
||||||
|
|
||||||
def _best_release(doc: dict) -> dict:
|
def _best_release(doc: dict) -> dict:
|
||||||
"""Pick the release used for canon album/year: prefer Official status and
|
"""Pick the release used for canon album/year: prefer an OFFICIAL studio
|
||||||
an Album release-group, then the earliest date. Returns {} if none."""
|
Album (primary Album with no Live/Compilation/… secondary type), then the
|
||||||
|
earliest date. Falls back to any release when none is clean. {} if none."""
|
||||||
releases = [r for r in (doc.get("releases") or []) if isinstance(r, dict)]
|
releases = [r for r in (doc.get("releases") or []) if isinstance(r, dict)]
|
||||||
if not releases:
|
if not releases:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def sort_key(r):
|
def sort_key(r):
|
||||||
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
|
||||||
rg = r.get("release-group") or {}
|
rg = r.get("release-group") or {}
|
||||||
album_ok = 0 if str(rg.get("primary-type", "")).lower() == "album" else 1
|
clean = 0 if _is_clean_studio_album(rg) else 1
|
||||||
|
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
||||||
date = str(r.get("date", "") or "9999")
|
date = str(r.get("date", "") or "9999")
|
||||||
return (status_ok, album_ok, date)
|
# Official FIRST, then prefer a clean studio album: this still surfaces
|
||||||
|
# the studio album over an (official) live/comp album for the display
|
||||||
|
# album/year, but never lets an UNofficial bootleg album outrank an
|
||||||
|
# official single/EP/comp — which `(clean, status_ok, …)` would.
|
||||||
|
return (status_ok, clean, date)
|
||||||
|
|
||||||
return sorted(releases, key=sort_key)[0]
|
return sorted(releases, key=sort_key)[0]
|
||||||
|
|
||||||
@@ -261,6 +326,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
artist_name, artist_id, artist_sort = _artist_credit(doc)
|
artist_name, artist_id, artist_sort = _artist_credit(doc)
|
||||||
release = _best_release(doc)
|
release = _best_release(doc)
|
||||||
|
studio = _is_clean_studio_album(release.get("release-group") or {})
|
||||||
length = doc.get("length")
|
length = doc.get("length")
|
||||||
try:
|
try:
|
||||||
duration = int(round(float(length) / 1000.0)) if length else None
|
duration = int(round(float(length) / 1000.0)) if length else None
|
||||||
@@ -281,6 +347,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
|
|||||||
"isrc": isrcs[0] if isrcs else "",
|
"isrc": isrcs[0] if isrcs else "",
|
||||||
"genres": _genres(doc),
|
"genres": _genres(doc),
|
||||||
"mb_score": int(doc.get("score") or 0),
|
"mb_score": int(doc.get("score") or 0),
|
||||||
|
"studio": studio,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6282,8 +6282,11 @@ def _mb_http_get(path: str, params: dict) -> dict | None:
|
|||||||
raise EnrichTransportError("bad JSON from musicbrainz") from e
|
raise EnrichTransportError("bad JSON from musicbrainz") from e
|
||||||
|
|
||||||
|
|
||||||
def _mb_search_recordings(artist, title, limit: int = 8) -> list[dict]:
|
def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
|
||||||
"""Text search (tier 2–4): denoised Lucene query over /recording."""
|
"""Text search (tier 2–4): denoised Lucene query over /recording. The query
|
||||||
|
now drops live-only recordings and our ranker rewards the studio take, so a
|
||||||
|
slightly larger default result set gives the re-ranker room to surface the
|
||||||
|
canonical version (one request per song regardless of limit)."""
|
||||||
query = mb_match.build_recording_query(artist, title)
|
query = mb_match.build_recording_query(artist, title)
|
||||||
if not query:
|
if not query:
|
||||||
return []
|
return []
|
||||||
@@ -7460,13 +7463,16 @@ def api_enrichment_pick(filename: str, data: dict = Body(...)):
|
|||||||
|
|
||||||
@app.get("/api/enrichment/search")
|
@app.get("/api/enrichment/search")
|
||||||
def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
|
def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
|
||||||
filename: str = ""):
|
filename: str = "", duration: float = 0.0):
|
||||||
"""Manual-search proxy to MusicBrainz (throttled + identified like the
|
"""Manual-search proxy to MusicBrainz (throttled + identified like the
|
||||||
background matcher — a user typing in the drawer must not sidestep the
|
background matcher — a user typing in the drawer must not sidestep the
|
||||||
rate limit). `filename` optionally scores results against that song's
|
rate limit). `filename` optionally scores results against that song's
|
||||||
stored identity (year/duration corroboration) instead of just the typed
|
stored identity (year/duration corroboration) instead of just the typed
|
||||||
text. Sync route on purpose: FastAPI runs it in the threadpool, so the
|
text. `duration` (seconds) lets a caller that HAS the audio but no library
|
||||||
throttle's sleep never blocks the event loop."""
|
row — e.g. the editor's create modal, which holds the master track — pass
|
||||||
|
its length so the studio take ranks above live/extended cuts. Sync route on
|
||||||
|
purpose: FastAPI runs it in the threadpool, so the throttle's sleep never
|
||||||
|
blocks the event loop."""
|
||||||
if not (artist.strip() or title.strip()):
|
if not (artist.strip() or title.strip()):
|
||||||
raise HTTPException(status_code=400, detail="artist or title required")
|
raise HTTPException(status_code=400, detail="artist or title required")
|
||||||
limit = max(1, min(int(limit), 25))
|
limit = max(1, min(int(limit), 25))
|
||||||
@@ -7480,6 +7486,10 @@ def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
|
|||||||
ref = meta_db.enrichment_song_row(filename)
|
ref = meta_db.enrichment_song_row(filename)
|
||||||
if ref is None:
|
if ref is None:
|
||||||
ref = {"artist": artist, "title": title}
|
ref = {"artist": artist, "title": title}
|
||||||
|
# A caller-supplied duration corroborates the take even without a library row.
|
||||||
|
if duration and duration > 0 and not ref.get("duration"):
|
||||||
|
ref = dict(ref)
|
||||||
|
ref["duration"] = duration
|
||||||
return {"candidates": mb_match.rank_candidates(ref, cands)}
|
return {"candidates": mb_match.rank_candidates(ref, cands)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+53
-1
@@ -145,11 +145,40 @@ def test_rank_candidates_orders_by_our_score():
|
|||||||
assert all("score" in c for c in ranked)
|
assert all("score" in c for c in ranked)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rank_candidates_studio_preference_is_dropped_for_live_charts():
|
||||||
|
"""Tied-score candidates: a studio chart prefers the studio take, but a
|
||||||
|
LIVE chart must NOT be forced to the studio recording."""
|
||||||
|
studio = {"recording_id": "studio", "artist": "AC/DC", "title": "Highway to Hell",
|
||||||
|
"studio": True, "mb_score": 90}
|
||||||
|
live = {"recording_id": "live", "artist": "AC/DC", "title": "Highway to Hell",
|
||||||
|
"studio": False, "mb_score": 95}
|
||||||
|
# Studio chart -> studio take wins the tie (studio flag), despite lower mb_score.
|
||||||
|
studio_song = {"artist": "AC/DC", "title": "Highway to Hell"}
|
||||||
|
assert m.rank_candidates(studio_song, [live, studio])[0]["recording_id"] == "studio"
|
||||||
|
# Live chart -> studio preference dropped, so the higher-mb_score live take wins.
|
||||||
|
live_song = {"artist": "AC/DC", "title": "Highway to Hell (Live at Donington)"}
|
||||||
|
assert m.rank_candidates(live_song, [studio, live])[0]["recording_id"] == "live"
|
||||||
|
|
||||||
|
|
||||||
# ── query building ────────────────────────────────────────────────────────────
|
# ── query building ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_build_recording_query_denoises_and_quotes():
|
def test_build_recording_query_denoises_and_quotes():
|
||||||
q = m.build_recording_query("ACDC", 'Thunderstruck (v2)')
|
q = m.build_recording_query("ACDC", 'Thunderstruck (v2)')
|
||||||
assert q == 'recording:"thunderstruck" AND artist:"acdc"'
|
# Live-only recordings are excluded — the studio take is never tagged Live,
|
||||||
|
# and it's the biggest source of junk in a flat recording search.
|
||||||
|
assert q == 'recording:"thunderstruck" AND artist:"acdc" AND -secondarytype:Live'
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_recording_query_keeps_live_for_live_charts():
|
||||||
|
"""A chart that IS a live take must NOT get the live filter, or its only
|
||||||
|
correct recording is excluded. A bare title word ("Live and Let Die") is a
|
||||||
|
real word, not a marker, so it still filters."""
|
||||||
|
live = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)")
|
||||||
|
assert "-secondarytype:Live" not in live
|
||||||
|
assert 'recording:"highway to hell"' in live
|
||||||
|
# A real word "live" in the title is not a live marker → still filtered.
|
||||||
|
bare = m.build_recording_query("Wings", "Live and Let Die")
|
||||||
|
assert "-secondarytype:Live" in bare
|
||||||
|
|
||||||
|
|
||||||
def test_build_recording_query_escapes_and_handles_missing_artist():
|
def test_build_recording_query_escapes_and_handles_missing_artist():
|
||||||
@@ -200,6 +229,29 @@ def test_parse_recording_doc_normalizes():
|
|||||||
assert c["mb_score"] == 98
|
assert c["mb_score"] == 98
|
||||||
|
|
||||||
|
|
||||||
|
def test_best_release_prefers_official_single_over_unofficial_album():
|
||||||
|
"""An OFFICIAL single/EP must outrank an UNofficial bootleg album for the
|
||||||
|
canonical album/year: official comes before the studio-album preference, so
|
||||||
|
a single-only song is never seeded from a bootleg. (`(clean, status_ok, …)`
|
||||||
|
would wrongly pick the bootleg.)"""
|
||||||
|
doc = {
|
||||||
|
"id": "rec-x", "title": "One-Off", "score": 90,
|
||||||
|
"artist-credit": [
|
||||||
|
{"name": "A", "joinphrase": "",
|
||||||
|
"artist": {"id": "a", "name": "A", "sort-name": "A"}}],
|
||||||
|
"releases": [
|
||||||
|
{"id": "rel-boot", "title": "Boot LP", "status": "Bootleg",
|
||||||
|
"date": "1990-01-01", "release-group": {"primary-type": "Album"}},
|
||||||
|
{"id": "rel-single", "title": "The Single", "status": "Official",
|
||||||
|
"date": "1988-01-01", "release-group": {"primary-type": "Single"}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
c = m.parse_recording_doc(doc)
|
||||||
|
assert c["release_id"] == "rel-single"
|
||||||
|
assert c["album"] == "The Single"
|
||||||
|
assert c["studio"] is False # a Single isn't a clean studio ALBUM
|
||||||
|
|
||||||
|
|
||||||
def test_parse_recording_doc_joined_artist_credit():
|
def test_parse_recording_doc_joined_artist_credit():
|
||||||
doc = dict(MB_DOC)
|
doc = dict(MB_DOC)
|
||||||
doc["artist-credit"] = [
|
doc["artist-credit"] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user