4dbb6d9d83
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>
204 lines
10 KiB
Markdown
204 lines
10 KiB
Markdown
# 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.
|