diff --git a/web/src/lib/musicbrainz.test.ts b/web/src/lib/musicbrainz.test.ts index 26b082a..be3b909 100644 --- a/web/src/lib/musicbrainz.test.ts +++ b/web/src/lib/musicbrainz.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { searchArtists, browseReleaseGroups, searchReleaseGroup } from "./musicbrainz"; +import { searchArtists, browseReleaseGroups, searchReleaseGroup, stripEditionQualifiers } from "./musicbrainz"; function jsonResponse(data: unknown) { return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data) } as Response); @@ -76,4 +76,39 @@ describe("musicbrainz module", () => { // the decoded query must contain the escaped quote, not a bare one that closes the phrase expect(decodeURIComponent(url)).toContain('Back in \\"Black\\"'); }); + + it("retries with an edition-stripped title when the exact phrase finds nothing", async () => { + // Last.fm hands us "The Stranger (Legacy Edition)"; the strict phrase for that returns 0, + // then the stripped "The Stranger" resolves to the release group. + const fetchMock = vi + .fn() + .mockReturnValueOnce(jsonResponse({ "release-groups": [] })) + .mockReturnValueOnce(jsonResponse({ "release-groups": [ + { id: "rg-str", title: "The Stranger", "primary-type": "Album", "secondary-types": [], "first-release-date": "1977", "artist-credit": [{ artist: { id: "bj", name: "Billy Joel" } }] }, + ] })); + vi.stubGlobal("fetch", fetchMock); + const m = await searchReleaseGroup("Billy Joel", "The Stranger (Legacy Edition)"); + expect(m?.rgMbid).toBe("rg-str"); + // first attempt used the full title, second used the stripped title + expect(decodeURIComponent(fetchMock.mock.calls[0][0])).toContain("The Stranger (Legacy Edition)"); + expect(decodeURIComponent(fetchMock.mock.calls[1][0])).toContain('releasegroup:"The Stranger"'); + }); +}); + +describe("stripEditionQualifiers", () => { + it("removes trailing edition parentheticals and suffixes", () => { + expect(stripEditionQualifiers("The Stranger (Legacy Edition)")).toBe("The Stranger"); + expect(stripEditionQualifiers("21 (Deluxe)")).toBe("21"); + expect(stripEditionQualifiers("Nevermind - Remastered")).toBe("Nevermind"); + expect(stripEditionQualifiers("Rumours - Remastered 2011")).toBe("Rumours"); + expect(stripEditionQualifiers("Blue [Deluxe Edition]")).toBe("Blue"); + expect(stripEditionQualifiers("Vitalogy (2016 Remaster)")).toBe("Vitalogy"); + }); + + it("leaves non-edition titles untouched", () => { + expect(stripEditionQualifiers("(What's the Story) Morning Glory?")).toBe("(What's the Story) Morning Glory?"); + expect(stripEditionQualifiers("Continuum")).toBe("Continuum"); + expect(stripEditionQualifiers("Songs in the Key of Life")).toBe("Songs in the Key of Life"); + expect(stripEditionQualifiers("Give Up")).toBe("Give Up"); // "Up" is not a stray edition word + }); }); diff --git a/web/src/lib/musicbrainz.ts b/web/src/lib/musicbrainz.ts index 6af2e92..612d259 100644 --- a/web/src/lib/musicbrainz.ts +++ b/web/src/lib/musicbrainz.ts @@ -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 { - 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).