feat: Qobuz pacing budget gate (human-like volume + cadence)
Avoid re-triggering a USER_BLOCKED account by capping Qobuz to a human-looking volume and cadence. A per-album gate (worker/lyra_worker/qobuz_gate.py) allows Qobuz only within active hours, under today's cap (a warm-up ramp of a steady cap), and past a randomized spacing since the last Qobuz download; the gap spreads the day's budget across active hours with jitter. When the gate is closed the pipeline drops Qobuz candidates so the job falls through to another source (and monitored albums upgrade to Qobuz later); a Qobuz-only setup is never gated. Config keys read live each loop; counters reset at the day boundary via the DB clock. Tunable in Settings -> Qobuz -> Pacing (new /api/qobuz/pacing route). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user