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