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