Files
Lyra/docs/superpowers/plans/2026-07-11-lyra-monitoring-web.md
T
Jonathan eb2a2207a1 docs: add monitoring-web implementation plan (slice 2, plan 2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:08:19 +02:00

52 KiB

Lyra Monitoring Web (UI + API) 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: Give the user the web surface for the library manager: a live MusicBrainz search/follow picker, watched-artist management with per-release monitor toggles and an auto-monitor-future toggle, a cross-artist wanted list, and "search now" buttons — all writing the WatchedArtist / MonitoredRelease / Request rows the worker's monitor (plan 1, already merged) consumes.

Architecture: A thin, read-only MusicBrainz module in the Next.js app (lib/musicbrainz.ts) powers a live artist search + discography browse for the picker. App-Router API routes do CRUD over the monitoring tables; "follow" persists the fetched discography as unmonitored MonitoredRelease rows so the detail view is instant. Three client-component pages (/artists, /artists/[id], /wanted) render it. All source downloads and credentials remain worker-only; the web's only external reach is public, credential-free MusicBrainz metadata.

Tech Stack: Next.js 15.5 (App Router, React 19), Prisma 6.1, TypeScript 5.7. Tests: Vitest (node env) — API routes and the MB module are unit-tested against a seeded Postgres with global.fetch/module mocks, following the existing route.test.ts pattern. UI pages are typecheck-verified (tsc --noEmit), matching the repo (no React Testing Library is installed and existing components like queue.tsx are untested).

Global Constraints

  • Next.js 15 App Router: dynamic route handlers receive { params }: { params: Promise<{ ... }> } — you MUST await params. Route tests call the handler directly, passing { params: Promise.resolve({ id }) }.
  • API handlers are export async function GET/POST/PATCH/DELETE(request: Request, ctx?) returning Response.json(...) / new Response(null, { status }). Prisma is imported from @/lib/db (never instantiate PrismaClient in a route). The @/ alias maps to web/src.
  • The web performs ONLY read-only, credential-free MusicBrainz metadata calls (artist search, release-group browse/search). No download source, no credential, and no worker/pipeline logic moves into the web. MusicBrainz calls MUST send a descriptive User-Agent header (MB requires it).
  • MonitoredRelease.rgMbid is globally unique. When persisting a followed artist's discography, use createMany({ ..., skipDuplicates: true }) so a release-group shared with an already-followed artist (a collaboration) doesn't fail the whole insert — mirroring the worker's ON CONFLICT DO NOTHING.
  • Data-model contract (from plan 1, in web/prisma/schema.prisma): WatchedArtist(id, mbid unique, name, autoMonitorFuture, monitorFrom, lastPolledAt, releases[]); MonitoredRelease(id, watchedArtistId?, artistMbid, artistName, rgMbid unique, album, primaryType?, secondaryTypes[], firstReleaseDate?, monitored, state[wanted|grabbed|fulfilled], currentQualityClass?, firstGrabbedAt?, lastSearchedAt?); Request has monitoredReleaseId?. A monitor-enqueued grab is a Request (with monitoredReleaseId) + a Job ({ create: {} } → defaults state requested, stage intake) — exactly how POST /api/requests already creates work.
  • Run web commands from /home/jonathan/Projects/lyra/web with DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra. Tests: DATABASE_URL=... npm test. Typecheck: npx tsc --noEmit.
  • No new npm dependency. Follow the minimal, unstyled UI style of queue.tsx/settings-form.tsx (plain elements, aria-labeled inputs). Every task ends with a commit.

Shared interfaces (defined by tasks below)

// web/src/lib/musicbrainz.ts (Task 1)
export type ArtistHit = { mbid: string; name: string; disambiguation: string };
export type ReleaseGroupInfo = {
  rgMbid: string; title: string; primaryType: string | null;
  secondaryTypes: string[]; firstReleaseDate: string | null;
};
export type ReleaseGroupMatch = ReleaseGroupInfo & { artistMbid: string; artistName: string };
export function searchArtists(query: string): Promise<ArtistHit[]>;
export function browseReleaseGroups(artistMbid: string): Promise<ReleaseGroupInfo[]>;
export function searchReleaseGroup(artist: string, album: string): Promise<ReleaseGroupMatch | null>;

// API routes (Tasks 2-6):
//   GET  /api/mb/artists?q=                          -> { artists: ArtistHit[] }
//   GET  /api/mb/artists/[mbid]/releases             -> { releases: ReleaseGroupInfo[] }
//   GET  /api/artists                                -> { artists: [{id,mbid,name,autoMonitorFuture,monitoredCount,haveCount,releaseCount}] }
//   POST /api/artists   {mbid,name}                  -> 201 {id,mbid,name,releaseCount}
//   GET  /api/artists/[id]                           -> { id,mbid,name,autoMonitorFuture, releases:[...] }
//   PATCH /api/artists/[id] {autoMonitorFuture}      -> { id, autoMonitorFuture }
//   DELETE /api/artists/[id]                         -> 204
//   PATCH /api/releases/[id] {monitored}             -> { id, monitored }
//   POST /api/releases/[id]/search                   -> 202 { enqueued: true }
//   GET  /api/wanted                                 -> { wanted: [...] }
//   POST /api/wanted {artist,album}                  -> 201|200 { id, ... } | 404

Task 1: Test-setup cleanup + read-only MusicBrainz module

Files:

  • Modify: web/src/test/setup.ts (clean the new tables)
  • Create: web/src/lib/musicbrainz.ts
  • Test: web/src/lib/musicbrainz.test.ts

Interfaces:

  • Produces: searchArtists, browseReleaseGroups, searchReleaseGroup (+ the ArtistHit/ReleaseGroupInfo/ReleaseGroupMatch types). All use global fetch with a User-Agent; browseReleaseGroups paginates at 100/page; non-2xx throws.

  • Step 1: Extend the test cleanup

In web/src/test/setup.ts, replace the body so all monitoring tables are cleared in FK-safe order:

import { beforeEach } from "vitest";
import { prisma } from "@/lib/db";

beforeEach(async () => {
  // Job cascades from Request; Request.monitoredReleaseId is SetNull. Delete children first.
  await prisma.job.deleteMany();
  await prisma.request.deleteMany();
  await prisma.monitoredRelease.deleteMany();
  await prisma.watchedArtist.deleteMany();
  await prisma.config.deleteMany();
});
  • Step 2: Write the failing test

web/src/lib/musicbrainz.test.ts:

import { describe, it, expect, vi, afterEach } from "vitest";
import { searchArtists, browseReleaseGroups, searchReleaseGroup } from "./musicbrainz";

function jsonResponse(data: unknown) {
  return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data) } as Response);
}

afterEach(() => vi.unstubAllGlobals());

describe("musicbrainz module", () => {
  it("searchArtists maps hits and sends a User-Agent", async () => {
    const fetchMock = vi.fn(() =>
      jsonResponse({ artists: [{ id: "a1", name: "John Mayer", disambiguation: "US singer" }] }),
    );
    vi.stubGlobal("fetch", fetchMock);
    const hits = await searchArtists("john ma");
    expect(hits).toEqual([{ mbid: "a1", name: "John Mayer", disambiguation: "US singer" }]);
    const [, init] = fetchMock.mock.calls[0];
    expect((init as RequestInit).headers).toMatchObject({ "User-Agent": expect.stringContaining("Lyra") });
  });

  it("browseReleaseGroups paginates until release-group-count is reached", async () => {
    const page1 = { "release-group-count": 2, "release-groups": [{ id: "rg1", title: "A", "primary-type": "Album", "secondary-types": [], "first-release-date": "2001" }] };
    const page2 = { "release-group-count": 2, "release-groups": [{ id: "rg2", title: "B", "primary-type": "EP", "secondary-types": ["Live"], "first-release-date": "" }] };
    const fetchMock = vi.fn().mockReturnValueOnce(jsonResponse(page1)).mockReturnValueOnce(jsonResponse(page2));
    vi.stubGlobal("fetch", fetchMock);
    const rels = await browseReleaseGroups("a1");
    expect(fetchMock).toHaveBeenCalledTimes(2);
    expect(rels).toEqual([
      { rgMbid: "rg1", title: "A", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2001" },
      { rgMbid: "rg2", title: "B", primaryType: "EP", secondaryTypes: ["Live"], firstReleaseDate: null },
    ]);
  });

  it("throws on a non-2xx MusicBrainz response", async () => {
    vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: false, status: 503 } as Response)));
    await expect(searchArtists("x")).rejects.toThrow(/503/);
  });

  it("searchReleaseGroup returns the top match with artist credit or null", async () => {
    vi.stubGlobal("fetch", vi.fn(() => jsonResponse({ "release-groups": [
      { id: "rg9", title: "Continuum", "primary-type": "Album", "secondary-types": [], "first-release-date": "2006-09-12", "artist-credit": [{ artist: { id: "a1", name: "John Mayer" } }] },
    ] })));
    const m = await searchReleaseGroup("John Mayer", "Continuum");
    expect(m).toEqual({ rgMbid: "rg9", title: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006-09-12", artistMbid: "a1", artistName: "John Mayer" });

    vi.stubGlobal("fetch", vi.fn(() => jsonResponse({ "release-groups": [] })));
    expect(await searchReleaseGroup("Nobody", "Nothing")).toBeNull();
  });
});
  • Step 3: Run the test to verify it fails
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- musicbrainz

Expected: FAIL — cannot resolve ./musicbrainz.

  • Step 4: Implement the module

web/src/lib/musicbrainz.ts:

const MB_BASE = "https://musicbrainz.org/ws/2";
const USER_AGENT = "Lyra/0.1 ( https://git.jger.nl/Jonathan/Lyra )";

export type ArtistHit = { mbid: string; name: string; disambiguation: string };
export type ReleaseGroupInfo = {
  rgMbid: string;
  title: string;
  primaryType: string | null;
  secondaryTypes: string[];
  firstReleaseDate: string | null;
};
export type ReleaseGroupMatch = ReleaseGroupInfo & { artistMbid: string; artistName: string };

async function mbGet(path: string): Promise<any> {
  const res = await fetch(`${MB_BASE}${path}`, {
    headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
  });
  if (!res.ok) throw new Error(`MusicBrainz request failed: ${res.status}`);
  return res.json();
}

function toReleaseGroup(g: any): ReleaseGroupInfo {
  return {
    rgMbid: g.id,
    title: g.title ?? "",
    primaryType: g["primary-type"] ?? null,
    secondaryTypes: g["secondary-types"] ?? [],
    firstReleaseDate: g["first-release-date"] || null,
  };
}

export async function searchArtists(query: string): Promise<ArtistHit[]> {
  const data = await mbGet(`/artist?query=${encodeURIComponent(query)}&fmt=json&limit=8`);
  return (data.artists ?? []).map((a: any) => ({
    mbid: a.id,
    name: a.name ?? "",
    disambiguation: a.disambiguation ?? "",
  }));
}

export async function browseReleaseGroups(artistMbid: string): Promise<ReleaseGroupInfo[]> {
  const out: ReleaseGroupInfo[] = [];
  let offset = 0;
  for (;;) {
    const data = await mbGet(
      `/release-group?artist=${encodeURIComponent(artistMbid)}&fmt=json&limit=100&offset=${offset}`,
    );
    const groups: any[] = data["release-groups"] ?? [];
    for (const g of groups) out.push(toReleaseGroup(g));
    offset += groups.length;
    const total = data["release-group-count"] ?? offset;
    if (groups.length === 0 || offset >= total) break;
  }
  return out;
}

export async function searchReleaseGroup(artist: string, album: string): Promise<ReleaseGroupMatch | null> {
  const query = `releasegroup:"${album}" AND artist:"${artist}"`;
  const data = await mbGet(`/release-group?query=${encodeURIComponent(query)}&fmt=json&limit=5`);
  const groups: any[] = data["release-groups"] ?? [];
  if (groups.length === 0) return null;
  const g = groups[0];
  const credit = g["artist-credit"]?.[0]?.artist ?? {};
  return { ...toReleaseGroup(g), artistMbid: credit.id ?? "", artistName: credit.name ?? artist };
}
  • Step 5: Run the test to verify it passes
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- musicbrainz

Expected: PASS — all four tests green.

  • Step 6: Commit
cd /home/jonathan/Projects/lyra
git add web/src/lib/musicbrainz.ts web/src/lib/musicbrainz.test.ts web/src/test/setup.ts
git commit -m "feat: read-only MusicBrainz module + monitoring test cleanup"

Task 2: MusicBrainz proxy API routes

Files:

  • Create: web/src/app/api/mb/artists/route.ts
  • Create: web/src/app/api/mb/artists/[mbid]/releases/route.ts
  • Test: web/src/app/api/mb/artists/route.test.ts
  • Test: web/src/app/api/mb/artists/[mbid]/releases/route.test.ts

Interfaces:

  • Consumes: searchArtists, browseReleaseGroups (Task 1).

  • Produces: GET /api/mb/artists?q={ artists } (400 if q blank, 502 on MB failure); GET /api/mb/artists/[mbid]/releases{ releases } (502 on MB failure).

  • Step 1: Write the failing tests

web/src/app/api/mb/artists/route.test.ts:

import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@/lib/musicbrainz", () => ({ searchArtists: vi.fn() }));
import { searchArtists } from "@/lib/musicbrainz";
import { GET } from "./route";

const mockSearch = vi.mocked(searchArtists);
beforeEach(() => mockSearch.mockReset());

function req(q: string | null) {
  return new Request(`http://localhost/api/mb/artists${q === null ? "" : `?q=${encodeURIComponent(q)}`}`);
}

describe("GET /api/mb/artists", () => {
  it("returns hits for a query", async () => {
    mockSearch.mockResolvedValue([{ mbid: "a1", name: "John Mayer", disambiguation: "" }]);
    const res = await GET(req("john"));
    expect(res.status).toBe(200);
    expect((await res.json()).artists[0].name).toBe("John Mayer");
  });

  it("400s a blank query", async () => {
    expect((await GET(req("  "))).status).toBe(400);
    expect((await GET(req(null))).status).toBe(400);
  });

  it("502s when MusicBrainz throws", async () => {
    mockSearch.mockRejectedValue(new Error("MusicBrainz request failed: 503"));
    expect((await GET(req("john"))).status).toBe(502);
  });
});

web/src/app/api/mb/artists/[mbid]/releases/route.test.ts:

import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@/lib/musicbrainz", () => ({ browseReleaseGroups: vi.fn() }));
import { browseReleaseGroups } from "@/lib/musicbrainz";
import { GET } from "./route";

const mockBrowse = vi.mocked(browseReleaseGroups);
beforeEach(() => mockBrowse.mockReset());

const ctx = (mbid: string) => ({ params: Promise.resolve({ mbid }) });

describe("GET /api/mb/artists/[mbid]/releases", () => {
  it("returns the discography", async () => {
    mockBrowse.mockResolvedValue([
      { rgMbid: "rg1", title: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006" },
    ]);
    const res = await GET(new Request("http://localhost/x"), ctx("a1"));
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.releases[0].rgMbid).toBe("rg1");
    expect(mockBrowse).toHaveBeenCalledWith("a1");
  });

  it("502s when MusicBrainz throws", async () => {
    mockBrowse.mockRejectedValue(new Error("boom"));
    expect((await GET(new Request("http://localhost/x"), ctx("a1"))).status).toBe(502);
  });
});
  • Step 2: Run the tests to verify they fail
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- api/mb

Expected: FAIL — cannot resolve ./route.

  • Step 3: Implement the routes

web/src/app/api/mb/artists/route.ts:

import { searchArtists } from "@/lib/musicbrainz";

export async function GET(request: Request) {
  const q = new URL(request.url).searchParams.get("q");
  if (!q || !q.trim()) {
    return Response.json({ error: "q is required" }, { status: 400 });
  }
  try {
    return Response.json({ artists: await searchArtists(q.trim()) });
  } catch {
    return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 });
  }
}

web/src/app/api/mb/artists/[mbid]/releases/route.ts:

import { browseReleaseGroups } from "@/lib/musicbrainz";

export async function GET(_request: Request, { params }: { params: Promise<{ mbid: string }> }) {
  const { mbid } = await params;
  try {
    return Response.json({ releases: await browseReleaseGroups(mbid) });
  } catch {
    return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 });
  }
}
  • Step 4: Run the tests to verify they pass
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- api/mb

Expected: PASS — all five tests green.

  • Step 5: Commit
cd /home/jonathan/Projects/lyra
git add web/src/app/api/mb
git commit -m "feat: MusicBrainz proxy API routes (artist search + discography browse)"

Task 3: Artists collection API — follow + list

Files:

  • Create: web/src/app/api/artists/route.ts
  • Test: web/src/app/api/artists/route.test.ts

Interfaces:

  • Consumes: prisma, browseReleaseGroups (Task 1).

  • Produces: GET /api/artists{ artists: [{id,mbid,name,autoMonitorFuture,monitoredCount,haveCount,releaseCount}] }; POST /api/artists {mbid,name} → 201 (creates WatchedArtist + persists discography as unmonitored MonitoredRelease rows via createMany({skipDuplicates:true})), 400 on bad body, 409 if already followed, 502 on MB failure.

  • Step 1: Write the failing test

web/src/app/api/artists/route.test.ts:

import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@/lib/musicbrainz", () => ({ browseReleaseGroups: vi.fn() }));
import { browseReleaseGroups } from "@/lib/musicbrainz";
import { prisma } from "@/lib/db";
import { GET, POST } from "./route";

const mockBrowse = vi.mocked(browseReleaseGroups);
beforeEach(() => mockBrowse.mockReset());

function postReq(body: unknown) {
  return new Request("http://localhost/api/artists", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
}

describe("artists collection API", () => {
  it("follows an artist and persists the discography unmonitored", async () => {
    mockBrowse.mockResolvedValue([
      { rgMbid: "rg1", title: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006-09-12" },
      { rgMbid: "rg2", title: "Battle Studies", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2009" },
    ]);
    const res = await POST(postReq({ mbid: "a1", name: "John Mayer" }));
    expect(res.status).toBe(201);
    expect((await res.json()).releaseCount).toBe(2);

    const rels = await prisma.monitoredRelease.findMany({ where: { artistMbid: "a1" } });
    expect(rels).toHaveLength(2);
    expect(rels.every((r) => r.monitored === false && r.state === "wanted")).toBe(true);
  });

  it("rejects a bad body and a duplicate follow", async () => {
    expect((await POST(postReq({ mbid: "a1" }))).status).toBe(400);
    mockBrowse.mockResolvedValue([]);
    expect((await POST(postReq({ mbid: "a1", name: "John Mayer" }))).status).toBe(201);
    expect((await POST(postReq({ mbid: "a1", name: "John Mayer" }))).status).toBe(409);
  });

  it("502s when the discography fetch fails and creates nothing", async () => {
    mockBrowse.mockRejectedValue(new Error("boom"));
    expect((await POST(postReq({ mbid: "a9", name: "X" }))).status).toBe(502);
    expect(await prisma.watchedArtist.findUnique({ where: { mbid: "a9" } })).toBeNull();
  });

  it("lists followed artists with counts", async () => {
    mockBrowse.mockResolvedValue([
      { rgMbid: "rg1", title: "A", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2001" },
    ]);
    await POST(postReq({ mbid: "a1", name: "John Mayer" }));
    // mark the one release as monitored + have, to exercise the counts
    await prisma.monitoredRelease.updateMany({ where: { artistMbid: "a1" }, data: { monitored: true, currentQualityClass: 2 } });

    const res = await GET();
    expect(res.status).toBe(200);
    const a = (await res.json()).artists[0];
    expect(a).toMatchObject({ mbid: "a1", name: "John Mayer", releaseCount: 1, monitoredCount: 1, haveCount: 1 });
  });
});
  • Step 2: Run the test to verify it fails
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- api/artists/route

Expected: FAIL — cannot resolve ./route.

  • Step 3: Implement the route

web/src/app/api/artists/route.ts:

import { prisma } from "@/lib/db";
import { browseReleaseGroups } from "@/lib/musicbrainz";

export async function GET() {
  const artists = await prisma.watchedArtist.findMany({
    orderBy: { createdAt: "desc" },
    include: { releases: { select: { monitored: true, currentQualityClass: true } } },
  });
  return Response.json({
    artists: artists.map((a) => ({
      id: a.id,
      mbid: a.mbid,
      name: a.name,
      autoMonitorFuture: a.autoMonitorFuture,
      releaseCount: a.releases.length,
      monitoredCount: a.releases.filter((r) => r.monitored).length,
      haveCount: a.releases.filter((r) => r.currentQualityClass !== null).length,
    })),
  });
}

export async function POST(request: Request) {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return Response.json({ error: "invalid JSON" }, { status: 400 });
  }
  const { mbid, name } = (body ?? {}) as { mbid?: unknown; name?: unknown };
  if (typeof mbid !== "string" || !mbid.trim() || typeof name !== "string" || !name.trim()) {
    return Response.json({ error: "mbid and name are required" }, { status: 400 });
  }
  if (await prisma.watchedArtist.findUnique({ where: { mbid } })) {
    return Response.json({ error: "already following this artist" }, { status: 409 });
  }

  let releases;
  try {
    releases = await browseReleaseGroups(mbid);
  } catch {
    return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 });
  }

  const artist = await prisma.watchedArtist.create({ data: { mbid, name: name.trim() } });
  if (releases.length > 0) {
    await prisma.monitoredRelease.createMany({
      data: releases.map((r) => ({
        watchedArtistId: artist.id,
        artistMbid: mbid,
        artistName: name.trim(),
        rgMbid: r.rgMbid,
        album: r.title,
        primaryType: r.primaryType,
        secondaryTypes: r.secondaryTypes,
        firstReleaseDate: r.firstReleaseDate,
        monitored: false,
      })),
      skipDuplicates: true,
    });
  }
  return Response.json(
    { id: artist.id, mbid: artist.mbid, name: artist.name, releaseCount: releases.length },
    { status: 201 },
  );
}
  • Step 4: Run the test to verify it passes
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- api/artists/route

Expected: PASS — all four tests green.

  • Step 5: Commit
cd /home/jonathan/Projects/lyra
git add web/src/app/api/artists/route.ts web/src/app/api/artists/route.test.ts
git commit -m "feat: artists API — follow (persist discography) + list"

Task 4: Artist item API — detail, auto-monitor toggle, unfollow

Files:

  • Create: web/src/app/api/artists/[id]/route.ts
  • Test: web/src/app/api/artists/[id]/route.test.ts

Interfaces:

  • Consumes: prisma.

  • Produces: GET /api/artists/[id]{id,mbid,name,autoMonitorFuture,releases:[{id,rgMbid,album,primaryType,secondaryTypes,firstReleaseDate,monitored,state,currentQualityClass}]} (404 if missing); PATCH /api/artists/[id] {autoMonitorFuture:boolean}{id,autoMonitorFuture} (400 bad body, 404 missing); DELETE /api/artists/[id] → 204 (cascades releases), 404 if missing.

  • Step 1: Write the failing test

web/src/app/api/artists/[id]/route.test.ts:

import { describe, it, expect } from "vitest";
import { prisma } from "@/lib/db";
import { GET, PATCH, DELETE } from "./route";

const ctx = (id: string) => ({ params: Promise.resolve({ id }) });

async function seedArtist() {
  return prisma.watchedArtist.create({
    data: {
      mbid: "a1",
      name: "John Mayer",
      releases: {
        create: [
          { artistMbid: "a1", artistName: "John Mayer", rgMbid: "rg1", album: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006-09-12", monitored: true, state: "grabbed", currentQualityClass: 1 },
        ],
      },
    },
  });
}

function patchReq(body: unknown) {
  return new Request("http://localhost/x", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
}

describe("artist item API", () => {
  it("returns detail with releases", async () => {
    const a = await seedArtist();
    const res = await GET(new Request("http://localhost/x"), ctx(a.id));
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.name).toBe("John Mayer");
    expect(body.releases[0]).toMatchObject({ rgMbid: "rg1", album: "Continuum", monitored: true, state: "grabbed", currentQualityClass: 1 });
  });

  it("404s an unknown artist on GET", async () => {
    expect((await GET(new Request("http://localhost/x"), ctx("nope"))).status).toBe(404);
  });

  it("toggles autoMonitorFuture", async () => {
    const a = await seedArtist();
    const res = await PATCH(patchReq({ autoMonitorFuture: true }), ctx(a.id));
    expect(res.status).toBe(200);
    expect((await res.json()).autoMonitorFuture).toBe(true);
    expect((await prisma.watchedArtist.findUnique({ where: { id: a.id } }))!.autoMonitorFuture).toBe(true);
  });

  it("400s a non-boolean toggle and 404s an unknown artist", async () => {
    const a = await seedArtist();
    expect((await PATCH(patchReq({ autoMonitorFuture: "yes" }), ctx(a.id))).status).toBe(400);
    expect((await PATCH(patchReq({ autoMonitorFuture: true }), ctx("nope"))).status).toBe(404);
  });

  it("unfollows and cascades releases", async () => {
    const a = await seedArtist();
    const res = await DELETE(new Request("http://localhost/x"), ctx(a.id));
    expect(res.status).toBe(204);
    expect(await prisma.watchedArtist.findUnique({ where: { id: a.id } })).toBeNull();
    expect(await prisma.monitoredRelease.count({ where: { rgMbid: "rg1" } })).toBe(0);
  });
});
  • Step 2: Run the test to verify it fails
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- -t "artist item API"

Expected: FAIL — cannot resolve ./route (the [id] path has regex-special brackets, so filter by test name, not path).

  • Step 3: Implement the route

web/src/app/api/artists/[id]/route.ts:

import { prisma } from "@/lib/db";

export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const artist = await prisma.watchedArtist.findUnique({
    where: { id },
    include: { releases: { orderBy: [{ firstReleaseDate: "desc" }, { album: "asc" }] } },
  });
  if (!artist) return Response.json({ error: "not found" }, { status: 404 });
  return Response.json({
    id: artist.id,
    mbid: artist.mbid,
    name: artist.name,
    autoMonitorFuture: artist.autoMonitorFuture,
    releases: artist.releases.map((r) => ({
      id: r.id,
      rgMbid: r.rgMbid,
      album: r.album,
      primaryType: r.primaryType,
      secondaryTypes: r.secondaryTypes,
      firstReleaseDate: r.firstReleaseDate,
      monitored: r.monitored,
      state: r.state,
      currentQualityClass: r.currentQualityClass,
    })),
  });
}

export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return Response.json({ error: "invalid JSON" }, { status: 400 });
  }
  const { autoMonitorFuture } = (body ?? {}) as { autoMonitorFuture?: unknown };
  if (typeof autoMonitorFuture !== "boolean") {
    return Response.json({ error: "autoMonitorFuture (boolean) is required" }, { status: 400 });
  }
  try {
    const updated = await prisma.watchedArtist.update({ where: { id }, data: { autoMonitorFuture } });
    return Response.json({ id: updated.id, autoMonitorFuture: updated.autoMonitorFuture });
  } catch {
    return Response.json({ error: "not found" }, { status: 404 });
  }
}

export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  try {
    await prisma.watchedArtist.delete({ where: { id } });
    return new Response(null, { status: 204 });
  } catch {
    return Response.json({ error: "not found" }, { status: 404 });
  }
}
  • Step 4: Run the test to verify it passes
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- -t "artist item API"

Expected: PASS — all five tests green.

  • Step 5: Commit
cd /home/jonathan/Projects/lyra
git add "web/src/app/api/artists/[id]"
git commit -m "feat: artist item API — detail, auto-monitor toggle, unfollow"

Task 5: Releases API — monitor toggle + search-now

Files:

  • Create: web/src/app/api/releases/[id]/route.ts
  • Create: web/src/app/api/releases/[id]/search/route.ts
  • Test: web/src/app/api/releases/[id]/route.test.ts
  • Test: web/src/app/api/releases/[id]/search/route.test.ts

Interfaces:

  • Consumes: prisma.

  • Produces: PATCH /api/releases/[id] {monitored:boolean}{id,monitored} (400 bad body, 404 missing); POST /api/releases/[id]/search → 202 {enqueued:true} (creates a Request with monitoredReleaseId + a Job, stamps lastSearchedAt), 404 if the release is missing.

  • Step 1: Write the failing tests

web/src/app/api/releases/[id]/route.test.ts:

import { describe, it, expect } from "vitest";
import { prisma } from "@/lib/db";
import { PATCH } from "./route";

const ctx = (id: string) => ({ params: Promise.resolve({ id }) });

async function seedRelease(monitored = false) {
  return prisma.monitoredRelease.create({
    data: { artistMbid: "a1", artistName: "John Mayer", rgMbid: "rg1", album: "Continuum", secondaryTypes: [], monitored },
  });
}
function patchReq(body: unknown) {
  return new Request("http://localhost/x", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
}

describe("PATCH /api/releases/[id]", () => {
  it("toggles monitored", async () => {
    const r = await seedRelease(false);
    const res = await PATCH(patchReq({ monitored: true }), ctx(r.id));
    expect(res.status).toBe(200);
    expect((await res.json()).monitored).toBe(true);
    expect((await prisma.monitoredRelease.findUnique({ where: { id: r.id } }))!.monitored).toBe(true);
  });

  it("400s a non-boolean and 404s an unknown release", async () => {
    const r = await seedRelease();
    expect((await PATCH(patchReq({ monitored: 1 }), ctx(r.id))).status).toBe(400);
    expect((await PATCH(patchReq({ monitored: true }), ctx("nope"))).status).toBe(404);
  });
});

web/src/app/api/releases/[id]/search/route.test.ts:

import { describe, it, expect } from "vitest";
import { prisma } from "@/lib/db";
import { POST } from "./route";

const ctx = (id: string) => ({ params: Promise.resolve({ id }) });

describe("POST /api/releases/[id]/search", () => {
  it("enqueues a Request+Job linked to the release and stamps lastSearchedAt", async () => {
    const r = await prisma.monitoredRelease.create({
      data: { artistMbid: "a1", artistName: "John Mayer", rgMbid: "rg1", album: "Continuum", secondaryTypes: [], monitored: true },
    });
    const res = await POST(new Request("http://localhost/x", { method: "POST" }), ctx(r.id));
    expect(res.status).toBe(202);
    expect((await res.json()).enqueued).toBe(true);

    const reqs = await prisma.request.findMany({ where: { monitoredReleaseId: r.id }, include: { job: true } });
    expect(reqs).toHaveLength(1);
    expect(reqs[0]).toMatchObject({ artist: "John Mayer", album: "Continuum" });
    expect(reqs[0].job!.state).toBe("requested");
    expect((await prisma.monitoredRelease.findUnique({ where: { id: r.id } }))!.lastSearchedAt).not.toBeNull();
  });

  it("404s an unknown release", async () => {
    expect((await POST(new Request("http://localhost/x", { method: "POST" }), ctx("nope"))).status).toBe(404);
  });
});
  • Step 2: Run the tests to verify they fail
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- "api/releases"

Expected: FAIL — cannot resolve ./route.

  • Step 3: Implement the routes

web/src/app/api/releases/[id]/route.ts:

import { prisma } from "@/lib/db";

export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return Response.json({ error: "invalid JSON" }, { status: 400 });
  }
  const { monitored } = (body ?? {}) as { monitored?: unknown };
  if (typeof monitored !== "boolean") {
    return Response.json({ error: "monitored (boolean) is required" }, { status: 400 });
  }
  try {
    const updated = await prisma.monitoredRelease.update({ where: { id }, data: { monitored } });
    return Response.json({ id: updated.id, monitored: updated.monitored });
  } catch {
    return Response.json({ error: "not found" }, { status: 404 });
  }
}

web/src/app/api/releases/[id]/search/route.ts:

import { prisma } from "@/lib/db";

export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const release = await prisma.monitoredRelease.findUnique({ where: { id } });
  if (!release) return Response.json({ error: "not found" }, { status: 404 });

  await prisma.request.create({
    data: {
      artist: release.artistName,
      album: release.album,
      monitoredReleaseId: release.id,
      job: { create: {} },
    },
  });
  await prisma.monitoredRelease.update({ where: { id }, data: { lastSearchedAt: new Date() } });
  return Response.json({ enqueued: true }, { status: 202 });
}
  • Step 4: Run the tests to verify they pass
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- "api/releases"

Expected: PASS — all four tests green.

  • Step 5: Commit
cd /home/jonathan/Projects/lyra
git add "web/src/app/api/releases"
git commit -m "feat: releases API — monitor toggle + search-now enqueue"

Task 6: Wanted list API — list + standalone add

Files:

  • Create: web/src/app/api/wanted/route.ts
  • Test: web/src/app/api/wanted/route.test.ts

Interfaces:

  • Consumes: prisma, searchReleaseGroup (Task 1).

  • Produces: GET /api/wanted{ wanted: [{id,artistName,album,state,currentQualityClass,lastSearchedAt,watchedArtistId}] } (all monitored=true and state != 'fulfilled', oldest-searched first); POST /api/wanted {artist,album} → 201 (new standalone monitored release) or 200 (re-monitor an existing release-group), 400 bad body, 404 if MB finds no match, 502 on MB failure.

  • Step 1: Write the failing test

web/src/app/api/wanted/route.test.ts:

import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@/lib/musicbrainz", () => ({ searchReleaseGroup: vi.fn() }));
import { searchReleaseGroup } from "@/lib/musicbrainz";
import { prisma } from "@/lib/db";
import { GET, POST } from "./route";

const mockSearch = vi.mocked(searchReleaseGroup);
beforeEach(() => mockSearch.mockReset());

function postReq(body: unknown) {
  return new Request("http://localhost/api/wanted", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
}

describe("wanted API", () => {
  it("lists monitored, non-fulfilled releases only", async () => {
    await prisma.monitoredRelease.createMany({
      data: [
        { artistMbid: "a1", artistName: "A", rgMbid: "rg1", album: "Wanted", secondaryTypes: [], monitored: true, state: "wanted" },
        { artistMbid: "a1", artistName: "A", rgMbid: "rg2", album: "Done", secondaryTypes: [], monitored: true, state: "fulfilled" },
        { artistMbid: "a1", artistName: "A", rgMbid: "rg3", album: "Dormant", secondaryTypes: [], monitored: false, state: "wanted" },
      ],
    });
    const res = await GET();
    expect(res.status).toBe(200);
    const wanted = (await res.json()).wanted;
    expect(wanted.map((w: any) => w.album)).toEqual(["Wanted"]);
  });

  it("adds a standalone wanted release from artist+album", async () => {
    mockSearch.mockResolvedValue({ rgMbid: "rg9", title: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006-09-12", artistMbid: "a1", artistName: "John Mayer" });
    const res = await POST(postReq({ artist: "John Mayer", album: "Continuum" }));
    expect(res.status).toBe(201);
    const row = await prisma.monitoredRelease.findUnique({ where: { rgMbid: "rg9" } });
    expect(row).toMatchObject({ artistName: "John Mayer", album: "Continuum", monitored: true, watchedArtistId: null });
  });

  it("re-monitors an existing release-group (200) instead of duplicating", async () => {
    await prisma.monitoredRelease.create({ data: { artistMbid: "a1", artistName: "John Mayer", rgMbid: "rg9", album: "Continuum", secondaryTypes: [], monitored: false } });
    mockSearch.mockResolvedValue({ rgMbid: "rg9", title: "Continuum", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2006", artistMbid: "a1", artistName: "John Mayer" });
    const res = await POST(postReq({ artist: "John Mayer", album: "Continuum" }));
    expect(res.status).toBe(200);
    expect((await prisma.monitoredRelease.findUnique({ where: { rgMbid: "rg9" } }))!.monitored).toBe(true);
    expect(await prisma.monitoredRelease.count({ where: { rgMbid: "rg9" } })).toBe(1);
  });

  it("400s a bad body, 404s no MB match, 502s on MB failure", async () => {
    expect((await POST(postReq({ artist: "X" }))).status).toBe(400);
    mockSearch.mockResolvedValue(null);
    expect((await POST(postReq({ artist: "X", album: "Y" }))).status).toBe(404);
    mockSearch.mockRejectedValue(new Error("boom"));
    expect((await POST(postReq({ artist: "X", album: "Y" }))).status).toBe(502);
  });
});
  • Step 2: Run the test to verify it fails
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- api/wanted

Expected: FAIL — cannot resolve ./route.

  • Step 3: Implement the route

web/src/app/api/wanted/route.ts:

import { prisma } from "@/lib/db";
import { searchReleaseGroup } from "@/lib/musicbrainz";

export async function GET() {
  const items = await prisma.monitoredRelease.findMany({
    where: { monitored: true, state: { not: "fulfilled" } },
    orderBy: [{ lastSearchedAt: { sort: "asc", nulls: "first" } }, { createdAt: "asc" }],
  });
  return Response.json({
    wanted: items.map((r) => ({
      id: r.id,
      artistName: r.artistName,
      album: r.album,
      state: r.state,
      currentQualityClass: r.currentQualityClass,
      lastSearchedAt: r.lastSearchedAt,
      watchedArtistId: r.watchedArtistId,
    })),
  });
}

export async function POST(request: Request) {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return Response.json({ error: "invalid JSON" }, { status: 400 });
  }
  const { artist, album } = (body ?? {}) as { artist?: unknown; album?: unknown };
  if (typeof artist !== "string" || !artist.trim() || typeof album !== "string" || !album.trim()) {
    return Response.json({ error: "artist and album are required" }, { status: 400 });
  }

  let match;
  try {
    match = await searchReleaseGroup(artist.trim(), album.trim());
  } catch {
    return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 });
  }
  if (!match) {
    return Response.json({ error: "no matching release-group found" }, { status: 404 });
  }

  const existing = await prisma.monitoredRelease.findUnique({ where: { rgMbid: match.rgMbid } });
  if (existing) {
    const updated = await prisma.monitoredRelease.update({ where: { rgMbid: match.rgMbid }, data: { monitored: true } });
    return Response.json({ id: updated.id, album: updated.album, monitored: updated.monitored }, { status: 200 });
  }
  const created = await prisma.monitoredRelease.create({
    data: {
      artistMbid: match.artistMbid,
      artistName: match.artistName,
      rgMbid: match.rgMbid,
      album: match.title,
      primaryType: match.primaryType,
      secondaryTypes: match.secondaryTypes,
      firstReleaseDate: match.firstReleaseDate,
      monitored: true,
    },
  });
  return Response.json({ id: created.id, album: created.album, monitored: created.monitored }, { status: 201 });
}
  • Step 4: Run the test to verify it passes
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test -- api/wanted

Expected: PASS — all four tests green.

  • Step 5: Run the FULL web suite (no regressions)
cd /home/jonathan/Projects/lyra/web
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test

Expected: PASS — every route/lib test green (existing requests/config/crypto suites unaffected).

  • Step 6: Commit
cd /home/jonathan/Projects/lyra
git add web/src/app/api/wanted
git commit -m "feat: wanted list API — list + standalone add"

Task 7: Artists page — list + live-search follow picker

Files:

  • Create: web/src/app/artists/page.tsx
  • Create: web/src/app/artists/artists-client.tsx
  • Modify: web/src/app/page.tsx (nav links)

Interfaces:

  • Consumes: /api/artists (GET/POST/DELETE-via-item), /api/mb/artists?q=, /api/artists/[id] (PATCH toggle). UI only — verified by typecheck (repo has no component test harness).

  • Step 1: Add the server page

web/src/app/artists/page.tsx:

import { ArtistsClient } from "./artists-client";

export default function ArtistsPage() {
  return (
    <main>
      <h1>Watched artists</h1>
      <p><a href="/">Home</a> · <a href="/wanted">Wanted</a></p>
      <ArtistsClient />
    </main>
  );
}
  • Step 2: Add the client component

web/src/app/artists/artists-client.tsx:

"use client";

import { useEffect, useState } from "react";

type Artist = {
  id: string;
  mbid: string;
  name: string;
  autoMonitorFuture: boolean;
  releaseCount: number;
  monitoredCount: number;
  haveCount: number;
};
type Hit = { mbid: string; name: string; disambiguation: string };

export function ArtistsClient() {
  const [artists, setArtists] = useState<Artist[]>([]);
  const [query, setQuery] = useState("");
  const [hits, setHits] = useState<Hit[]>([]);
  const [searching, setSearching] = useState(false);

  async function refresh() {
    const res = await fetch("/api/artists");
    setArtists((await res.json()).artists);
  }
  useEffect(() => {
    refresh();
  }, []);

  async function search(e: React.FormEvent) {
    e.preventDefault();
    if (!query.trim()) return;
    setSearching(true);
    try {
      const res = await fetch(`/api/mb/artists?q=${encodeURIComponent(query.trim())}`);
      setHits(res.ok ? (await res.json()).artists : []);
    } finally {
      setSearching(false);
    }
  }

  async function follow(hit: Hit) {
    await fetch("/api/artists", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ mbid: hit.mbid, name: hit.name }),
    });
    setHits([]);
    setQuery("");
    refresh();
  }

  async function toggleAuto(a: Artist) {
    await fetch(`/api/artists/${a.id}`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ autoMonitorFuture: !a.autoMonitorFuture }),
    });
    refresh();
  }

  async function unfollow(a: Artist) {
    await fetch(`/api/artists/${a.id}`, { method: "DELETE" });
    refresh();
  }

  return (
    <div>
      <form onSubmit={search}>
        <input aria-label="artist search" placeholder="Search an artist…" value={query} onChange={(e) => setQuery(e.target.value)} />
        <button type="submit">Search</button>
      </form>
      {searching ? <p>Searching</p> : null}
      {hits.length > 0 ? (
        <ul>
          {hits.map((h) => (
            <li key={h.mbid}>
              {h.name}
              {h.disambiguation ? ` (${h.disambiguation})` : ""}{" "}
              <button onClick={() => follow(h)}>Follow</button>
            </li>
          ))}
        </ul>
      ) : null}

      <ul>
        {artists.map((a) => (
          <li key={a.id}>
            <a href={`/artists/${a.id}`}>{a.name}</a> · {a.haveCount}/{a.monitoredCount} have/monitored of {a.releaseCount}{" "}
            <label>
              <input type="checkbox" aria-label={`auto-monitor ${a.name}`} checked={a.autoMonitorFuture} onChange={() => toggleAuto(a)} /> auto-monitor future
            </label>{" "}
            <button onClick={() => unfollow(a)}>Unfollow</button>
          </li>
        ))}
      </ul>
    </div>
  );
}
  • Step 3: Add nav links on the home page

Replace web/src/app/page.tsx:

import { Queue } from "./queue";

export default function Home() {
  return (
    <main>
      <h1>Lyra</h1>
      <p><a href="/artists">Artists</a> · <a href="/wanted">Wanted</a> · <a href="/settings">Settings</a></p>
      <Queue />
    </main>
  );
}
  • Step 4: Typecheck
cd /home/jonathan/Projects/lyra/web
npx tsc --noEmit

Expected: no type errors.

  • Step 5: Commit
cd /home/jonathan/Projects/lyra
git add web/src/app/artists/page.tsx web/src/app/artists/artists-client.tsx web/src/app/page.tsx
git commit -m "feat: artists page with live-search follow picker"

Task 8: Artist discography page + wanted page

Files:

  • Create: web/src/app/artists/[id]/page.tsx
  • Create: web/src/app/artists/[id]/discography-client.tsx
  • Create: web/src/app/wanted/page.tsx
  • Create: web/src/app/wanted/wanted-client.tsx

Interfaces:

  • Consumes: /api/artists/[id] (GET), /api/releases/[id] (PATCH monitor), /api/releases/[id]/search (POST), /api/wanted (GET/POST). UI only — verified by typecheck.

  • Step 1: Add the discography server page

web/src/app/artists/[id]/page.tsx:

import { DiscographyClient } from "./discography-client";

export default async function ArtistDetailPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return (
    <main>
      <p><a href="/artists"> Artists</a></p>
      <DiscographyClient id={id} />
    </main>
  );
}
  • Step 2: Add the discography client

web/src/app/artists/[id]/discography-client.tsx:

"use client";

import { useEffect, useState } from "react";

type Release = {
  id: string;
  album: string;
  primaryType: string | null;
  secondaryTypes: string[];
  firstReleaseDate: string | null;
  monitored: boolean;
  state: string;
  currentQualityClass: number | null;
};
type Detail = { id: string; name: string; autoMonitorFuture: boolean; releases: Release[] };

function badge(r: Release): string {
  if (r.currentQualityClass !== null) return `Have (q${r.currentQualityClass})`;
  if (r.monitored) return r.state === "wanted" ? "Wanted" : r.state;
  return "Dormant";
}

export function DiscographyClient({ id }: { id: string }) {
  const [detail, setDetail] = useState<Detail | null>(null);

  async function refresh() {
    const res = await fetch(`/api/artists/${id}`);
    setDetail(res.ok ? await res.json() : null);
  }
  useEffect(() => {
    refresh();
  }, [id]);

  async function toggleMonitor(r: Release) {
    await fetch(`/api/releases/${r.id}`, {
      method: "PATCH",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ monitored: !r.monitored }),
    });
    refresh();
  }
  async function searchNow(r: Release) {
    await fetch(`/api/releases/${r.id}/search`, { method: "POST" });
    refresh();
  }

  if (!detail) return <p>Loading</p>;
  return (
    <div>
      <h1>{detail.name}</h1>
      <ul>
        {detail.releases.map((r) => (
          <li key={r.id}>
            <strong>{r.album}</strong>
            {r.firstReleaseDate ? ` (${r.firstReleaseDate.slice(0, 4)})` : ""}
            {r.primaryType ? ` · ${r.primaryType}` : ""}
            {r.secondaryTypes.length ? ` [${r.secondaryTypes.join(", ")}]` : ""} · <em>{badge(r)}</em>{" "}
            <label>
              <input type="checkbox" aria-label={`monitor ${r.album}`} checked={r.monitored} onChange={() => toggleMonitor(r)} /> monitor
            </label>{" "}
            <button onClick={() => searchNow(r)}>Search now</button>
          </li>
        ))}
      </ul>
    </div>
  );
}
  • Step 3: Add the wanted server page

web/src/app/wanted/page.tsx:

import { WantedClient } from "./wanted-client";

export default function WantedPage() {
  return (
    <main>
      <h1>Wanted</h1>
      <p><a href="/">Home</a> · <a href="/artists">Artists</a></p>
      <WantedClient />
    </main>
  );
}
  • Step 4: Add the wanted client

web/src/app/wanted/wanted-client.tsx:

"use client";

import { useEffect, useState } from "react";

type Wanted = {
  id: string;
  artistName: string;
  album: string;
  state: string;
  currentQualityClass: number | null;
  lastSearchedAt: string | null;
};

export function WantedClient() {
  const [rows, setRows] = useState<Wanted[]>([]);
  const [artist, setArtist] = useState("");
  const [album, setAlbum] = useState("");
  const [error, setError] = useState("");

  async function refresh() {
    const res = await fetch("/api/wanted");
    setRows((await res.json()).wanted);
  }
  useEffect(() => {
    refresh();
  }, []);

  async function add(e: React.FormEvent) {
    e.preventDefault();
    setError("");
    if (!artist.trim() || !album.trim()) return;
    const res = await fetch("/api/wanted", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ artist, album }),
    });
    if (!res.ok) {
      setError((await res.json()).error ?? "failed to add");
      return;
    }
    setArtist("");
    setAlbum("");
    refresh();
  }

  async function searchNow(w: Wanted) {
    await fetch(`/api/releases/${w.id}/search`, { method: "POST" });
    refresh();
  }

  return (
    <div>
      <form onSubmit={add}>
        <input aria-label="wanted artist" placeholder="Artist" value={artist} onChange={(e) => setArtist(e.target.value)} />
        <input aria-label="wanted album" placeholder="Album" value={album} onChange={(e) => setAlbum(e.target.value)} />
        <button type="submit">Add to wanted</button>
        {error ? <span> {error}</span> : null}
      </form>
      <ul>
        {rows.map((w) => (
          <li key={w.id}>
            {w.artistName}  {w.album} · <em>{w.currentQualityClass !== null ? `have q${w.currentQualityClass}, upgrading` : w.state}</em>
            {w.lastSearchedAt ? ` · last tried ${new Date(w.lastSearchedAt).toLocaleString()}` : " · never tried"}{" "}
            <button onClick={() => searchNow(w)}>Search now</button>
          </li>
        ))}
      </ul>
    </div>
  );
}
  • Step 5: Typecheck and run the full web suite
cd /home/jonathan/Projects/lyra/web
npx tsc --noEmit
DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra npm test

Expected: no type errors; every web test still green.

  • Step 6: Commit
cd /home/jonathan/Projects/lyra
git add "web/src/app/artists/[id]" web/src/app/wanted
git commit -m "feat: artist discography page + wanted page"

Notes: slice 2 complete

With this plan the library manager is end-to-end: follow an artist via live MusicBrainz search, see the whole discography instantly (persisted on follow), monitor exactly the releases you want (or flip auto-monitor-future per artist), maintain a cross-artist wanted list with standalone adds, and trigger an immediate grab with "search now" — all feeding the plan-1 worker monitor, which discovers, enqueues, upgrades (within the window), and reconciles through the slice-1 pipeline. Deferred (future slices): notifications, a release calendar, discovery (slice 3). Operational note: enabling monitoring is still a config step — set monitor.enabled=true (plus optional monitor.* tuning) in the settings/Config surface — and MusicBrainz asks clients to stay within ~1 request/second, which is comfortable for interactive single-user searches; the worker's scheduled discovery is the only sustained MB user.