From 170180d47166a559190cb5ce1904be03ff039c88 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sun, 12 Jul 2026 01:31:18 +0200 Subject: [PATCH] docs: discovery preview implementation plan (6 tasks, TDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MB lib (getArtistName + browseReleaseGroupTracks) → tracks route → preview route (annotated discography) → want route → preview page/client → feed+Find-similar wiring. Web-only, each route TDD against lyra_test; pages build-verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-12-lyra-discovery-preview.md | 838 ++++++++++++++++++ 1 file changed, 838 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-lyra-discovery-preview.md diff --git a/docs/superpowers/plans/2026-07-12-lyra-discovery-preview.md b/docs/superpowers/plans/2026-07-12-lyra-discovery-preview.md new file mode 100644 index 0000000..f5a429a --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-lyra-discovery-preview.md @@ -0,0 +1,838 @@ +# 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.