docs: discovery preview design — artist preview page (disco + tracklists + own-state)

Follow-up to slice 3: a read-only artist preview page keyed by MBID, reachable from
suggested artists, suggested albums, and Find-similar results. Shows discography,
lazy per-album tracklists, own-state badges; Follow (reuse /api/artists) + Want
(new /api/discover/want upsert). Web-side MB reads only, no worker changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-12 01:26:16 +02:00
parent decb6302d4
commit 4dbb6d9d83
@@ -0,0 +1,203 @@
# Lyra — Discovery Preview (Design)
**Date:** 2026-07-12
**Status:** Approved design, ready for implementation planning
**Slice:** 3 follow-up (Discovery preview / depth + actionability)
## Overview
Slice 3 shipped a discovery feed that is view-only: you can Follow / Want / Dismiss
a suggestion, but you cannot inspect it first (no discography, no tracklist), and
the "Find similar" seed-search results are a dead end (no actions at all). This
follow-up adds **depth and actionability** via a single read-only **artist preview
page keyed by MusicBrainz MBID**, reachable from every discovery entry point.
From a suggested artist, a suggested album, or a Find-similar result you can now
open a preview page that shows the artist's discography, expands any album to its
tracklist, marks albums you already have or monitor, and lets you **Follow** the
artist or **Want** a specific album — all before committing. All MusicBrainz reads
happen web-side (the pattern slices 23 established for interactive lookups); there
are **no worker changes and no new config**.
### Goals
- Let the user inspect a recommendation in depth before acting: artist → albums →
tracklist, without following first.
- Make **Find-similar** results actionable (currently plain text): each links to
the preview page and has a direct Follow.
- Show **own-state** on the preview so the user doesn't re-request music they
already have or monitor.
- Reuse existing primitives: Follow = the existing `POST /api/artists`; the MB
discography route already exists; Want reuses the discovery want upsert.
- Keep it web-side and credential-free; fully testable with a fake/mocked MB.
### Non-goals (this slice)
- No worker changes, no new `Config` keys, no changes to the discovery sweep.
- No tracklist caching/persistence — fetched live from MB on expand.
- No audio preview/playback.
- No separate album page — album entry points reuse the artist preview anchored to
the album.
- No monitor-toggling on the preview (that stays on `/artists/[id]` after follow).
## Scope
**In scope:** a `browseReleaseGroupTracks` MB lib function; a
`GET /api/discover/preview/[mbid]` endpoint (annotated discography + own-state +
followed flag); a `GET /api/mb/release-groups/[rgMbid]/tracks` endpoint; a
`POST /api/discover/want` endpoint (want a specific release-group by metadata); the
`/discover/artist/[mbid]` preview page; and wiring the three discovery entry points
(suggested artist, suggested album, Find-similar result) to the preview page + a
Follow action on seed-search results.
**Out of scope:** everything under Non-goals.
## Architecture
```
/discover feed (discover-client.tsx)
suggested artist name ─► /discover/artist/[artistMbid]?name=…
suggested album ─► /discover/artist/[artistMbid]?name=…#rg-<rgMbid>
Find-similar result ─► name link to /discover/artist/[mbid]?name=…
+ Follow button ─► POST /api/artists {mbid,name}
/discover/artist/[mbid] (preview page, read-only)
GET /api/discover/preview/[mbid] ─► artistName, followed, releases[
{rgMbid, album, types, date, monitored, have} ]
(MB browse release-groups + join MonitoredRelease/LibraryItem + WatchedArtist)
per-album expand:
GET /api/mb/release-groups/[rgMbid]/tracks ─► { tracks[] } (lazy, MB)
actions:
Follow ─► POST /api/artists {mbid,name} (existing)
Want ─► POST /api/discover/want {rgMbid, …} (upsert monitored MonitoredRelease)
```
Everything the page needs is one MB-backed endpoint plus lazy per-album track
fetches; actions reuse the existing follow primitive and a small want-by-metadata
upsert.
## Components
### MB lib — `web/src/lib/musicbrainz.ts`
Add two functions:
`getArtistName(mbid: string): Promise<string>` — MB `/artist/<mbid>?fmt=json`,
returns the `name` (or `""`). Used only as the fallback when the caller didn't
pass `?name=`.
`browseReleaseGroupTracks(rgMbid: string): Promise<Track[]>` where
`Track = { position: number; title: string; lengthMs: number | null }`.
- Query `/release?release-group=<rgMbid>&inc=recordings&fmt=json&limit=25`.
- Choose one representative release: prefer `status === "Official"`, then the
earliest `date`; fall back to the first release. If none, return `[]`.
- Flatten its `media[].tracks[]` to `{ position, title, lengthMs: track.length ?? null }`,
ordered by media then track position.
- Reuses the existing `mbGet` helper + `USER_AGENT`.
### `GET /api/discover/preview/[mbid]`
Returns the data for the whole preview page:
```ts
{
artistName: string,
followed: boolean, // an existing WatchedArtist with this mbid?
releases: Array<{
rgMbid: string,
album: string,
primaryType: string | null,
secondaryTypes: string[],
firstReleaseDate: string | null,
monitored: boolean, // a monitored MonitoredRelease for this rgMbid
have: boolean, // MonitoredRelease.currentQualityClass set, OR a LibraryItem match
}>
}
```
Implementation:
1. `browseReleaseGroups(mbid)` for the discography. Default the response to **core
releases** (`isCoreRelease`); accept `?all=true` to include every type (the
preview page's "show all types" toggle passes this).
2. Resolve `artistName`: use the `?name=` query param if present (all in-app
callers pass it); otherwise `getArtistName(mbid)`. The `?name=` shortcut avoids
an extra MB call on the common path; direct navigation still resolves correctly.
3. `followed`: `WatchedArtist.findUnique({ where: { mbid } }) != null`.
4. Own-state: one `MonitoredRelease.findMany({ where: { rgMbid: { in: rgMbids } } })`
→ map `monitored` and `have = currentQualityClass != null`; plus a `LibraryItem`
match by `(artistName, album)` contributes `have`. Annotate each release-group.
5. `502` if MB is unavailable.
### `GET /api/mb/release-groups/[rgMbid]/tracks`
Thin wrapper over `browseReleaseGroupTracks`: `{ tracks }` on success, `502` on MB
failure. Lazy — the preview page calls it only when an album is expanded.
### `POST /api/discover/want`
Body `{ rgMbid, artistMbid, artistName, album, primaryType?, secondaryTypes?, firstReleaseDate? }`.
Validates `rgMbid`/`artistMbid`/`artistName`/`album` are non-empty strings, then
`prisma.monitoredRelease.upsert({ where: { rgMbid }, create: { …, monitored: true },
update: { monitored: true } })` — identical to the discovery action route's want
branch, but keyed off caller-supplied metadata (the preview page already has it
from the discography). Returns `{ id, album, monitored }`. `400` on a bad body.
### Preview page — `/discover/artist/[mbid]/page.tsx` + `preview-client.tsx`
- Server page reads `params.mbid` and `searchParams.name`, renders the client with
both + a nav line.
- Client:
- On mount, `GET /api/discover/preview/[mbid]?name=…(&all=…)`.
- **Header:** artist name; **Follow** button → `POST /api/artists` (hidden or
disabled + labelled "Following" when `followed`); a "MusicBrainz ↗" external
link to `https://musicbrainz.org/artist/<mbid>`.
- **"Show all types"** checkbox → refetch with `all=true`.
- **Discography list:** each row keyed/anchored `id="rg-<rgMbid>"`, shows album +
type + year, an own-state badge (`In library` / `Monitored` / none), a **Want**
button (disabled when `have || monitored`) → `POST /api/discover/want` with the
row's metadata, and an expand toggle that lazy-loads
`GET /api/mb/release-groups/[rgMbid]/tracks` and lists `## Title (m:ss)`.
- On load, if the URL has a `#rg-<rgMbid>` hash, scroll it into view and highlight.
### Feed wiring — `web/src/app/discover/discover-client.tsx`
- Suggested-artist `artistName``<a href="/discover/artist/{artistMbid}?name=…">`.
- Suggested-album `album``<a href="/discover/artist/{artistMbid}?name=…#rg-{rgMbid}">`.
- Find-similar result rows → name linked to `/discover/artist/{mbid}?name=…`, plus a
**Follow** button calling `POST /api/artists { mbid, name }` (optimistically
removes/greys the row on success).
## Error handling
| Failure | Behavior |
|---|---|
| MB unavailable (preview browse) | preview endpoint returns `502`; page shows an error with a retry. |
| MB unavailable (tracklist) | tracks endpoint `502`; the expanded album shows "couldn't load tracks", other rows unaffected. |
| Release-group has no releases/tracks | `browseReleaseGroupTracks` returns `[]`; UI shows "no track data". |
| Want a release already monitored | `upsert` flips `monitored=true` idempotently; button was already disabled if own-state known. |
| Follow an already-followed artist | `POST /api/artists` returns 409; UI treats it as already-followed (no error surfaced). |
| Direct navigation without `?name=` | endpoint resolves the name from MB; slightly slower, still correct. |
## Testing (web, against `lyra_test`)
- **`browseReleaseGroupTracks`** — mocked `fetch`: parses tracks, prefers Official
then earliest release, orders by media/position, `[]` when no releases.
- **`/api/discover/preview/[mbid]`** — mock `browseReleaseGroups`; seed
`WatchedArtist` / `MonitoredRelease` (monitored + have) / `LibraryItem`; assert
own-state annotation, `followed`, core-only default vs `?all=true`, `502` on MB
throw, `?name=` shortcut.
- **`/api/mb/release-groups/[rgMbid]/tracks`** — mock lib: tracks shape + `502`.
- **`/api/discover/want`** — DB-backed: creates a monitored `MonitoredRelease` from
the body; re-want idempotent; `400` on bad body.
- **Preview page + client and feed wiring** — verified by `tsc --noEmit` + `npm run
build` (UI wiring, consistent with how the slice-3 pages were verified).
## Notes / deferred
- Tracklists are fetched live per expand; if MB rate-limiting bites, a short-lived
cache or persisting tracklists on Want is a later option.
- The preview shows a single 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.