diff --git a/docs/superpowers/plans/2026-07-13-lastfm-integration.md b/docs/superpowers/plans/2026-07-13-lastfm-integration.md new file mode 100644 index 0000000..99f29e4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-lastfm-integration.md @@ -0,0 +1,93 @@ +# Last.fm Integration — Implementation Plan + +Approved via plan mode 2026-07-13. Built page-first in 3 shippable slices (each ships + +verifies before the next), via subagent-driven-development (per-task review + Opus +whole-branch review per slice), branch `feature/lastfm` → ff-merge `main` → deploy. + +## Context +Lyra's Discovery surfaces new artists/albums via pluggable `SimilaritySource`s (today only +ListenBrainz). Weave Last.fm in two ways: (1) a **second discovery source** (`artist.getSimilar`), +and (2) a **"Last.fm" browse page** that pulls the user's own top artists/albums (listening +history) into Artists/Albums tabs, with a hide-in-library toggle and the existing modal + +Follow/Want flow for new items. + +## Confirmed decisions +- **Auth = username + API key** (no OAuth). `user.getTopArtists`/`getTopAlbums`/`artist.getSimilar` + work with an API key + a public username. +- **"Hide items in my library" = owned OR followed/wanted** (LibraryItem owned, WatchedArtist + followed, or MonitoredRelease monitored/wanted). +- **Page-first, 3 slices**: (1) creds + Settings tab, (2) browse page, (3) discovery source. +- **No Prisma migration** — everything on existing tables + Config keys. + +## Cross-cutting facts +- Config is a flat KV table; the worker decrypts `secret` rows (`worker/lyra_worker/config.py` + `get_config` + `crypto.py`, a port of `web/src/lib/crypto.ts`). Store `lastfm.api_key` + **encrypted** (`secret:true`); both layers read it. +- Pipeline is **MBID-native**; Last.fm returns names (mbid often empty). Resolve names→MB + identities before follow/want/own-state line up. Own-state / library-match is exact-string on + the MB canonical name/title. +- MB rate limit is a shared global ~1 req/s (`musicbrainzngs` in worker; web MB client unthrottled + but used one-item-at-a-time). Resolve lazily on-demand + cache. +- **Last.fm API:** base `https://ws.audioscrobbler.com/2.0/`, `?method=...&api_key=KEY&format=json`. + `user.gettopartists`/`user.gettopalbums` (`&user=&period=overall|7day|1month|3month|6month|12month&page=&limit=`); + `artist.getsimilar` (`&mbid=` or `&artist=`, `&limit=`). Errors return `{error,message}` at HTTP + 200 — parse the `error` field. + +## Slice 1 — credentials + Settings tab +- `web/src/app/api/config/route.ts` — `FIELDS` += `lastfmApiKey→{key:"lastfm.api_key",secret:true}`, + `lastfmUsername→{key:"lastfm.username",secret:false}`. GET returns `lastfmUsername` (plain) + + `lastfmApiKeySet`. PUT/DELETE unchanged (iterate FIELDS; DELETE hasOwnProperty guard covers new keys). +- `web/src/app/api/config/route.test.ts` — PUT encrypts key + stores username plain; GET masks key; + DELETE `?field=lastfmApiKey` clears. +- `web/src/app/settings/settings-form.tsx` — new `"Last.fm"` tab (Tab type + TABS) mirroring the + Soulseek tab: secret API-key input (•••• stored + `[Clear]` via existing `clearField`) + plain + username input; load in the GET effect; add both to the `saveCredentials` PUT body; update eyebrow. +- Reuse: FIELDS mechanism, `encryptSecret`, `clearField`, Qobuz/Soulseek tab markup, `*Set` masking. + +## Slice 2 — `/lastfm` browse page +- New `web/src/lib/lastfm.ts` (server-side): `topArtists(user,apiKey,{period,page,limit})` → + `{artists:{name,playcount,mbid|null}[],totalPages}`; `topAlbums(...)` → + `{albums:{name,artist,playcount,mbid|null}[],totalPages}`; shared `lfmGet(method,params)` that + appends `api_key`/`format=json` and throws on the `error` field. +- New routes: `GET /api/lastfm/top-artists?period=&page=` and `.../top-albums` — read + `lastfm.username`/`lastfm.api_key` (400 if unset), call the lib, annotate own-state from one cheap + DB read (artists: `followed`=WatchedArtist name/mbid, `owned`=LibraryItem.artist; albums: + `owned`=(artist,album) in LibraryItem, `wanted`=MonitoredRelease). `inLibrary = owned||followed||wanted`. + Name-based (no per-item MB calls). `GET /api/mb/release-group?artist=&album=` — thin wrapper over + `searchReleaseGroup` (`web/src/lib/musicbrainz.ts:61`) → `{rgMbid,artistMbid,artistName,...}` or 404. +- New page `web/src/app/lastfm/page.tsx` → ``; add `{href:"/lastfm",label:"Last.fm"}` + to `_ui/contents-nav.tsx`. +- `lastfm-client.tsx`: PageHead; `.tabs` Artists|Albums; period `