Commit Graph

252 Commits

Author SHA1 Message Date
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
Jonathan 1fbe58013f feat(web): discovery modals — artist + album, not preview navigation
Clicking a suggested (or find-similar) artist opens an ArtistModal: Follow +
a cover-art grid of their releases with per-album Want (reuses the preview
endpoint), plus a "Full page" link to the deep-linkable preview route. Clicking
a suggested album opens the AlbumModal (tracklist + Want). Lighter than jumping
to a full preview page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:29:46 +02:00
Jonathan 4f6c5995f8 feat(web): Library page — owned-albums grid with cover art
New /library: GET /api/library lists LibraryItems enriched with rgMbid + year
from the matching MonitoredRelease. Client renders a cover-art grid with a live
filter; clicking an album opens the AlbumModal (tracklist). Added Library to the
nav. The album-only overview to see everything downloaded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:24:16 +02:00
Jonathan d05d092d53 feat(web): artist detail — category tabs, cover thumbs, album modal
Discography now has category tabs (All/Albums/Singles/EPs/Live/Comps/Other
with counts, defaults to Albums), cover-art thumbnails, and clicking an album
opens the AlbumModal with its tracklist. Detail GET gains ?all=true so the
client filters by tab (studio-only default still governs the artists-list
counts). Replaces the old flat date-sorted list + show-all toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:20:06 +02:00
Jonathan 34235a2355 feat(web): CoverArt + reusable AlbumModal
CoverArt loads album art from the Cover Art Archive CDN by release-group MBID
(browser-side, cached, graceful placeholder fallback). AlbumModal shows cover +
tracklist (fetched from MB) with slots for a status chip and context actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:14:34 +02:00
Jonathan 3284059aba feat(web): reusable accessible Modal (portal, focus trap, scroll lock)
Pressing Plant styled modal — ESC + backdrop close, Tab focus trap, body
scroll lock, focus restore, reduced-motion. Foundation for album/artist modals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:12:36 +02:00
Jonathan 3f29ac1930 feat(web): widen app container 900px -> 1120px
More horizontal room for lists, tiles, and the upcoming Library grid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:11:19 +02:00
Jonathan 082365005c fix(worker): reconcile every job + backfill owned releases
Wanted releases pressed while the monitor is off never got reconciled, so
they lingered on the wanted list. Extract finish_job() and call it for every
finished job (not just when monitor.enabled) so the MonitoredRelease
transitions to fulfilled. Add fulfill_owned_releases(), run once at startup,
to backfill releases already owned at/above the quality cutoff (self-heals
items stuck from before this fix). Both idempotent + tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:08:24 +02:00
Jonathan 50949f747b feat(web): restyle Settings + read-only library path panel (Phase 3)
Settings onto the Pressing Plant system: credentials with clear set/unset
affordances, a Library section that shows the configured /music path with a
"change MUSIC_DIR in docker-compose + rebuild" note (honest about the mount
being non-remountable from the UI), restyled scan trigger/result, and the
discovery section. All logic and aria-labels preserved. Completes the
frontend redesign (all pages + shell on one system, both themes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:34:46 +02:00
Jonathan ead5f33bcc feat(web): restyle Artists, Discover, Wanted, discography + preview (Phase 2)
Convert the remaining pages onto the Pressing Plant system: PageHead
component, shared list-row / toggle / small+ghost button / tracklist classes,
and page wrappers trimmed now that the shell provides masthead + nav. All
logic and test hooks (aria-labels, button text) preserved. Verified in the
real app across both themes (Playwright): artists/discover/wanted/discography
render cohesively with real data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:27:43 +02:00
Jonathan 7f1fe52a7c feat(web): rebuild home as "The Floor" on the Pressing Plant system
Home reads /api/requests + /api/wanted + /api/artists: summary tiles
(on the press / pressed / wanted / watching), the on-the-press job list
(state -> stripe + chip + stepped bar via describeJob; needs-attention shows
a literal recovery note), a request form (test hooks preserved), an empty
state, and a recently-pressed deadwax list. Verified in the real app (dark
desktop + mobile); light theme matches the approved comp.

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