The press page interleaved active downloads, queued items, and errors in one long
list — hard to see what's actually pressing. Split the non-imported rows into three
tabs (Active / In queue / Errors) with live counts; the default tab follows the work
(Active if anything's running, else Queue, else Errors) until the user picks one.
Move the press-level actions (Pause/Resume, Clear queue, Stop now) off the toolbar
into a vertical "⋮" kebab menu on the right of the header — Clear queue disables when
the queue is empty, Stop now only shows while downloading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Avoid re-triggering a USER_BLOCKED account by capping Qobuz to a human-looking
volume and cadence. A per-album gate (worker/lyra_worker/qobuz_gate.py) allows
Qobuz only within active hours, under today's cap (a warm-up ramp of a steady
cap), and past a randomized spacing since the last Qobuz download; the gap spreads
the day's budget across active hours with jitter. When the gate is closed the
pipeline drops Qobuz candidates so the job falls through to another source (and
monitored albums upgrade to Qobuz later); a Qobuz-only setup is never gated.
Config keys read live each loop; counters reset at the day boundary via the DB
clock. Tunable in Settings -> Qobuz -> Pacing (new /api/qobuz/pacing route).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A literal NUL had crept into the `${r.artist} ${r.album}` dedupe key on the
Floor, making git treat queue.tsx as a binary file (unreviewable diffs). Restore
the intended space; behavior is unchanged (the key stays unique per album).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Strengthen test to prove the job's paused state remains unmodified when
pause/unpause is rejected for a downloading job. Tests now seeds paused=true,
attempts paused=false, verifies 400, and fetches from DB to assert paused is
still true — catching regressions that would mutate before the guard check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Last.fm's artist MBID can point at the wrong same-named band (it mapped
"Looking Glass" to an Australian stoner band, not the US pop group), and the
/lastfm rows trusted it verbatim — so opening/following grabbed the wrong
artist and the grabbed album never showed on the artist page. Now open/follow
resolve via MB search: resolveArtistChoice keeps candidates whose name matches
exactly — 1 match is used directly (even over a disagreeing Last.fm MBID), 2+
surface an ArtistPicker showing each MB disambiguation ("70's US pop group,
track 'Brandy'" vs "Australian stoner rock") with MB's top hit flagged
Suggested. Core decision logic unit-tested; no worker/schema change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Qobuz login/metadata/artwork phase (and a Soulseek peer-queue or YouTube
info-fetch) can sit at 0% for a few minutes before byte progress flows, which
read as a stuck bar. While a downloading job's progress is still 0, render the
indeterminate "preparing…" bar (same style as the searching/finishing phases);
it flips to the determinate % bar on the first reported byte and never back
(the progress aggregator only increases from 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously the /lastfm rows only showed a status chip; following an artist or
wanting an album meant opening the modal first. Add an inline Follow button to
each Artists-tab row and an inline Want button to each Albums-tab row, reusing
the same endpoints as the modals (resolve MB id/release-group as needed, then
POST /api/artists or /api/discover/want). Optimistic per-row state flips the
button to the Following/Wanted chip without a reload; the button is disabled
while the request is in flight.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/discover/reset sets discover.resetRequested; the worker, at the
start of the next sweep, clears all PENDING suggestions + contributions +
seed throttles and nulls WatchedArtist.lastDiscoveredAt (keeping
followed/wanted/dismissed decisions), then regenerates everything with current
logic. A 'Reset' button on /discover (2-step confirm) triggers it.
worker 247, web 190 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Client filters the row's album thumbnails to full albums only (hides
singles/EPs left over from pre-albums-only sweeps).
- Worker: re-deriving an artist deletes its pending album suggestions that are
no longer picks (stale singles / now-owned), keeping wanted/dismissed rows,
so the suggestion set self-cleans instead of accumulating.
worker 246, web 189 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each suggested album is now a small horizontal item (30px cover + title/badge
on one line, like Recently Pressed but smaller) stacked vertically, so both
albums fit under each other without enlarging the row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/discover is now one list of artist rows (no Artists|Albums tabs). Each row
shows the artist (name links to the full page — ArtistModal dropped from
Discover), the 'Similar to' reason, and its newest/most-played album
thumbnails with badges. Album-click opens the AlbumModal (cover + tracklist +
Want). Find-similar results link to the full page too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/discover now returns rows[] grouped by artist, each carrying its album
suggestions (with albumReason) + the seed names, ordered by score. Album
suggestions whose artist has no pending artist suggestion still yield a row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Discovery seeded only from followed artists. Now it also seeds from artists
you own albums by (LibraryItem.artistMbid, captured since the MBID own-state
work) but don't follow — so your library shapes suggestions, not just your
follows.
- New DiscoverySeed throttle table (migration add_discovery_seed) mirroring
WatchedArtist.lastDiscoveredAt, so each library-derived seed is swept once
per interval (WatchedArtist seeds keep their own throttle).
- _seed_artists merges both seed sources least-recently-swept first, capped at
chunk_size; run_discovery stamps the right throttle per seed and evicts
contributions from seeds that are neither followed nor owned.
- Artists you already have (followed OR owned) are excluded from the suggestion
candidates — discovery surfaces only genuinely new artists.
- discover.seedFromLibrary config (default true) + a Discovery settings toggle.
NOTE: existing LibraryItem rows have null artistMbid (populated on new
import/scan), so a re-scan is needed before old albums seed discovery.
worker 236, web 187 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each suggestion now carries the followed artists (seeds) that surfaced it,
from DiscoverySeedContribution joined to the current watched roster (an
unfollowed seed drops out), highest-scoring seed first. /discover renders
"Similar to Arctic Monkeys, Coldplay +2" under each artist and album row —
both kinds key off the candidate's artistMbid, so an album inherits its
artist's seeds. web 187 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Own-state ("in library") relied on an exact artist-NAME match, so an owned
album whose name differed from the MB canonical (punctuation/locale/feat.)
showed as not-owned even when followed. Capture the MusicBrainz artist MBID
on LibraryItem and prefer it for matching.
- New LibraryItem.artistMbid (migration add_library_artist_mbid). Populated at
import (pipeline) + scan, both of which already hold the resolved target;
the pipeline also now stores rgMbid (it previously didn't). COALESCE on
conflict backfills MBIDs on re-scan without clobbering.
- /api/artists "in library, not followed" matches by artistMbid first, name
case-insensitively as fallback (rows predating the column). Library route
prefers the item's own artistMbid over the release join.
Last.fm/discover own-state still use name-match (they work) — a smaller
follow-up. worker 233, web 186 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Select mode on /library: album cards become multi-select, with a bulk
bar to Delete selected (2-step confirm) or Ignore selected. POST
/api/library/bulk { action, ids } applies the op per id and reports ok/failed
counts.
Extracted the delete + ignore logic into lib/library-ops.ts
(removeLibraryItem, ignoreLibraryItem); the single DELETE route now reuses
removeLibraryItem. web 185 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Request.force (migration add_request_force): the pipeline's intake now
skips the "already in library" dedupe for a forced job, so an owned album is
re-downloaded. The import step still keeps the new copy only when it's higher
quality, so a forced upgrade can never downgrade what's on disk.
- Worker: _is_force_job → dedupe bypassed when Request.force.
- POST /api/library/[id]/upgrade finds the matching MonitoredRelease and
enqueues a force request (guards against stacking on an in-flight job).
- Library route exposes monitoredReleaseId; the album modal shows a
"Replace / upgrade" button when a release exists.
Worker + web tests added (pipeline force re-acquire; upgrade route).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/library/integrity stats every album folder on disk (via the /music
mount) and flags: missing folders, folders with no audio, zero-byte/
unreadable (truncated) files, and on-disk track count drift vs the names
captured at import/scan. Metadata-only (readdir + stat), so it runs only when
the user clicks "Check now" from a new Integrity section on /library; each
flagged album opens its modal to fix or delete. SectionHeader.note widened to
ReactNode to host the button. web 178 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/library/duplicates groups library items that look like the same
release held more than once — sharing a MusicBrainz release-group, or the
same artist + edition-normalized title (stripEditionQualifiers), unioned so
transitive matches collapse into one group. The (artist,album) unique
constraint rules out exact dupes, so these near-dupes are what's worth
flagging.
/library shows a "Possible duplicates" section; each entry opens the album
modal to edit or delete. web 175 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correct an owned album's artist/album/year from the Library modal ("Edit
metadata"). PATCH /api/library/[id] renames the folder to the canonical
Artist/Album (Year) scheme on the /music mount, updates the LibraryItem,
and re-resolves the MusicBrainz release-group (cover art) when the
artist/album changed (best-effort — keeps the old rgMbid on an MB outage).
- New shared lib/library-path.ts (safeName, albumDir, yearFromPath); the
manual-import route now uses it so edits and imports name folders
identically.
- Library GET falls back to the folder's "(YYYY)" for the year when there's
no matching release, so edited/manual years show in the grid.
- Does NOT rewrite embedded file tags (a worker/mutagen job) — Lyra reads
from the folder + DB. Guards paths inside the library root; 409 on folder
or (artist,album) collision.
web 172 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add MonitoredRelease.ignored (migration add_release_ignored). The monitor's
enqueue_due skips ignored releases, and they drop off the Wanted list —
independent of `monitored`, so auto-monitor-future can't silently re-grab
an album the user chose to ignore.
- Worker enqueue_due: AND NOT mr.ignored.
- /api/releases/[id] PATCH now accepts `ignored`; /api/wanted filters it out;
artist detail exposes `ignored`.
- UI: Ignore button on Wanted rows; discography shows an "Ignored" badge with
an Ignore/Un-ignore toggle (monitor + search disabled while ignored).
Worker + web tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The library scan silently skipped album folders with no MusicBrainz match
(or an unreadable file). Persist them so they're visible and fixable.
- New UnmatchedAlbum model (migration add_unmatched_album), keyed by path.
- scan_chunk records a skipped album as unmatched (reason "no MusicBrainz
match" or "unreadable: <err>"), clears the row when an album later
resolves, and prunes stale rows (a prior scan's, or a removed folder)
when a scan completes.
- GET /api/library/unmatched lists them; DELETE ?id= dismisses one.
- /library shows a "Couldn't match" section (path + reason + Dismiss).
Worker + web tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Sort dropdown to /library next to the filter: Recently added
(default, importedAt desc — the prior order), Release date (album year,
newest first, unknown-year last), Album A–Z, Artist A–Z. Client-side sort
of the already-fetched albums; importedAt was already on the route, just
declared on the client type now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Sort dropdown to /artists next to the filter: Recently followed
(default, createdAt desc — the prior order), Name A–Z, Most in library,
Most monitored, Most releases. Client-side sort of the already-fetched
roster; the filter box and "In library, not followed" section are
unchanged. Exposes WatchedArtist.createdAt on /api/artists for the sort.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Suggested artists and Suggested albums were stacked sections; make them
tabs like /lastfm (.tabs bar + count badge). Discover-now/freshness and
Find-similar stay above the tabs.
The Albums tab filters to full-length albums (isFullAlbum) — a client-side
stopgap that hides any pre-D1 singles/EP suggestions still in the DB (new
sweeps already filter server-side via discover.albumsOnly).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Discovery's "Suggested albums" surfaced singles and EPs because
_derive_albums filtered with is_core_release (Album/Single/EP). Add a
discovery-only _album_kind_ok gate that, when discover.albumsOnly (new
config, default true), requires primary_type == "Album" with no secondary
types. is_core_release is untouched — the monitor/scan still use it.
Exposed discover.albumsOnly in the discover config route + a Discovery
settings toggle. Worker + web tests added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A manually-imported album had no release-group MBID, so it showed the ◉
placeholder until a Scan matched it. Now the import resolves the release group
(searchReleaseGroup, via the shared MB cache) and stores it on the new
LibraryItem.rgMbid column (migration add_library_rg_mbid). The library route
prefers LibraryItem.rgMbid, falling back to the MonitoredRelease join for
scanned items — so a manual album gets cover art immediately. Best-effort: a
no-match / MB outage just leaves rgMbid null.
web 164 tests, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Cover art now loads through GET /api/cover/[rgMbid], which fetches from the
Cover Art Archive once, caches the image on disk (LYRA_COVER_CACHE, default
/tmp/lyra-covers), and serves it with a 30-day Cache-Control — so the browser
stops re-fetching it on every Library / Recently-Pressed render. Missing covers
get a negative marker (re-checked daily). CoverArt points at the endpoint.
- Recently Pressed dedupes by artist+album (re-requesting/re-downloading an album
left multiple completed Requests showing the same album repeatedly); newest of
each is kept.
web 164 tests, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The real cause of the failed 584MB album upload: Next.js caps the request body
at 10MB for any route that passes through middleware, and the import route went
through the auth gate. Exclude /api/library/import from the middleware matcher so
the body streams uncapped to busboy, and authenticate it inline instead (new
lib/auth.isAuthed reads + verifies the session cookie from the request).
web 161 tests, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Manual import used request.formData(), which buffers the whole body and caps out
around ~15MB — a real FLAC album is hundreds of MB (Hollywood's Bleeding is
584MB), so it failed with "expected multipart form data". Now the multipart body
is STREAMED with busboy: each file pipes straight to a hidden .import-<uuid>
staging dir on the library filesystem, then the dir is atomically renamed into
place. No full-body buffering, no size cap. Cleaned up on any failure; guards
(400 missing fields/audio, 409 exists) unchanged. web 161 tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A real folder drag silently did nothing for a user (the logic is fine — a
simulated drop works — so a real drop was either erroring silently or not
firing). Harden it and give a guaranteed alternative:
- onDrop now captures the entries synchronously, wraps the async walk in
try/catch, and TOASTS on any failure or empty result (no more silent no-op).
- dragEnter preventDefault + dragover dropEffect="copy" for reliable drop-target
registration.
- New "Choose a folder" affordance (a webkitdirectory <input>) that sidesteps
drag entirely — pick the album folder and it collects the files + prefills
artist/album/year from the folder name. Plus "Choose files".
tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Manual import only accepted individual audio files — dragging a folder yielded
nothing (dataTransfer.files doesn't recurse). Now the drop zone walks the entry
tree via webkitGetAsEntry, so you can drag a whole album folder (the natural
shape of a ripped CD) and it collects the audio + cover inside. When a single
folder is dropped, artist/album/year are prefilled by parsing its name
("Artist - Album (Year)"), only filling fields you haven't typed into. Clicking
still opens the multi-file picker. No API change — same files reach the route.
web 161 tests, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second Library Management feature. An "Add album" button on the Library page
opens a modal: drag-drop (or pick) the audio files (+ optional cover image),
enter artist/album/year, and Lyra writes the album into the library.
- POST /api/library/import (auth-gated, multipart): sanitizes the folder name
like the worker's safe_name, assembles into a sibling ".importing" temp dir
then renames into place (never a half-written album), writes a cover.jpg from
any image, and records a source="manual" LibraryItem with the on-disk
trackNames + a rough quality class from the extension (a later Scan re-probes /
MB-resolves for cover art + hi-res detection). Guards: 400 (missing
artist/album or no audio), 409 (folder exists / already in library).
- Library page: ImportAlbum drop-zone modal wired to refresh the grid.
web 161 tests (write-to-disk + trackNames + guards + filename sanitization).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Also: DELETE now prunes an emptied artist folder (best-effort).
First Library Management feature. On the Library album modal, "Delete album"
(two-step confirm) removes the album:
- deletes the folder on disk (guarded to only ever remove a path strictly inside
the library root, LYRA_LIBRARY_ROOT, default /music),
- drops the LibraryItem,
- sets the matching MonitoredRelease monitored=false + currentQualityClass=null
so the monitor won't immediately re-download it (re-Want later to restore).
Requires the web container to see the library, so docker-compose now bind-mounts
MUSIC_DIR at /music (rw) for web too. New DELETE /api/library/[id] (auth-gated,
path-guarded). web 157 tests (delete happy-path + 404 + outside-root guard).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
docker-compose only health-checked db. Add:
- web: an unauthenticated GET /api/health (excluded from the auth middleware) +
a compose healthcheck that hits it (node global fetch — no curl/wget in image).
- worker: a background daemon thread touches /tmp/worker.alive every ~15s
(independent of the main loop, so it stays fresh through a long download) +
a compose healthcheck on the file's mtime (unhealthy if >90s stale).
Now `docker compose ps` reports real health for all services.
worker 223 tests, web 154, tsc + build + compose config clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Several client mutations either ignored a non-ok response (silent no-op) or
toasted success unconditionally even when the request failed. Sweep them so every
mutation gives feedback:
- artists: toggle auto-monitor, unfollow → error toast on failure.
- discography + wanted: toggle monitor / search-now → success or error toast.
- discover suggestion follow/want/dismiss → error toast (was toasting success
even on failure).
- The Floor: queue request + retry → error toast on failure.
- artist-modal + preview follow/want, Last.fm want → error toast on failure
(closes the deferred "want() shows no toast on non-ok" item).
web 153 tests, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Library modal showed MusicBrainz's canonical tracklist, hiding incomplete
downloads / edition or track-order mismatches, and showed nothing for albums
without an rgMbid. Now it shows the REAL files on disk.
- LibraryItem gains trackNames String[] (migration add_library_track_names):
the album's on-disk "## Title.ext" audio filenames.
- Captured at import (pipeline._import lists the final album folder) and at scan
(scan._record_owned now also refreshes trackNames on re-scan via ON CONFLICT
DO UPDATE + xmax=0 to preserve the newly-imported flag). New shared
library.list_audio_files() helper.
- /api/library exposes trackNames; the AlbumModal prefers on-disk tracks when
present (parsed "## Title" → position/title, zero network, labeled "On disk ·
N tracks"), falling back to the MusicBrainz listing otherwise. Items imported
before this column show the MB fallback until re-scanned.
worker 221 tests / 7-skip, web 153, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a section at the bottom of the Artists overview listing artists you own
music by (LibraryItem) but don't follow (WatchedArtist), each with a Follow
action — so you can start monitoring artists already in your library.
- /api/artists GET now also returns `ownedNotFollowed`: distinct LibraryItem
artist names not matching the watched roster (case-insensitive; the known
have-via-scan name-match caveat, #18).
- Follow resolves the library name → MBID (/api/mb/artists) then POST /api/artists
with the canonical MB name (mirrors the Last.fm/discover follow flow); a
no-match surfaces a toast. Followed names hide immediately via a session set,
even if the MB canonical name differs from the library name.
web 153 tests (owned-not-followed + case-insensitive match asserted), tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Qobuz/Monitor/Discovery help I just added was always-visible and verbose.
Replace each with a compact "?" button (new reusable HelpButton) that opens the
same content in a popup (reuses the Modal), keeping the settings tabs clean.
web tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow affordance in ALL album modals (user request): thread artistMbid through
the remaining callers — Library, Wanted, and artist Discography. /api/library
and /api/wanted now expose artistMbid (from the joined MonitoredRelease); the
artist-detail route already exposed mbid. So every album modal now links the
artist name to their page + shows a Follow/Following button.
Settings polish (#10):
- Qobuz tab: drop the email/password inputs (the engine prefers the more
reliable user-id + auth-token path); a "Clear legacy login" affordance appears
only when old email/password creds are still stored, and the PUT no longer
sends them.
- Clear-button alignment fixed: .field label is a flex row and .field-grid
bottom-aligns inputs, so a set-secret field (with its · set + Clear) lines up
with a plain sibling field.
- Library tab gains a read-only Staging path panel (/staging, STAGING_DIR, why
fast local disk, rebuild-to-change).
- Monitor + Discovery tabs gain grounded helptext (quality-class 1/2/3 cutoff,
poll/retry/upgrade-window; sweep cadence + seeds-per-chunk + ListenBrainz URL).
web 152 tests, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AlbumModal showed the artist name as static text — no way to follow the artist
or jump to them. Adds a Follow affordance (todo #13):
- AlbumInfo gains optional `artistMbid`. When present, the modal title links the
artist name to /discover/artist/{mbid} and shows a Follow/Following button that
reflects live follow state.
- Follow state fetched via a new lightweight GET /api/artists?mbid= check
(avoids pulling the full watched list + discography); Follow reuses
POST /api/artists. Non-ok POST surfaces a toast (also chips at todo #14).
- Wired artistMbid in the three discovery contexts where following is valuable:
Discover, Discover artist preview, and Last.fm. Library/discography callers
pass none (already-followed artists) so the button stays hidden — graceful.
web 152 tests (the ?mbid= follow-state check asserted), tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/discover felt "stale until I hit Discover now": it showed the result string but
never WHEN the sweep last ran, and "Discovering…" ended the moment the worker
claimed the request (requested→false) rather than when the sweep finished.
- /api/discover/run GET now also returns `inProgress` (discover.inProgress) and
`lastRunAt` (the discover.result Config row's updatedAt = last completion).
- The client treats requested||inProgress as "running", so "Discovering…"
persists for the whole sweep, and polls until both clear before refreshing.
- The tool row shows "Last discovered {N ago} · {result}", a sweeping message
while running, and a clear "Not yet run" empty state.
web 151 tests (inProgress + lastRunAt asserted), tsc + build clean. No worker
change (signals already written); NOT a page cache (would worsen staleness).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/lastfm hit the Last.fm API live on every load / period switch / page. Cache the
raw Last.fm payload in the shared ApiCache table (12h TTL) while keeping the
own-state annotation (followed/owned/wanted) computed LIVE on top — so a follow
made after the payload was cached shows immediately.
- cached() gains a { force } option to bypass a fresh row (still serves stale if
the forced fetch fails); CACHE_TTL.HOUR added.
- All three routes wrap their Last.fm call: lfm-top-artists / lfm-top-albums
:{user}:{period}:{page}, lfm-artist-plays:{user}:{artist} (keys normalized).
- A "Refresh" button on /lastfm force-bypasses the cache (?refresh=1).
- Factored the copy-pasted getCreds/PERIODS/LIMIT into a route-only _creds.ts
(closes the Last.fm-routes DRY todo #19) — prisma stays out of any client
bundle since lib/lastfm.ts is imported by a client component.
web 150 tests (cache-hit + refresh + own-state-live asserted), tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reopening an artist (discography), an album (tracklist), or repeating a lookup
re-hit MusicBrainz every time with no cache and no throttle. Adds a generic
Postgres-backed read-through cache keyed on the LOGICAL operation (not the
request URL) so the worker and web app share entries.
- New ApiCache(key, json, fetchedAt) model + migration (generic name so the
Last.fm browse cache can ride the same table).
- lib/apicache.ts cached(): read-through, TTL from fetchedAt (tunable, no
migration), serve-stale-on-outage, never caches null.
- Wrap the high-level MB fns with logical keys + TTLs: discography &
tracklists & artist-name 30d, searchReleaseGroup 7d, searchArtists 1d.
Search keys normalized (trim+lowercase) to match the worker byte-for-byte.
- album-modal: a session-scoped Map<rgMbid,Track[]> so reopening is instant
with zero network (complements the DB cache).
- Test setup clears ApiCache between tests. web 147 tests, tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>