docs: implementation plan for Last.fm integration (3 slices)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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` → `<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` 0–1 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.
|
||||||
Reference in New Issue
Block a user