docs: discovery preview implementation plan (6 tasks, TDD)

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) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-12 01:31:18 +02:00
parent 4dbb6d9d83
commit 170180d471
@@ -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) <noreply@anthropic.com>`
---
## 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<string>`; `browseReleaseGroupTracks(rgMbid: string): Promise<Track[]>` (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<string> {
const data = await mbGet(`/artist/${encodeURIComponent(mbid)}?fmt=json`);
return data.name ?? "";
}
export async function browseReleaseGroupTracks(rgMbid: string): Promise<Track[]> {
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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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<string, unknown>;
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) <noreply@anthropic.com>"
```
---
## 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 (
<main>
<p>
<a href="/discover"> Discover</a> · <a href="/artists">Artists</a>
</p>
<PreviewClient mbid={mbid} initialName={name ?? ""} />
</main>
);
}
```
- [ ] **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<Preview | null>(null);
const [showAll, setShowAll] = useState(false);
const [followed, setFollowed] = useState(false);
const [tracks, setTracks] = useState<Record<string, Track[] | "loading" | "error">>({});
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-<rgMbid>) 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 (
<p>
Couldnt load this artist. <button onClick={load}>Retry</button>
</p>
);
if (!data) return <p>Loading</p>;
return (
<div>
<h1>{data.artistName || "Artist"}</h1>
<p>
{followed ? <span>Following</span> : <button onClick={follow}>Follow</button>}
{" · "}
<a href={`https://musicbrainz.org/artist/${mbid}`} target="_blank" rel="noreferrer">
MusicBrainz
</a>
</p>
<label>
<input type="checkbox" checked={showAll} onChange={(e) => setShowAll(e.target.checked)} /> Show all release
types
</label>
<ul>
{data.releases.map((r) => (
<li key={r.rgMbid} id={`rg-${r.rgMbid}`}>
<strong>{r.album}</strong>{" "}
<small>
{r.primaryType}
{r.firstReleaseDate ? ` · ${r.firstReleaseDate.slice(0, 4)}` : ""}
</small>{" "}
{r.have ? <em>In library</em> : r.monitored ? <em>Monitored</em> : null}{" "}
<button onClick={() => want(r)} disabled={r.have || r.monitored}>
Want
</button>{" "}
<button onClick={() => toggleTracks(r.rgMbid)}>{tracks[r.rgMbid] ? "Hide tracks" : "Tracks"}</button>
{tracks[r.rgMbid] === "loading" && <p>Loading tracks</p>}
{tracks[r.rgMbid] === "error" && <p>Couldnt load tracks.</p>}
{Array.isArray(tracks[r.rgMbid]) && (
<ol>
{(tracks[r.rgMbid] as Track[]).map((t) => (
<li key={t.position}>
{t.title} {t.lengthMs != null && <small>({fmt(t.lengthMs)})</small>}
</li>
))}
</ol>
)}
</li>
))}
</ul>
</div>
);
}
```
- [ ] **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) <noreply@anthropic.com>"
```
---
## 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<Set<string>>(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
<li key={s.mbid}>
{s.name} <small>({s.score.toFixed(1)})</small>
</li>
```
with:
```tsx
<li key={s.mbid}>
<a href={`/discover/artist/${s.mbid}?name=${encodeURIComponent(s.name)}`}>{s.name}</a>{" "}
<small>({s.score.toFixed(1)})</small>{" "}
{followedSimilar.has(s.mbid) ? (
<span>Followed</span>
) : (
<button onClick={() => followSimilar(s)}>Follow</button>
)}
</li>
```
- [ ] **Step 3: Link the suggested-artist name to the preview**
Replace, in the "Suggested artists" section:
```tsx
<strong>{s.artistName}</strong>{" "}
```
with:
```tsx
<strong>
<a href={`/discover/artist/${s.artistMbid}?name=${encodeURIComponent(s.artistName)}`}>
{s.artistName}
</a>
</strong>{" "}
```
- [ ] **Step 4: Link the suggested-album to the preview (anchored to the album)**
Replace, in the "Suggested albums" section:
```tsx
<strong>{s.album}</strong> {s.artistName}{" "}
```
with:
```tsx
<strong>
<a
href={`/discover/artist/${s.artistMbid}?name=${encodeURIComponent(s.artistName)}#rg-${s.rgMbid}`}
>
{s.album}
</a>
</strong>{" "}
{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 14 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) <noreply@anthropic.com>"
```
---
## 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.