# 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 2–3 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- 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` — MB `/artist/?fmt=json`, returns the `name` (or `""`). Used only as the fallback when the caller didn't pass `?name=`. `browseReleaseGroupTracks(rgMbid: string): Promise` where `Track = { position: number; title: string; lengthMs: number | null }`. - Query `/release?release-group=&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/`. - **"Show all types"** checkbox → refetch with `all=true`. - **Discography list:** each row keyed/anchored `id="rg-"`, 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-` hash, scroll it into view and highlight. ### Feed wiring — `web/src/app/discover/discover-client.tsx` - Suggested-artist `artistName` → ``. - Suggested-album `album` → ``. - 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.