Files
Lyra/docs/superpowers/plans/2026-07-13-lastfm-integration.md
Jonathan 5315b240f0 docs: implementation plan for Last.fm integration (3 slices)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:51:03 +02:00

94 lines
6.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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``<LastfmClient/>`; add `{href:"/lastfm",label:"Last.fm"}`
to `_ui/contents-nav.tsx`.
- `lastfm-client.tsx`: PageHead; `.tabs` Artists|Albums; period `<select>`, "Hide items in my
library" `.toggle`, substring filter, Prev/Next pagination off `totalPages`. Artists rows → click
resolves mbid (row.mbid else `/api/mb/artists?q=` hit[0]) → `<ArtistModal mbid initialName/>`
(Follow built in). Albums rows → click `/api/mb/release-group``<AlbumModal album={{rgMbid,album,
artist}} status={chip} actions={Want}/>``POST /api/discover/want`. Hide toggle filters
`inLibrary`. `toast` on success. Graceful creds-missing / Last.fm-error / unresolvable-item states.
- Reuse: ArtistModal, AlbumModal, CoverArt, `.tabs`/`.list`/`.chip`/`.toggle`, toast,
`searchArtists`/`searchReleaseGroup`, `POST /api/artists`, `POST /api/discover/want`,
`/api/mb/artists`, `/api/mb/release-groups/[rgMbid]/tracks`.
## Slice 3 — `LastfmSource` discovery source
- New `worker/lyra_worker/similarity/_lastfm.py``LastfmSource`, `name="lastfm"`, mirrors
`_listenbrainz.py`. `__init__(api_key, browser, base_url, timeout=15.0, similar_limit=50)` +
instance name→mbid cache. `health()` cheap call `<500` & no `error`. `similar_artists(mbid)`:
`artist.getsimilar&mbid=`; parse `similarartists.artist[]`; per row prefer its `mbid`, else
`browser.search_artist(name)[0].mbid` (cache by lowercased name, skip unresolvable/self);
emit `SimilarArtist(mbid,name,score=float(match))`.
- `worker/lyra_worker/registry.py` `build_similarity_sources`: import `LastfmSource`; if
`config.get("lastfm.api_key")` present, append `LastfmSource(api_key=key, browser=MusicBrainzBrowser())`.
- `worker/tests/test_lastfm_source.py` — fake requests+browser: parse rows; use supplied mbid;
resolve+cache when absent (browser called once per unique name); skip unresolvable/self; health paths.
- Reuse: `SimilaritySource`/`SimilarArtist`/`MbBrowser.search_artist`; downstream pipeline unchanged
(Tier B1 contributions sum across sources by candidate MBID, union `sources`).
- Caveat (defer): Last.fm `match` 01 vs ListenBrainz large counts → Last.fm mostly adds *new*
candidates; cross-source normalization/weight is a Tier-C follow-up.
## Verification
- Per slice: unit tests (`cd web && npm test -- <file>`; `cd worker && source .venv/bin/activate &&
python -m pytest <path>`), `tsc` + `next build` clean, worker suite green. Deploy per slice via
`docker compose up -d --build web worker` (never `down -v`; no migration). Confirm ListenBrainz-only
discovery unchanged when `lastfm.api_key` unset.
## Out of scope / deferred
OAuth/scrobbling; cross-source score normalization; server-side Last.fm response caching; no migration.