Archilyzer · Source

archilyzer

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

commit 18b08d1104fb9376c14b5743b3f0a853194be620
parent 6df0de3d7dcd280509c72182fadd782673685fb4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  2 Oct 2026 01:30:49 -0400

common: a subtitle 429 beside another subtitle failure takes that failure's class; a run that asked nothing is not clean; clear-platform-hold is a declared kind (re-review R1, R3, R4)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/jobs/jobKinds.ts | 12++++++++++++
Mcommon/lib/availability.test.ts | 16++++++++++++++++
Mcommon/lib/availability.ts | 9++++++++-
Acommon/ytdlp/platformClean.test.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/ytdlp/runYtdlp.ts | 15++++++++++++++-
5 files changed, 108 insertions(+), 2 deletions(-)

diff --git a/common/jobs/jobKinds.ts b/common/jobs/jobKinds.ts @@ -100,6 +100,18 @@ const JOB_KINDS: Record<string, JobKindMeta> = { queueKeyStrategy: "platform", needsMedia: true, }, + // THE OPERATOR'S "Clear hold" on /operations/download (release 17 slice + // RL): one step that drops a held platform's hold, backoff and raised pace, + // and says so in its log. Its own queue (`pacing:<platform>`), so a Sync + // running on the platform's queue never makes the click wait. Touches no + // channel and no file but the auto-queue state. + "clear-platform-hold": { + kind: "clear-platform-hold", + label: "Clear rate-limit hold", + drainable: false, + replayable: false, + queueKeyStrategy: "custom", + }, // The two OPERATION lanes' runners. Same shape as the two above — one // long-lived job per lane on queueKey "", drainable, never replayable — with // one difference worth stating: their units make NO job record. A digest or a diff --git a/common/lib/availability.test.ts b/common/lib/availability.test.ts @@ -199,3 +199,19 @@ test("a subtitle failure that is not a 429 is told apart; a WARNING webpage 429 false, ); }); + +test("a subtitle 429 beside another subtitle failure classes as the OTHER failure, never rate_limit (re-review R1)", () => { + const w429 = "WARNING: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests"; + const chat404 = "WARNING: Unable to download video subtitles for 'live_chat': HTTP Error 404: Not Found"; + const sub403 = "WARNING: Unable to download video subtitles for 'en-orig': HTTP Error 403: Forbidden"; + assert.equal(classifyDownloadFailure(`${w429}\n${chat404}`, "error"), "unknown"); + assert.equal(classifyDownloadFailure(`${w429}\n${sub403}`, "error"), "network"); + // A real platform 429 beside them still wins. + assert.equal( + classifyDownloadFailure( + `${w429}\n${chat404}\nERROR: [youtube] abc: Unable to download webpage: HTTP Error 429: Too Many Requests`, + "error", + ), + "rate_limit", + ); +}); diff --git a/common/lib/availability.ts b/common/lib/availability.ts @@ -330,7 +330,14 @@ export function classifyDownloadFailure( ) { return "per_video"; } - const s = stderrTail.toLowerCase(); + // A subtitle 429 beside another subtitle failure is still not a platform + // signal (release 17 re-review R1): its lines are dropped before the + // generic rate-limit test, so the class is the OTHER failure's. + const s = stderrTail + .split("\n") + .filter((l) => !(SUBTITLE_FAILURE_RE.test(l) && RATE_LIMIT_WORDS_RE.test(l))) + .join("\n") + .toLowerCase(); if ( /http error 429/.test(s) || /too many requests/.test(s) || diff --git a/common/ytdlp/platformClean.test.ts b/common/ytdlp/platformClean.test.ts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common exec tsx --test ytdlp/platformClean.test.ts +// +// A CLEAN MANUAL RUN SETTLES THE PLATFORM — ONLY IF IT ASKED THE SOURCE +// SOMETHING (release 17 slice RL, re-review R3). runYtdlp calls +// `onPlatformClean` after a run that neither threw nor recorded a backoff; a +// run that made no request (download-missing with nothing to fetch) must not, +// or it would lift a due hold on no evidence. getPaths() memoizes, so the env +// is set before anything imports it. +const ROOT = mkdtempSync(path.join(os.tmpdir(), "platform-clean-")); +process.env.TRANSCRIPTS_DIR = ROOT; +process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); +writeFileSync( + process.env.SETTINGS_FILE, + JSON.stringify({ minFreeDiskGB: 0, sleepBetweenDownloadsSeconds: 0 }) + "\n", +); +const BIN = path.join(ROOT, "fake-ytdlp.sh"); +writeFileSync(BIN, "#!/bin/sh\nexit 0\n"); +chmodSync(BIN, 0o755); +process.env.YTDLP_BIN = BIN; +mkdirSync(path.join(ROOT, "channels", "c", "data"), { recursive: true }); +// An empty saved playlist: download-missing has nothing to fetch. +writeFileSync(path.join(ROOT, "channels", "c", "playlist"), ""); + +const { runYtdlp } = await import("./runYtdlp"); +const { getPaths } = await import("../lib/paths"); + +async function cleanCalls(mode: "download-missing" | "download-one-audio"): Promise<number> { + let calls = 0; + await runYtdlp({ + channelSlug: "c", + mode, + channelConfig: { handling: "transcribe", url: "https://www.youtube.com/@c/videos" } as never, + paths: getPaths(), + onLog: () => {}, + signal: new AbortController().signal, + singleVideoUrl: "https://www.youtube.com/watch?v=abcdefghijk", + onPlatformClean: async () => { + calls++; + return null; + }, + }); + return calls; +} + +test("a run that made no request is not a clean run", async () => { + assert.equal(await cleanCalls("download-missing"), 0); +}); + +test("a run that made a request and recorded no backoff is", async () => { + assert.equal(await cleanCalls("download-one-audio"), 1); +}); diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts @@ -185,6 +185,11 @@ export type RunYtdlpOpts = { // backoff and any hold clear; release 17 review H2). Returns a line for the // log, or null. onPlatformClean?: () => Promise<string | null>; + // Internal: how many requests this run made of the source (a listing, a + // per-video download, a raw spawn). Set by runYtdlpMode and shared through + // every `{...opts}` copy by reference; a run that asked nothing is not clean + // (release 17 re-review R3). + requestCounter?: { n: number }; }; // The batch modes whose child writes media into `data/<id>/` — yt-dlp's own @@ -235,14 +240,19 @@ async function runYtdlpMode(opts: RunYtdlpOpts): Promise<void> { if (!opts.onPlatformClean) return runYtdlpModeInner(opts); let backedOff = false; const inner = opts.onPlatformBackoff; + const requestCounter = { n: 0 }; await runYtdlpModeInner({ ...opts, + requestCounter, onPlatformBackoff: async (failureClass) => { backedOff = true; await inner?.(failureClass); }, }); - if (backedOff || opts.signal.aborted) return; + // A run that asked the source nothing (download-missing with nothing to + // fetch, download-missing-subs with every video deferred) says nothing about + // the platform, and must not lift a due hold. + if (backedOff || opts.signal.aborted || requestCounter.n === 0) return; try { const line = await opts.onPlatformClean(); if (line) opts.onLog(line); @@ -488,6 +498,7 @@ async function enumeratePlaylistUrls( opts.channelConfig.url!, ); opts.onLog(`$ ${opts.paths.ytdlpBin} ${args.join(" ")}\n`); + if (opts.requestCounter) opts.requestCounter.n++; const child = execa(opts.paths.ytdlpBin, args, { cwd: root, @@ -1125,6 +1136,7 @@ export async function runManagedDownloads( }); let outcome; try { + if (opts.requestCounter) opts.requestCounter.n++; outcome = await downloadOne({ channelSlug: opts.channelSlug, channelConfig: effectiveChannelConfig, @@ -2120,6 +2132,7 @@ async function runChildAndStream( args: string[], ): Promise<string> { opts.onLog(`$ ${opts.paths.ytdlpBin} ${args.join(" ")}\n`); + if (opts.requestCounter) opts.requestCounter.n++; const child = execa(opts.paths.ytdlpBin, args, { cwd, cancelSignal: opts.signal,