From b6623944ff32057911aec0bc84641ee0d4cf5f66 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 13 Jul 2026 21:17:31 +0200 Subject: [PATCH] docs: plan for Last.fm artist modal (personal most-played songs + albums) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-13-lastfm-artist-plays.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-lastfm-artist-plays.md diff --git a/docs/superpowers/plans/2026-07-13-lastfm-artist-plays.md b/docs/superpowers/plans/2026-07-13-lastfm-artist-plays.md new file mode 100644 index 0000000..0b8e1f8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-lastfm-artist-plays.md @@ -0,0 +1,59 @@ +# Last.fm artist modal — your most-played songs + albums + +Enhancement to the `/lastfm` page's artist modal: pull the user's **personal** Last.fm +play data for the clicked artist. Built in 2 slices (lib+endpoint, then modal wiring), +subagent-driven per-task review, branch `feature/lastfm-artist-plays` → ff `main` → deploy. + +## Context +Clicking an artist on `/lastfm` opens the shared `ArtistModal` (Follow + MB discography grid +with per-album Want). The user wants two extra sections sourced from THEIR Last.fm scrobbles: +**most-played songs** (read-only) and **most-played albums** (each Want-able). Decisions: +personal plays (not global); albums wantable, songs informational. + +Last.fm has no per-user-per-artist "top" endpoint, so we tally `user.getArtistTracks` (the +user's scrobbles of one artist) server-side — one dataset yields both songs and albums by the +user's actual play counts. + +## Slice 1 — lib + endpoint (data layer) +- `web/src/lib/lastfm.ts`: add `artistPlays(user, artist, apiKey, { maxPages }?): Promise` + where `ArtistPlays = { topTracks: {name:string; plays:number}[]; topAlbums: {name:string; plays:number}[]; sampled: boolean }`. + - Call `user.getArtistTracks` (`&user=&artist=&page=`) page 1 → read `artisttracks["@attr"].totalPages`. + - Parallel-fetch pages 2..min(totalPages, maxPages) (default `maxPages = 20`, ~1000 scrobbles). + - Coerce `artisttracks.track` single-object→array. Tally by `track.name` (songs) and by + `track.album["#text"]` (albums), skipping blank album text. Sort desc by plays, take top 10 each. + - `sampled = totalPages > maxPages`. Defensive parsing throughout; reuse the existing `lfmGet` + helper (it already throws on the HTTP-200 `{error}` envelope). +- `GET /api/lastfm/artist-plays?artist=` (`web/src/app/api/lastfm/artist-plays/route.ts`): + read `lastfm.username` + decrypted `lastfm.api_key` from Config (reuse the same cred-read as + `top-artists`/`top-albums`); 400 if unset; call `artistPlays`; 502 on a Last.fm throw; return + `{ topTracks, topAlbums, sampled }`. +- Tests: `web/src/lib/lastfm.test.ts` — mock fetch with a 2-page `getArtistTracks` response, assert + tally/sort/top-10, single-object coercion, blank-album skipped, `sampled` when totalPages>maxPages. + Route test — creds-missing 400, happy path with a mocked lib, 502 on throw. + +## Slice 2 — ArtistModal sections + wiring +- `web/src/app/_ui/artist-modal.tsx`: add optional prop `lastfmName?: string`. When set + open, + async-fetch `/api/lastfm/artist-plays?artist=` (independent of the preview fetch; + own loading/error state) and render TWO sections **above** the existing release grid: + - **Most played songs** — ranked read-only list (rank · name · `N plays`). + - **Most played albums** — ranked list; each item: name · `N plays` + a **Want** control that + resolves `GET /api/mb/release-group?artist=&album=` → POST `/api/discover/want` + (rgMbid + the modal's artist `mbid` + album) → toast; disable/replace with a "Wanted" chip after. + Name-match each album (case-insensitive) against the already-loaded `data.releases` to show an + "In library" chip when `have`, or "Wanted" when `monitored`. + - Show a `sampled` note ("top of your most recent ~1000 scrobbles") when returned. + - Sections render only when `lastfmName` is set; Discover/preview/artists callers pass nothing → + byte-for-byte unchanged behavior. Loads async: the MB grid shows immediately; sections show a + spinner then fill; on error, a muted note (don't block the modal). +- `web/src/app/lastfm/lastfm-client.tsx`: pass `lastfmName={artistTarget.name}` to ``. +- No web unit test for the modal (node vitest / no jsdom) — verified by tsc + build + live Playwright. + +## Verification +- `cd web && npm test -- ` for lib+route; `npx tsc --noEmit && npm run build` clean. +- Deploy web (`docker compose up -d --build web`; no migration). Live: open a real artist on `/lastfm`, + confirm the two sections populate from the user's plays, a Want on a top album lands on `/wanted`, + and the Discover artist modal is unchanged (no Last.fm sections). + +## Out of scope / deferred +Global-popularity mode; song→album resolution (songs stay read-only); server-side caching of +artist-plays (add if the paging cost bites); track→album disambiguation.