From 9dd235c2bc127cdcd14ba697861d250cff80e0da Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 14 Jul 2026 23:29:23 +0200 Subject: [PATCH] docs: spec for Discover unified rows + newest/most-popular albums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...2026-07-14-discover-unified-rows-design.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-discover-unified-rows-design.md diff --git a/docs/superpowers/specs/2026-07-14-discover-unified-rows-design.md b/docs/superpowers/specs/2026-07-14-discover-unified-rows-design.md new file mode 100644 index 0000000..7b8e3c2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-discover-unified-rows-design.md @@ -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 `