From e9759b8ef281fee29543c7fd75bee8ce0c18b440 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 12 Jul 2026 01:33:44 +0200 Subject: [PATCH] feat(preview): MB getArtistName + browseReleaseGroupTracks Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/lib/musicbrainz-tracks.test.ts | 34 ++++++++++++++++++++++++++ web/src/lib/musicbrainz.ts | 33 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 web/src/lib/musicbrainz-tracks.test.ts diff --git a/web/src/lib/musicbrainz-tracks.test.ts b/web/src/lib/musicbrainz-tracks.test.ts new file mode 100644 index 0000000..91311b2 --- /dev/null +++ b/web/src/lib/musicbrainz-tracks.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { browseReleaseGroupTracks, getArtistName } from "./musicbrainz"; + +afterEach(() => vi.restoreAllMocks()); +const ok = (obj: unknown) => new Response(JSON.stringify(obj), { status: 200 }); + +describe("getArtistName", () => { + it("returns the MB artist name", async () => { + vi.spyOn(global, "fetch").mockResolvedValue(ok({ name: "Radiohead" })); + expect(await getArtistName("mbid")).toBe("Radiohead"); + }); +}); + +describe("browseReleaseGroupTracks", () => { + it("picks the Official release and flattens its tracks in order", async () => { + vi.spyOn(global, "fetch").mockResolvedValue(ok({ + releases: [ + { status: "Bootleg", date: "2001", media: [{ tracks: [{ position: 1, title: "Boot", length: 1000 }] }] }, + { status: "Official", date: "2000", media: [ + { tracks: [{ position: 1, title: "One", length: 200000 }, { position: 2, title: "Two", length: null }] }, + ] }, + ], + })); + expect(await browseReleaseGroupTracks("rg")).toEqual([ + { position: 1, title: "One", lengthMs: 200000 }, + { position: 2, title: "Two", lengthMs: null }, + ]); + }); + + it("returns [] when there are no releases", async () => { + vi.spyOn(global, "fetch").mockResolvedValue(ok({ releases: [] })); + expect(await browseReleaseGroupTracks("rg")).toEqual([]); + }); +}); diff --git a/web/src/lib/musicbrainz.ts b/web/src/lib/musicbrainz.ts index d8f2f83..896b47c 100644 --- a/web/src/lib/musicbrainz.ts +++ b/web/src/lib/musicbrainz.ts @@ -63,3 +63,36 @@ export async function searchReleaseGroup(artist: string, album: string): Promise const credit = g["artist-credit"]?.[0]?.artist ?? {}; return { ...toReleaseGroup(g), artistMbid: credit.id ?? "", artistName: credit.name ?? artist }; } + +export type Track = { position: number; title: string; lengthMs: number | null }; + +export async function getArtistName(mbid: string): Promise { + const data = await mbGet(`/artist/${encodeURIComponent(mbid)}?fmt=json`); + return data.name ?? ""; +} + +export async function browseReleaseGroupTracks(rgMbid: string): Promise { + const data = await mbGet( + `/release?release-group=${encodeURIComponent(rgMbid)}&inc=recordings&fmt=json&limit=25`, + ); + const releases: any[] = data.releases ?? []; + if (releases.length === 0) return []; + // Prefer an Official release, then the earliest date. + const pick = [...releases].sort((a, b) => { + const ao = a.status === "Official" ? 0 : 1; + const bo = b.status === "Official" ? 0 : 1; + if (ao !== bo) return ao - bo; + return (a.date ?? "9999").localeCompare(b.date ?? "9999"); + })[0]; + const tracks: Track[] = []; + for (const medium of pick.media ?? []) { + for (const t of medium.tracks ?? []) { + tracks.push({ + position: Number(t.position ?? tracks.length + 1), + title: t.title ?? t.recording?.title ?? "", + lengthMs: t.length ?? t.recording?.length ?? null, + }); + } + } + return tracks; +}