Files
Lyra/docs/superpowers/plans/2026-07-12-lyra-discovery-preview.md
T
Jonathan 3f77bb1f4c fix(preview): guard want() on res.ok; docs: preview in README + artists-search TODO
- preview-client want() no longer flips the Monitored badge when the POST fails
  (deferred item #1 from the discovery-preview whole-branch review).
- README: discovery section notes the artist preview page.
- Plan Notes + project-state: TODO to reuse the preview page from the /artists
  live search (wire artists-client.tsx hits to /discover/artist/[mbid]).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 02:03:08 +02:00

841 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
- **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).