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

6.7 KiB
Raw Permalink Blame History

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 SimilaritySources (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.tsFIELDS += 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.pyLastfmSource, 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.