Files
Lyra/docs/superpowers/specs/2026-07-14-discover-unified-rows-design.md
Jonathan 9dd235c2bc 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>
2026-07-14 23:29:23 +02:00

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

  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.