# 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. ```