b6623944ff
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
60 lines
4.3 KiB
Markdown
60 lines
4.3 KiB
Markdown
# 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.
|