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,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { GET, PATCH } from "./route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
function patch(body: Record<string, string>) {
|
||||
return new Request("http://localhost/api/qobuz/pacing", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("qobuz pacing config API", () => {
|
||||
it("returns defaults when nothing is stored", async () => {
|
||||
const cfg = await (await GET()).json();
|
||||
expect(cfg["qobuz.pacing.enabled"]).toBe("true");
|
||||
expect(cfg["qobuz.pacing.activeStartHour"]).toBe("8");
|
||||
expect(cfg["qobuz.pacing.activeEndHour"]).toBe("23");
|
||||
expect(cfg["qobuz.pacing.dailyCap"]).toBe("40");
|
||||
expect(cfg["qobuz.pacing.warmupStartDate"]).toBe("");
|
||||
});
|
||||
|
||||
it("round-trips allow-listed settings and persists them", async () => {
|
||||
await PATCH(patch({ "qobuz.pacing.dailyCap": "25", "qobuz.pacing.activeStartHour": "9" }));
|
||||
const stored = await prisma.config.findUnique({ where: { key: "qobuz.pacing.dailyCap" } });
|
||||
expect(stored?.value).toBe("25");
|
||||
const cfg = await (await GET()).json();
|
||||
expect(cfg["qobuz.pacing.dailyCap"]).toBe("25");
|
||||
expect(cfg["qobuz.pacing.activeStartHour"]).toBe("9");
|
||||
});
|
||||
|
||||
it("ignores keys not in the allow-list (e.g. worker-managed counters)", async () => {
|
||||
await PATCH(patch({ "qobuz.pacing.countToday": "999", "qobuz.pacing.dailyCap": "30" }));
|
||||
expect(await prisma.config.findUnique({ where: { key: "qobuz.pacing.countToday" } })).toBeNull();
|
||||
expect((await prisma.config.findUnique({ where: { key: "qobuz.pacing.dailyCap" } }))?.value).toBe("30");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// Qobuz pacing ("budget gate") settings. The worker reads these Config keys live; state keys
|
||||
// (countDay/countToday/nextAllowedAt) are worker-managed and intentionally NOT editable here.
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
"qobuz.pacing.enabled": "true",
|
||||
"qobuz.pacing.activeStartHour": "8",
|
||||
"qobuz.pacing.activeEndHour": "23",
|
||||
"qobuz.pacing.dailyCap": "40",
|
||||
"qobuz.pacing.warmupStartDate": "",
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const rows = await prisma.config.findMany({ where: { key: { in: Object.keys(DEFAULTS) } } });
|
||||
const stored = Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||||
return Response.json(
|
||||
Object.fromEntries(Object.entries(DEFAULTS).map(([k, d]) => [k, stored[k] ?? d])),
|
||||
);
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const entries = Object.entries((body ?? {}) as Record<string, unknown>).filter(([k]) =>
|
||||
Object.prototype.hasOwnProperty.call(DEFAULTS, k),
|
||||
);
|
||||
for (const [key, value] of entries) {
|
||||
await prisma.config.upsert({
|
||||
where: { key },
|
||||
create: { key, value: String(value), secret: false },
|
||||
update: { value: String(value) },
|
||||
});
|
||||
}
|
||||
return Response.json({ updated: entries.length });
|
||||
}
|
||||
@@ -54,6 +54,8 @@ export function SettingsForm() {
|
||||
const [discoverSaved, setDiscoverSaved] = useState(false);
|
||||
const [monitorCfg, setMonitorCfg] = useState<Record<string, string>>({});
|
||||
const [monitorSaved, setMonitorSaved] = useState(false);
|
||||
const [pacingCfg, setPacingCfg] = useState<Record<string, string>>({});
|
||||
const [pacingSaved, setPacingSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/scan")
|
||||
@@ -106,6 +108,12 @@ export function SettingsForm() {
|
||||
.then((c: Record<string, string>) => setMonitorCfg(c));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/qobuz/pacing")
|
||||
.then((r) => r.json())
|
||||
.then((c: Record<string, string>) => setPacingCfg(c));
|
||||
}, []);
|
||||
|
||||
async function saveCredentials(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await fetch("/api/config", {
|
||||
@@ -160,6 +168,21 @@ export function SettingsForm() {
|
||||
setTimeout(() => setMonitorSaved(false), 1500);
|
||||
}
|
||||
|
||||
function setPacing(key: string, value: string) {
|
||||
setPacingCfg((c) => ({ ...c, [key]: value }));
|
||||
}
|
||||
|
||||
async function savePacing(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await fetch("/api/qobuz/pacing", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(pacingCfg),
|
||||
});
|
||||
setPacingSaved(true);
|
||||
setTimeout(() => setPacingSaved(false), 1500);
|
||||
}
|
||||
|
||||
const secretHint = (set: boolean) => (set ? <span className="set"> · set</span> : null);
|
||||
|
||||
async function clearField(field: "qobuzToken" | "slskdApiKey" | "lastfmApiKey") {
|
||||
@@ -193,6 +216,7 @@ export function SettingsForm() {
|
||||
</div>
|
||||
|
||||
{tab === "Qobuz" ? (
|
||||
<>
|
||||
<form className="settings-section" onSubmit={saveCredentials}>
|
||||
<div className="subhead">
|
||||
Auth token
|
||||
@@ -235,6 +259,55 @@ export function SettingsForm() {
|
||||
{saved ? <span className="saved-note">Saved</span> : null}
|
||||
</div>
|
||||
</form>
|
||||
<form className="settings-section" onSubmit={savePacing}>
|
||||
<div className="subhead">
|
||||
Pacing
|
||||
<HelpButton title="Qobuz pacing">
|
||||
Throttles Qobuz to a human-looking volume and cadence so the account isn't flagged
|
||||
for bulk downloading. Within active hours it downloads up to the daily cap, spaced out
|
||||
with randomized gaps; when throttled, albums fall through to Soulseek/YouTube and
|
||||
upgrade to Qobuz hi-res later. The daily cap ramps up over the first two weeks from the
|
||||
new-account start date.
|
||||
</HelpButton>
|
||||
</div>
|
||||
<label className="field-inline">
|
||||
<input type="checkbox" aria-label="pace qobuz downloads"
|
||||
checked={pacingCfg["qobuz.pacing.enabled"] !== "false"}
|
||||
onChange={(e) => setPacing("qobuz.pacing.enabled", e.target.checked ? "true" : "false")} />
|
||||
<span>Pace Qobuz downloads</span>
|
||||
</label>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>Active hours — start (0–24)</span>
|
||||
<input type="number" min={0} max={24} aria-label="qobuz active start hour"
|
||||
value={pacingCfg["qobuz.pacing.activeStartHour"] ?? ""}
|
||||
onChange={(e) => setPacing("qobuz.pacing.activeStartHour", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Active hours — end (0–24)</span>
|
||||
<input type="number" min={0} max={24} aria-label="qobuz active end hour"
|
||||
value={pacingCfg["qobuz.pacing.activeEndHour"] ?? ""}
|
||||
onChange={(e) => setPacing("qobuz.pacing.activeEndHour", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Daily cap (steady)</span>
|
||||
<input type="number" min={0} aria-label="qobuz daily cap"
|
||||
value={pacingCfg["qobuz.pacing.dailyCap"] ?? ""}
|
||||
onChange={(e) => setPacing("qobuz.pacing.dailyCap", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>New-account start (warm-up)</span>
|
||||
<input type="date" aria-label="qobuz warmup start date"
|
||||
value={pacingCfg["qobuz.pacing.warmupStartDate"] ?? ""}
|
||||
onChange={(e) => setPacing("qobuz.pacing.warmupStartDate", e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="save-row">
|
||||
<button type="submit" className="btn accent">Save</button>
|
||||
{pacingSaved ? <span className="saved-note">Saved</span> : null}
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{tab === "Soulseek" ? (
|
||||
|
||||
Reference in New Issue
Block a user