diff --git a/docs/superpowers/plans/2026-07-11-lyra-monitoring-web.md b/docs/superpowers/plans/2026-07-11-lyra-monitoring-web.md new file mode 100644 index 0000000..89b61ed --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-lyra-monitoring-web.md @@ -0,0 +1,1417 @@ +# Lyra Monitoring Web (UI + API) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the user the web surface for the library manager: a live MusicBrainz search/follow picker, watched-artist management with per-release monitor toggles and an auto-monitor-future toggle, a cross-artist wanted list, and "search now" buttons — all writing the `WatchedArtist` / `MonitoredRelease` / `Request` rows the worker's monitor (plan 1, already merged) consumes. + +**Architecture:** A thin, read-only MusicBrainz module in the Next.js app (`lib/musicbrainz.ts`) powers a live artist search + discography browse for the picker. App-Router API routes do CRUD over the monitoring tables; "follow" persists the fetched discography as unmonitored `MonitoredRelease` rows so the detail view is instant. Three client-component pages (`/artists`, `/artists/[id]`, `/wanted`) render it. All source downloads and credentials remain worker-only; the web's only external reach is public, credential-free MusicBrainz metadata. + +**Tech Stack:** Next.js 15.5 (App Router, React 19), Prisma 6.1, TypeScript 5.7. Tests: Vitest (node env) — API routes and the MB module are unit-tested against a seeded Postgres with `global.fetch`/module mocks, following the existing `route.test.ts` pattern. UI pages are typecheck-verified (`tsc --noEmit`), matching the repo (no React Testing Library is installed and existing components like `queue.tsx` are untested). + +## Global Constraints + +- Next.js 15 App Router: dynamic route handlers receive `{ params }: { params: Promise<{ ... }> }` — you MUST `await params`. Route tests call the handler directly, passing `{ params: Promise.resolve({ id }) }`. +- API handlers are `export async function GET/POST/PATCH/DELETE(request: Request, ctx?)` returning `Response.json(...)` / `new Response(null, { status })`. Prisma is imported from `@/lib/db` (never instantiate `PrismaClient` in a route). The `@/` alias maps to `web/src`. +- The web performs ONLY read-only, credential-free MusicBrainz metadata calls (artist search, release-group browse/search). No download source, no credential, and no worker/pipeline logic moves into the web. MusicBrainz calls MUST send a descriptive `User-Agent` header (MB requires it). +- `MonitoredRelease.rgMbid` is globally unique. When persisting a followed artist's discography, use `createMany({ ..., skipDuplicates: true })` so a release-group shared with an already-followed artist (a collaboration) doesn't fail the whole insert — mirroring the worker's `ON CONFLICT DO NOTHING`. +- Data-model contract (from plan 1, in `web/prisma/schema.prisma`): `WatchedArtist(id, mbid unique, name, autoMonitorFuture, monitorFrom, lastPolledAt, releases[])`; `MonitoredRelease(id, watchedArtistId?, artistMbid, artistName, rgMbid unique, album, primaryType?, secondaryTypes[], firstReleaseDate?, monitored, state[wanted|grabbed|fulfilled], currentQualityClass?, firstGrabbedAt?, lastSearchedAt?)`; `Request` has `monitoredReleaseId?`. A monitor-enqueued grab is a `Request` (with `monitoredReleaseId`) + a `Job` (`{ create: {} }` → defaults state `requested`, stage `intake`) — exactly how `POST /api/requests` already creates work. +- Run web commands from `/home/jonathan/Projects/lyra/web` with `DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra`. Tests: `DATABASE_URL=... npm test`. Typecheck: `npx tsc --noEmit`. +- No new npm dependency. Follow the minimal, unstyled UI style of `queue.tsx`/`settings-form.tsx` (plain elements, `aria-label`ed inputs). Every task ends with a commit. + +## Shared interfaces (defined by tasks below) + +```ts +// web/src/lib/musicbrainz.ts (Task 1) +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 }; +export function searchArtists(query: string): Promise; +export function browseReleaseGroups(artistMbid: string): Promise; +export function searchReleaseGroup(artist: string, album: string): Promise; + +// API routes (Tasks 2-6): +// GET /api/mb/artists?q= -> { artists: ArtistHit[] } +// GET /api/mb/artists/[mbid]/releases -> { releases: ReleaseGroupInfo[] } +// GET /api/artists -> { artists: [{id,mbid,name,autoMonitorFuture,monitoredCount,haveCount,releaseCount}] } +// POST /api/artists {mbid,name} -> 201 {id,mbid,name,releaseCount} +// GET /api/artists/[id] -> { id,mbid,name,autoMonitorFuture, releases:[...] } +// PATCH /api/artists/[id] {autoMonitorFuture} -> { id, autoMonitorFuture } +// DELETE /api/artists/[id] -> 204 +// PATCH /api/releases/[id] {monitored} -> { id, monitored } +// POST /api/releases/[id]/search -> 202 { enqueued: true } +// GET /api/wanted -> { wanted: [...] } +// POST /api/wanted {artist,album} -> 201|200 { id, ... } | 404 +``` + +--- + +### Task 1: Test-setup cleanup + read-only MusicBrainz module + +**Files:** +- Modify: `web/src/test/setup.ts` (clean the new tables) +- Create: `web/src/lib/musicbrainz.ts` +- Test: `web/src/lib/musicbrainz.test.ts` + +**Interfaces:** +- Produces: `searchArtists`, `browseReleaseGroups`, `searchReleaseGroup` (+ the `ArtistHit`/`ReleaseGroupInfo`/`ReleaseGroupMatch` types). All use global `fetch` with a `User-Agent`; `browseReleaseGroups` paginates at 100/page; non-2xx throws. + +- [ ] **Step 1: Extend the test cleanup** + +In `web/src/test/setup.ts`, replace the body so all monitoring tables are cleared in FK-safe order: +```ts +import { beforeEach } from "vitest"; +import { prisma } from "@/lib/db"; + +beforeEach(async () => { + // 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(); +}); +``` + +- [ ] **Step 2: Write the failing test** + +`web/src/lib/musicbrainz.test.ts`: +```ts +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(); + }); +}); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- musicbrainz +``` +Expected: FAIL — cannot resolve `./musicbrainz`. + +- [ ] **Step 4: Implement the module** + +`web/src/lib/musicbrainz.ts`: +```ts +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 { + 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 { + 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 { + 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 { + 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 }; +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- musicbrainz +``` +Expected: PASS — all four tests green. + +- [ ] **Step 6: Commit** + +```bash +cd /home/jonathan/Projects/lyra +git add web/src/lib/musicbrainz.ts web/src/lib/musicbrainz.test.ts web/src/test/setup.ts +git commit -m "feat: read-only MusicBrainz module + monitoring test cleanup" +``` + +--- + +### Task 2: MusicBrainz proxy API routes + +**Files:** +- Create: `web/src/app/api/mb/artists/route.ts` +- Create: `web/src/app/api/mb/artists/[mbid]/releases/route.ts` +- Test: `web/src/app/api/mb/artists/route.test.ts` +- Test: `web/src/app/api/mb/artists/[mbid]/releases/route.test.ts` + +**Interfaces:** +- Consumes: `searchArtists`, `browseReleaseGroups` (Task 1). +- Produces: `GET /api/mb/artists?q=` → `{ artists }` (400 if `q` blank, 502 on MB failure); `GET /api/mb/artists/[mbid]/releases` → `{ releases }` (502 on MB failure). + +- [ ] **Step 1: Write the failing tests** + +`web/src/app/api/mb/artists/route.test.ts`: +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/musicbrainz", () => ({ searchArtists: vi.fn() })); +import { searchArtists } from "@/lib/musicbrainz"; +import { GET } from "./route"; + +const mockSearch = vi.mocked(searchArtists); +beforeEach(() => mockSearch.mockReset()); + +function req(q: string | null) { + return new Request(`http://localhost/api/mb/artists${q === null ? "" : `?q=${encodeURIComponent(q)}`}`); +} + +describe("GET /api/mb/artists", () => { + it("returns hits for a query", async () => { + mockSearch.mockResolvedValue([{ mbid: "a1", name: "John Mayer", disambiguation: "" }]); + const res = await GET(req("john")); + expect(res.status).toBe(200); + expect((await res.json()).artists[0].name).toBe("John Mayer"); + }); + + it("400s a blank query", async () => { + expect((await GET(req(" "))).status).toBe(400); + expect((await GET(req(null))).status).toBe(400); + }); + + it("502s when MusicBrainz throws", async () => { + mockSearch.mockRejectedValue(new Error("MusicBrainz request failed: 503")); + expect((await GET(req("john"))).status).toBe(502); + }); +}); +``` + +`web/src/app/api/mb/artists/[mbid]/releases/route.test.ts`: +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/musicbrainz", () => ({ browseReleaseGroups: vi.fn() })); +import { browseReleaseGroups } from "@/lib/musicbrainz"; +import { GET } from "./route"; + +const mockBrowse = vi.mocked(browseReleaseGroups); +beforeEach(() => mockBrowse.mockReset()); + +const ctx = (mbid: string) => ({ params: Promise.resolve({ mbid }) }); + +describe("GET /api/mb/artists/[mbid]/releases", () => { + it("returns the discography", async () => { + mockBrowse.mockResolvedValue([ + { rgMbid: "rg1", title: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006" }, + ]); + const res = await GET(new Request("http://localhost/x"), ctx("a1")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.releases[0].rgMbid).toBe("rg1"); + expect(mockBrowse).toHaveBeenCalledWith("a1"); + }); + + it("502s when MusicBrainz throws", async () => { + mockBrowse.mockRejectedValue(new Error("boom")); + expect((await GET(new Request("http://localhost/x"), ctx("a1"))).status).toBe(502); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- api/mb +``` +Expected: FAIL — cannot resolve `./route`. + +- [ ] **Step 3: Implement the routes** + +`web/src/app/api/mb/artists/route.ts`: +```ts +import { searchArtists } from "@/lib/musicbrainz"; + +export async function GET(request: Request) { + const q = new URL(request.url).searchParams.get("q"); + if (!q || !q.trim()) { + return Response.json({ error: "q is required" }, { status: 400 }); + } + try { + return Response.json({ artists: await searchArtists(q.trim()) }); + } catch { + return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 }); + } +} +``` + +`web/src/app/api/mb/artists/[mbid]/releases/route.ts`: +```ts +import { browseReleaseGroups } from "@/lib/musicbrainz"; + +export async function GET(_request: Request, { params }: { params: Promise<{ mbid: string }> }) { + const { mbid } = await params; + try { + return Response.json({ releases: await browseReleaseGroups(mbid) }); + } catch { + return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 }); + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- api/mb +``` +Expected: PASS — all five tests green. + +- [ ] **Step 5: Commit** + +```bash +cd /home/jonathan/Projects/lyra +git add web/src/app/api/mb +git commit -m "feat: MusicBrainz proxy API routes (artist search + discography browse)" +``` + +--- + +### Task 3: Artists collection API — follow + list + +**Files:** +- Create: `web/src/app/api/artists/route.ts` +- Test: `web/src/app/api/artists/route.test.ts` + +**Interfaces:** +- Consumes: `prisma`, `browseReleaseGroups` (Task 1). +- Produces: `GET /api/artists` → `{ artists: [{id,mbid,name,autoMonitorFuture,monitoredCount,haveCount,releaseCount}] }`; `POST /api/artists {mbid,name}` → 201 (creates `WatchedArtist` + persists discography as unmonitored `MonitoredRelease` rows via `createMany({skipDuplicates:true})`), 400 on bad body, 409 if already followed, 502 on MB failure. + +- [ ] **Step 1: Write the failing test** + +`web/src/app/api/artists/route.test.ts`: +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/musicbrainz", () => ({ browseReleaseGroups: vi.fn() })); +import { browseReleaseGroups } from "@/lib/musicbrainz"; +import { prisma } from "@/lib/db"; +import { GET, POST } from "./route"; + +const mockBrowse = vi.mocked(browseReleaseGroups); +beforeEach(() => mockBrowse.mockReset()); + +function postReq(body: unknown) { + return new Request("http://localhost/api/artists", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("artists collection API", () => { + it("follows an artist and persists the discography unmonitored", async () => { + mockBrowse.mockResolvedValue([ + { rgMbid: "rg1", title: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006-09-12" }, + { rgMbid: "rg2", title: "Battle Studies", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2009" }, + ]); + const res = await POST(postReq({ mbid: "a1", name: "John Mayer" })); + expect(res.status).toBe(201); + expect((await res.json()).releaseCount).toBe(2); + + const rels = await prisma.monitoredRelease.findMany({ where: { artistMbid: "a1" } }); + expect(rels).toHaveLength(2); + expect(rels.every((r) => r.monitored === false && r.state === "wanted")).toBe(true); + }); + + it("rejects a bad body and a duplicate follow", async () => { + expect((await POST(postReq({ mbid: "a1" }))).status).toBe(400); + mockBrowse.mockResolvedValue([]); + expect((await POST(postReq({ mbid: "a1", name: "John Mayer" }))).status).toBe(201); + expect((await POST(postReq({ mbid: "a1", name: "John Mayer" }))).status).toBe(409); + }); + + it("502s when the discography fetch fails and creates nothing", async () => { + mockBrowse.mockRejectedValue(new Error("boom")); + expect((await POST(postReq({ mbid: "a9", name: "X" }))).status).toBe(502); + expect(await prisma.watchedArtist.findUnique({ where: { mbid: "a9" } })).toBeNull(); + }); + + it("lists followed artists with counts", async () => { + mockBrowse.mockResolvedValue([ + { rgMbid: "rg1", title: "A", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2001" }, + ]); + await POST(postReq({ mbid: "a1", name: "John Mayer" })); + // mark the one release as monitored + have, to exercise the counts + await prisma.monitoredRelease.updateMany({ where: { artistMbid: "a1" }, data: { monitored: true, currentQualityClass: 2 } }); + + const res = await GET(); + expect(res.status).toBe(200); + const a = (await res.json()).artists[0]; + expect(a).toMatchObject({ mbid: "a1", name: "John Mayer", releaseCount: 1, monitoredCount: 1, haveCount: 1 }); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- api/artists/route +``` +Expected: FAIL — cannot resolve `./route`. + +- [ ] **Step 3: Implement the route** + +`web/src/app/api/artists/route.ts`: +```ts +import { prisma } from "@/lib/db"; +import { browseReleaseGroups } from "@/lib/musicbrainz"; + +export async function GET() { + const artists = await prisma.watchedArtist.findMany({ + orderBy: { createdAt: "desc" }, + include: { releases: { select: { monitored: true, currentQualityClass: 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, + })), + }); +} + +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "invalid JSON" }, { status: 400 }); + } + const { mbid, name } = (body ?? {}) as { mbid?: unknown; name?: unknown }; + if (typeof mbid !== "string" || !mbid.trim() || typeof name !== "string" || !name.trim()) { + return Response.json({ error: "mbid and name are required" }, { status: 400 }); + } + if (await prisma.watchedArtist.findUnique({ where: { mbid } })) { + return Response.json({ error: "already following this artist" }, { status: 409 }); + } + + let releases; + try { + releases = await browseReleaseGroups(mbid); + } catch { + return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 }); + } + + const artist = await prisma.watchedArtist.create({ data: { mbid, name: name.trim() } }); + if (releases.length > 0) { + await prisma.monitoredRelease.createMany({ + data: releases.map((r) => ({ + watchedArtistId: artist.id, + artistMbid: mbid, + artistName: name.trim(), + rgMbid: r.rgMbid, + album: r.title, + primaryType: r.primaryType, + secondaryTypes: r.secondaryTypes, + firstReleaseDate: r.firstReleaseDate, + monitored: false, + })), + skipDuplicates: true, + }); + } + return Response.json( + { id: artist.id, mbid: artist.mbid, name: artist.name, releaseCount: releases.length }, + { status: 201 }, + ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- api/artists/route +``` +Expected: PASS — all four tests green. + +- [ ] **Step 5: Commit** + +```bash +cd /home/jonathan/Projects/lyra +git add web/src/app/api/artists/route.ts web/src/app/api/artists/route.test.ts +git commit -m "feat: artists API — follow (persist discography) + list" +``` + +--- + +### Task 4: Artist item API — detail, auto-monitor toggle, unfollow + +**Files:** +- Create: `web/src/app/api/artists/[id]/route.ts` +- Test: `web/src/app/api/artists/[id]/route.test.ts` + +**Interfaces:** +- Consumes: `prisma`. +- Produces: `GET /api/artists/[id]` → `{id,mbid,name,autoMonitorFuture,releases:[{id,rgMbid,album,primaryType,secondaryTypes,firstReleaseDate,monitored,state,currentQualityClass}]}` (404 if missing); `PATCH /api/artists/[id] {autoMonitorFuture:boolean}` → `{id,autoMonitorFuture}` (400 bad body, 404 missing); `DELETE /api/artists/[id]` → 204 (cascades releases), 404 if missing. + +- [ ] **Step 1: Write the failing test** + +`web/src/app/api/artists/[id]/route.test.ts`: +```ts +import { describe, it, expect } from "vitest"; +import { prisma } from "@/lib/db"; +import { GET, PATCH, DELETE } from "./route"; + +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); + +async function seedArtist() { + return prisma.watchedArtist.create({ + data: { + mbid: "a1", + name: "John Mayer", + releases: { + create: [ + { artistMbid: "a1", artistName: "John Mayer", rgMbid: "rg1", album: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006-09-12", monitored: true, state: "grabbed", currentQualityClass: 1 }, + ], + }, + }, + }); +} + +function patchReq(body: unknown) { + return new Request("http://localhost/x", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); +} + +describe("artist item API", () => { + it("returns detail with releases", 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.releases[0]).toMatchObject({ rgMbid: "rg1", album: "Continuum", monitored: true, state: "grabbed", currentQualityClass: 1 }); + }); + + it("404s an unknown artist on GET", async () => { + expect((await GET(new Request("http://localhost/x"), ctx("nope"))).status).toBe(404); + }); + + it("toggles autoMonitorFuture", async () => { + const a = await seedArtist(); + const res = await PATCH(patchReq({ autoMonitorFuture: true }), ctx(a.id)); + expect(res.status).toBe(200); + expect((await res.json()).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 () => { + const a = await seedArtist(); + expect((await PATCH(patchReq({ autoMonitorFuture: "yes" }), ctx(a.id))).status).toBe(400); + expect((await PATCH(patchReq({ autoMonitorFuture: true }), ctx("nope"))).status).toBe(404); + }); + + it("unfollows and cascades releases", async () => { + const a = await seedArtist(); + const res = await DELETE(new Request("http://localhost/x"), ctx(a.id)); + expect(res.status).toBe(204); + expect(await prisma.watchedArtist.findUnique({ where: { id: a.id } })).toBeNull(); + expect(await prisma.monitoredRelease.count({ where: { rgMbid: "rg1" } })).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- -t "artist item API" +``` +Expected: FAIL — cannot resolve `./route` (the `[id]` path has regex-special brackets, so filter by test name, not path). + +- [ ] **Step 3: Implement the route** + +`web/src/app/api/artists/[id]/route.ts`: +```ts +import { prisma } from "@/lib/db"; + +export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const artist = await prisma.watchedArtist.findUnique({ + where: { id }, + include: { releases: { orderBy: [{ firstReleaseDate: "desc" }, { album: "asc" }] } }, + }); + if (!artist) return Response.json({ error: "not found" }, { status: 404 }); + return Response.json({ + id: artist.id, + mbid: artist.mbid, + name: artist.name, + autoMonitorFuture: artist.autoMonitorFuture, + releases: artist.releases.map((r) => ({ + id: r.id, + rgMbid: r.rgMbid, + album: r.album, + primaryType: r.primaryType, + secondaryTypes: r.secondaryTypes, + firstReleaseDate: r.firstReleaseDate, + monitored: r.monitored, + state: r.state, + currentQualityClass: r.currentQualityClass, + })), + }); +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + let body: unknown; + try { + body = await request.json(); + } 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 }); + } + try { + const updated = await prisma.watchedArtist.update({ where: { id }, data: { autoMonitorFuture } }); + return Response.json({ id: updated.id, autoMonitorFuture: updated.autoMonitorFuture }); + } catch { + return Response.json({ error: "not found" }, { status: 404 }); + } +} + +export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + try { + await prisma.watchedArtist.delete({ where: { id } }); + return new Response(null, { status: 204 }); + } catch { + return Response.json({ error: "not found" }, { status: 404 }); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- -t "artist item API" +``` +Expected: PASS — all five tests green. + +- [ ] **Step 5: Commit** + +```bash +cd /home/jonathan/Projects/lyra +git add "web/src/app/api/artists/[id]" +git commit -m "feat: artist item API — detail, auto-monitor toggle, unfollow" +``` + +--- + +### Task 5: Releases API — monitor toggle + search-now + +**Files:** +- Create: `web/src/app/api/releases/[id]/route.ts` +- Create: `web/src/app/api/releases/[id]/search/route.ts` +- Test: `web/src/app/api/releases/[id]/route.test.ts` +- Test: `web/src/app/api/releases/[id]/search/route.test.ts` + +**Interfaces:** +- Consumes: `prisma`. +- Produces: `PATCH /api/releases/[id] {monitored:boolean}` → `{id,monitored}` (400 bad body, 404 missing); `POST /api/releases/[id]/search` → 202 `{enqueued:true}` (creates a `Request` with `monitoredReleaseId` + a `Job`, stamps `lastSearchedAt`), 404 if the release is missing. + +- [ ] **Step 1: Write the failing tests** + +`web/src/app/api/releases/[id]/route.test.ts`: +```ts +import { describe, it, expect } from "vitest"; +import { prisma } from "@/lib/db"; +import { PATCH } from "./route"; + +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); + +async function seedRelease(monitored = false) { + return prisma.monitoredRelease.create({ + data: { artistMbid: "a1", artistName: "John Mayer", rgMbid: "rg1", album: "Continuum", secondaryTypes: [], monitored }, + }); +} +function patchReq(body: unknown) { + return new Request("http://localhost/x", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); +} + +describe("PATCH /api/releases/[id]", () => { + it("toggles monitored", async () => { + const r = await seedRelease(false); + const res = await PATCH(patchReq({ monitored: true }), ctx(r.id)); + expect(res.status).toBe(200); + expect((await res.json()).monitored).toBe(true); + expect((await prisma.monitoredRelease.findUnique({ where: { id: r.id } }))!.monitored).toBe(true); + }); + + it("400s a non-boolean and 404s an unknown release", async () => { + const r = await seedRelease(); + expect((await PATCH(patchReq({ monitored: 1 }), ctx(r.id))).status).toBe(400); + expect((await PATCH(patchReq({ monitored: true }), ctx("nope"))).status).toBe(404); + }); +}); +``` + +`web/src/app/api/releases/[id]/search/route.test.ts`: +```ts +import { describe, it, expect } from "vitest"; +import { prisma } from "@/lib/db"; +import { POST } from "./route"; + +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); + +describe("POST /api/releases/[id]/search", () => { + it("enqueues a Request+Job linked to the release and stamps lastSearchedAt", async () => { + const r = await prisma.monitoredRelease.create({ + data: { artistMbid: "a1", artistName: "John Mayer", rgMbid: "rg1", album: "Continuum", secondaryTypes: [], monitored: true }, + }); + const res = await POST(new Request("http://localhost/x", { method: "POST" }), ctx(r.id)); + expect(res.status).toBe(202); + expect((await res.json()).enqueued).toBe(true); + + const reqs = await prisma.request.findMany({ where: { monitoredReleaseId: r.id }, include: { job: true } }); + expect(reqs).toHaveLength(1); + expect(reqs[0]).toMatchObject({ artist: "John Mayer", album: "Continuum" }); + expect(reqs[0].job!.state).toBe("requested"); + expect((await prisma.monitoredRelease.findUnique({ where: { id: r.id } }))!.lastSearchedAt).not.toBeNull(); + }); + + it("404s an unknown release", async () => { + expect((await POST(new Request("http://localhost/x", { method: "POST" }), ctx("nope"))).status).toBe(404); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- "api/releases" +``` +Expected: FAIL — cannot resolve `./route`. + +- [ ] **Step 3: Implement the routes** + +`web/src/app/api/releases/[id]/route.ts`: +```ts +import { prisma } from "@/lib/db"; + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "invalid JSON" }, { status: 400 }); + } + const { monitored } = (body ?? {}) as { monitored?: unknown }; + if (typeof monitored !== "boolean") { + return Response.json({ error: "monitored (boolean) is required" }, { status: 400 }); + } + try { + const updated = await prisma.monitoredRelease.update({ where: { id }, data: { monitored } }); + return Response.json({ id: updated.id, monitored: updated.monitored }); + } catch { + return Response.json({ error: "not found" }, { status: 404 }); + } +} +``` + +`web/src/app/api/releases/[id]/search/route.ts`: +```ts +import { prisma } from "@/lib/db"; + +export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const release = await prisma.monitoredRelease.findUnique({ where: { id } }); + if (!release) return Response.json({ error: "not found" }, { status: 404 }); + + await prisma.request.create({ + data: { + artist: release.artistName, + album: release.album, + monitoredReleaseId: release.id, + job: { create: {} }, + }, + }); + await prisma.monitoredRelease.update({ where: { id }, data: { lastSearchedAt: new Date() } }); + return Response.json({ enqueued: true }, { status: 202 }); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- "api/releases" +``` +Expected: PASS — all four tests green. + +- [ ] **Step 5: Commit** + +```bash +cd /home/jonathan/Projects/lyra +git add "web/src/app/api/releases" +git commit -m "feat: releases API — monitor toggle + search-now enqueue" +``` + +--- + +### Task 6: Wanted list API — list + standalone add + +**Files:** +- Create: `web/src/app/api/wanted/route.ts` +- Test: `web/src/app/api/wanted/route.test.ts` + +**Interfaces:** +- Consumes: `prisma`, `searchReleaseGroup` (Task 1). +- Produces: `GET /api/wanted` → `{ wanted: [{id,artistName,album,state,currentQualityClass,lastSearchedAt,watchedArtistId}] }` (all `monitored=true` and `state != 'fulfilled'`, oldest-searched first); `POST /api/wanted {artist,album}` → 201 (new standalone monitored release) or 200 (re-monitor an existing release-group), 400 bad body, 404 if MB finds no match, 502 on MB failure. + +- [ ] **Step 1: Write the failing test** + +`web/src/app/api/wanted/route.test.ts`: +```ts +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn() })); +import { searchReleaseGroup } from "@/lib/musicbrainz"; +import { prisma } from "@/lib/db"; +import { GET, POST } from "./route"; + +const mockSearch = vi.mocked(searchReleaseGroup); +beforeEach(() => mockSearch.mockReset()); + +function postReq(body: unknown) { + return new Request("http://localhost/api/wanted", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); +} + +describe("wanted API", () => { + it("lists monitored, non-fulfilled releases only", async () => { + await prisma.monitoredRelease.createMany({ + data: [ + { artistMbid: "a1", artistName: "A", rgMbid: "rg1", album: "Wanted", secondaryTypes: [], monitored: true, state: "wanted" }, + { artistMbid: "a1", artistName: "A", rgMbid: "rg2", album: "Done", secondaryTypes: [], monitored: true, state: "fulfilled" }, + { artistMbid: "a1", artistName: "A", rgMbid: "rg3", album: "Dormant", secondaryTypes: [], monitored: false, state: "wanted" }, + ], + }); + const res = await GET(); + expect(res.status).toBe(200); + const wanted = (await res.json()).wanted; + expect(wanted.map((w: any) => w.album)).toEqual(["Wanted"]); + }); + + it("adds a standalone wanted release from artist+album", async () => { + mockSearch.mockResolvedValue({ rgMbid: "rg9", title: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006-09-12", artistMbid: "a1", artistName: "John Mayer" }); + const res = await POST(postReq({ artist: "John Mayer", album: "Continuum" })); + expect(res.status).toBe(201); + const row = await prisma.monitoredRelease.findUnique({ where: { rgMbid: "rg9" } }); + expect(row).toMatchObject({ artistName: "John Mayer", album: "Continuum", monitored: true, watchedArtistId: null }); + }); + + it("re-monitors an existing release-group (200) instead of duplicating", async () => { + await prisma.monitoredRelease.create({ data: { artistMbid: "a1", artistName: "John Mayer", rgMbid: "rg9", album: "Continuum", secondaryTypes: [], monitored: false } }); + mockSearch.mockResolvedValue({ rgMbid: "rg9", title: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006", artistMbid: "a1", artistName: "John Mayer" }); + const res = await POST(postReq({ artist: "John Mayer", album: "Continuum" })); + expect(res.status).toBe(200); + expect((await prisma.monitoredRelease.findUnique({ where: { rgMbid: "rg9" } }))!.monitored).toBe(true); + expect(await prisma.monitoredRelease.count({ where: { rgMbid: "rg9" } })).toBe(1); + }); + + it("400s a bad body, 404s no MB match, 502s on MB failure", async () => { + expect((await POST(postReq({ artist: "X" }))).status).toBe(400); + mockSearch.mockResolvedValue(null); + expect((await POST(postReq({ artist: "X", album: "Y" }))).status).toBe(404); + mockSearch.mockRejectedValue(new Error("boom")); + expect((await POST(postReq({ artist: "X", album: "Y" }))).status).toBe(502); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- api/wanted +``` +Expected: FAIL — cannot resolve `./route`. + +- [ ] **Step 3: Implement the route** + +`web/src/app/api/wanted/route.ts`: +```ts +import { prisma } from "@/lib/db"; +import { searchReleaseGroup } from "@/lib/musicbrainz"; + +export async function GET() { + const items = await prisma.monitoredRelease.findMany({ + where: { monitored: true, state: { not: "fulfilled" } }, + orderBy: [{ lastSearchedAt: { sort: "asc", nulls: "first" } }, { createdAt: "asc" }], + }); + return Response.json({ + wanted: items.map((r) => ({ + id: r.id, + artistName: r.artistName, + album: r.album, + state: r.state, + currentQualityClass: r.currentQualityClass, + lastSearchedAt: r.lastSearchedAt, + watchedArtistId: r.watchedArtistId, + })), + }); +} + +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "invalid JSON" }, { status: 400 }); + } + const { artist, album } = (body ?? {}) as { artist?: unknown; album?: unknown }; + if (typeof artist !== "string" || !artist.trim() || typeof album !== "string" || !album.trim()) { + return Response.json({ error: "artist and album are required" }, { status: 400 }); + } + + let match; + try { + match = await searchReleaseGroup(artist.trim(), album.trim()); + } catch { + return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 }); + } + if (!match) { + return Response.json({ error: "no matching release-group found" }, { status: 404 }); + } + + const existing = await prisma.monitoredRelease.findUnique({ where: { rgMbid: match.rgMbid } }); + if (existing) { + const updated = await prisma.monitoredRelease.update({ where: { rgMbid: match.rgMbid }, data: { monitored: true } }); + return Response.json({ id: updated.id, album: updated.album, monitored: updated.monitored }, { status: 200 }); + } + const created = await prisma.monitoredRelease.create({ + data: { + artistMbid: match.artistMbid, + artistName: match.artistName, + rgMbid: match.rgMbid, + album: match.title, + primaryType: match.primaryType, + secondaryTypes: match.secondaryTypes, + firstReleaseDate: match.firstReleaseDate, + monitored: true, + }, + }); + return Response.json({ id: created.id, album: created.album, monitored: created.monitored }, { status: 201 }); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- api/wanted +``` +Expected: PASS — all four tests green. + +- [ ] **Step 5: Run the FULL web suite (no regressions)** + +```bash +cd /home/jonathan/Projects/lyra/web +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test +``` +Expected: PASS — every route/lib test green (existing requests/config/crypto suites unaffected). + +- [ ] **Step 6: Commit** + +```bash +cd /home/jonathan/Projects/lyra +git add web/src/app/api/wanted +git commit -m "feat: wanted list API — list + standalone add" +``` + +--- + +### Task 7: Artists page — list + live-search follow picker + +**Files:** +- Create: `web/src/app/artists/page.tsx` +- Create: `web/src/app/artists/artists-client.tsx` +- Modify: `web/src/app/page.tsx` (nav links) + +**Interfaces:** +- Consumes: `/api/artists` (GET/POST/DELETE-via-item), `/api/mb/artists?q=`, `/api/artists/[id]` (PATCH toggle). UI only — verified by typecheck (repo has no component test harness). + +- [ ] **Step 1: Add the server page** + +`web/src/app/artists/page.tsx`: +```tsx +import { ArtistsClient } from "./artists-client"; + +export default function ArtistsPage() { + return ( +
+

Watched artists

+

Home · Wanted

+ +
+ ); +} +``` + +- [ ] **Step 2: Add the client component** + +`web/src/app/artists/artists-client.tsx`: +```tsx +"use client"; + +import { useEffect, useState } from "react"; + +type Artist = { + id: string; + mbid: string; + name: string; + autoMonitorFuture: boolean; + releaseCount: number; + monitoredCount: number; + haveCount: number; +}; +type Hit = { mbid: string; name: string; disambiguation: string }; + +export function ArtistsClient() { + const [artists, setArtists] = useState([]); + const [query, setQuery] = useState(""); + const [hits, setHits] = useState([]); + const [searching, setSearching] = useState(false); + + async function refresh() { + const res = await fetch("/api/artists"); + setArtists((await res.json()).artists); + } + useEffect(() => { + refresh(); + }, []); + + async function search(e: React.FormEvent) { + e.preventDefault(); + if (!query.trim()) return; + setSearching(true); + try { + const res = await fetch(`/api/mb/artists?q=${encodeURIComponent(query.trim())}`); + setHits(res.ok ? (await res.json()).artists : []); + } finally { + setSearching(false); + } + } + + async function follow(hit: Hit) { + await fetch("/api/artists", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mbid: hit.mbid, name: hit.name }), + }); + setHits([]); + setQuery(""); + refresh(); + } + + async function toggleAuto(a: Artist) { + await fetch(`/api/artists/${a.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ autoMonitorFuture: !a.autoMonitorFuture }), + }); + refresh(); + } + + async function unfollow(a: Artist) { + await fetch(`/api/artists/${a.id}`, { method: "DELETE" }); + refresh(); + } + + return ( +
+
+ setQuery(e.target.value)} /> + +
+ {searching ?

Searching…

: null} + {hits.length > 0 ? ( +
    + {hits.map((h) => ( +
  • + {h.name} + {h.disambiguation ? ` (${h.disambiguation})` : ""}{" "} + +
  • + ))} +
+ ) : null} + +
    + {artists.map((a) => ( +
  • + {a.name} · {a.haveCount}/{a.monitoredCount} have/monitored of {a.releaseCount}{" "} + {" "} + +
  • + ))} +
+
+ ); +} +``` + +- [ ] **Step 3: Add nav links on the home page** + +Replace `web/src/app/page.tsx`: +```tsx +import { Queue } from "./queue"; + +export default function Home() { + return ( +
+

Lyra

+

Artists · Wanted · Settings

+ +
+ ); +} +``` + +- [ ] **Step 4: Typecheck** + +```bash +cd /home/jonathan/Projects/lyra/web +npx tsc --noEmit +``` +Expected: no type errors. + +- [ ] **Step 5: Commit** + +```bash +cd /home/jonathan/Projects/lyra +git add web/src/app/artists/page.tsx web/src/app/artists/artists-client.tsx web/src/app/page.tsx +git commit -m "feat: artists page with live-search follow picker" +``` + +--- + +### Task 8: Artist discography page + wanted page + +**Files:** +- Create: `web/src/app/artists/[id]/page.tsx` +- Create: `web/src/app/artists/[id]/discography-client.tsx` +- Create: `web/src/app/wanted/page.tsx` +- Create: `web/src/app/wanted/wanted-client.tsx` + +**Interfaces:** +- Consumes: `/api/artists/[id]` (GET), `/api/releases/[id]` (PATCH monitor), `/api/releases/[id]/search` (POST), `/api/wanted` (GET/POST). UI only — verified by typecheck. + +- [ ] **Step 1: Add the discography server page** + +`web/src/app/artists/[id]/page.tsx`: +```tsx +import { DiscographyClient } from "./discography-client"; + +export default async function ArtistDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ( +
+

← Artists

+ +
+ ); +} +``` + +- [ ] **Step 2: Add the discography client** + +`web/src/app/artists/[id]/discography-client.tsx`: +```tsx +"use client"; + +import { useEffect, useState } from "react"; + +type Release = { + id: string; + album: string; + primaryType: string | null; + secondaryTypes: string[]; + firstReleaseDate: string | null; + monitored: boolean; + state: string; + currentQualityClass: number | null; +}; +type Detail = { id: string; name: string; autoMonitorFuture: boolean; releases: Release[] }; + +function badge(r: Release): string { + if (r.currentQualityClass !== null) return `Have (q${r.currentQualityClass})`; + if (r.monitored) return r.state === "wanted" ? "Wanted" : r.state; + return "Dormant"; +} + +export function DiscographyClient({ id }: { id: string }) { + const [detail, setDetail] = useState(null); + + async function refresh() { + const res = await fetch(`/api/artists/${id}`); + setDetail(res.ok ? await res.json() : null); + } + useEffect(() => { + refresh(); + }, [id]); + + async function toggleMonitor(r: Release) { + await fetch(`/api/releases/${r.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ monitored: !r.monitored }), + }); + refresh(); + } + async function searchNow(r: Release) { + await fetch(`/api/releases/${r.id}/search`, { method: "POST" }); + refresh(); + } + + if (!detail) return

Loading…

; + return ( +
+

{detail.name}

+
    + {detail.releases.map((r) => ( +
  • + {r.album} + {r.firstReleaseDate ? ` (${r.firstReleaseDate.slice(0, 4)})` : ""} + {r.primaryType ? ` · ${r.primaryType}` : ""} + {r.secondaryTypes.length ? ` [${r.secondaryTypes.join(", ")}]` : ""} · {badge(r)}{" "} + {" "} + +
  • + ))} +
+
+ ); +} +``` + +- [ ] **Step 3: Add the wanted server page** + +`web/src/app/wanted/page.tsx`: +```tsx +import { WantedClient } from "./wanted-client"; + +export default function WantedPage() { + return ( +
+

Wanted

+

Home · Artists

+ +
+ ); +} +``` + +- [ ] **Step 4: Add the wanted client** + +`web/src/app/wanted/wanted-client.tsx`: +```tsx +"use client"; + +import { useEffect, useState } from "react"; + +type Wanted = { + id: string; + artistName: string; + album: string; + state: string; + currentQualityClass: number | null; + lastSearchedAt: string | null; +}; + +export function WantedClient() { + const [rows, setRows] = useState([]); + const [artist, setArtist] = useState(""); + const [album, setAlbum] = useState(""); + const [error, setError] = useState(""); + + async function refresh() { + const res = await fetch("/api/wanted"); + setRows((await res.json()).wanted); + } + useEffect(() => { + refresh(); + }, []); + + async function add(e: React.FormEvent) { + e.preventDefault(); + setError(""); + if (!artist.trim() || !album.trim()) return; + const res = await fetch("/api/wanted", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ artist, album }), + }); + if (!res.ok) { + setError((await res.json()).error ?? "failed to add"); + return; + } + setArtist(""); + setAlbum(""); + refresh(); + } + + async function searchNow(w: Wanted) { + await fetch(`/api/releases/${w.id}/search`, { method: "POST" }); + refresh(); + } + + return ( +
+
+ setArtist(e.target.value)} /> + setAlbum(e.target.value)} /> + + {error ? {error} : null} +
+
    + {rows.map((w) => ( +
  • + {w.artistName} — {w.album} · {w.currentQualityClass !== null ? `have q${w.currentQualityClass}, upgrading` : w.state} + {w.lastSearchedAt ? ` · last tried ${new Date(w.lastSearchedAt).toLocaleString()}` : " · never tried"}{" "} + +
  • + ))} +
+
+ ); +} +``` + +- [ ] **Step 5: Typecheck and run the full web suite** + +```bash +cd /home/jonathan/Projects/lyra/web +npx tsc --noEmit +DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test +``` +Expected: no type errors; every web test still green. + +- [ ] **Step 6: Commit** + +```bash +cd /home/jonathan/Projects/lyra +git add "web/src/app/artists/[id]" web/src/app/wanted +git commit -m "feat: artist discography page + wanted page" +``` + +--- + +## Notes: slice 2 complete + +With this plan the library manager is end-to-end: follow an artist via live MusicBrainz search, see the whole discography instantly (persisted on follow), monitor exactly the releases you want (or flip auto-monitor-future per artist), maintain a cross-artist wanted list with standalone adds, and trigger an immediate grab with "search now" — all feeding the plan-1 worker monitor, which discovers, enqueues, upgrades (within the window), and reconciles through the slice-1 pipeline. Deferred (future slices): notifications, a release calendar, discovery (slice 3). Operational note: enabling monitoring is still a config step — set `monitor.enabled=true` (plus optional `monitor.*` tuning) in the settings/`Config` surface — and MusicBrainz asks clients to stay within ~1 request/second, which is comfortable for interactive single-user searches; the worker's scheduled discovery is the only sustained MB user. +```