From e74d1d9c7b6711ff6005d7624ea82e20ed1c3693 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 14 Jul 2026 23:33:04 +0200 Subject: [PATCH] docs: implementation plan for Discover unified rows Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-14-discover-unified-rows.md | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-discover-unified-rows.md diff --git a/docs/superpowers/plans/2026-07-14-discover-unified-rows.md b/docs/superpowers/plans/2026-07-14-discover-unified-rows.md new file mode 100644 index 0000000..9c9b7cb --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-discover-unified-rows.md @@ -0,0 +1,247 @@ +# Discover Unified Rows + Newest/Most-Popular Albums — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Collapse the Discover Artists|Albums tabs into one artist-centric row that shows each similar artist's newest AND most-popular album inline (album-click → tracklist), with the artist name linking to the full page. + +**Architecture:** Worker derives two albums per surfaced artist (newest = existing; most-popular = Last.fm `artist.getTopAlbums` matched into the browsed MB discography), tagging each `DiscoverySuggestion.albumReason`. The `/api/discover` route groups pending suggestions by artist into rows carrying `albums[]` + `seeds[]`. The client renders one row per artist with album thumbnails. + +**Tech Stack:** Python 3 + psycopg (worker), Next.js 15 App Router + Prisma + vitest (web), Playwright (verify). + +## Global Constraints + +- Popularity source is Last.fm `artist.getTopAlbums`; **graceful fallback to newest-only** when no `lastfm.api_key` or no match. No new required config. +- `albumReason` values are exactly `"newest"`, `"popular"`, `"newest,popular"`; badge labels `Newest` / `Most played` / both; `null` → no badge. +- Never break the sweep: any Last.fm/popularity error → treat as no popular pick. +- Deploy is `docker compose up -d --build web worker` (never `down -v`). Tests: worker `lyra_test`, web `lyra_test`. +- Reverts the D2 tab bar (intentional). + +--- + +### Task 1: `albumReason` column + migration + +**Files:** +- Modify: `web/prisma/schema.prisma` (DiscoverySuggestion model) +- Create: `web/prisma/migrations/_add_discovery_album_reason/migration.sql` (via prisma) + +- [ ] **Step 1:** Add `albumReason String?` to `model DiscoverySuggestion` with a comment (nullable, backward compatible; null → no badge). +- [ ] **Step 2:** `cd web && DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test npx prisma migrate dev --name add_discovery_album_reason`. Expected: "Applying migration … add_discovery_album_reason". +- [ ] **Step 3:** Commit (`feat(discover): add DiscoverySuggestion.albumReason`). + +--- + +### Task 2: worker Last.fm album-popularity helper + +**Files:** +- Create: `worker/lyra_worker/similarity/_lastfm_albums.py` +- Modify: `worker/lyra_worker/registry.py` (add `build_album_popularity`) +- Test: `worker/tests/test_lastfm_albums.py` + +**Interfaces:** +- Produces: `LastfmAlbumPopularity(api_key, base_url=_BASE, timeout=15.0)` with + `top_albums(conn, artist_name) -> list[[name:str, mbid:str|None, playcount:int]]` + (playcount desc; cached in ApiCache key `lfm-artist-top-albums:{norm}`, 12h). Errors → `[]`. +- Produces: `build_album_popularity(config, dsn) -> LastfmAlbumPopularity | None` + (None when `lastfm.api_key` unset). + +- [ ] **Step 1: failing test** — parse a canned `artist.gettopalbums` JSON body into + `[[name, mbid, playcount], …]` ordered by playcount desc; a `dict` (single album) is + wrapped; missing mbid → None; a body with `error` → `[]`. Inject the parse via a helper + `_parse_top_albums(data)` so no network is needed. Also assert `build_album_popularity({}, None) is None`. +- [ ] **Step 2:** run → FAIL (module missing). +- [ ] **Step 3: implement.** + +```python +# _lastfm_albums.py +import requests +from lyra_worker.apicache import cached_api + +_BASE = "https://ws.audioscrobbler.com/2.0/" + + +def _parse_top_albums(data) -> list[list]: + if isinstance(data.get("error"), (int, float)): + return [] + rows = data.get("topalbums", {}).get("album", []) + if isinstance(rows, dict): + rows = [rows] + out = [] + for r in rows: + name = r.get("name") or "" + if not name: + continue + out.append([name, r.get("mbid") or None, int(r.get("playcount") or 0)]) + out.sort(key=lambda a: a[2], reverse=True) + return out + + +class LastfmAlbumPopularity: + def __init__(self, api_key, base_url=_BASE, timeout: float = 15.0, limit: int = 25): + self._key, self._base, self._timeout, self._limit = api_key, base_url, timeout, limit + + def _fetch(self, artist_name: str) -> list[list]: + try: + res = requests.get(self._base, params={ + "method": "artist.gettopalbums", "artist": artist_name, + "api_key": self._key, "format": "json", "limit": str(self._limit), + "autocorrect": "1", + }, timeout=self._timeout) + return _parse_top_albums(res.json()) + except Exception: + return [] + + def top_albums(self, conn, artist_name: str) -> list[list]: + norm = (artist_name or "").strip().lower() + if not norm: + return [] + return cached_api(conn, f"lfm-artist-top-albums:{norm}", 43200, + lambda: self._fetch(artist_name)) +``` + +```python +# registry.py — add +from lyra_worker.similarity._lastfm_albums import LastfmAlbumPopularity + +def build_album_popularity(config: dict, dsn: str | None = None): + key = config.get("lastfm.api_key") + return LastfmAlbumPopularity(api_key=key) if key else None +``` + +- [ ] **Step 4:** run → PASS. +- [ ] **Step 5:** commit (`feat(discover): Last.fm album-popularity helper`). + +--- + +### Task 3: `_derive_albums` picks newest + most-popular + +**Files:** +- Modify: `worker/lyra_worker/discovery.py` (`_derive_albums`, `_upsert_album`, `run_discovery`) +- Test: `worker/tests/test_discovery_albums.py` + +**Interfaces:** +- Consumes: `popularity.top_albums(conn, name)` from Task 2 (or None). +- Produces: `_derive_albums(conn, browser, surfaced, cfg, popularity=None)`; `run_discovery(conn, sources, browser, cfg, popularity=None)`; `_upsert_album(conn, artist, rg, reason)`. + +- [ ] **Step 1: failing tests** (inject a fake popularity — no live Last.fm): + - newest + most-popular → two album rows, `albumReason` `"newest"` / `"popular"`. + - popular pick == newest rg → one row, `albumReason == "newest,popular"`. + - `popularity=None` → newest-only, `albumReason == "newest"`. + - popular match resolves by title when Last.fm mbid absent; skips owned/non-qualifying and falls to next-highest playcount; no match → newest-only. + +```python +class FakePopularity: + def __init__(self, data): self._d = data # {artist_name: [[name, mbid, plays], …]} + def top_albums(self, conn, name): return self._d.get(name, []) +``` + +- [ ] **Step 2:** run → FAIL. +- [ ] **Step 3: implement.** In `_derive_albums`, after building `core` (qualifying un-owned, newest-first): + +```python +def _norm(s): return (s or "").strip().lower() + +# newest picks (existing) +picks = {} # rg_mbid -> reason +for rg in core[: cfg.albums_per_artist]: + picks[rg.rg_mbid] = "newest" + +# most-popular pick via Last.fm, matched into `core` +if popularity is not None and core: + by_mbid = {g.rg_mbid: g for g in core} + by_title = {_norm(g.title): g for g in core} + for name, mbid, _plays in popularity.top_albums(conn, artist["name"]): + g = (by_mbid.get(mbid) if mbid else None) or by_title.get(_norm(name)) + if g is None: + continue + picks[g.rg_mbid] = "newest,popular" if picks.get(g.rg_mbid) == "newest" else "popular" + break + +for rg in core: + if rg.rg_mbid in picks: + albums += _upsert_album(conn, artist, rg, picks[rg.rg_mbid]) + have.add(rg.rg_mbid) +``` + +Add `reason` param to `_upsert_album` → include `"albumReason"` in the INSERT column list + +`ON CONFLICT DO UPDATE SET … "albumReason" = EXCLUDED."albumReason"`. Thread `popularity` +through `run_discovery` → `_derive_albums`. + +- [ ] **Step 4:** run → PASS; then full worker suite green. +- [ ] **Step 5:** commit (`feat(discover): derive newest + most-popular album per artist`). + +--- + +### Task 4: wire popularity into the worker loop + +**Files:** +- Modify: `worker/lyra_worker/main.py` (`_run_discovery` builds + passes popularity) +- Test: existing `test_discovery_trigger.py` still green (popularity defaults None there). + +- [ ] **Step 1:** In `main.py`, import `build_album_popularity`; in `_run_discovery` build it from `get_config(conn)` and pass to `run_discovery(conn, sources, browser, cfg, popularity=pop)`. +- [ ] **Step 2:** run worker suite → PASS. +- [ ] **Step 3:** commit (`feat(discover): supply Last.fm popularity to the sweep`). + +--- + +### Task 5: `/api/discover` groups by artist + +**Files:** +- Modify: `web/src/app/api/discover/route.ts` +- Test: `web/src/app/api/discover/route.test.ts` + +**Interfaces:** +- Produces: `GET` returns `{ rows: [{ id, artistMbid, artistName, score, seedCount, sources, seeds, albums: [{ id, rgMbid, album, primaryType, secondaryTypes, firstReleaseDate, albumReason }] }], search? }`. Rows ordered by score desc. + +- [ ] **Step 1: failing test** — seed pending artist + its two album suggestions (`albumReason` newest/popular) sharing `artistMbid`; assert one row with `albums.length === 2`, ordered; seeds present; an album whose artist has no pending artist-suggestion still yields a row. +- [ ] **Step 2:** run → FAIL. +- [ ] **Step 3: implement.** Fetch all pending suggestions (as today) + the seeds map (as today). Group by `artistMbid`: artist-kind rows seed the row header (id/score/seedCount/sources); album-kind rows push into `albums[]` (include `albumReason`). For an `artistMbid` with only album rows, synthesize a header from the album's `artistName`/`artistMbid` (no `id`, score = max album score). Sort rows by score desc; sort each `albums[]` by `firstReleaseDate` desc. Return `{ rows }`. +- [ ] **Step 4:** run → PASS. +- [ ] **Step 5:** commit (`feat(discover): group suggestions into artist rows`). + +--- + +### Task 6: dismiss cascade + +**Files:** +- Modify: `web/src/app/api/discover/[id]/route.ts` +- Test: `web/src/app/api/discover/[id]/route.test.ts` + +- [ ] **Step 1: failing test** — dismissing an artist suggestion also marks its pending album suggestions (same `artistMbid`) dismissed; a dismissed album is not returned by `/api/discover`. +- [ ] **Step 2:** run → FAIL. +- [ ] **Step 3: implement.** In the dismiss branch, after dismissing the artist suggestion, `updateMany({ where: { kind: "album", artistMbid, status: "pending" }, data: { status: "dismissed" } })`. Follow/Want unchanged. +- [ ] **Step 4:** run → PASS. +- [ ] **Step 5:** commit (`fix(discover): dismissing an artist also dismisses its albums`). + +--- + +### Task 7: unified-row client (drop tabs + ArtistModal) + +**Files:** +- Modify: `web/src/app/discover/discover-client.tsx` +- Modify: `web/src/app/design.css` (row + thumb strip + badge styles) + +- [ ] **Step 1:** Replace `Feed`/`tab` state with `rows` from `/api/discover` (`{ rows }`). Remove the tab bar, `ArtistModal` import + `artistModal` state + its JSX + the `isFullAlbum`/albums-tab code. +- [ ] **Step 2:** Render one `
  • ` per row: + - artist block: `{row.artistName}`, `score`, `similarTo(row.seeds)` (keep existing helper). + - album strip: `row.albums.map` → a `