Commit Graph

312 Commits

Author SHA1 Message Date
Jonathan c918cba654 docs: note generalizing the MB cache table to a shared ApiCache
The Last.fm browse page has the same live-external-call-per-load problem;
name the cache table/helper generically (ApiCache) so Last.fm rides the
same read-through, with a manual Refresh button and live own-state
annotation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:37:31 +02:00
Jonathan 2158d988f8 docs: add MusicBrainz Postgres cache implementation plan
A read-through cache shared by the web app and the worker so reopening an
artist/album (and repeat lookups during scans/discovery) stop re-hitting
MusicBrainz. Postgres-only (Redis disregarded — PG is already shared across
web + worker); keys on the logical operation so both processes share entries;
serve-stale-on-outage; TTL-only invalidation. Four tasks: MbCache table →
web read-through → worker CachingMbBrowser → client Map + pruning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:33:03 +02:00
Jonathan 8922af757c docs: update README for Last.fm, redesign, progress/retry, and pages
Bring the README current with everything shipped since it was written:
Last.fm as a second discovery source + the /lastfm browse page, the
/library grid, The Floor's three-phase progress + retry, and the
Pressing Plant design system. Add a "Running it" quick-start. Fix a
pre-existing inaccuracy: slskd is an external daemon reached via
config, not a compose service (stack is db + web + worker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:53:30 +02:00
Jonathan 9561e6e196 feat(web): three-phase progress with live download % on The Floor
Replace the misleading 6-micro-step progress bar with three honest
phases (Searching -> Downloading -> Finishing). Downloading now shows
a live determinate percentage sourced from job.downloadProgress (Slice
A), with a "NN% · done/total tracks" caption when the track count is
known. Searching and Finishing stay indeterminate.

- status.ts: describeJob collapses to {label, kind}; drop step/totalSteps
  and the STAGES array.
- progress-bar.tsx: ProgressBar takes {kind, indeterminate?, percent?,
  caption?} instead of step/total/stageLabel.
- job-row.tsx: JobRow takes a `bar?: ReactNode` slot instead of
  step/total/indeterminate/stageLabel props.
- queue.tsx: builds the per-state bar + meta; downloading meta drops
  the track count (now shown in the bar). Retry button + error note
  from the retry feature preserved exactly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:29:33 +02:00
Jonathan 9d5e1ff58a feat: track download progress on Job (migration + worker poller + API)
Add Job.downloadProgress (0.0-1.0), derived from a worker background
poller that counts audio files landing in staging / expected track
count, since streamrip only reports 0/1. No UI yet (slice B).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:23:15 +02:00
Jonathan 52e74f7709 feat(web): retry a needs-attention job from The Floor
Add POST /api/requests/[id]/retry: 404 if the request/job is missing,
400 if the job isn't in needs_attention, otherwise clears the job's
stale candidates and resets it to state=requested/currentStage=intake
(error/claimedAt cleared) plus request status=pending, in a
transaction, so the worker's requested-only claim re-picks it up.
Wire a Retry button into the attention-row note on the Floor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:12:03 +02:00
Jonathan 53c0dcc256 fix(worker): run streamrip on one persistent event loop (fixes Qobuz download failures)
Each search/download called asyncio.run, creating and closing a fresh event loop.
streamrip's module-global download-concurrency Semaphore binds to the loop on first
use, so the 2nd+ download reused a Semaphore bound to a closed loop -> "Semaphore is
bound to a different event loop", tracks failed, album arrived short -> "incomplete
download" / needs_attention. Run all streamrip coroutines on one persistent loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:04:25 +02:00
Jonathan fdca692bb5 fix(worker): mark the chosen candidate at download-start, not after success
_mark_chosen ran only after a download succeeded, so throughout the 'downloading'
state no candidate was chosen — the Floor row's "source · format · tracks" line was
blank (only elapsed showed). Now mark the candidate exclusively at each download
attempt so `chosen` reflects the source in flight (and updates on fall-through).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:17:18 +02:00
Jonathan 381426082e feat(web): richer active-job rows on The Floor (source/format/tracks, stage, elapsed, error)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:05:07 +02:00
Jonathan 3ec1547e10 fix(web): artistPlays via user.getTop{Tracks,Albums} (getArtistTracks deprecated)
Last.fm returns error 27 "Deprecated" for user.getArtistTracks, so the per-artist
plays modal was 502ing for every artist. Pivot to filtering the user's supported
all-time top tracks + top albums (which carry real per-item play counts) to the
artist — exact personal counts, cheaper, and the clicked artists are the user's
top artists so their items cluster near the front. Adds topTracks(); route + modal
return shape unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:38:22 +02:00
Jonathan 2291308b90 feat(web): Last.fm most-played songs + albums in the artist modal
Adds an optional lastfmName prop to ArtistModal. When set (only on
/lastfm) it fetches /api/lastfm/artist-plays independently of the
existing preview fetch and renders a most-played-songs list plus a
most-played-albums list above the release grid. Album Want resolves
via /api/mb/release-group then POSTs /api/discover/want, mirroring
the existing want(r) flow. Own-state chips name-match Last.fm albums
against the already-loaded release grid. Other ArtistModal callers
(discover-client.tsx) pass no lastfmName, so their behavior is
unchanged.
2026-07-13 21:27:18 +02:00
Jonathan 5816a17952 feat(web): artistPlays lib + /api/lastfm/artist-plays (personal per-artist tally) 2026-07-13 21:20:47 +02:00
Jonathan b6623944ff docs: plan for Last.fm artist modal (personal most-played songs + albums)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:17:31 +02:00
Jonathan 13126addbc fix(web): resolve album names to the studio Album, not a same-title Single
searchReleaseGroup took the first MusicBrainz hit, so a title MB ranks with the
single first (e.g. "Thriller" ties single+album at score 100) resolved to the
single — the Last.fm album modal and the Want flow then showed a 1-track single.
Prefer a clean Album > EP > Single > album-with-secondary, keeping MB order as the
in-tier tiebreak; single-only titles still resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:06:15 +02:00
Jonathan 146bf32f23 feat(worker): Last.fm artist.getSimilar as a second discovery SimilaritySource
Adds LastfmSource, resolving Last.fm's often-empty artist mbid via the
injected MB browser (name->mbid cached) so every emitted SimilarArtist.mbid
is a real MusicBrainz artist MBID. Registered in build_similarity_sources,
gated on lastfm.api_key so the ListenBrainz-only path is unchanged when
unset.
2026-07-13 19:23:18 +02:00
Jonathan 284872b307 style(web): theme the .field select (Last.fm period dropdown) to match inputs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:13:05 +02:00
Jonathan 7e1515fa99 feat(web): /lastfm browse page (artists/albums tabs, hide-in-library, follow/want) 2026-07-13 19:08:12 +02:00
Jonathan ecdad88973 feat(web): Last.fm client lib + top-artists/albums + mb release-group routes 2026-07-13 19:01:04 +02:00
Jonathan 5eb4fbfd55 feat(web): Last.fm credentials (api key + username) + Settings tab 2026-07-13 18:53:58 +02:00
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
Jonathan 6f325d30b9 fix(worker): evict removed-seed contributions + clear scan worklist on abandon
Two row-leak bugs found in whole-branch review:

- discovery.run_discovery now prunes DiscoverySeedContribution rows whose
  seedMbid is no longer a WatchedArtist (unfollow/removal has no FK cascade)
  and recomputes the touched candidates' scores this sweep, instead of
  letting a dead seed's contribution inflate the aggregate forever.

- maybe_run_scan's exception handler around scan_chunk now calls
  clear_worklist and clears scan.id, matching the drain path. Previously
  an exception mid-chunk left the already-done rows plus the remaining
  worklist for that scan_id orphaned forever, since the next scan mints a
  fresh uuid. Also clear scan.id in the build_worklist failure handler for
  consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:39:38 +02:00
Jonathan 0115b82867 feat(worker): persisted scan worklist (O(N) scan, robust to mid-scan changes)
Replace scan_chunk's O(N^2) tree re-walk-and-skip-to-cursor with a
persisted ScanWorkItem worklist: build_worklist walks the tree once at
scan start and inserts one row per album (idempotent via ON CONFLICT),
scan_chunk pops up to `limit` not-done rows by indexed cursor and marks
them done, and clear_worklist drops the scan's rows once drained.
scan_library is reimplemented on top of the worklist (one-shot scan_id,
drain, clear) with an unchanged signature/behavior. main.py's
maybe_run_scan now drives a generated scan.id through Config
(scan.id/scan.inProgress/scan.progress/scan.result) instead of a
scan.cursor string, so a mid-scan library change no longer risks
skipping or re-walking albums.
2026-07-13 15:26:44 +02:00
Jonathan de052a4ef7 feat(worker): per-seed discovery contributions so score sums across chunks/sweeps
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:18:30 +02:00
Jonathan bdd8fe72e9 feat: add DiscoverySeedContribution + ScanWorkItem tables 2026-07-13 15:14:58 +02:00
Jonathan db72cab99b fix(web): label Monitor toggle for a11y + correct Settings eyebrow 2026-07-13 15:11:09 +02:00
Jonathan bbe1616ed2 fix(web): harden config PATCH allowlists against prototype-chain keys
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:06:49 +02:00
Jonathan 6decdc7fc9 docs: implementation plan for cleanup batch (Tier A + B)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:04:19 +02:00
Jonathan 785385f0a2 docs: spec for cleanup batch (Tier A loose ends + Tier B design fixes)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:27:51 +02:00
Jonathan e008ee8d13 fix(web): remove inert global autoMonitorFuture checkbox from Monitor tab
The worker never reads the global monitor.autoMonitorFuture Config key;
auto-monitor-future is governed solely by the per-artist
WatchedArtist.autoMonitorFuture toggle on the Artists page. Removes the
misleading no-op checkbox and its DEFAULTS/test entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:08:05 +02:00
Jonathan a67c552f29 feat(worker): chunk the discovery sweep (one chunk per loop iteration)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:57:00 +02:00
Jonathan 5351cd029d feat(worker): discovery chunk_size + seed-count in result (retire max_seeds)
Reconciled the pre-existing test_config_from_config_parses_and_defaults
test in test_discovery.py, which referenced max_seeds/maxSeeds and would
otherwise have broken.
2026-07-13 00:52:08 +02:00
Jonathan df7bf2fcf8 fix(worker): import_album recovers orphaned .old/.importing from an interrupted swap
Adds a crash-recovery preamble that restores `final` from `.old` when a
prior swap was interrupted after rename(final, old) but before
rename(tmp, final), and always drops stale `.old`/`.importing` orphans.

Also guards the swap: if nothing new was staged into tmp (empty/missing
staging) and `final` already exists, leave it untouched instead of
swapping in an empty copy. This is required beyond the literal preamble
because the pre-existing swap logic unconditionally replaced `final`
with `tmp` regardless of whether anything was actually imported, which
would otherwise destroy a just-recovered (or any existing) copy whenever
staging is empty.
2026-07-13 00:46:40 +02:00
Jonathan 44c1b1a62a fix(web): wanted POST converges on P2002 unique race instead of 500
Two concurrent Wants for the same release-group both pass the
findUnique existence check as absent; the loser's create() then hits
the rgMbid unique constraint and threw an uncaught P2002, 500ing the
request. Wrap the create in try/catch and fall back to the same
update-and-return-200 path on P2002, converging instead of erroring.
Non-P2002 errors are rethrown unchanged.
2026-07-13 00:40:29 +02:00
Jonathan 9c793f42d1 fix(web): escape Lucene quotes in searchReleaseGroup query
A `"` or `\` inside artist/album terminates the quoted Lucene phrase
and corrupts the MusicBrainz search query. Escape both terms before
interpolation.
2026-07-13 00:37:00 +02:00
Jonathan fd0bb6aaa0 feat(web): surface discover.listenBrainzUrl; swap maxSeeds->chunkSize in Discovery tab 2026-07-13 00:33:49 +02:00
Jonathan 98735b5353 feat(web): clear-credential buttons on Settings 2026-07-13 00:30:35 +02:00
Jonathan 011852d091 fix(web): close prototype-chain bypass in DELETE /api/config allowlist
FIELDS[field] walked the prototype chain, so field=__proto__ (or
constructor, hasOwnProperty, etc.) resolved to a truthy inherited value
whose .key was undefined. Prisma drops undefined filter values, so
deleteMany({ where: { key: undefined } }) collapsed to deleteMany({}),
wiping the entire Config table. Guard with an own-property check before
indexing FIELDS so only real allowlist entries pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:27:22 +02:00
Jonathan 789eb2c76a feat(web): DELETE /api/config?field= to clear a credential
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:23:21 +02:00
Jonathan e048c68f40 feat(web): Monitor settings tab 2026-07-13 00:20:18 +02:00
Jonathan 2a50c1f518 feat(web): monitor config API route
Implement GET and PATCH endpoints for monitor configuration, matching the existing discover config pattern. GET returns stored or default values; PATCH upserts known keys with allowlist filtering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:16:43 +02:00
Jonathan 34ebe39dd7 docs: implementation plan for deferred-items batch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:12:56 +02:00
Jonathan ab94945f9f docs: spec for deferred-items batch (settings, correctness, chunk discovery)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:07:15 +02:00
Jonathan 05b7090b60 chore: gitignore local ./staging download dir
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:46:06 +02:00
Jonathan e825e3d7ad feat(worker): separate download staging volume (STAGING_DIR)
Staging is no longer forced under the library. The worker reads STAGING_ROOT
(container path, default /music/.staging) and stages per-job downloads there;
docker-compose mounts ${STAGING_DIR:-${MUSIC_DIR}/.staging} at /staging with
STAGING_ROOT=/staging. Set STAGING_DIR to a fast local disk when the library
(MUSIC_DIR) is a network share so temp download I/O stays off the share.

Safe cross-volume: import_album already assembles into a temp dir on the
library volume and swaps atomically there, so partial downloads never touch
the library and the final swap stays atomic. clear_staging_root now clears the
root's contents (mount-safe) rather than removing the root. Documented in
.env.example; new tests cover the separate-root path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:44:27 +02:00
Jonathan 891793953f feat(web): artists page — add-artist modal + filter watched list
The MusicBrainz search+follow moves into a modal opened by an "Add artist"
button; the main view gets a filter that narrows the watched roster (with a
"N of N" count). Follow keeps the modal open (toast per follow) so several can
be added in a row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:22:37 +02:00
Jonathan 606ea82b70 feat: real worker status, covers on Recently Pressed, library counter
- Worker stamps a throttled worker.heartbeat; GET /api/worker reports online
  from its updatedAt recency (DB-time); masthead shows a live listening/offline
  dot instead of a static decoration.
- /api/requests enriched with rgMbid; Recently Pressed shows cover thumbs.
- Library album count moved from the filter row into a section header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:58:23 +02:00
Jonathan e624d12089 feat(web): rework preview page onto tabs + covers + album modal
The standalone /discover/artist/[mbid] preview page was an inconsistent
orphan (no art, no tabs, old inline TRACKS toggle). Rebuild it to mirror the
artist-detail page: category tabs, cover thumbnails, and the album modal for
tracks, with per-release Want. Extract shared categoryOf/TAB_ORDER util.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:58:23 +02:00
Jonathan 92d1345503 feat(web): favicon + toast feedback (polish)
Add a vinyl-record favicon (icon.svg) — fixes the favicon.ico 404 console
error. Lightweight toast system (ToastHost + toast()) fired on request, wanted
add, search, follow, and want actions so submits give explicit feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:44:24 +02:00
Jonathan c027945191 feat(web): cover art on Wanted + Discover album rows
Wanted API returns rgMbid/type/year; Wanted rows show a cover thumb and open
the album modal. Discover suggested-album rows get a cover thumb too. Completes
cover art across the app (artist detail, Library, modals, Wanted, Discover).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:37:02 +02:00
Jonathan 76eab9e703 feat(web): Settings tabs per service (Qobuz/Soulseek/Library/Discovery)
Split the long Settings page into tabs so each service stands alone, instead
of one long mixed scroll. Same handlers/aria-labels; credential save still
PUTs the full config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:33:20 +02:00