From 13126addbcab87ca3bccdad32e84a835748b3d77 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 13 Jul 2026 21:06:15 +0200 Subject: [PATCH] fix(web): resolve album names to the studio Album, not a same-title Single MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit searchReleaseGroup took the first MusicBrainz hit, so a title MB ranks with the single first (e.g. "Thriller" ties single+album at score 100) resolved to the single — the Last.fm album modal and the Want flow then showed a 1-track single. Prefer a clean Album > EP > Single > album-with-secondary, keeping MB order as the in-tier tiebreak; single-only titles still resolve. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/lib/musicbrainz.test.ts | 20 ++++++++++++++++++++ web/src/lib/musicbrainz.ts | 19 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/web/src/lib/musicbrainz.test.ts b/web/src/lib/musicbrainz.test.ts index bd3fb3f..26b082a 100644 --- a/web/src/lib/musicbrainz.test.ts +++ b/web/src/lib/musicbrainz.test.ts @@ -48,6 +48,26 @@ describe("musicbrainz module", () => { expect(await searchReleaseGroup("Nobody", "Nothing")).toBeNull(); }); + it("prefers a clean studio Album over a same-title Single/comp that MB ranks first", async () => { + // MB ties the "Thriller" single with the album at the same score and lists the single + // first; we must resolve to the studio album, not the single. + vi.stubGlobal("fetch", vi.fn(() => jsonResponse({ "release-groups": [ + { id: "single", title: "Thriller", "primary-type": "Single", "secondary-types": [], "first-release-date": "1983", "artist-credit": [{ artist: { id: "mj", name: "Michael Jackson" } }] }, + { id: "comp", title: "Thriller", "primary-type": "Album", "secondary-types": ["Compilation"], "first-release-date": "2015", "artist-credit": [{ artist: { id: "mj", name: "Michael Jackson" } }] }, + { id: "album", title: "Thriller", "primary-type": "Album", "secondary-types": [], "first-release-date": "1982", "artist-credit": [{ artist: { id: "mj", name: "Michael Jackson" } }] }, + ] }))); + const m = await searchReleaseGroup("Michael Jackson", "Thriller"); + expect(m?.rgMbid).toBe("album"); + expect(m?.primaryType).toBe("Album"); + }); + + it("falls back to a Single when no album-type release group matches", async () => { + vi.stubGlobal("fetch", vi.fn(() => jsonResponse({ "release-groups": [ + { id: "single-only", title: "Standalone", "primary-type": "Single", "secondary-types": [], "first-release-date": "2020", "artist-credit": [{ artist: { id: "a1", name: "Artist" } }] }, + ] }))); + expect((await searchReleaseGroup("Artist", "Standalone"))?.rgMbid).toBe("single-only"); + }); + it("escapes quotes in the release-group query", async () => { const fetchMock = vi.fn((_url: string, _init?: RequestInit) => jsonResponse({ "release-groups": [] })); vi.stubGlobal("fetch", fetchMock); diff --git a/web/src/lib/musicbrainz.ts b/web/src/lib/musicbrainz.ts index 55f55e5..6af2e92 100644 --- a/web/src/lib/musicbrainz.ts +++ b/web/src/lib/musicbrainz.ts @@ -58,12 +58,29 @@ function escapeLucenePhrase(s: string): string { return s.replace(/[\\"]/g, (c) => "\\" + c); } +// 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 +// a tier. A title that only exists as a single still resolves (it's the best available). +function albumPreferenceRank(g: any): number { + const primary = g["primary-type"] ?? ""; + const hasSecondary = (g["secondary-types"] ?? []).length > 0; + if (primary === "Album" && !hasSecondary) return 0; // clean studio album + if (primary === "EP" && !hasSecondary) return 1; + if (primary === "Single" && !hasSecondary) return 2; + if (primary === "Album") return 3; // album with a secondary type (compilation, live, demo…) + return 4; +} + 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"] ?? []; if (groups.length === 0) return null; - const g = groups[0]; + // 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). + const g = groups.reduce((best, cur) => + albumPreferenceRank(cur) < albumPreferenceRank(best) ? cur : best); const credit = g["artist-credit"]?.[0]?.artist ?? {}; return { ...toReleaseGroup(g), artistMbid: credit.id ?? "", artistName: credit.name ?? artist }; }