# Lyra Discovery Preview 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:** Add a read-only artist preview page (discography + lazy tracklists + own-state badges + Follow/Want) reachable from every discovery entry point, and make "Find similar" results actionable. **Architecture:** All web-side. A new `/discover/artist/[mbid]` page is powered by one MB-backed endpoint (`GET /api/discover/preview/[mbid]`) that annotates the artist's MusicBrainz discography with own-state from `MonitoredRelease`/`LibraryItem`/`WatchedArtist`; albums lazy-load tracklists from `GET /api/mb/release-groups/[rgMbid]/tracks`; Follow reuses `POST /api/artists`, Want uses a new `POST /api/discover/want` upsert. The existing `/discover` feed and Find-similar results link into the preview. No worker changes, no new config. **Tech Stack:** Next.js 15.5 / React 19 (App Router), Prisma 6.1 + Postgres, vitest. ## Global Constraints - **Test DB only.** Web tests run via **`npm test`** (the package.json script sets `DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test`). **Never run `npx vitest` directly** — without that env var the guard in `src/test/setup.ts` throws and every test fails spuriously. Never point tests at prod `lyra`. - **Route handler signature:** `(request: Request, { params }: { params: Promise<{ … }> })` with `await params`. Server pages likewise `await` `params`/`searchParams` (both are Promises in Next 15). - **Test filter globs with brackets** (`[mbid]`, `[rgMbid]`) can match nothing under this shell/vitest combo — use a bracket-free substring filter (e.g. `npm test -- discover/preview`, `npm test -- release-groups`, `npm test -- discover/want`). Confirm RED/GREEN by the count of that suite's tests. - **All MB reads are web-side**, credential-free, via the existing `mbGet` helper in `web/src/lib/musicbrainz.ts` (User-Agent already set). No worker changes. - **Reuse, don't duplicate:** Follow = existing `POST /api/artists` `{mbid,name}`; core-release filter = existing `isCoreRelease` from `@/lib/release-filter`; discography browse = existing `browseReleaseGroups`. - **Commit after every task.** End each commit message with: `Co-Authored-By: Claude Opus 4.8 (1M context) ` --- ## File structure **New:** - `web/src/lib/musicbrainz-tracks.test.ts` — tests for the two new MB lib functions - `web/src/app/api/mb/release-groups/[rgMbid]/tracks/route.ts` (+ `route.test.ts`) - `web/src/app/api/discover/preview/[mbid]/route.ts` (+ `route.test.ts`) - `web/src/app/api/discover/want/route.ts` (+ `route.test.ts`) - `web/src/app/discover/artist/[mbid]/page.tsx` - `web/src/app/discover/artist/[mbid]/preview-client.tsx` **Modified:** - `web/src/lib/musicbrainz.ts` — add `Track` type, `getArtistName`, `browseReleaseGroupTracks` - `web/src/app/discover/discover-client.tsx` — link entry points to the preview; add Follow on Find-similar results --- ## Task 1: MB lib — `getArtistName` + `browseReleaseGroupTracks` **Files:** - Modify: `web/src/lib/musicbrainz.ts` - Test: `web/src/lib/musicbrainz-tracks.test.ts` **Interfaces:** - Produces: `type Track = { position: number; title: string; lengthMs: number | null }`; `getArtistName(mbid: string): Promise`; `browseReleaseGroupTracks(rgMbid: string): Promise` (prefers an Official release, then earliest date; `[]` when no releases). - [ ] **Step 1: Write the failing test** Create `web/src/lib/musicbrainz-tracks.test.ts`: ```typescript import { describe, it, expect, vi, afterEach } from "vitest"; import { browseReleaseGroupTracks, getArtistName } from "./musicbrainz"; afterEach(() => vi.restoreAllMocks()); const ok = (obj: unknown) => new Response(JSON.stringify(obj), { status: 200 }); describe("getArtistName", () => { it("returns the MB artist name", async () => { vi.spyOn(global, "fetch").mockResolvedValue(ok({ name: "Radiohead" })); expect(await getArtistName("mbid")).toBe("Radiohead"); }); }); describe("browseReleaseGroupTracks", () => { it("picks the Official release and flattens its tracks in order", async () => { vi.spyOn(global, "fetch").mockResolvedValue(ok({ releases: [ { status: "Bootleg", date: "2001", media: [{ tracks: [{ position: 1, title: "Boot", length: 1000 }] }] }, { status: "Official", date: "2000", media: [ { tracks: [{ position: 1, title: "One", length: 200000 }, { position: 2, title: "Two", length: null }] }, ] }, ], })); expect(await browseReleaseGroupTracks("rg")).toEqual([ { position: 1, title: "One", lengthMs: 200000 }, { position: 2, title: "Two", lengthMs: null }, ]); }); it("returns [] when there are no releases", async () => { vi.spyOn(global, "fetch").mockResolvedValue(ok({ releases: [] })); expect(await browseReleaseGroupTracks("rg")).toEqual([]); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd web && npm test -- musicbrainz-tracks` Expected: FAIL — `browseReleaseGroupTracks`/`getArtistName` are not exported. - [ ] **Step 3: Add the code to `musicbrainz.ts`** Append to `web/src/lib/musicbrainz.ts`: ```typescript export type Track = { position: number; title: string; lengthMs: number | null }; export async function getArtistName(mbid: string): Promise { const data = await mbGet(`/artist/${encodeURIComponent(mbid)}?fmt=json`); return data.name ?? ""; } export async function browseReleaseGroupTracks(rgMbid: string): Promise { const data = await mbGet( `/release?release-group=${encodeURIComponent(rgMbid)}&inc=recordings&fmt=json&limit=25`, ); const releases: any[] = data.releases ?? []; if (releases.length === 0) return []; // Prefer an Official release, then the earliest date. const pick = [...releases].sort((a, b) => { const ao = a.status === "Official" ? 0 : 1; const bo = b.status === "Official" ? 0 : 1; if (ao !== bo) return ao - bo; return (a.date ?? "9999").localeCompare(b.date ?? "9999"); })[0]; const tracks: Track[] = []; for (const medium of pick.media ?? []) { for (const t of medium.tracks ?? []) { tracks.push({ position: Number(t.position ?? tracks.length + 1), title: t.title ?? t.recording?.title ?? "", lengthMs: t.length ?? t.recording?.length ?? null, }); } } return tracks; } ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd web && npm test -- musicbrainz-tracks` Expected: PASS (3 tests). - [ ] **Step 5: Type-check + commit** Run: `cd web && npx tsc --noEmit` → clean. ```bash git add web/src/lib/musicbrainz.ts web/src/lib/musicbrainz-tracks.test.ts git commit -m "feat(preview): MB getArtistName + browseReleaseGroupTracks Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 2: `GET /api/mb/release-groups/[rgMbid]/tracks` **Files:** - Create: `web/src/app/api/mb/release-groups/[rgMbid]/tracks/route.ts` - Test: `web/src/app/api/mb/release-groups/[rgMbid]/tracks/route.test.ts` **Interfaces:** - Consumes: `browseReleaseGroupTracks` (Task 1). - Produces: `GET` → `{ tracks: Track[] }`; `502` on MB failure. - [ ] **Step 1: Write the failing test** Create `web/src/app/api/mb/release-groups/[rgMbid]/tracks/route.test.ts`: ```typescript import { describe, it, expect, vi, afterEach } from "vitest"; vi.mock("@/lib/musicbrainz", () => ({ browseReleaseGroupTracks: vi.fn() })); import { browseReleaseGroupTracks } from "@/lib/musicbrainz"; import { GET } from "./route"; const mock = vi.mocked(browseReleaseGroupTracks); afterEach(() => mock.mockReset()); function get(rgMbid: string) { return GET(new Request(`http://localhost/api/mb/release-groups/${rgMbid}/tracks`), { params: Promise.resolve({ rgMbid }), }); } describe("release-group tracks API", () => { it("returns tracks", async () => { mock.mockResolvedValue([{ position: 1, title: "One", lengthMs: 1000 }]); const body = await (await get("rg1")).json(); expect(body.tracks).toEqual([{ position: 1, title: "One", lengthMs: 1000 }]); }); it("502s on MB failure", async () => { mock.mockRejectedValue(new Error("down")); expect((await get("rg1")).status).toBe(502); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd web && npm test -- release-groups` Expected: FAIL — `./route` not found. - [ ] **Step 3: Write the route** Create `web/src/app/api/mb/release-groups/[rgMbid]/tracks/route.ts`: ```typescript import { browseReleaseGroupTracks } from "@/lib/musicbrainz"; export async function GET(_request: Request, { params }: { params: Promise<{ rgMbid: string }> }) { const { rgMbid } = await params; try { return Response.json({ tracks: await browseReleaseGroupTracks(rgMbid) }); } catch { return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 }); } } ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd web && npm test -- release-groups` Expected: PASS (2 tests). - [ ] **Step 5: Type-check + commit** Run: `cd web && npx tsc --noEmit` → clean. ```bash git add web/src/app/api/mb/release-groups git commit -m "feat(preview): GET /api/mb/release-groups/[rgMbid]/tracks Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 3: `GET /api/discover/preview/[mbid]` **Files:** - Create: `web/src/app/api/discover/preview/[mbid]/route.ts` - Test: `web/src/app/api/discover/preview/[mbid]/route.test.ts` **Interfaces:** - Consumes: `browseReleaseGroups` + `getArtistName` from `@/lib/musicbrainz`; `isCoreRelease` from `@/lib/release-filter`. - Produces: `GET ?name=&all=` → `{ artistName, followed, releases: [{ rgMbid, album, primaryType, secondaryTypes, firstReleaseDate, monitored, have }] }`. Core-only unless `all=true`; `name` from `?name=` else `getArtistName`; `have` = `MonitoredRelease.currentQualityClass != null` OR a `LibraryItem` `(artist=name, album)` match; `monitored` from the `MonitoredRelease`; `followed` = a `WatchedArtist` with this mbid exists. `502` on MB failure. - [ ] **Step 1: Write the failing test** Create `web/src/app/api/discover/preview/[mbid]/route.test.ts`: ```typescript import { describe, it, expect, vi, afterEach } from "vitest"; vi.mock("@/lib/musicbrainz", () => ({ browseReleaseGroups: vi.fn(), getArtistName: vi.fn() })); import { browseReleaseGroups, getArtistName } from "@/lib/musicbrainz"; import { prisma } from "@/lib/db"; import { GET } from "./route"; const mockBrowse = vi.mocked(browseReleaseGroups); const mockName = vi.mocked(getArtistName); afterEach(() => { mockBrowse.mockReset(); mockName.mockReset(); }); function get(mbid: string, qs = "") { return GET(new Request(`http://localhost/api/discover/preview/${mbid}${qs}`), { params: Promise.resolve({ mbid }), }); } describe("discover preview API", () => { it("annotates own-state, defaults to core releases, reports followed", async () => { mockBrowse.mockResolvedValue([ { rgMbid: "rg1", title: "Studio", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2000" }, { rgMbid: "rg2", title: "Live One", primaryType: "Album", secondaryTypes: ["Live"], firstReleaseDate: "2001" }, { rgMbid: "rg3", title: "Owned", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2002" }, { rgMbid: "rg4", title: "Scanned", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2003" }, ]); await prisma.watchedArtist.create({ data: { mbid: "a1", name: "Band" } }); await prisma.monitoredRelease.create({ data: { artistMbid: "a1", artistName: "Band", rgMbid: "rg1", album: "Studio", secondaryTypes: [], monitored: true } }); await prisma.monitoredRelease.create({ data: { artistMbid: "a1", artistName: "Band", rgMbid: "rg3", album: "Owned", secondaryTypes: [], monitored: false, currentQualityClass: 3 } }); await prisma.libraryItem.create({ data: { artist: "Band", album: "Scanned", path: "/x", source: "scan", format: "FLAC", qualityClass: 3 } }); const body = await (await get("a1", "?name=Band")).json(); expect(body.followed).toBe(true); expect(body.artistName).toBe("Band"); expect(body.releases.map((r: any) => r.album)).toEqual(["Studio", "Owned", "Scanned"]); // Live excluded expect(body.releases.find((r: any) => r.rgMbid === "rg1")).toMatchObject({ monitored: true, have: false }); expect(body.releases.find((r: any) => r.rgMbid === "rg3")).toMatchObject({ monitored: false, have: true }); expect(body.releases.find((r: any) => r.rgMbid === "rg4")).toMatchObject({ have: true }); // via LibraryItem expect(mockName).not.toHaveBeenCalled(); // ?name= shortcut }); it("?all=true includes non-core; missing ?name falls back to getArtistName; 502 on MB throw", async () => { mockBrowse.mockResolvedValue([ { rgMbid: "rg2", title: "Live One", primaryType: "Album", secondaryTypes: ["Live"], firstReleaseDate: "2001" }, ]); mockName.mockResolvedValue("Resolved"); const body = await (await get("a1", "?all=true")).json(); expect(body.releases.map((r: any) => r.album)).toEqual(["Live One"]); expect(body.artistName).toBe("Resolved"); expect(body.followed).toBe(false); mockBrowse.mockRejectedValue(new Error("down")); expect((await get("a1")).status).toBe(502); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd web && npm test -- discover/preview` Expected: FAIL — `./route` not found. - [ ] **Step 3: Write the route** Create `web/src/app/api/discover/preview/[mbid]/route.ts`: ```typescript import { prisma } from "@/lib/db"; import { browseReleaseGroups, getArtistName } from "@/lib/musicbrainz"; import { isCoreRelease } from "@/lib/release-filter"; export async function GET(request: Request, { params }: { params: Promise<{ mbid: string }> }) { const { mbid } = await params; const url = new URL(request.url); const nameParam = url.searchParams.get("name"); const showAll = url.searchParams.get("all") === "true"; let groups; try { groups = await browseReleaseGroups(mbid); } catch { return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 }); } const visible = showAll ? groups : groups.filter(isCoreRelease); let artistName = nameParam?.trim() || ""; if (!artistName) { try { artistName = await getArtistName(mbid); } catch { artistName = ""; } } const followed = (await prisma.watchedArtist.findUnique({ where: { mbid } })) != null; const rgMbids = visible.map((g) => g.rgMbid); const albums = visible.map((g) => g.title); const monitored = await prisma.monitoredRelease.findMany({ where: { rgMbid: { in: rgMbids } } }); const monMap = new Map(monitored.map((m) => [m.rgMbid, m])); const libItems = await prisma.libraryItem.findMany({ where: { artist: artistName, album: { in: albums } } }); const haveAlbums = new Set(libItems.map((l) => l.album)); const releases = visible.map((g) => { const m = monMap.get(g.rgMbid); return { rgMbid: g.rgMbid, album: g.title, primaryType: g.primaryType, secondaryTypes: g.secondaryTypes, firstReleaseDate: g.firstReleaseDate, monitored: m?.monitored ?? false, have: m?.currentQualityClass != null || haveAlbums.has(g.title), }; }); return Response.json({ artistName, followed, releases }); } ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd web && npm test -- discover/preview` Expected: PASS (2 tests). - [ ] **Step 5: Type-check + commit** Run: `cd web && npx tsc --noEmit` → clean. ```bash git add web/src/app/api/discover/preview git commit -m "feat(preview): GET /api/discover/preview/[mbid] annotated discography Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 4: `POST /api/discover/want` **Files:** - Create: `web/src/app/api/discover/want/route.ts` - Test: `web/src/app/api/discover/want/route.test.ts` **Interfaces:** - Produces: `POST` body `{ rgMbid, artistMbid, artistName, album, primaryType?, secondaryTypes?, firstReleaseDate? }` → upserts a `monitored=true` `MonitoredRelease` keyed on `rgMbid`; returns `{ id, album, monitored }` (201). `400` when `rgMbid`/`artistMbid`/`artistName`/`album` missing/blank. - [ ] **Step 1: Write the failing test** Create `web/src/app/api/discover/want/route.test.ts`: ```typescript import { describe, it, expect } from "vitest"; import { prisma } from "@/lib/db"; import { POST } from "./route"; function post(body: unknown) { return POST(new Request("http://localhost/api/discover/want", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), })); } describe("discover want API", () => { it("wants a release-group from metadata (201), idempotent re-want flips monitored", async () => { const res = await post({ rgMbid: "rg1", artistMbid: "a1", artistName: "Band", album: "Rec", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2020" }); expect(res.status).toBe(201); expect(await prisma.monitoredRelease.findUnique({ where: { rgMbid: "rg1" } })) .toMatchObject({ album: "Rec", monitored: true, artistName: "Band" }); await prisma.monitoredRelease.update({ where: { rgMbid: "rg1" }, data: { monitored: false } }); await post({ rgMbid: "rg1", artistMbid: "a1", artistName: "Band", album: "Rec" }); expect((await prisma.monitoredRelease.findUnique({ where: { rgMbid: "rg1" } }))!.monitored).toBe(true); expect(await prisma.monitoredRelease.count({ where: { rgMbid: "rg1" } })).toBe(1); }); it("400s on a bad body", async () => { expect((await post({ rgMbid: "rg1" })).status).toBe(400); expect((await post({})).status).toBe(400); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd web && npm test -- discover/want` Expected: FAIL — `./route` not found. - [ ] **Step 3: Write the route** Create `web/src/app/api/discover/want/route.ts`: ```typescript import { prisma } from "@/lib/db"; export async function POST(request: Request) { let body: unknown; try { body = await request.json(); } catch { return Response.json({ error: "invalid JSON" }, { status: 400 }); } const { rgMbid, artistMbid, artistName, album, primaryType, secondaryTypes, firstReleaseDate } = (body ?? {}) as Record; if ([rgMbid, artistMbid, artistName, album].some((v) => typeof v !== "string" || !v.trim())) { return Response.json({ error: "rgMbid, artistMbid, artistName, album are required" }, { status: 400 }); } const rel = await prisma.monitoredRelease.upsert({ where: { rgMbid: rgMbid as string }, create: { artistMbid: artistMbid as string, artistName: artistName as string, rgMbid: rgMbid as string, album: album as string, primaryType: typeof primaryType === "string" ? primaryType : null, secondaryTypes: Array.isArray(secondaryTypes) ? (secondaryTypes as string[]) : [], firstReleaseDate: typeof firstReleaseDate === "string" ? firstReleaseDate : null, monitored: true, }, update: { monitored: true }, }); return Response.json({ id: rel.id, album: rel.album, monitored: rel.monitored }, { status: 201 }); } ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd web && npm test -- discover/want` Expected: PASS (2 tests). - [ ] **Step 5: Type-check + commit** Run: `cd web && npx tsc --noEmit` → clean. ```bash git add web/src/app/api/discover/want git commit -m "feat(preview): POST /api/discover/want (want a release-group by metadata) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 5: Preview page + client **Files:** - Create: `web/src/app/discover/artist/[mbid]/page.tsx` - Create: `web/src/app/discover/artist/[mbid]/preview-client.tsx` **Interfaces:** - Consumes: `GET /api/discover/preview/[mbid]`, `GET /api/mb/release-groups/[rgMbid]/tracks`, `POST /api/artists` (follow), `POST /api/discover/want`. - Produces: the `/discover/artist/[mbid]` route. UI-wiring task with no unit test — verified by `tsc --noEmit` + `npm run build`. - [ ] **Step 1: Write the server page** Create `web/src/app/discover/artist/[mbid]/page.tsx`: ```tsx import { PreviewClient } from "./preview-client"; export default async function PreviewPage({ params, searchParams, }: { params: Promise<{ mbid: string }>; searchParams: Promise<{ name?: string }>; }) { const { mbid } = await params; const { name } = await searchParams; return (

← Discover · Artists

); } ``` - [ ] **Step 2: Write the client component** Create `web/src/app/discover/artist/[mbid]/preview-client.tsx`: ```tsx "use client"; import { useCallback, useEffect, useState } from "react"; type Release = { rgMbid: string; album: string; primaryType: string | null; secondaryTypes: string[]; firstReleaseDate: string | null; monitored: boolean; have: boolean; }; type Preview = { artistName: string; followed: boolean; releases: Release[] }; type Track = { position: number; title: string; lengthMs: number | null }; function fmt(ms: number | null): string { if (ms == null) return ""; const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; } export function PreviewClient({ mbid, initialName }: { mbid: string; initialName: string }) { const [data, setData] = useState(null); const [showAll, setShowAll] = useState(false); const [followed, setFollowed] = useState(false); const [tracks, setTracks] = useState>({}); const [error, setError] = useState(false); const load = useCallback(async () => { setError(false); const qs = new URLSearchParams(); if (initialName) qs.set("name", initialName); if (showAll) qs.set("all", "true"); const res = await fetch(`/api/discover/preview/${mbid}?${qs.toString()}`); if (!res.ok) { setError(true); return; } const d: Preview = await res.json(); setData(d); setFollowed(d.followed); }, [mbid, initialName, showAll]); useEffect(() => { load(); }, [load]); // Scroll to the album an album-suggestion deep-linked to (#rg-) once data is in. useEffect(() => { if (!data) return; const hash = window.location.hash; if (!hash) return; document.getElementById(hash.slice(1))?.scrollIntoView(); }, [data]); async function follow() { const name = data?.artistName || initialName; const res = await fetch("/api/artists", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ mbid, name }), }); if (res.ok || res.status === 409) setFollowed(true); } async function want(r: Release) { await fetch("/api/discover/want", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ rgMbid: r.rgMbid, artistMbid: mbid, artistName: data?.artistName || initialName, album: r.album, primaryType: r.primaryType, secondaryTypes: r.secondaryTypes, firstReleaseDate: r.firstReleaseDate, }), }); setData((d) => d ? { ...d, releases: d.releases.map((x) => (x.rgMbid === r.rgMbid ? { ...x, monitored: true } : x)) } : d, ); } async function toggleTracks(rgMbid: string) { if (tracks[rgMbid]) { setTracks((t) => { const n = { ...t }; delete n[rgMbid]; return n; }); return; } setTracks((t) => ({ ...t, [rgMbid]: "loading" })); const res = await fetch(`/api/mb/release-groups/${rgMbid}/tracks`); if (!res.ok) { setTracks((t) => ({ ...t, [rgMbid]: "error" })); return; } const { tracks: ts } = await res.json(); setTracks((t) => ({ ...t, [rgMbid]: ts })); } if (error) return (

Couldn’t load this artist.

); if (!data) return

Loading…

; return (

{data.artistName || "Artist"}

{followed ? Following : } {" · "} MusicBrainz ↗

    {data.releases.map((r) => (
  • {r.album}{" "} {r.primaryType} {r.firstReleaseDate ? ` · ${r.firstReleaseDate.slice(0, 4)}` : ""} {" "} {r.have ? In library : r.monitored ? Monitored : null}{" "} {" "} {tracks[r.rgMbid] === "loading" &&

    Loading tracks…

    } {tracks[r.rgMbid] === "error" &&

    Couldn’t load tracks.

    } {Array.isArray(tracks[r.rgMbid]) && (
      {(tracks[r.rgMbid] as Track[]).map((t) => (
    1. {t.title} {t.lengthMs != null && ({fmt(t.lengthMs)})}
    2. ))}
    )}
  • ))}
); } ``` - [ ] **Step 3: Verify the build compiles** Run: `cd web && npx tsc --noEmit && npm run build` Expected: type-check passes and `next build` completes with a `/discover/artist/[mbid]` route listed. - [ ] **Step 4: Commit** ```bash git add web/src/app/discover/artist git commit -m "feat(preview): /discover/artist/[mbid] preview page (disco + tracks + own-state) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 6: Wire the discovery feed to the preview + Follow on Find-similar **Files:** - Modify: `web/src/app/discover/discover-client.tsx` **Interfaces:** - Consumes: the `/discover/artist/[mbid]` route (Task 5); `POST /api/artists` (follow). - Produces: suggested-artist and suggested-album entries link to the preview; Find-similar rows link to the preview and gain a Follow button. UI-wiring task with no unit test — verified by `tsc --noEmit` + `npm run build`. - [ ] **Step 1: Add follow state + handler for Find-similar results** In `web/src/app/discover/discover-client.tsx`, add a state hook alongside the existing `useState`s (after `const [busy, setBusy] = useState(false);`): ```tsx const [followedSimilar, setFollowedSimilar] = useState>(new Set()); ``` Add this handler alongside the other functions (e.g. after `runSearch`): ```tsx async function followSimilar(a: { mbid: string; name: string }) { const res = await fetch("/api/artists", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ mbid: a.mbid, name: a.name }), }); if (res.ok || res.status === 409) { setFollowedSimilar((s) => new Set(s).add(a.mbid)); } } ``` - [ ] **Step 2: Link + action the Find-similar results** In the render, replace the Find-similar list item: ```tsx
  • {s.name} ({s.score.toFixed(1)})
  • ``` with: ```tsx
  • {s.name}{" "} ({s.score.toFixed(1)}){" "} {followedSimilar.has(s.mbid) ? ( Followed ) : ( )}
  • ``` - [ ] **Step 3: Link the suggested-artist name to the preview** Replace, in the "Suggested artists" section: ```tsx {s.artistName}{" "} ``` with: ```tsx {s.artistName} {" "} ``` - [ ] **Step 4: Link the suggested-album to the preview (anchored to the album)** Replace, in the "Suggested albums" section: ```tsx {s.album} — {s.artistName}{" "} ``` with: ```tsx {s.album} {" "} — {s.artistName}{" "} ``` - [ ] **Step 5: Verify the build compiles** Run: `cd web && npx tsc --noEmit && npm run build` Expected: type-check + build pass. - [ ] **Step 6: Run the full web suite (no regressions)** Run: `cd web && npm test` Expected: all pass (the slice-3 suite count plus the new Task 1–4 tests). - [ ] **Step 7: Commit** ```bash git add web/src/app/discover/discover-client.tsx git commit -m "feat(preview): link discovery feed + Find-similar to the artist preview Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Final verification - [ ] **Full web suite:** `cd web && npm test` → all pass. - [ ] **Web build:** `cd web && npm run build` → succeeds, `/discover/artist/[mbid]` route present; `tsc --noEmit` clean. - [ ] **Manual smoke (post-deploy):** on `/discover`, click a suggested artist's name → preview page shows discography; expand an album → tracklist loads; **Want** an album → it becomes Monitored and appears in `/wanted`; **Follow** → artist appears in `/artists`. On a suggested album, clicking it opens the preview scrolled to that album. Run **Find similar**, click a result name → preview; click its **Follow** → artist followed. Own-state badges show for albums you already have/monitor. ## Notes / deferred - Tracklists are fetched live per expand; a short-lived cache (or persisting on Want) is a later option if MB rate-limiting bites. - The preview shows one representative release's tracklist; multi-disc/edition differences aren't surfaced. - `have` via `LibraryItem` matches on `(artistName, album)` (name-based, like the existing wanted-add path); MBID-based library matching is a broader future change. - **TODO — reuse the preview page from the normal `/artists` live search.** The artists search→follow picker (`artists-client.tsx`) currently only follows; wire each MB search hit to also link to `/discover/artist/[mbid]?name=…` so you can preview (disco + tracklists + own-state) before following there too. The preview page + endpoints already exist — this is a small link-wiring task in `artists-client.tsx`. - **Applied post-merge:** `want()` in `preview-client.tsx` now guards on `res.ok` before optimistically flipping the Monitored badge (was deferred item #1 from the whole-branch review).