docs: plan for Last.fm artist modal (personal most-played songs + albums)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-13 21:17:31 +02:00
parent 13126addbc
commit b6623944ff
@@ -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<ArtistPlays>`
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=<name>` (`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=<lastfmName>` (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=<lastfmName>&album=<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 `<ArtistModal/>`.
- No web unit test for the modal (node vitest / no jsdom) — verified by tsc + build + live Playwright.
## Verification
- `cd web && npm test -- <files>` 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.