feat: read-only MusicBrainz module + monitoring test cleanup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<any> {
|
||||
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<ArtistHit[]> {
|
||||
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<ReleaseGroupInfo[]> {
|
||||
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<ReleaseGroupMatch | null> {
|
||||
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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user