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>
10 KiB
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
Configkeys, 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 earliestdate; 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
mbGethelper +USER_AGENT.
GET /api/discover/preview/[mbid]
Returns the data for the whole preview page:
{
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:
browseReleaseGroups(mbid)for the discography. Default the response to core releases (isCoreRelease); accept?all=trueto include every type (the preview page's "show all types" toggle passes this).- Resolve
artistName: use the?name=query param if present (all in-app callers pass it); otherwisegetArtistName(mbid). The?name=shortcut avoids an extra MB call on the common path; direct navigation still resolves correctly. followed:WatchedArtist.findUnique({ where: { mbid } }) != null.- Own-state: one
MonitoredRelease.findMany({ where: { rgMbid: { in: rgMbids } } })→ mapmonitoredandhave = currentQualityClass != null; plus aLibraryItemmatch by(artistName, album)contributeshave. Annotate each release-group. 502if 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.mbidandsearchParams.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" whenfollowed); a "MusicBrainz ↗" external link tohttps://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 whenhave || monitored) →POST /api/discover/wantwith the row's metadata, and an expand toggle that lazy-loadsGET /api/mb/release-groups/[rgMbid]/tracksand lists## Title (m:ss). - On load, if the URL has a
#rg-<rgMbid>hash, scroll it into view and highlight.
- On mount,
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 callingPOST /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— mockedfetch: parses tracks, prefers Official then earliest release, orders by media/position,[]when no releases./api/discover/preview/[mbid]— mockbrowseReleaseGroups; seedWatchedArtist/MonitoredRelease(monitored + have) /LibraryItem; assert own-state annotation,followed, core-only default vs?all=true,502on MB throw,?name=shortcut./api/mb/release-groups/[rgMbid]/tracks— mock lib: tracks shape +502./api/discover/want— DB-backed: creates a monitoredMonitoredReleasefrom the body; re-want idempotent;400on 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.
haveviaLibraryItemmatches on(artistName, album)(name-based, like the existing wanted-add path); MBID-based library matching is a broader future change.