diff --git a/web/src/app/api/artists/[id]/route.test.ts b/web/src/app/api/artists/[id]/route.test.ts index 2f5bfa1..1d001a4 100644 --- a/web/src/app/api/artists/[id]/route.test.ts +++ b/web/src/app/api/artists/[id]/route.test.ts @@ -12,6 +12,7 @@ async function seedArtist() { releases: { create: [ { artistMbid: "a1", artistName: "John Mayer", rgMbid: "rg1", album: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006-09-12", monitored: true, state: "grabbed", currentQualityClass: 1 }, + { artistMbid: "a1", artistName: "John Mayer", rgMbid: "rg2", album: "Any Given Thursday", primaryType: "Album", secondaryTypes: ["Live"], firstReleaseDate: "2003", monitored: false, state: "wanted", currentQualityClass: null }, ], }, }, @@ -23,15 +24,27 @@ function patchReq(body: unknown) { } describe("artist item API", () => { - it("returns detail with releases", async () => { + it("returns detail with only core releases by default", async () => { const a = await seedArtist(); const res = await GET(new Request("http://localhost/x"), ctx(a.id)); expect(res.status).toBe(200); const body = await res.json(); expect(body.name).toBe("John Mayer"); + expect(body.showAllTypes).toBe(false); + expect(body.releases).toHaveLength(1); expect(body.releases[0]).toMatchObject({ rgMbid: "rg1", album: "Continuum", monitored: true, state: "grabbed", currentQualityClass: 1 }); }); + it("returns all releases when showAllTypes is set", async () => { + const a = await seedArtist(); + await prisma.watchedArtist.update({ where: { id: a.id }, data: { showAllTypes: true } }); + const res = await GET(new Request("http://localhost/x"), ctx(a.id)); + const body = await res.json(); + expect(body.showAllTypes).toBe(true); + expect(body.releases).toHaveLength(2); + expect(body.releases.map((r: { rgMbid: string }) => r.rgMbid).sort()).toEqual(["rg1", "rg2"]); + }); + it("404s an unknown artist on GET", async () => { expect((await GET(new Request("http://localhost/x"), ctx("nope"))).status).toBe(404); }); @@ -44,9 +57,19 @@ describe("artist item API", () => { expect((await prisma.watchedArtist.findUnique({ where: { id: a.id } }))!.autoMonitorFuture).toBe(true); }); - it("400s a non-boolean toggle and 404s an unknown artist", async () => { + it("toggles showAllTypes", async () => { + const a = await seedArtist(); + const res = await PATCH(patchReq({ showAllTypes: true }), ctx(a.id)); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.showAllTypes).toBe(true); + expect((await prisma.watchedArtist.findUnique({ where: { id: a.id } }))!.showAllTypes).toBe(true); + }); + + it("400s a body with neither valid boolean field and 404s an unknown artist", async () => { const a = await seedArtist(); expect((await PATCH(patchReq({ autoMonitorFuture: "yes" }), ctx(a.id))).status).toBe(400); + expect((await PATCH(patchReq({}), ctx(a.id))).status).toBe(400); expect((await PATCH(patchReq({ autoMonitorFuture: true }), ctx("nope"))).status).toBe(404); }); diff --git a/web/src/app/api/artists/[id]/route.ts b/web/src/app/api/artists/[id]/route.ts index 45855ff..0c22104 100644 --- a/web/src/app/api/artists/[id]/route.ts +++ b/web/src/app/api/artists/[id]/route.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; import { Prisma } from "@prisma/client"; +import { isCoreRelease } from "@/lib/release-filter"; export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -8,12 +9,14 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: include: { releases: { orderBy: [{ firstReleaseDate: "desc" }, { album: "asc" }] } }, }); if (!artist) return Response.json({ error: "not found" }, { status: 404 }); + const visibleReleases = artist.showAllTypes ? artist.releases : artist.releases.filter(isCoreRelease); return Response.json({ id: artist.id, mbid: artist.mbid, name: artist.name, autoMonitorFuture: artist.autoMonitorFuture, - releases: artist.releases.map((r) => ({ + showAllTypes: artist.showAllTypes, + releases: visibleReleases.map((r) => ({ id: r.id, rgMbid: r.rgMbid, album: r.album, @@ -35,13 +38,26 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id } catch { return Response.json({ error: "invalid JSON" }, { status: 400 }); } - const { autoMonitorFuture } = (body ?? {}) as { autoMonitorFuture?: unknown }; - if (typeof autoMonitorFuture !== "boolean") { - return Response.json({ error: "autoMonitorFuture (boolean) is required" }, { status: 400 }); + const { autoMonitorFuture, showAllTypes } = (body ?? {}) as { + autoMonitorFuture?: unknown; + showAllTypes?: unknown; + }; + const data: { autoMonitorFuture?: boolean; showAllTypes?: boolean } = {}; + if (typeof autoMonitorFuture === "boolean") data.autoMonitorFuture = autoMonitorFuture; + if (typeof showAllTypes === "boolean") data.showAllTypes = showAllTypes; + if (Object.keys(data).length === 0) { + return Response.json( + { error: "autoMonitorFuture or showAllTypes (boolean) is required" }, + { status: 400 }, + ); } try { - const updated = await prisma.watchedArtist.update({ where: { id }, data: { autoMonitorFuture } }); - return Response.json({ id: updated.id, autoMonitorFuture: updated.autoMonitorFuture }); + const updated = await prisma.watchedArtist.update({ where: { id }, data }); + return Response.json({ + id: updated.id, + autoMonitorFuture: updated.autoMonitorFuture, + showAllTypes: updated.showAllTypes, + }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { return Response.json({ error: "not found" }, { status: 404 }); diff --git a/web/src/app/api/artists/route.test.ts b/web/src/app/api/artists/route.test.ts index 4b964be..5eb593f 100644 --- a/web/src/app/api/artists/route.test.ts +++ b/web/src/app/api/artists/route.test.ts @@ -57,4 +57,26 @@ describe("artists collection API", () => { const a = (await res.json()).artists[0]; expect(a).toMatchObject({ mbid: "a1", name: "John Mayer", releaseCount: 1, monitoredCount: 1, haveCount: 1 }); }); + + it("counts only core releases unless showAllTypes is set", async () => { + await prisma.watchedArtist.create({ + data: { + mbid: "a2", + name: "Live Band", + releases: { + create: [ + { artistMbid: "a2", artistName: "Live Band", rgMbid: "rg-core", album: "Studio Album", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2010", monitored: true, state: "grabbed", currentQualityClass: 1 }, + { artistMbid: "a2", artistName: "Live Band", rgMbid: "rg-live", album: "Live at the Roxy", primaryType: "Album", secondaryTypes: ["Live"], firstReleaseDate: "2011", monitored: true, state: "wanted", currentQualityClass: 2 }, + ], + }, + }, + }); + + const before = (await (await GET()).json()).artists.find((a: { mbid: string }) => a.mbid === "a2"); + expect(before).toMatchObject({ showAllTypes: false, releaseCount: 1, monitoredCount: 1, haveCount: 1 }); + + await prisma.watchedArtist.update({ where: { mbid: "a2" }, data: { showAllTypes: true } }); + const after = (await (await GET()).json()).artists.find((a: { mbid: string }) => a.mbid === "a2"); + expect(after).toMatchObject({ showAllTypes: true, releaseCount: 2, monitoredCount: 2, haveCount: 2 }); + }); }); diff --git a/web/src/app/api/artists/route.ts b/web/src/app/api/artists/route.ts index d04fd45..b80820e 100644 --- a/web/src/app/api/artists/route.ts +++ b/web/src/app/api/artists/route.ts @@ -1,21 +1,30 @@ import { prisma } from "@/lib/db"; import { browseReleaseGroups } from "@/lib/musicbrainz"; +import { isCoreRelease } from "@/lib/release-filter"; export async function GET() { const artists = await prisma.watchedArtist.findMany({ orderBy: { createdAt: "desc" }, - include: { releases: { select: { monitored: true, currentQualityClass: true } } }, + include: { + releases: { + select: { monitored: true, currentQualityClass: true, primaryType: true, secondaryTypes: true }, + }, + }, }); return Response.json({ - artists: artists.map((a) => ({ - id: a.id, - mbid: a.mbid, - name: a.name, - autoMonitorFuture: a.autoMonitorFuture, - releaseCount: a.releases.length, - monitoredCount: a.releases.filter((r) => r.monitored).length, - haveCount: a.releases.filter((r) => r.currentQualityClass !== null).length, - })), + artists: artists.map((a) => { + const visible = a.showAllTypes ? a.releases : a.releases.filter(isCoreRelease); + return { + id: a.id, + mbid: a.mbid, + name: a.name, + autoMonitorFuture: a.autoMonitorFuture, + showAllTypes: a.showAllTypes, + releaseCount: visible.length, + monitoredCount: visible.filter((r) => r.monitored).length, + haveCount: visible.filter((r) => r.currentQualityClass !== null).length, + }; + }), }); } diff --git a/web/src/lib/release-filter.test.ts b/web/src/lib/release-filter.test.ts new file mode 100644 index 0000000..29faced --- /dev/null +++ b/web/src/lib/release-filter.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { isCoreRelease } from "./release-filter"; + +describe("isCoreRelease", () => { + it("accepts Album/Single/EP with no secondary types", () => { + expect(isCoreRelease({ primaryType: "Album", secondaryTypes: [] })).toBe(true); + expect(isCoreRelease({ primaryType: "Single", secondaryTypes: [] })).toBe(true); + expect(isCoreRelease({ primaryType: "EP", secondaryTypes: [] })).toBe(true); + }); + it("rejects secondary types and non-core primaries and null", () => { + expect(isCoreRelease({ primaryType: "Album", secondaryTypes: ["Live"] })).toBe(false); + expect(isCoreRelease({ primaryType: "Album", secondaryTypes: ["Compilation"] })).toBe(false); + expect(isCoreRelease({ primaryType: "Broadcast", secondaryTypes: [] })).toBe(false); + expect(isCoreRelease({ primaryType: null, secondaryTypes: [] })).toBe(false); + }); +}); diff --git a/web/src/lib/release-filter.ts b/web/src/lib/release-filter.ts new file mode 100644 index 0000000..d02731d --- /dev/null +++ b/web/src/lib/release-filter.ts @@ -0,0 +1,6 @@ +const CORE_PRIMARY = new Set(["Album", "Single", "EP"]); + +/** A "core" studio release: Album/Single/EP with no secondary types (live, compilation, etc.). */ +export function isCoreRelease(r: { primaryType: string | null; secondaryTypes: string[] }): boolean { + return CORE_PRIMARY.has(r.primaryType ?? "") && r.secondaryTypes.length === 0; +}