Replaces the sequential worker with a download-manager-style pool
(§0 item 4). Model: the user's connection is the limit; semantics are
fair-share ACROSS feeds, not goroutine-per-book (a global FIFO would
make Book B wait behind Book A's 100 files).
- Atomic claim: GetNextQueuedEpisode + SetPodcastEpisodeDownloading
was a read-then-write race under concurrency; ClaimQueuedPodcastEpisode
converts queued→downloading in ONE guarded statement (two claimants,
one winner — the loser gets ErrNoRows). SetPodcastEpisodeDownloading
retired.
- Queue tickets: download_queue_pos BIGINT fed by a Postgres SEQUENCE
stamped at enqueue — the enqueue loop's ordering IS the claim order
(the foundation §0 item 5 builds on; previously all rows in a batch
shared one updated_at, so within-feed order was arbitrary).
- Pool: up to podcast_download_concurrency (default 4, live range 1–16)
claim-loop goroutines per drain round; the setting re-reads every
round so admin changes take effect without restart.
- Fair share: claim prefers the feed least-recently-served
(pickFeedToServe; never-served first), earliest ticket within a feed —
books proceed in parallel while chapters land in order (start
listening while the rest download).
- Bandwidth: shared token bucket across all workers
(podcast_download_speed_mbps, default 0 = unlimited); take() reserves
bandwidth and callers sleep outside the lock.
- Settings: new float setting type end to end (registry defaults +
clamping, save validation, admin UI number-with-step input) + the two
schema seeds; worker wires the registry via SetSettings (tests run on
registered defaults).
- Tests: unit coverage for the bucket reservation math + fair-share
pick; integration TestPodcastDownloadPool pins ticket order = enqueue
order, claim exclusivity, and a full 3-episode pool drain.
Dev-DB note: the column/sequence/settings were applied live (idempotent
DDL, same statements the schema runs at next boot) since the running
container predates them.
WHY: downloads needed manual refreshes to see progress; the 204 +
HX-Trigger event indirection missed its listener, and the worker
buffered whole enclosures so no progress existed to show.
- Worker streams enclosures to disk through a counting loop (64KiB
chunks, scheme/redirect/size guards preserved) with an in-memory
per-episode progress snapshot (received/total; total from
Content-Length, seeded by the feed's declared length). Deliberately
transient — the DB row state remains the durable truth.
- Episode-list renderer seam: the router injects a closure into
PodcastHandler (handlers can't import templates — MediaDetail cycle)
so htmx download/retry/Download All/refresh/order/policy actions
answer with the REFRESHED partial directly (200, hx-target=
#episode-list) — no events to miss. Non-HX callers keep JSON; tests
without a renderer keep 204+trigger.
- Polling tightens to 2s while downloads are active; downloading rows
render a percentage bar + MB counter (quote-free inline style per
templ gotcha #8) or an indeterminate bar when the server sends no
length.
- Issue #3: Download All (button AND the add-feed checkbox) flips the
CALLER's subscription policy to 'all' — per-user, never global (D6:
files shared, policies not) — and the auto-download select
out-of-band-swaps so the dropdown shows what the user is getting.
They can turn it down afterwards; the flip is honest communication,
not coercion (user-approved 2026-10-29).
PINs: Download All with HX answers 200 + episode-list + oob select and
flips newest → all; add-feed download_all sets policy all.
One sequential worker (day one) over a DB-backed queue
(download_state='queued' IS the worklist — the episode list always
renders truth, D8/D19). State machine: pending → queued → downloading
→ downloaded | failed (+ download_error + download_attempts).
Idempotency: downloaded re-request is a no-op; a file already on disk
that probes cleanly re-marks the row without re-fetching
(crash-between-move-and-update reconciliation). Retry: manual path
resets attempts (human override), auto policies re-queue up to
maxAutoDownloadAttempts=5 then stay failed until a human retries.
Pipeline: enclosure fetch (same SSRF/size guards as the fetcher) →
.part temp → declared-size pre-check → MIME/size validation (empty /
over-cap / content-sniff refuses non-audio) → ffprobe reconcile +
embedded-cover extraction (scanner machinery reuse: runFFProbe,
extractEmbeddedAudioCover, AudioMetadataPayload) → atomic rename →
SUCCESS-ONLY row update (invariant 7: a failure never clears existing
episode data).
Policy union (D6): subscriber policies per feed — 'all' → everything
missing; 'newest N' → the N tail positions; 'off' contributes nothing;
auto-retry budget respected at queue time. DeleteFeedFiles for the
admin-only removal path (episode files + cover siblings + artwork).