diff --git a/web/src/app/api/discover/preview/[mbid]/route.test.ts b/web/src/app/api/discover/preview/[mbid]/route.test.ts new file mode 100644 index 0000000..08e6eba --- /dev/null +++ b/web/src/app/api/discover/preview/[mbid]/route.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; + +vi.mock("@/lib/musicbrainz", () => ({ browseReleaseGroups: vi.fn(), getArtistName: vi.fn() })); +import { browseReleaseGroups, getArtistName } from "@/lib/musicbrainz"; +import { prisma } from "@/lib/db"; +import { GET } from "./route"; + +const mockBrowse = vi.mocked(browseReleaseGroups); +const mockName = vi.mocked(getArtistName); +afterEach(() => { mockBrowse.mockReset(); mockName.mockReset(); }); + +function get(mbid: string, qs = "") { + return GET(new Request(`http://localhost/api/discover/preview/${mbid}${qs}`), { + params: Promise.resolve({ mbid }), + }); +} + +describe("discover preview API", () => { + it("annotates own-state, defaults to core releases, reports followed", async () => { + mockBrowse.mockResolvedValue([ + { rgMbid: "rg1", title: "Studio", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2000" }, + { rgMbid: "rg2", title: "Live One", primaryType: "Album", secondaryTypes: ["Live"], firstReleaseDate: "2001" }, + { rgMbid: "rg3", title: "Owned", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2002" }, + { rgMbid: "rg4", title: "Scanned", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2003" }, + ]); + await prisma.watchedArtist.create({ data: { mbid: "a1", name: "Band" } }); + await prisma.monitoredRelease.create({ data: { artistMbid: "a1", artistName: "Band", rgMbid: "rg1", album: "Studio", secondaryTypes: [], monitored: true } }); + await prisma.monitoredRelease.create({ data: { artistMbid: "a1", artistName: "Band", rgMbid: "rg3", album: "Owned", secondaryTypes: [], monitored: false, currentQualityClass: 3 } }); + await prisma.libraryItem.create({ data: { artist: "Band", album: "Scanned", path: "/x", source: "scan", format: "FLAC", qualityClass: 3 } }); + + const body = await (await get("a1", "?name=Band")).json(); + expect(body.followed).toBe(true); + expect(body.artistName).toBe("Band"); + expect(body.releases.map((r: any) => r.album)).toEqual(["Studio", "Owned", "Scanned"]); // Live excluded + expect(body.releases.find((r: any) => r.rgMbid === "rg1")).toMatchObject({ monitored: true, have: false }); + expect(body.releases.find((r: any) => r.rgMbid === "rg3")).toMatchObject({ monitored: false, have: true }); + expect(body.releases.find((r: any) => r.rgMbid === "rg4")).toMatchObject({ have: true }); // via LibraryItem + expect(mockName).not.toHaveBeenCalled(); // ?name= shortcut + }); + + it("?all=true includes non-core; missing ?name falls back to getArtistName; 502 on MB throw", async () => { + mockBrowse.mockResolvedValue([ + { rgMbid: "rg2", title: "Live One", primaryType: "Album", secondaryTypes: ["Live"], firstReleaseDate: "2001" }, + ]); + mockName.mockResolvedValue("Resolved"); + const body = await (await get("a1", "?all=true")).json(); + expect(body.releases.map((r: any) => r.album)).toEqual(["Live One"]); + expect(body.artistName).toBe("Resolved"); + expect(body.followed).toBe(false); + + mockBrowse.mockRejectedValue(new Error("down")); + expect((await get("a1")).status).toBe(502); + }); +}); diff --git a/web/src/app/api/discover/preview/[mbid]/route.ts b/web/src/app/api/discover/preview/[mbid]/route.ts new file mode 100644 index 0000000..a293f8f --- /dev/null +++ b/web/src/app/api/discover/preview/[mbid]/route.ts @@ -0,0 +1,51 @@ +import { prisma } from "@/lib/db"; +import { browseReleaseGroups, getArtistName } from "@/lib/musicbrainz"; +import { isCoreRelease } from "@/lib/release-filter"; + +export async function GET(request: Request, { params }: { params: Promise<{ mbid: string }> }) { + const { mbid } = await params; + const url = new URL(request.url); + const nameParam = url.searchParams.get("name"); + const showAll = url.searchParams.get("all") === "true"; + + let groups; + try { + groups = await browseReleaseGroups(mbid); + } catch { + return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 }); + } + const visible = showAll ? groups : groups.filter(isCoreRelease); + + let artistName = nameParam?.trim() || ""; + if (!artistName) { + try { + artistName = await getArtistName(mbid); + } catch { + artistName = ""; + } + } + + const followed = (await prisma.watchedArtist.findUnique({ where: { mbid } })) != null; + + const rgMbids = visible.map((g) => g.rgMbid); + const albums = visible.map((g) => g.title); + const monitored = await prisma.monitoredRelease.findMany({ where: { rgMbid: { in: rgMbids } } }); + const monMap = new Map(monitored.map((m) => [m.rgMbid, m])); + const libItems = await prisma.libraryItem.findMany({ where: { artist: artistName, album: { in: albums } } }); + const haveAlbums = new Set(libItems.map((l) => l.album)); + + const releases = visible.map((g) => { + const m = monMap.get(g.rgMbid); + return { + rgMbid: g.rgMbid, + album: g.title, + primaryType: g.primaryType, + secondaryTypes: g.secondaryTypes, + firstReleaseDate: g.firstReleaseDate, + monitored: m?.monitored ?? false, + have: m?.currentQualityClass != null || haveAlbums.has(g.title), + }; + }); + + return Response.json({ artistName, followed, releases }); +}