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

31 KiB
Raw Blame History

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:

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[] }; 502 on 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 + 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:

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: 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:

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>
        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
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 14 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 --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).