# 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 `