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.