feat: filter artist discography + counts to core releases unless showAllTypes

This commit is contained in:
Jonathan
2026-07-11 18:05:46 +02:00
parent f2dbef047c
commit 560fdaa4b0
6 changed files with 110 additions and 18 deletions
+25 -2
View File
@@ -12,6 +12,7 @@ async function seedArtist() {
releases: { releases: {
create: [ 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: "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", () => { 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 a = await seedArtist();
const res = await GET(new Request("http://localhost/x"), ctx(a.id)); const res = await GET(new Request("http://localhost/x"), ctx(a.id));
expect(res.status).toBe(200); expect(res.status).toBe(200);
const body = await res.json(); const body = await res.json();
expect(body.name).toBe("John Mayer"); 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 }); 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 () => { it("404s an unknown artist on GET", async () => {
expect((await GET(new Request("http://localhost/x"), ctx("nope"))).status).toBe(404); 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); 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(); const a = await seedArtist();
expect((await PATCH(patchReq({ autoMonitorFuture: "yes" }), ctx(a.id))).status).toBe(400); 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); expect((await PATCH(patchReq({ autoMonitorFuture: true }), ctx("nope"))).status).toBe(404);
}); });
+22 -6
View File
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { isCoreRelease } from "@/lib/release-filter";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params; 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" }] } }, include: { releases: { orderBy: [{ firstReleaseDate: "desc" }, { album: "asc" }] } },
}); });
if (!artist) return Response.json({ error: "not found" }, { status: 404 }); if (!artist) return Response.json({ error: "not found" }, { status: 404 });
const visibleReleases = artist.showAllTypes ? artist.releases : artist.releases.filter(isCoreRelease);
return Response.json({ return Response.json({
id: artist.id, id: artist.id,
mbid: artist.mbid, mbid: artist.mbid,
name: artist.name, name: artist.name,
autoMonitorFuture: artist.autoMonitorFuture, autoMonitorFuture: artist.autoMonitorFuture,
releases: artist.releases.map((r) => ({ showAllTypes: artist.showAllTypes,
releases: visibleReleases.map((r) => ({
id: r.id, id: r.id,
rgMbid: r.rgMbid, rgMbid: r.rgMbid,
album: r.album, album: r.album,
@@ -35,13 +38,26 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
} catch { } catch {
return Response.json({ error: "invalid JSON" }, { status: 400 }); return Response.json({ error: "invalid JSON" }, { status: 400 });
} }
const { autoMonitorFuture } = (body ?? {}) as { autoMonitorFuture?: unknown }; const { autoMonitorFuture, showAllTypes } = (body ?? {}) as {
if (typeof autoMonitorFuture !== "boolean") { autoMonitorFuture?: unknown;
return Response.json({ error: "autoMonitorFuture (boolean) is required" }, { status: 400 }); 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 { try {
const updated = await prisma.watchedArtist.update({ where: { id }, data: { autoMonitorFuture } }); const updated = await prisma.watchedArtist.update({ where: { id }, data });
return Response.json({ id: updated.id, autoMonitorFuture: updated.autoMonitorFuture }); return Response.json({
id: updated.id,
autoMonitorFuture: updated.autoMonitorFuture,
showAllTypes: updated.showAllTypes,
});
} catch (e) { } catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
return Response.json({ error: "not found" }, { status: 404 }); return Response.json({ error: "not found" }, { status: 404 });
+22
View File
@@ -57,4 +57,26 @@ describe("artists collection API", () => {
const a = (await res.json()).artists[0]; const a = (await res.json()).artists[0];
expect(a).toMatchObject({ mbid: "a1", name: "John Mayer", releaseCount: 1, monitoredCount: 1, haveCount: 1 }); 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 });
});
}); });
+19 -10
View File
@@ -1,21 +1,30 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { browseReleaseGroups } from "@/lib/musicbrainz"; import { browseReleaseGroups } from "@/lib/musicbrainz";
import { isCoreRelease } from "@/lib/release-filter";
export async function GET() { export async function GET() {
const artists = await prisma.watchedArtist.findMany({ const artists = await prisma.watchedArtist.findMany({
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { releases: { select: { monitored: true, currentQualityClass: true } } }, include: {
releases: {
select: { monitored: true, currentQualityClass: true, primaryType: true, secondaryTypes: true },
},
},
}); });
return Response.json({ return Response.json({
artists: artists.map((a) => ({ artists: artists.map((a) => {
id: a.id, const visible = a.showAllTypes ? a.releases : a.releases.filter(isCoreRelease);
mbid: a.mbid, return {
name: a.name, id: a.id,
autoMonitorFuture: a.autoMonitorFuture, mbid: a.mbid,
releaseCount: a.releases.length, name: a.name,
monitoredCount: a.releases.filter((r) => r.monitored).length, autoMonitorFuture: a.autoMonitorFuture,
haveCount: a.releases.filter((r) => r.currentQualityClass !== null).length, showAllTypes: a.showAllTypes,
})), releaseCount: visible.length,
monitoredCount: visible.filter((r) => r.monitored).length,
haveCount: visible.filter((r) => r.currentQualityClass !== null).length,
};
}),
}); });
} }
+16
View File
@@ -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);
});
});
+6
View File
@@ -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;
}