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>
30 KiB
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 setsDATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test). Never runnpx vitestdirectly — without that env var the guard insrc/test/setup.tsthrows and every test fails spuriously. Never point tests at prodlyra. - Route handler signature:
(request: Request, { params }: { params: Promise<{ … }> })withawait params. Server pages likewiseawaitparams/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
mbGethelper inweb/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 = existingisCoreReleasefrom@/lib/release-filter; discography browse = existingbrowseReleaseGroups. - 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 functionsweb/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.tsxweb/src/app/discover/artist/[mbid]/preview-client.tsx
Modified:
web/src/lib/musicbrainz.ts— addTracktype,getArtistName,browseReleaseGroupTracksweb/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:
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:
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.
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[] };502on MB failure. -
Step 1: Write the failing test
Create web/src/app/api/mb/release-groups/[rgMbid]/tracks/route.test.ts:
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:
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.
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+getArtistNamefrom@/lib/musicbrainz;isCoreReleasefrom@/lib/release-filter. -
Produces:
GET ?name=&all=→{ artistName, followed, releases: [{ rgMbid, album, primaryType, secondaryTypes, firstReleaseDate, monitored, have }] }. Core-only unlessall=true;namefrom?name=elsegetArtistName;have=MonitoredRelease.currentQualityClass != nullOR aLibraryItem(artist=name, album)match;monitoredfrom theMonitoredRelease;followed= aWatchedArtistwith this mbid exists.502on MB failure. -
Step 1: Write the failing test
Create web/src/app/api/discover/preview/[mbid]/route.test.ts:
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:
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.
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:
POSTbody{ rgMbid, artistMbid, artistName, album, primaryType?, secondaryTypes?, firstReleaseDate? }→ upserts amonitored=trueMonitoredReleasekeyed onrgMbid; returns{ id, album, monitored }(201).400whenrgMbid/artistMbid/artistName/albummissing/blank. -
Step 1: Write the failing test
Create web/src/app/api/discover/want/route.test.ts:
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:
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.
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:
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:
"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>
Couldn’t 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>Couldn’t 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
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 useStates (after const [busy, setBusy] = useState(false);):
const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(new Set());
Add this handler alongside the other functions (e.g. after runSearch):
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:
<li key={s.mbid}>
{s.name} <small>({s.score.toFixed(1)})</small>
</li>
with:
<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:
<strong>{s.artistName}</strong>{" "}
with:
<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:
<strong>{s.album}</strong> — {s.artistName}{" "}
with:
<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 1–4 tests).
- Step 7: Commit
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 --noEmitclean. - 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.
haveviaLibraryItemmatches on(artistName, album)(name-based, like the existing wanted-add path); MBID-based library matching is a broader future change.