# Qobuz pacing ("budget gate") — design Date: 2026-07-15 ## Problem Bulk hi-res downloading got the Qobuz account blocked (`403 USER_BLOCKED`). We need to keep using Qobuz for hi-res but at a volume and cadence that looks like a human listener, so the account isn't flagged again — while a large wanted-list still drains promptly via other sources. ## Approach: a per-job Qobuz eligibility gate One gate decides, **per album**, whether Qobuz is an eligible source *right now*. If the gate is closed, Qobuz candidates are dropped before ranking, so the worker falls through to the best non-Qobuz source (Soulseek/YouTube) and keeps moving — no idling. Monitored albums grabbed that way are upgraded to Qobuz hi-res later, automatically, via the existing quality-upgrade window, once Qobuz has budget again. **Qobuz is eligible for a job only if ALL hold:** 1. **Active hours** — current hour ∈ [activeStart, activeEnd) (a daytime window; start < end). 2. **Under today's cap** — Qobuz downloads counted today < today's cap. 3. **Spacing elapsed** — now ≥ the randomized "next allowed" time set after the last Qobuz download. After each successful Qobuz download the worker bumps today's counter and sets the next-allowed time. The counter resets at the day boundary (tracked by a stored date). **Today's cap = warm-up ramp of the steady cap**, by account age (days since `warmupStartDate`): | Account age (days) | Cap | |---|---| | 0–2 | min(5, steady) | | 3–6 | min(10, steady) | | 7–13 | min(20, steady) | | 14+ | steady | If `warmupStartDate` is unset, no ramp (steady cap applies). **Spacing / jitter (combined):** the gap to the next Qobuz download is randomized around `activeWindowSeconds / todayCap` (so the day's budget spreads evenly across active hours), ±30%, with a hard floor (≥ 60 s). This *is* the jitter — never a fixed metronome — and it prevents front-loading the whole daily cap into the first hour. ## Configuration (Config keys; editable in Settings → Qobuz → Pacing) Settings: - `qobuz.pacing.enabled` (default `true`) - `qobuz.pacing.activeStartHour` (default `8`) - `qobuz.pacing.activeEndHour` (default `23`) - `qobuz.pacing.dailyCap` (steady cap, default `40`) - `qobuz.pacing.warmupStartDate` (ISO date; set to the new account's start day) Worker-managed state (not user-edited): - `qobuz.pacing.countDay` (date the counter is for) - `qobuz.pacing.countToday` (int) - `qobuz.pacing.nextAllowedAt` (epoch seconds) Current hour / day come from the **DB clock** (`now()`), avoiding container-timezone ambiguity. Config is read fresh each worker loop, so changes take effect with no restart. ## Worker integration - New module `worker/lyra_worker/qobuz_gate.py`: a `QobuzPacing` config dataclass (`from_config`), a pure `today_cap(...)`, a pure `is_allowed(...)` decision, and thin DB helpers `gate_open(conn, now)` (reads state + decides) and `record_download(conn, pacing, now)` (bumps counter + sets next-allowed). - `pipeline.py`: after searching sources, if `gate_open` is false AND at least one non-Qobuz candidate exists, drop Qobuz candidates before ranking (fall-through). If Qobuz is the *only* source available, keep it (a Qobuz-only setup isn't gated — holding would strand the album). - After a successful download where `winner.source == "qobuz"`, call `record_download`. ## Testing Pure-logic unit tests for `is_allowed` (each gate condition), `today_cap` (ramp boundaries), and the spacing computation; pipeline tests that a gated Qobuz candidate is dropped in favour of a non-Qobuz source, that an open gate keeps Qobuz, and that a successful Qobuz download records the counter/next-allowed. ## Out of scope - Holding/parking an album for later Qobuz when Qobuz is the only source (fails/last-source as today). Multi-source setups fall through, which is the common case. - Timezone-aware active-hours windows that wrap past midnight (start < end only).