Files
Lyra/docs/superpowers/specs/2026-07-15-qobuz-pacing-design.md
T
Jonathan 67f374c470 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>
2026-07-15 18:45:56 +02:00

3.9 KiB
Raw Blame History

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
02 min(5, steady)
36 min(10, steady)
713 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).