commit 1c11b0db9e6a7662b03aa7597a605ed83c428408
parent f2a898a3dba12d5f3e3d1351e3275e15f3fb63de
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 23:12:15 -0400
editor: the lane page shows holds, the pace and deferred subtitles; held platforms refuse manual fetches; doctor reports pacing
The download lane's "Rate-limit cooldown" region gains "Platforms held"
(since, failures, next probe), "Request pace" (seconds between requests and
the base) and "Deferred subtitles" (count, time left; "left alone" from the
third). A manual Sync, download or metadata scan on a held platform is
refused with a sentence naming the hold and the next probe; the video
page's fetch-window and full-source refusals use it too. The video page
draws one line for a video whose subtitles are deferred. `archilyzer doctor`
warns on a platform in a cooldown or a hold and on a raised pace. The
availability check's no-config probe paces like every other spawn; a
manual Sync's backoff passes its failure class (only a rate limit doubles
the pace).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
10 files changed, 377 insertions(+), 11 deletions(-)
diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts
@@ -581,3 +581,40 @@ test("the drive-health timings: applied from settings.storage.health before the
assert.equal(healthTimings().budgetMs, 5000, "applied to this process");
applyHealthTimings();
});
+
+test("download pacing: a held platform and a raised pace warn, and nothing is written (release 17, RL)", async () => {
+ const c = checkout();
+ const stateFile = path.join(c.paths.transcriptsDir, ".auto-queue", "state.json");
+ (c.paths as { autoQueueStateFile: string }).autoQueueStateFile = stateFile;
+ mkdirSync(path.dirname(stateFile), { recursive: true });
+ const now = Date.UTC(2026, 9, 1, 12, 0);
+ writeFileSync(
+ stateFile,
+ JSON.stringify({
+ download: {
+ platformBackoff: {
+ youtube: { until: now + 42 * 60_000, fails: 8 },
+ rumble: { until: now + 90_000, fails: 2 },
+ },
+ platformHolds: { youtube: { since: now - 60_000, probeAt: now + 42 * 60_000 } },
+ platformPace: { youtube: { sleepRequestsSeconds: 4, baseSeconds: 1, cleanUnits: 1 } },
+ },
+ }),
+ );
+ const before = tree(c.root);
+ const r = await run(c, {}, { now: new Date(now) });
+ assert.equal(r.ok, true, renderDoctorReport(r));
+ const dp = r.checks.filter((x) => x.section === "download pacing");
+ assert.deepEqual(
+ dp.map((x) => [x.id, x.status]),
+ [
+ ["youtube", "warn"],
+ ["rumble", "warn"],
+ ["youtube pace", "warn"],
+ ],
+ );
+ assert.match(dp[0].detail, /^held since .* after 8 rate-limited\/network failures in a row; next probe in 42 min/);
+ assert.match(dp[1].detail, /^in a rate-limit cooldown for 2 min more \(attempt 2\)$/);
+ assert.match(dp[2].detail, /^4s between requests \(base 1s\)/);
+ assert.deepEqual(tree(c.root), before);
+});
diff --git a/common/bin/doctor.ts b/common/bin/doctor.ts
@@ -183,6 +183,46 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor
}
}
+ // ── download pacing ──────────────────────────────────────────────────────
+ // The auto-download lane's persisted rate-limit state (release 17, slice
+ // RL): a platform in a cooldown or a HOLD, and a request pace a rate limit
+ // raised. A warning, never a failure — the lane is doing what it should, and
+ // the operator may want to know why YouTube has been quiet all evening.
+ // Read-only: the state reader never writes.
+ const DP = "download pacing";
+ if (paths.autoQueueStateFile && existsSync(paths.autoQueueStateFile)) {
+ const { readAutoQueueState } = await import("../jobs/autoQueueState");
+ const st = (await readAutoQueueState(paths)).download;
+ const nowMs = (deps.now ?? new Date()).getTime();
+ const mins = (ms: number) => `${Math.max(1, Math.ceil(ms / 60_000))} min`;
+ let quiet = true;
+ for (const [pf, e] of Object.entries(st.platformBackoff)) {
+ const hold = st.platformHolds[pf];
+ if (hold) {
+ quiet = false;
+ add(DP, pf, "warn",
+ `held since ${stamp(new Date(hold.since))} after ${e.fails} rate-limited/network failures in a row; ` +
+ `next probe ${e.until > nowMs ? `in ${mins(e.until - nowMs)}` : "due now"} — manual fetches on it are refused until a probe comes back clean`);
+ } else if (e.until > nowMs) {
+ quiet = false;
+ add(DP, pf, "warn", `in a rate-limit cooldown for ${mins(e.until - nowMs)} more (attempt ${e.fails})`);
+ }
+ }
+ for (const [pf, p] of Object.entries(st.platformPace)) {
+ quiet = false;
+ add(DP, `${pf} pace`, "warn",
+ `${p.sleepRequestsSeconds}s between requests (base ${p.baseSeconds}s) — a rate limit raised it; ${p.cleanUnits} clean unit(s) toward the next easing`);
+ }
+ const subs = Object.values(st.subtitleDeferrals).filter((d) => d.until > nowMs);
+ if (subs.length > 0) {
+ add(DP, "subtitles", "info",
+ `${subs.length} video(s) with subtitles deferred after a subtitle 429 (${subs.filter((d) => d.count >= 3).length} left alone for 7 days) — /operations/download lists them`);
+ }
+ if (quiet) add(DP, "platforms", "ok", "no platform in a cooldown or a hold; every pace at its base");
+ } else {
+ add(DP, "platforms", "info", "no auto-queue state yet — nothing has been rate-limited");
+ }
+
// ── settings ─────────────────────────────────────────────────────────────
const S = "settings";
const settingsText = readOrNull(paths.settingsFile);
diff --git a/common/controller/checkAvailability.ts b/common/controller/checkAvailability.ts
@@ -27,7 +27,7 @@ import { getSettings } from "../lib/settings";
// digest registry's claude-code lane, which has the same problem.
import { parseStdoutJson } from "../lib/parseStdoutJson";
import { readChannelConfig } from "./channels";
-import { channelExtraArgs, platformArgs } from "../ytdlp/channelArgs";
+import { channelExtraArgs, pacedPlatformArgs } from "../ytdlp/channelArgs";
import { detectPlatform } from "../lib/platform";
import { resolveShardItems } from "./shard";
import type { Paths } from "../lib/paths";
@@ -250,7 +250,8 @@ export async function runAvailabilityCheck({
? channelExtraArgs(config, probeCookies)
: [
...cookieArgs(probeCookies),
- ...platformArgs(detectPlatform(url)),
+ // At the platform's current pace (release 17, slice RL).
+ ...pacedPlatformArgs(detectPlatform(url)),
]),
"--",
url,
diff --git a/common/views/autoQueueStatus.test.ts b/common/views/autoQueueStatus.test.ts
@@ -87,14 +87,40 @@ test("a cooldown is filtered by the injected clock, newest first", () => {
};
const payload = buildAutoQueueStatusPayload(inputs({ state }));
assert.deepEqual(payload.download.cooldowns, [
- { platform: "twitch", untilMs: NOW + 600_000, fails: 5 },
- { platform: "rumble", untilMs: NOW + 60_000, fails: 2 },
+ { platform: "twitch", untilMs: NOW + 600_000, fails: 5, hold: null },
+ { platform: "rumble", untilMs: NOW + 60_000, fails: 2, hold: null },
]);
// The clock is a function, so the same state read later says something else.
assert.deepEqual(
buildAutoQueueStatusPayload(inputs({ state, now: () => NOW + 120_000 }))
.download.cooldowns,
- [{ platform: "twitch", untilMs: NOW + 600_000, fails: 5 }],
+ [{ platform: "twitch", untilMs: NOW + 600_000, fails: 5, hold: null }],
+ );
+});
+
+test("a hold, the pace and the subtitle deferrals reach the download lane (release 17, RL)", () => {
+ const state = emptyAutoQueueState();
+ state.download.platformBackoff = { youtube: { until: NOW + 3_600_000, fails: 8 } };
+ state.download.platformHolds = { youtube: { since: NOW - 60_000, probeAt: NOW + 3_600_000 } };
+ state.download.platformPace = {
+ youtube: { sleepRequestsSeconds: 4, baseSeconds: 1, cleanUnits: 0 },
+ };
+ state.download.subtitleDeferrals = {
+ b: { count: 3, lastAt: NOW - 1, until: NOW + 7 * 86_400_000, channelSlug: "alpha" },
+ a: { count: 1, lastAt: NOW - 1, until: NOW + 3_600_000, channelSlug: "alpha" },
+ gone: { count: 1, lastAt: NOW - 10, until: NOW - 1, channelSlug: "alpha" },
+ };
+ const d = buildAutoQueueStatusPayload(inputs({ state })).download;
+ assert.deepEqual(d.cooldowns, [
+ { platform: "youtube", untilMs: NOW + 3_600_000, fails: 8, hold: { sinceMs: NOW - 60_000 } },
+ ]);
+ assert.deepEqual(d.pace, [{ platform: "youtube", sleepRequestsSeconds: 4, baseSeconds: 1 }]);
+ assert.deepEqual(
+ d.subtitleDeferred.map((x) => [x.videoId, x.count, x.held]),
+ [
+ ["a", 1, false],
+ ["b", 3, true],
+ ],
);
});
diff --git a/common/views/autoQueueStatus.ts b/common/views/autoQueueStatus.ts
@@ -31,6 +31,28 @@ export type PlatformCooldownView = {
platform: string;
untilMs: number;
fails: number;
+ // The platform's HOLD (release 17, slice RL): its rate limit outlasted the
+ // cooldown cap, and `untilMs` is the next probe. Null for a plain cooldown.
+ hold: { sinceMs: number } | null;
+};
+
+// A platform whose request pace is above its static value (release 17, slice
+// RL): what every yt-dlp spawn against it now waits between requests.
+export type PlatformPaceView = {
+ platform: string;
+ sleepRequestsSeconds: number;
+ baseSeconds: number;
+};
+
+// A video whose subtitle fetch answered 429 while its media came down.
+// `held` once the count reached three (left alone for 7 days).
+export type SubtitleDeferralView = {
+ videoId: string;
+ channelSlug: string;
+ count: number;
+ lastAtMs: number;
+ untilMs: number;
+ held: boolean;
};
// A video the auto-download pick skips until `untilMs` because it was
@@ -69,6 +91,11 @@ export type AutoQueueKindStatus = {
// Videos deferred after a rate limit, soonest to return first (download
// kind only; empty elsewhere and whenever none is live).
deferred: VideoDeferralView[];
+ // Platforms whose pace a rate limit raised, and the videos whose subtitles
+ // are deferred (download kind only; release 17, slice RL). The deferrals
+ // are listed while their window is open, soonest first.
+ pace: PlatformPaceView[];
+ subtitleDeferred: SubtitleDeferralView[];
// This runner's lane gate — what the page's pause button draws, and what lets
// the operations rail show a runner HOLDING for the first time.
//
@@ -152,8 +179,42 @@ function buildKind(
inputs.state[kind].platformBackoff,
)
.filter(([, e]) => e.until > now)
- .map(([platform, e]) => ({ platform, untilMs: e.until, fails: e.fails }))
+ .map(([platform, e]) => {
+ const hold = inputs.state[kind].platformHolds?.[platform];
+ return {
+ platform,
+ untilMs: e.until,
+ fails: e.fails,
+ hold: hold ? { sinceMs: hold.since } : null,
+ };
+ })
.sort((a, b) => b.untilMs - a.untilMs);
+ const pace: PlatformPaceView[] = Object.entries(
+ inputs.state[kind].platformPace ?? {},
+ )
+ .map(([platform, p]) => ({
+ platform,
+ sleepRequestsSeconds: p.sleepRequestsSeconds,
+ baseSeconds: p.baseSeconds,
+ }))
+ .sort((a, b) => (a.platform < b.platform ? -1 : a.platform > b.platform ? 1 : 0));
+ const subtitleDeferred: SubtitleDeferralView[] = Object.entries(
+ inputs.state[kind].subtitleDeferrals ?? {},
+ )
+ .filter(([, d]) => d.until > now)
+ .map(([videoId, d]) => ({
+ videoId,
+ channelSlug: d.channelSlug,
+ count: d.count,
+ lastAtMs: d.lastAt,
+ untilMs: d.until,
+ held: d.count >= 3,
+ }))
+ .sort(
+ (a, b) =>
+ a.untilMs - b.untilMs ||
+ (a.videoId < b.videoId ? -1 : a.videoId > b.videoId ? 1 : 0),
+ );
const deferred: VideoDeferralView[] = Object.entries(
inputs.state[kind].videoDeferrals ?? {},
)
@@ -182,6 +243,8 @@ function buildKind(
picks: inputs.state[kind].picks,
cooldowns,
deferred,
+ pace,
+ subtitleDeferred,
// THE ASYMMETRY IS TRANSCRIPTION'S ALONE, and it is not a special case for
// "the first lane": its hold is LIVE on the worker pool, while every other
// lane's flag IS its gate. See lib/pauseGates.ts.
diff --git a/editor/app/channels/[slug]/pipelineActions.ts b/editor/app/channels/[slug]/pipelineActions.ts
@@ -14,6 +14,7 @@ import {
} from "yt-dlp-transcript-common/lib/queueKeys";
import { detectPlatform } from "yt-dlp-transcript-common/lib/platform";
import {
+ heldPlatformRefusal,
platformCooldownRemainingMs,
recordDownloadBackoff,
} from "yt-dlp-transcript-common/jobs/downloadBackoff";
@@ -126,6 +127,19 @@ async function runPipelineAction(
// Platform key shared with the auto-download runner (detectPlatform(url) ??
// "unknown") — drives the 429 cooldown both honor.
const platform = detectPlatform(channelConfig.url) ?? "unknown";
+ // A HELD platform refuses every manual fetch, not only a Sync (release 17,
+ // slice RL): its rate limit outlasted the cooldown cap, and the lane is
+ // probing it once an hour — a click is not a probe. The sentence names the
+ // hold and the next probe. store-playlist stays allowed, as it is under the
+ // downloads pause.
+ if (mode !== "store-playlist") {
+ const held = await heldPlatformRefusal(
+ platform,
+ mode === "sync" ? "Sync" : "This download",
+ paths,
+ );
+ if (held) return { ok: false, info: true, error: held };
+ }
// A manually clicked Sync RESPECTS the per-platform rate-limit cooldown: if
// auto-download (or a prior sync) hit a 429, refuse rather than re-storm the
// source. Neutral (info) result so the UI shows it as a notice, not an error.
@@ -211,8 +225,10 @@ async function runPipelineAction(
setProgress,
progressBaseline,
// On a 429/network failure, record the shared per-platform cooldown so
- // the auto-download runner (and a later manual sync) back off too.
- onPlatformBackoff: () => recordDownloadBackoff(platform, paths),
+ // the auto-download runner (and a later manual sync) back off too. The
+ // class matters since release 17: only a rate limit doubles the pace.
+ onPlatformBackoff: (failureClass) =>
+ recordDownloadBackoff(platform, paths, failureClass),
});
// The channel report (snapshot) is regenerated automatically after this
// job finishes, via the global debounced scheduler hooked into
@@ -342,6 +358,8 @@ export async function runMetadataScanAction(
return { ok: false, error: "Channel has no `url` configured" };
}
const platform = detectPlatform(channelConfig.url) ?? "unknown";
+ const held = await heldPlatformRefusal(platform, "The metadata scan", paths);
+ if (held) return { ok: false, info: true, error: held };
const remainingMs = await platformCooldownRemainingMs(platform, paths);
if (remainingMs > 0) {
const secs = Math.ceil(remainingMs / 1000);
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -35,6 +35,7 @@ import { RedownloadSection } from "./cards/RedownloadSection";
import { ShortAudioBanner } from "./cards/ShortAudioBanner";
import { SourceVideoSection } from "./cards/SourceVideoSection";
import { TranscriptSourceSection } from "./cards/TranscriptSourceSection";
+import { SubtitleDeferralLine } from "./cards/SubtitleDeferralLine";
import {
CANONICAL_VTT,
WHISPER_FILENAME,
@@ -314,6 +315,8 @@ export function VideoPanel({
</div>
</PipelineStageCard>
+ <SubtitleDeferralLine slug={slug} videoId={videoId} />
+
{vttTracks.length > 0 && (
<PipelineStageCard
id="transcript-source"
diff --git a/editor/app/channels/[slug]/videos/[id]/components/cards/SubtitleDeferralLine.tsx b/editor/app/channels/[slug]/videos/[id]/components/cards/SubtitleDeferralLine.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { subtitleDeferralAction } from "../../videoActions";
+
+// THE SUBTITLE DEFERRAL, IN ONE LINE (release 17, slice RL). A video whose
+// YouTube subtitles answered HTTP 429 while its media came down: how many
+// times, when last, and until when the batch subtitle fetch (Download missing
+// subs) leaves it alone — 6 h after a strike, 7 days from the third. A
+// download from this page still fetches them. Draws nothing when there is no
+// deferral, which is every video but a handful.
+export function SubtitleDeferralLine({
+ slug,
+ videoId,
+}: {
+ slug: string;
+ videoId: string;
+}) {
+ const [d, setD] = useState<{ count: number; lastAt: number; until: number } | null>(null);
+ useEffect(() => {
+ let live = true;
+ subtitleDeferralAction(slug, videoId)
+ .then((r) => {
+ if (live) setD(r);
+ })
+ .catch(() => {});
+ return () => {
+ live = false;
+ };
+ }, [slug, videoId]);
+ if (!d) return null;
+ const day = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " ") + " UTC";
+ const held = d.count >= 3;
+ const active = d.until > Date.now();
+ return (
+ <p
+ role="note"
+ aria-label="subtitle deferral"
+ className="rounded-md border border-warning/30 bg-warning-soft px-3 py-2 text-sm text-warning"
+ >
+ YouTube's subtitles for this video were rate-limited (HTTP 429){" "}
+ {d.count}× — last {day(d.lastAt)}.{" "}
+ {active
+ ? held
+ ? `Left alone by Download missing subs until ${day(d.until)}.`
+ : `Download missing subs retries them after ${day(d.until)}.`
+ : "Download missing subs will retry them."}{" "}
+ A download from this page still fetches them.
+ </p>
+ );
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts
@@ -65,7 +65,9 @@ import {
import { detectPlatform } from "yt-dlp-transcript-common/lib/platform";
import { applyTagAssignmentsAction } from "../../../../tags/actions";
import {
+ heldPlatformRefusal,
platformCooldownRemainingMs,
+ readSubtitleDeferrals,
recordDownloadBackoff,
} from "yt-dlp-transcript-common/jobs/downloadBackoff";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
@@ -944,7 +946,12 @@ export async function fetchWindowAction(req: {
// handful of seconds, but a 429 is a fact about the SOURCE, not about the
// size of the request.
const platform = detectPlatform(url) ?? "unknown";
+ // A held platform's refusal names the hold and the next probe (release 17).
+ const held = await heldPlatformRefusal(platform, "This fetch", paths);
const cooldownMs = await platformCooldownRemainingMs(platform, paths);
+ if (held && cooldownMs > 0) {
+ return { ok: false, status: 409, cooldownMs, platform, error: held };
+ }
if (cooldownMs > 0) {
return {
ok: false,
@@ -1096,7 +1103,12 @@ export async function fetchFullSourceAction(req: {
};
}
const platform = detectPlatform(url) ?? "unknown";
+ // A held platform's refusal names the hold and the next probe (release 17).
+ const held = await heldPlatformRefusal(platform, "This fetch", paths);
const cooldownMs = await platformCooldownRemainingMs(platform, paths);
+ if (held && cooldownMs > 0) {
+ return { ok: false, status: 409, cooldownMs, platform, error: held };
+ }
if (cooldownMs > 0) {
return {
ok: false,
@@ -1136,3 +1148,17 @@ export async function fetchFullSourceAction(req: {
to: 0,
};
}
+
+// THE VIDEO'S SUBTITLE DEFERRAL (release 17, slice RL), for the line on the
+// video page: how many times its subtitle fetch answered 429, when last, and
+// until when the batch subtitle fetch leaves it alone. Null when it has none.
+// Read-only; the video page's own download never consults it.
+export async function subtitleDeferralAction(
+ slug: string,
+ videoId: string,
+): Promise<{ count: number; lastAt: number; until: number } | null> {
+ const deferrals = await readSubtitleDeferrals(getPaths()).catch(() => ({}));
+ const d = (deferrals as Record<string, { count: number; lastAt: number; until: number; channelSlug: string }>)[videoId];
+ if (!d || d.channelSlug !== slug) return null;
+ return { count: d.count, lastAt: d.lastAt, until: d.until };
+}
diff --git a/editor/app/operations/components/RunnerOperationView.tsx b/editor/app/operations/components/RunnerOperationView.tsx
@@ -6,6 +6,8 @@ import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState
import type {
AutoQueueKindStatus,
PlatformCooldownView,
+ PlatformPaceView,
+ SubtitleDeferralView,
VideoDeferralView,
} from "yt-dlp-transcript-common/views/autoQueueStatus";
import type { LaneWorker } from "yt-dlp-transcript-common/views/autoQueueLanes";
@@ -115,10 +117,15 @@ export function RunnerOperationView({
</p>
)}
- {(status.cooldowns.length > 0 || status.deferred.length > 0) && (
+ {(status.cooldowns.length > 0 ||
+ status.deferred.length > 0 ||
+ (status.pace?.length ?? 0) > 0 ||
+ (status.subtitleDeferred?.length ?? 0) > 0) && (
<CooldownStrip
cooldowns={status.cooldowns}
deferred={status.deferred}
+ pace={status.pace ?? []}
+ subtitleDeferred={status.subtitleDeferred ?? []}
now={now}
/>
)}
@@ -202,27 +209,66 @@ export function useNow(): number | null {
// lapses; the runner skips it meanwhile. A deferred video is skipped by
// auto-download only — a manual Sync / download-missing still fetches it.
//
+// RELEASE 17, SLICE RL adds three lists to the same region: the platforms
+// HELD (a rate limit that outlasted the cooldown cap — one probe at a time,
+// and every manual fetch on them refused), the request PACE of every platform
+// a rate limit slowed, and the videos whose SUBTITLES are deferred (their
+// media came down; the platform was not backed off for them).
+//
// A <div role="region">, NOT a <section>: this sits inside the lane's own
// <section>, and the structural contract above forbids a nested one. The
// accessible role and name are the same either way.
function CooldownStrip({
cooldowns,
deferred,
+ pace,
+ subtitleDeferred,
now,
}: {
cooldowns: PlatformCooldownView[];
deferred: VideoDeferralView[];
+ pace: PlatformPaceView[];
+ subtitleDeferred: SubtitleDeferralView[];
now: number | null;
}) {
const left = (untilMs: number) =>
now === null ? null : Math.max(0, Math.ceil((untilMs - now) / 1000));
+ const paceOf = (platform: string) =>
+ pace.find((p) => p.platform === platform)?.sleepRequestsSeconds;
+ const cooling = cooldowns.filter((c) => !c.hold);
+ const held = cooldowns.filter((c) => c.hold);
return (
<div
role="region"
aria-label="Rate-limit cooldown"
className="flex flex-col gap-1 rounded-md border border-warning/30 bg-warning-soft px-3 py-2 text-sm"
>
- {cooldowns.length > 0 && (
+ {held.length > 0 && (
+ <>
+ <span className="font-medium text-warning">
+ Held — the rate limit outlasted the cooldown cap; auto-download
+ probes once at a time, and manual fetches are refused:
+ </span>
+ <ul
+ aria-label="Platforms held"
+ className="flex flex-wrap gap-x-4 gap-y-1 text-warning"
+ >
+ {held.map((c) => {
+ const secs = left(c.untilMs);
+ const p = paceOf(c.platform);
+ return (
+ <li key={c.platform} className="tabular-nums">
+ <span className="font-mono">{c.platform}</span> — held since{" "}
+ {formatClock(c.hold!.sinceMs)} ({c.fails} failures in a row)
+ {secs !== null && <>, next probe in {formatCooldown(secs)}</>}
+ {p !== undefined && <> · pace {p}s</>}
+ </li>
+ );
+ })}
+ </ul>
+ </>
+ )}
+ {cooling.length > 0 && (
<>
<span className="font-medium text-warning">
Rate-limit cooldown — auto-download and manual sync are paused on:
@@ -231,20 +277,41 @@ function CooldownStrip({
aria-label="Platforms in cooldown"
className="flex flex-wrap gap-x-4 gap-y-1 text-warning"
>
- {cooldowns.map((c) => {
+ {cooling.map((c) => {
const secs = left(c.untilMs);
+ const p = paceOf(c.platform);
return (
<li key={c.platform} className="tabular-nums">
<span className="font-mono">{c.platform}</span>
{secs !== null && (
<> — {formatCooldown(secs)} left (attempt {c.fails})</>
)}
+ {p !== undefined && <> · pace {p}s</>}
</li>
);
})}
</ul>
</>
)}
+ {pace.length > 0 && (
+ <>
+ <span className="font-medium text-warning">
+ Request pace — seconds between requests, raised by a rate limit:
+ </span>
+ <ul
+ aria-label="Request pace"
+ className="flex flex-wrap gap-x-4 gap-y-1 text-warning"
+ >
+ {pace.map((p) => (
+ <li key={p.platform} className="tabular-nums">
+ <span className="font-mono">{p.platform}</span> —{" "}
+ {p.sleepRequestsSeconds}s between requests (base{" "}
+ {p.baseSeconds}s)
+ </li>
+ ))}
+ </ul>
+ </>
+ )}
{deferred.length > 0 && (
<>
<span className="font-medium text-warning">
@@ -271,6 +338,40 @@ function CooldownStrip({
</ul>
</>
)}
+ {subtitleDeferred.length > 0 && (
+ <>
+ <span className="font-medium text-warning">
+ Deferred subtitles — downloaded, but YouTube's subtitles
+ answered HTTP 429; Download missing subs retries them after:
+ </span>
+ <ul
+ aria-label="Deferred subtitles"
+ className="flex flex-wrap gap-x-4 gap-y-1 text-warning"
+ >
+ {subtitleDeferred.map((d) => {
+ const secs = left(d.untilMs);
+ return (
+ <li key={d.videoId} className="tabular-nums">
+ <Link
+ href={`/channels/${d.channelSlug}/videos/${d.videoId}`}
+ className="font-mono underline underline-offset-2 hover:text-brand"
+ >
+ {d.channelSlug}/{d.videoId}
+ </Link>{" "}
+ — {d.count}×
+ {secs !== null && (
+ <>
+ , {d.held ? "left alone " : ""}
+ {formatCooldown(secs)}
+ {d.held ? "" : " left"}
+ </>
+ )}
+ </li>
+ );
+ })}
+ </ul>
+ </>
+ )}
</div>
);
}