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:
@@ -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 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-<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.
|
||||||
Reference in New Issue
Block a user