Files
Lyra/docs/superpowers/plans/2026-07-13-lastfm-artist-plays.md
T
2026-07-13 21:17:31 +02:00

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: 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.