diff --git a/web/src/lib/musicbrainz.test.ts b/web/src/lib/musicbrainz.test.ts new file mode 100644 index 0000000..bbe5bb4 --- /dev/null +++ b/web/src/lib/musicbrainz.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { searchArtists, browseReleaseGroups, searchReleaseGroup } 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(() => + 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(); + }); +}); diff --git a/web/src/lib/musicbrainz.ts b/web/src/lib/musicbrainz.ts new file mode 100644 index 0000000..d8f2f83 --- /dev/null +++ b/web/src/lib/musicbrainz.ts @@ -0,0 +1,65 @@ +const MB_BASE = "https://musicbrainz.org/ws/2"; +const USER_AGENT = "Lyra/0.1 ( https://git.jger.nl/Jonathan/Lyra )"; + +export type ArtistHit = { mbid: string; name: string; disambiguation: string }; +export type ReleaseGroupInfo = { + rgMbid: string; + title: string; + primaryType: string | null; + secondaryTypes: string[]; + firstReleaseDate: string | null; +}; +export type ReleaseGroupMatch = ReleaseGroupInfo & { artistMbid: string; artistName: string }; + +async function mbGet(path: string): Promise { + const res = await fetch(`${MB_BASE}${path}`, { + headers: { "User-Agent": USER_AGENT, Accept: "application/json" }, + }); + if (!res.ok) throw new Error(`MusicBrainz request failed: ${res.status}`); + return res.json(); +} + +function toReleaseGroup(g: any): ReleaseGroupInfo { + return { + rgMbid: g.id, + title: g.title ?? "", + primaryType: g["primary-type"] ?? null, + secondaryTypes: g["secondary-types"] ?? [], + firstReleaseDate: g["first-release-date"] || null, + }; +} + +export async function searchArtists(query: string): Promise { + const data = await mbGet(`/artist?query=${encodeURIComponent(query)}&fmt=json&limit=8`); + return (data.artists ?? []).map((a: any) => ({ + mbid: a.id, + name: a.name ?? "", + disambiguation: a.disambiguation ?? "", + })); +} + +export async function browseReleaseGroups(artistMbid: string): Promise { + const out: ReleaseGroupInfo[] = []; + let offset = 0; + for (;;) { + const data = await mbGet( + `/release-group?artist=${encodeURIComponent(artistMbid)}&fmt=json&limit=100&offset=${offset}`, + ); + const groups: any[] = data["release-groups"] ?? []; + for (const g of groups) out.push(toReleaseGroup(g)); + offset += groups.length; + const total = data["release-group-count"] ?? offset; + if (groups.length === 0 || offset >= total) break; + } + return out; +} + +export async function searchReleaseGroup(artist: string, album: string): Promise { + const query = `releasegroup:"${album}" AND artist:"${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]; + const credit = g["artist-credit"]?.[0]?.artist ?? {}; + return { ...toReleaseGroup(g), artistMbid: credit.id ?? "", artistName: credit.name ?? artist }; +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index b83f3da..5d0b12e 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -2,8 +2,10 @@ import { beforeEach } from "vitest"; import { prisma } from "@/lib/db"; beforeEach(async () => { - // Job has a cascade FK to Request; delete Job first. + // Job cascades from Request; Request.monitoredReleaseId is SetNull. Delete children first. await prisma.job.deleteMany(); await prisma.request.deleteMany(); + await prisma.monitoredRelease.deleteMany(); + await prisma.watchedArtist.deleteMany(); await prisma.config.deleteMany(); });