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>
8.2 KiB
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
- 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.
- Collapse the Artists|Albums tabs into one unified list — one row per suggested artist, carrying that artist's album thumbnails inline.
- 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:
- 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. - Newest: the newest
cfg.albums_per_artist(default 1) byfirst_release_datedesc — unchanged, taggedreason="newest". - Most popular: if
popularityis 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). Tagreason="popular". - 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". - Upsert each as a
DiscoverySuggestion(kindalbum) as today, additionally writingalbumReason.
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
tabstate; render one<ul>of artist rows. - Remove
ArtistModalimport + 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 smallCoverArtthumbnails, 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'srgMbid/title/artist → cover + tracklist- Want.
- a badge from
- 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 viaPOST /api/artists; Dismiss is hidden for that (degenerate) case. find-similarresults keep their existing rendering, minusArtistModal— their artist links also go to the full page (consistency;ArtistModalis 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/discovergroups albums under their artist row; row carriesalbums[]+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.