docs: spec for Discover unified rows + newest/most-popular albums

Design for collapsing the Artists|Albums tabs into one artist-centric row,
surfacing each similar artist's newest AND most-popular (Last.fm) album inline,
album-click → AlbumModal tracklist, artist name → full page (drop the Discover
ArtistModal). Approved design; implementation plan next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 23:29:23 +02:00
parent e04c1c9795
commit 9dd235c2bc
@@ -0,0 +1,156 @@
# Discover redesign: unified artist rows + newest/most-popular albums
**Date:** 2026-07-14
**Status:** Approved (design), pending implementation plan
## Problem
`/discover` splits suggested artists and suggested albums into two tabs, but album
suggestions are not independent — each one is just "a notable album by a suggested similar
artist" (derived in `_derive_albums` from the surfaced artists). The two-tab split fragments a
single idea. Two further gaps:
- Only the **newest** un-owned album is surfaced per similar artist; the album the artist is
best known for (their **most popular**) is invisible unless it happens to be newest.
- Seeing an album's tracklist requires an extra modal hop, and the artist modal on Discover is
a lightweight duplicate of the richer full artist page.
## Goals
1. Keep album suggestions artist-mediated (no album-level similarity data exists in our
stack), but surface **two** albums per similar artist: **newest** and **most popular**.
2. Collapse the Artists|Albums tabs into **one unified list** — one row per suggested artist,
carrying that artist's album thumbnails inline.
3. On Discover, replace the artist modal with a direct link to the full artist page; keep
album-click → the existing `AlbumModal` (cover + tracklist + Want).
Non-goals: album-to-album similarity (no data source); collection-completion suggestions;
changing the Last.fm page's artist modal (it keeps its personal most-played view).
## Data-source decision: popularity
- **Chosen:** Last.fm `artist.getTopAlbums` — returns an artist's albums ranked by playcount,
each with a name and (usually) an MBID. The user already has Last.fm configured; no new
config. Confirmed live: the method exists (error 10 "invalid key" with a bogus key, not
error 3 "unknown method").
- **Rejected:** ListenBrainz `/1/popularity/release-group` — MBID-native and cleaner in
principle, but now requires a ListenBrainz auth token (anti-scraper), which is new config
friction. MusicBrainz exposes no popularity signal at all.
- **Fallback:** without Last.fm creds, or when no top album matches a qualifying un-owned
release group, the artist yields **newest-only** — byte-for-byte today's behavior.
## Design
### Part A — Album derivation (worker)
`_derive_albums(conn, browser, surfaced, cfg, popularity=None)` in
`worker/lyra_worker/discovery.py`.
For each surfaced artist:
1. Browse the artist's release groups from MB (already done) → filter to qualifying,
un-owned albums (`_album_kind_ok` + not in `_existing_rg_mbids`) — unchanged.
2. **Newest:** the newest `cfg.albums_per_artist` (default 1) by `first_release_date` desc —
unchanged, tagged `reason="newest"`.
3. **Most popular:** if `popularity` is available, fetch the artist's Last.fm top albums, and
pick the highest-playcount album that maps to a qualifying un-owned release group in the
browsed set — match by MBID first (when Last.fm supplies one that is in the browsed set),
else by normalized title (`stripEditionQualifiers`-equivalent, lowercased/trimmed). Tag
`reason="popular"`.
4. If the popular pick is the same release group as a newest pick, do not insert a second
row — the existing row's reason becomes `"newest,popular"`.
5. Upsert each as a `DiscoverySuggestion` (kind `album`) as today, additionally writing
`albumReason`.
**Popularity helper (injected, testable — mirrors `#16`'s `measure_duration`).** An object
with `top_albums(artist_name) -> list[(name, mbid_or_None, playcount)]`, ordered by playcount
desc. Built only when `lastfm.api_key` is set (worker decrypts via `crypto.py`); the fetch is
wrapped in `cached_api` (`ApiCache`, ~12h TTL, key `lfm-artist-top-albums:{norm}`). Errors are
swallowed → treated as "no popular pick" (newest-only). `run_discovery` constructs it from
config and passes it to `_derive_albums`; tests inject a fake or `None`.
**Cost:** ≤1 Last.fm call per surfaced artist per sweep, cached; sweeps are infrequent and
chunked. Only surfaced artists (post `min_score`) are queried.
### Part B — `/api/discover` groups suggestions by artist
`web/src/app/api/discover/route.ts` returns **rows** instead of two flat arrays. Build rows
from the union of pending artist-suggestions and the distinct artists of pending
album-suggestions (so an album is never orphaned), keyed by `artistMbid`:
```
rows: [{
id, // the artist suggestion id (for Follow/Dismiss), when present
artistMbid, artistName,
score, seedCount, sources,
seeds: string[], // "why suggested" (existing DiscoverySeedContribution join)
albums: [{
id, rgMbid, album, primaryType, secondaryTypes, firstReleaseDate,
albumReason, // "newest" | "popular" | "newest,popular"
}]
}]
```
Rows are ordered by `score` desc (artist suggestion score; album-only rows use the album's
score). The `find-similar` search flow is unchanged (separate endpoint, no albums).
### Part C — dismiss cascade
`POST /api/discover/[id]` (dismiss): when the target is an **artist** suggestion, also mark
its pending **album** suggestions (same `artistMbid`) dismissed, so the unified row disappears
cleanly rather than leaving stray albums. Follow/Want are unchanged.
### Part D — `/discover` client (unified rows, no tabs, no modal)
`web/src/app/discover/discover-client.tsx`:
- Remove the Artists|Albums tab bar and the `tab` state; render one `<ul>` of artist rows.
- Remove `ArtistModal` import + usage entirely. The artist **name links** to
`/discover/artist/{artistMbid}` (an `<a href>`; matches the existing full-page route).
- Each row renders its `albums[]` as small `CoverArt` thumbnails, each with:
- a **badge** from `albumReason`: `"newest"``Newest`, `"popular"``Most played`,
`"newest,popular"`→both; `null`→no badge;
- a **Want** action (`act(album.id, "want")`);
- **click → `AlbumModal`** (kept) with the album's `rgMbid`/title/artist → cover + tracklist
+ Want.
- Row actions stay: **Follow** / **Dismiss** (on `row.id`, the artist suggestion). If a row
has no artist suggestion id (album-only, e.g. artist dismissed earlier), Follow resolves by
MBID via `POST /api/artists`; Dismiss is hidden for that (degenerate) case.
- `find-similar` results keep their existing rendering, minus `ArtistModal` — their artist
links also go to the full page (consistency; `ArtistModal` is being removed from this file).
- New CSS for the row layout (artist block + horizontal album-thumb strip + actions),
responsive: the thumb strip wraps/stacks under the artist on narrow viewports.
### Schema / migration
`DiscoverySuggestion.albumReason String?` — nullable, backward compatible. Existing album
rows read as `null` (client treats null as no badge) until the next sweep re-derives them.
Migration `add_discovery_album_reason`.
## Testing
**Worker (`test_discovery_albums.py`):**
- newest + most-popular produce two rows with correct `albumReason`;
- dedupe when newest == popular → one row, `reason="newest,popular"`;
- no popularity helper → newest-only (unchanged), `reason="newest"`;
- popular pick skips owned / non-qualifying release groups and falls to the next-highest
playcount; no match → newest-only;
- injected fake popularity (no live Last.fm).
**Web:**
- `/api/discover` groups albums under their artist row; row carries `albums[]` + `seeds[]` +
`albumReason`; album-only artist still yields a row.
- dismiss cascade: dismissing an artist marks its album suggestions dismissed.
- existing route tests updated to the grouped shape.
**Live verify (Playwright):** unified rows render; artist name navigates to the full page;
album thumbnail opens `AlbumModal` with a tracklist; badges show; a fresh "Discover now" sweep
populates `albumReason` (needs a re-scan first so owned-artist MBIDs exist — pre-existing
caveat from `#18`).
## Risks / notes
- Last.fm top-album title↔MB release-group matching can miss (edition wording, localized
titles); acceptable — a miss just means newest-only for that artist. Normalized-title
matching + MBID-first keeps misses rare.
- Adds a Last.fm dependency to the album step of the sweep; fully optional + cached, and the
artist step already uses Last.fm as a similarity source.
- Reverts the D2 tab bar shipped earlier the same day; intentional per this design.