fix(web): resolve edition-qualified album titles against MusicBrainz

searchReleaseGroup used a strict exact-phrase releasegroup:"<album>", so
Last.fm album names with per-release edition/version qualifiers ("The Stranger
(Legacy Edition)", "Nevermind - Remastered") returned 0 hits → 404 → "No
MusicBrainz match", even though MB knows the artist and the base title.

Add stripEditionQualifiers (removes a trailing edition parenthetical/bracket or
" - " suffix only when it names an edition — deluxe/remaster/legacy/edition/…,
never a plain title parenthetical) and search in decreasing precision: exact
phrase on the given title → exact phrase on the stripped title → a loose
unquoted fallback. Clean titles still resolve in one request; albumPreferenceRank
ranking unchanged. Fixes both callers (/api/mb/release-group + /api/wanted).

Verified live against real MB: "The Stranger (Legacy Edition)"→The Stranger,
"Nevermind - Remastered"→Nevermind, Continuum unchanged. web 141 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 10:54:31 +02:00
parent 5e10dc24a5
commit d275a0ddd6
2 changed files with 72 additions and 4 deletions
+36 -3
View File
@@ -58,6 +58,27 @@ function escapeLucenePhrase(s: string): string {
return s.replace(/[\\"]/g, (c) => "\\" + c);
}
// Last.fm album names carry per-RELEASE edition/version qualifiers ("(Legacy Edition)",
// " - Remastered 2011", "[Deluxe]") that are NOT part of the release-GROUP title, so a
// strict phrase search finds nothing. Strip a trailing parenthetical/bracket or " - "
// suffix ONLY when it names an edition/version (never a plain parenthetical like
// "(What's the Story) Morning Glory?", which isn't trailing anyway).
const EDITION_RE =
/\b(deluxe|expanded|legacy|remaster(ed)?|anniversary|reissue|bonus|special|collector'?s?|edition|version|mono|stereo|explicit|clean)\b/i;
export function stripEditionQualifiers(album: string): string {
let s = album.trim();
for (;;) {
const before = s;
// trailing (...) or [...]
s = s.replace(/\s*[([][^()[\]]*[)\]]\s*$/, (m) => (EDITION_RE.test(m) ? "" : m)).trimEnd();
// trailing " - ..." / " ..." / " — ..." suffix
s = s.replace(/\s+[-–—]\s+[^-–—]*$/, (m) => (EDITION_RE.test(m) ? "" : m)).trimEnd();
if (s === before) break;
}
return s.trim() || album.trim(); // never collapse to empty
}
// When several release groups share the searched title (e.g. MusicBrainz ties the
// "Thriller" single with the album at the same score, listing the single first), prefer
// the canonical studio album. Lower rank = better; MB's own ordering breaks ties within
@@ -73,9 +94,21 @@ function albumPreferenceRank(g: any): number {
}
export async function searchReleaseGroup(artist: string, album: string): Promise<ReleaseGroupMatch | null> {
const query = `releasegroup:"${escapeLucenePhrase(album)}" AND artist:"${escapeLucenePhrase(artist)}"`;
const data = await mbGet(`/release-group?query=${encodeURIComponent(query)}&fmt=json&limit=5`);
const groups: any[] = data["release-groups"] ?? [];
const art = escapeLucenePhrase(artist);
const stripped = stripEditionQualifiers(album);
// Decreasing precision: exact phrase on the given title, then on the edition-stripped
// title, then a loose (unquoted, OR'd terms) fallback. Stop at the first that yields hits
// so the clean case still costs one request.
const attempts = [`releasegroup:"${escapeLucenePhrase(album)}" AND artist:"${art}"`];
if (stripped !== album) attempts.push(`releasegroup:"${escapeLucenePhrase(stripped)}" AND artist:"${art}"`);
attempts.push(`releasegroup:(${escapeLucenePhrase(stripped)}) AND artist:"${art}"`);
let groups: any[] = [];
for (const query of attempts) {
const data = await mbGet(`/release-group?query=${encodeURIComponent(query)}&fmt=json&limit=10`);
groups = data["release-groups"] ?? [];
if (groups.length > 0) break;
}
if (groups.length === 0) return null;
// groups arrive in MB relevance order; keep that as the tiebreak (reduce only switches on
// a strictly better rank, so equal-ranked earlier hits win).