Files
Lyra/docs/superpowers/plans/2026-07-11-lyra-monitoring-web.md
T
Jonathan eb2a2207a1 docs: add monitoring-web implementation plan (slice 2, plan 2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:08:19 +02:00

1418 lines
52 KiB
Markdown

# 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<ArtistHit[]>;
export function browseReleaseGroups(artistMbid: string): Promise<ReleaseGroupInfo[]>;
export function searchReleaseGroup(artist: string, album: string): Promise<ReleaseGroupMatch | null>;
// 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<any> {
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<ArtistHit[]> {
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<ReleaseGroupInfo[]> {
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<ReleaseGroupMatch | null> {
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 (
<main>
<h1>Watched artists</h1>
<p><a href="/">Home</a> · <a href="/wanted">Wanted</a></p>
<ArtistsClient />
</main>
);
}
```
- [ ] **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<Artist[]>([]);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<Hit[]>([]);
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 (
<div>
<form onSubmit={search}>
<input aria-label="artist search" placeholder="Search an artist…" value={query} onChange={(e) => setQuery(e.target.value)} />
<button type="submit">Search</button>
</form>
{searching ? <p>Searching</p> : null}
{hits.length > 0 ? (
<ul>
{hits.map((h) => (
<li key={h.mbid}>
{h.name}
{h.disambiguation ? ` (${h.disambiguation})` : ""}{" "}
<button onClick={() => follow(h)}>Follow</button>
</li>
))}
</ul>
) : null}
<ul>
{artists.map((a) => (
<li key={a.id}>
<a href={`/artists/${a.id}`}>{a.name}</a> · {a.haveCount}/{a.monitoredCount} have/monitored of {a.releaseCount}{" "}
<label>
<input type="checkbox" aria-label={`auto-monitor ${a.name}`} checked={a.autoMonitorFuture} onChange={() => toggleAuto(a)} /> auto-monitor future
</label>{" "}
<button onClick={() => unfollow(a)}>Unfollow</button>
</li>
))}
</ul>
</div>
);
}
```
- [ ] **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 (
<main>
<h1>Lyra</h1>
<p><a href="/artists">Artists</a> · <a href="/wanted">Wanted</a> · <a href="/settings">Settings</a></p>
<Queue />
</main>
);
}
```
- [ ] **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 (
<main>
<p><a href="/artists"> Artists</a></p>
<DiscographyClient id={id} />
</main>
);
}
```
- [ ] **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<Detail | null>(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 <p>Loading</p>;
return (
<div>
<h1>{detail.name}</h1>
<ul>
{detail.releases.map((r) => (
<li key={r.id}>
<strong>{r.album}</strong>
{r.firstReleaseDate ? ` (${r.firstReleaseDate.slice(0, 4)})` : ""}
{r.primaryType ? ` · ${r.primaryType}` : ""}
{r.secondaryTypes.length ? ` [${r.secondaryTypes.join(", ")}]` : ""} · <em>{badge(r)}</em>{" "}
<label>
<input type="checkbox" aria-label={`monitor ${r.album}`} checked={r.monitored} onChange={() => toggleMonitor(r)} /> monitor
</label>{" "}
<button onClick={() => searchNow(r)}>Search now</button>
</li>
))}
</ul>
</div>
);
}
```
- [ ] **Step 3: Add the wanted server page**
`web/src/app/wanted/page.tsx`:
```tsx
import { WantedClient } from "./wanted-client";
export default function WantedPage() {
return (
<main>
<h1>Wanted</h1>
<p><a href="/">Home</a> · <a href="/artists">Artists</a></p>
<WantedClient />
</main>
);
}
```
- [ ] **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<Wanted[]>([]);
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 (
<div>
<form onSubmit={add}>
<input aria-label="wanted artist" placeholder="Artist" value={artist} onChange={(e) => setArtist(e.target.value)} />
<input aria-label="wanted album" placeholder="Album" value={album} onChange={(e) => setAlbum(e.target.value)} />
<button type="submit">Add to wanted</button>
{error ? <span> {error}</span> : null}
</form>
<ul>
{rows.map((w) => (
<li key={w.id}>
{w.artistName} {w.album} · <em>{w.currentQualityClass !== null ? `have q${w.currentQualityClass}, upgrading` : w.state}</em>
{w.lastSearchedAt ? ` · last tried ${new Date(w.lastSearchedAt).toLocaleString()}` : " · never tried"}{" "}
<button onClick={() => searchNow(w)}>Search now</button>
</li>
))}
</ul>
</div>
);
}
```
- [ ] **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.
```