Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4.3 KiB
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: addartistPlays(user, artist, apiKey, { maxPages }?): Promise<ArtistPlays>whereArtistPlays = { topTracks: {name:string; plays:number}[]; topAlbums: {name:string; plays:number}[]; sampled: boolean }.- Call
user.getArtistTracks(&user=&artist=&page=) page 1 → readartisttracks["@attr"].totalPages. - Parallel-fetch pages 2..min(totalPages, maxPages) (default
maxPages = 20, ~1000 scrobbles). - Coerce
artisttracks.tracksingle-object→array. Tally bytrack.name(songs) and bytrack.album["#text"](albums), skipping blank album text. Sort desc by plays, take top 10 each. sampled = totalPages > maxPages. Defensive parsing throughout; reuse the existinglfmGethelper (it already throws on the HTTP-200{error}envelope).
- Call
GET /api/lastfm/artist-plays?artist=<name>(web/src/app/api/lastfm/artist-plays/route.ts): readlastfm.username+ decryptedlastfm.api_keyfrom Config (reuse the same cred-read astop-artists/top-albums); 400 if unset; callartistPlays; 502 on a Last.fm throw; return{ topTracks, topAlbums, sampled }.- Tests:
web/src/lib/lastfm.test.ts— mock fetch with a 2-pagegetArtistTracksresponse, assert tally/sort/top-10, single-object coercion, blank-album skipped,sampledwhen 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 proplastfmName?: 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 resolvesGET /api/mb/release-group?artist=<lastfmName>&album=<album>→ POST/api/discover/want(rgMbid + the modal's artistmbid+ album) → toast; disable/replace with a "Wanted" chip after. Name-match each album (case-insensitive) against the already-loadeddata.releasesto show an "In library" chip whenhave, or "Wanted" whenmonitored. - Show a
samplednote ("top of your most recent ~1000 scrobbles") when returned. - Sections render only when
lastfmNameis 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).
- Most played songs — ranked read-only list (rank · name ·
web/src/app/lastfm/lastfm-client.tsx: passlastfmName={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 buildclean.- 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.