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