Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/bin/doctor.test.ts | 37+++++++++++++++++++++++++++++++++++++
Mcommon/bin/doctor.ts | 40++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/checkAvailability.ts | 5+++--
Mcommon/views/autoQueueStatus.test.ts | 32+++++++++++++++++++++++++++++---
Mcommon/views/autoQueueStatus.ts | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/channels/[slug]/pipelineActions.ts | 22++++++++++++++++++++--
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 3+++
Aeditor/app/channels/[slug]/videos/[id]/components/cards/SubtitleDeferralLine.tsx | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 26++++++++++++++++++++++++++
Meditor/app/operations/components/RunnerOperationView.tsx | 107++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
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&apos;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&apos;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> ); }