5315b240f0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6.7 KiB
6.7 KiB
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.getSimilarwork 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
secretrows (worker/lyra_worker/config.pyget_config+crypto.py, a port ofweb/src/lib/crypto.ts). Storelastfm.api_keyencrypted (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 (
musicbrainzngsin 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 theerrorfield.
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 returnslastfmUsername(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=lastfmApiKeyclears.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 existingclearField) + plain username input; load in the GET effect; add both to thesaveCredentialsPUT body; update eyebrow.- Reuse: FIELDS mechanism,
encryptSecret,clearField, Qobuz/Soulseek tab markup,*Setmasking.
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}; sharedlfmGet(method,params)that appendsapi_key/format=jsonand throws on theerrorfield. - New routes:
GET /api/lastfm/top-artists?period=&page=and.../top-albums— readlastfm.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 oversearchReleaseGroup(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;.tabsArtists|Albums; period<select>, "Hide items in my library".toggle, substring filter, Prev/Next pagination offtotalPages. 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 filtersinLibrary.toaston 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& noerror.similar_artists(mbid):artist.getsimilar&mbid=; parsesimilarartists.artist[]; per row prefer itsmbid, elsebrowser.search_artist(name)[0].mbid(cache by lowercased name, skip unresolvable/self); emitSimilarArtist(mbid,name,score=float(match)). worker/lyra_worker/registry.pybuild_similarity_sources: importLastfmSource; ifconfig.get("lastfm.api_key")present, appendLastfmSource(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, unionsources). - Caveat (defer): Last.fm
match0–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 buildclean, worker suite green. Deploy per slice viadocker compose up -d --build web worker(neverdown -v; no migration). Confirm ListenBrainz-only discovery unchanged whenlastfm.api_keyunset.
Out of scope / deferred
OAuth/scrobbling; cross-source score normalization; server-side Last.fm response caching; no migration.