Commit Graph

21 Commits

Author SHA1 Message Date
Jonathan 8c8a34e320 feat(library): replace/upgrade an owned album on demand
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>
2026-07-14 21:28:54 +02:00
Jonathan 426b36775f fix(worker): reject a download with 0-byte/missing tracks (#16 partial)
Completeness only compared the adapter's self-reported track_count to expected,
so a silently-skipped track (0-byte placeholder — see the Qobuz 24K Magic bug)
imported as a "complete" album. Now the verify also counts the NON-EMPTY audio
actually staged and fails "incomplete download" when fewer than expected are
present (guarded with `staged and ...` so fake adapters that stage nothing keep
using the count path). A real broken download now goes needs_attention (or falls
through to another source) instead of importing a dead track.

Full per-track duration matching against MB recording lengths (the rest of #16)
remains a larger follow-up. worker 224 tests / 7-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:36:11 +02:00
Jonathan bc42e546f0 fix(worker): raise http.client header limit so Qobuz tracks stop failing
Bruno Mars "24K Magic" track 1 repeatedly downloaded as a 0-byte file. The
worker log showed the real cause: streamrip's track download hit
HTTPException("got more than 100 headers") — Qobuz's CDN returns >100 response
headers for some tracks and Python's http.client rejects them at the default
_MAXHEADERS=100, so streamrip skips the track and leaves an empty placeholder.

- Raise http.client._MAXHEADERS to 1000 on import of the Qobuz adapter, so those
  responses parse and the track downloads.
- _count_staged_audio now skips 0-byte files (a failed-track placeholder is not a
  real track) — improves the download-progress estimate and stops counting dead
  tracks. (A verify-level "reject 0-byte tracks" safety net is a follow-up under
  #16 per-track duration matching — it needs the pipeline fakes to write real
  files, deferred to avoid disproportionate churn here.)

worker 223 tests / 7-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:17:20 +02:00
Jonathan dcf0bb0696 feat: show real on-disk tracklist in the Library album modal (#9)
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>
2026-07-14 12:55:54 +02:00
Jonathan b1757b631c feat(worker): smooth download % from adapter byte-level progress
The Floor bar used a file-count poller that jumps 0→~99% for small/fast albums
(streamrip reports only 0/1). But yt-dlp and slskd already emit real byte-level
progress via the on_progress callback the pipeline was discarding
(lambda _pct: None).

- Thread a throttled on_progress → Job.downloadProgress (write on a >=1% move or
  every 0.5s; clamped 0..1; write failures swallowed). Called synchronously from
  adapter.download on the pipeline thread, so it reuses the main conn safely.
- The file-count poller stays as the FALLBACK: it skips writing while a
  `reports` flag is set (an adapter is driving real progress), and the flag is
  cleared per attempt so a fall-through to streamrip still gets the estimate.
- Smooth bars for YouTube + Soulseek now; Qobuz/streamrip keeps the file-count
  estimate until its per-track callback is hooked later.

worker 216 tests / 7-skip (on_progress write/throttle/clamp covered; existing
pipeline tests now exercise the fake's on_progress end-to-end).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:41:51 +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 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 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 88ef4149d9 fix: atomic import swap, disk/DB replace consistency, guarded pipeline loop
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:27:48 +02:00
Jonathan 29604db9cd feat: download to staging + atomically promote complete album into the library
Rewrite run_pipeline's download->import section to download into a per-job
staging dir (cleared before each candidate), verify completeness there,
promote only a complete album into the library via import_album, tag the
final dir, then DB-import. The staging dir is always removed in a
try/finally, so a partial/failed download never touches or corrupts the
library, and a retry can no longer leave orphan files behind.

Also point test_upgrade_e2e.py's run_pipeline call at dest_root="/tmp/lib"
(matching every other pipeline test) since import_album now unconditionally
creates the final dir, which requires a writable dest_root.
2026-07-11 15:13:31 +02:00
Jonathan 4481f1d998 fix: make intake dedupe cutoff-aware so monitor upgrade path works end-to-end
The monitor's quality-upgrade path was inert: run_pipeline's intake
short-circuit treated any existing LibraryItem as "done", so a
monitor-driven upgrade job for a below-cutoff copy jumped straight to
imported without downloading. Intake now checks whether the job is
monitor-driven (Request.monitoredReleaseId IS NOT NULL) and, if so,
only short-circuits when the existing copy already meets the quality
cutoff; plain manual requests keep exact slice-1 behavior. Also drops
an unused `field` import in browser.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:52:03 +02:00
Jonathan 3934a0c45d feat: import stage upserts to replace lower-quality library items 2026-07-11 12:40:11 +02:00
Jonathan f91117092b refactor: use one album path for tagging and library record
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 00:09:42 +02:00
Jonathan 1c02a870f3 feat: Tagger seam and pipeline tag-stage integration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:47:20 +02:00
Jonathan 2812695465 feat: organized album directory and persistent /music bind-mount
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:43:15 +02:00
Jonathan c16e9f1035 feat: MusicBrainz-aware intake and completeness check against canonical track count 2026-07-10 23:17:06 +02:00
Jonathan cf5632aa5f fix: isolate per-adapter search failures in the pipeline match loop
Wrap each adapter's search() call in the match stage in try/except so
a down source (dead slskd, Qobuz auth error, etc.) contributes no
candidates instead of crashing run_pipeline and killing the worker.
2026-07-10 23:12:33 +02:00
Jonathan d3bec2ee10 refactor: make adapter .tier the single source of truth for ranking
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:07:58 +02:00
Jonathan 7c28eddd47 fix: persist all found candidates and guard duplicate adapter names
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:54:15 +02:00
Jonathan 71c7945da2 feat: replace fake pipeline with staged adapter-driven pipeline
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:45:01 +02:00
Jonathan d441d0561f feat: add fake acquisition pipeline 2026-07-10 17:04:41 +02:00