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