d275a0ddd6
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>
115 lines
6.9 KiB
TypeScript
115 lines
6.9 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import { searchArtists, browseReleaseGroups, searchReleaseGroup, stripEditionQualifiers } from "./musicbrainz";
|
|
|
|
function jsonResponse(data: unknown) {
|
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data) } as Response);
|
|
}
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
describe("musicbrainz module", () => {
|
|
it("searchArtists maps hits and sends a User-Agent", async () => {
|
|
const fetchMock = vi.fn((_url: string, _init?: RequestInit) =>
|
|
jsonResponse({ artists: [{ id: "a1", name: "John Mayer", disambiguation: "US singer" }] }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const hits = await searchArtists("john ma");
|
|
expect(hits).toEqual([{ mbid: "a1", name: "John Mayer", disambiguation: "US singer" }]);
|
|
const [, init] = fetchMock.mock.calls[0];
|
|
expect((init as RequestInit).headers).toMatchObject({ "User-Agent": expect.stringContaining("Lyra") });
|
|
});
|
|
|
|
it("browseReleaseGroups paginates until release-group-count is reached", async () => {
|
|
const page1 = { "release-group-count": 2, "release-groups": [{ id: "rg1", title: "A", "primary-type": "Album", "secondary-types": [], "first-release-date": "2001" }] };
|
|
const page2 = { "release-group-count": 2, "release-groups": [{ id: "rg2", title: "B", "primary-type": "EP", "secondary-types": ["Live"], "first-release-date": "" }] };
|
|
const fetchMock = vi.fn().mockReturnValueOnce(jsonResponse(page1)).mockReturnValueOnce(jsonResponse(page2));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const rels = await browseReleaseGroups("a1");
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(rels).toEqual([
|
|
{ rgMbid: "rg1", title: "A", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2001" },
|
|
{ rgMbid: "rg2", title: "B", primaryType: "EP", secondaryTypes: ["Live"], firstReleaseDate: null },
|
|
]);
|
|
});
|
|
|
|
it("throws on a non-2xx MusicBrainz response", async () => {
|
|
vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: false, status: 503 } as Response)));
|
|
await expect(searchArtists("x")).rejects.toThrow(/503/);
|
|
});
|
|
|
|
it("searchReleaseGroup returns the top match with artist credit or null", async () => {
|
|
vi.stubGlobal("fetch", vi.fn(() => jsonResponse({ "release-groups": [
|
|
{ id: "rg9", title: "Continuum", "primary-type": "Album", "secondary-types": [], "first-release-date": "2006-09-12", "artist-credit": [{ artist: { id: "a1", name: "John Mayer" } }] },
|
|
] })));
|
|
const m = await searchReleaseGroup("John Mayer", "Continuum");
|
|
expect(m).toEqual({ rgMbid: "rg9", title: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006-09-12", artistMbid: "a1", artistName: "John Mayer" });
|
|
|
|
vi.stubGlobal("fetch", vi.fn(() => jsonResponse({ "release-groups": [] })));
|
|
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);
|
|
await searchReleaseGroup("AC/DC", 'Back in "Black"');
|
|
const [url] = fetchMock.mock.calls[0];
|
|
// 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
|
|
});
|
|
});
|