Archilyzer · Source

archilyzer

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

commit c033cc421678de189db5f844cf0b26075f4f8521
parent 6bd4e5bd804199cfe223f16b96dacc2adde1581e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  2 Oct 2026 01:09:35 -0400

common: only a subtitle 429 takes the media path; any other subtitle failure is a failed attempt, as before (review H1, N9)

--ignore-errors turns every subtitle failure into a WARNING and exit 0. A
403/404/5xx, a failed live_chat replay or an unwritable file now keeps the
attempt failed: not ok, not archived, no media pass, classified from the
tail. isSubtitleRateLimitOnly also refuses a tail with a non-subtitle 429
line or a non-429 subtitle failure. Unit cases for a 403 and a live_chat
failure.

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

Diffstat:
Mcommon/lib/availability.test.ts | 15+++++++++++++++
Mcommon/lib/availability.ts | 27++++++++++++++++++++++-----
Mcommon/ytdlp/downloadOneManaged.ts | 22++++++++++++++++++++++
Mcommon/ytdlp/subtitleRateLimit.test.ts | 34+++++++++++++++++++++++++++++++---
4 files changed, 90 insertions(+), 8 deletions(-)

diff --git a/common/lib/availability.test.ts b/common/lib/availability.test.ts @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { classifyDownloadFailure, + hasNonRateLimitSubtitleFailure, hasSubtitleRateLimit, isSoftBlock, isSubtitleRateLimitOnly, @@ -184,3 +185,17 @@ test("a subtitle failure that is not a rate limit is not subs_rate_limit", () => assert.equal(hasSubtitleRateLimit(sub404), false); assert.equal(classifyDownloadFailure(sub404, "error"), "unknown"); }); + +test("a subtitle failure that is not a 429 is told apart; a WARNING webpage 429 is a platform signal (review H1, N9)", () => { + const w403 = "WARNING: Unable to download video subtitles for 'en': HTTP Error 403: Forbidden"; + const chat = "WARNING: Unable to download video subtitles for 'live_chat': HTTP Error 404: Not Found"; + const w429 = "WARNING: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests"; + assert.equal(hasNonRateLimitSubtitleFailure(w403), true); + assert.equal(hasNonRateLimitSubtitleFailure(chat), true); + assert.equal(hasNonRateLimitSubtitleFailure(w429), false); + assert.equal(isSubtitleRateLimitOnly(`${w429}\n${chat}`), false); + assert.equal( + isSubtitleRateLimitOnly(`${w429}\nWARNING: [youtube] abc: Unable to download webpage: HTTP Error 429: Too Many Requests (retrying)`), + false, + ); +}); diff --git a/common/lib/availability.ts b/common/lib/availability.ts @@ -257,17 +257,34 @@ export function hasSubtitleRateLimit(stderr: string): boolean { return SUBTITLE_RATE_LIMIT_RE.test(stderr); } +const SUBTITLE_FAILURE_RE = /unable to download video subtitles/i; +const RATE_LIMIT_WORDS_RE = /http error 429|too many requests|rate[- ]?limit/i; + +// A subtitle failure that is NOT a rate limit — a 403/404/5xx on the subtitle +// URL, a failed live_chat replay, an OSError writing the file. Under +// --ignore-errors yt-dlp reports EVERY subtitle failure as a WARNING and exits +// 0, so the managed downloader asks this to keep such a failure a failure: +// only a 429 takes the "download the media anyway" path (review H1). +export function hasNonRateLimitSubtitleFailure(stderr: string): boolean { + return stderr + .split("\n") + .some((l) => SUBTITLE_FAILURE_RE.test(l) && !RATE_LIMIT_WORDS_RE.test(l)); +} + // True when a subtitle 429 is the ONLY failure in the tail: every ERROR line // is a subtitle-download line (yt-dlp's own "The info failed to download … -// trying with URL" retry is a WARNING and is allowed), and neither the soft -// block nor the bot check — both platform signals — is anywhere in it. +// trying with URL" retry is a WARNING and is allowed), every subtitle failure +// is a rate limit, every 429 / too-many-requests line is a subtitle line (a +// retried webpage 429 logged as a WARNING is a platform signal), and neither +// the soft block nor the bot check — both platform signals — is anywhere in it. export function isSubtitleRateLimitOnly(stderr: string): boolean { if (!hasSubtitleRateLimit(stderr)) return false; if (isSoftBlock(stderr) || isBotCheck(stderr)) return false; + if (hasNonRateLimitSubtitleFailure(stderr)) return false; for (const line of stderr.split("\n")) { - if (/^\s*ERROR:/i.test(line) && !/unable to download video subtitles/i.test(line)) { - return false; - } + const subtitleLine = SUBTITLE_FAILURE_RE.test(line); + if (/^\s*ERROR:/i.test(line) && !subtitleLine) return false; + if (RATE_LIMIT_WORDS_RE.test(line) && !subtitleLine) return false; } return true; } diff --git a/common/ytdlp/downloadOneManaged.ts b/common/ytdlp/downloadOneManaged.ts @@ -7,6 +7,7 @@ import { execa } from "execa"; import { AUTH_RETRY_CLASSES, classifyDownloadFailure, + hasNonRateLimitSubtitleFailure, hasSubtitleRateLimit, parseUnavailableFromStderr, } from "../lib/availability"; @@ -1224,7 +1225,13 @@ async function runManagedDownload( // - any other primary that died on its subtitles alone (a channel whose // own args ask for subtitles) is run ONCE more with every subtitle // refused: a second spawn, the media, no timedtext request. + // - ONLY a rate limit (review H1). --ignore-errors turns EVERY subtitle + // failure into a WARNING and exit 0 — a 403/404/5xx, a failed live_chat + // replay, a file it could not write. Any of those keeps today's meaning: + // the attempt failed (not archived, classified from the tail), exactly + // as the exit 1 it used to be. let subsRateLimited = false; + let subtitleFailedOtherwise = false; if ( !audioCheckEnabled && !audioCheckCorruptSource && @@ -1232,6 +1239,20 @@ async function runManagedDownload( ) { if ( attemptSucceeded(primaryRes.exitCode) && + hasNonRateLimitSubtitleFailure(primaryRes.stderrTail) + ) { + subtitleFailedOtherwise = true; + const last = attempts.at(-1); + if (last) { + last.error = trimError(primaryRes.stderrTail); + last.availabilityClass = parseUnavailableFromStderr(primaryRes.stderrTail); + } + opts.onLog( + `The subtitle fetch for ${canonicalId ?? opts.videoUrl} failed (not a rate limit); ` + + `the download is a failed attempt, as before.\n`, + ); + } else if ( + attemptSucceeded(primaryRes.exitCode) && hasSubtitleRateLimit(primaryRes.stderrTail) ) { subsRateLimited = true; @@ -1282,6 +1303,7 @@ async function runManagedDownload( let lastSucceeded = !audioCheckCorruptSource && !audioCheckCorruptFullSource && + !subtitleFailedOtherwise && attemptSucceeded(primaryRes.exitCode); if (lastSucceeded) { // "ok-with-cookies" keeps meaning "cookies were NEEDED" (the prefetch only diff --git a/common/ytdlp/subtitleRateLimit.test.ts b/common/ytdlp/subtitleRateLimit.test.ts @@ -38,12 +38,13 @@ if (has("--no-write-auto-subs") && has("--write-info-json") && !has("-x") && !ha } const subsOn = (has("--write-subs") || has("--write-auto-subs")) && !(argv.lastIndexOf("--no-write-subs") > argv.lastIndexOf("--write-subs")); +const SUB_FAIL = process.env.FAKE_SUB_FAIL || ${JSON.stringify(SUB_429)}; if (subsOn) { if (has("--ignore-errors")) { - process.stderr.write("WARNING: [youtube] ${ID}: ${SUB_429}\\n"); + process.stderr.write("WARNING: [youtube] ${ID}: " + SUB_FAIL + "\\n"); if (has("--skip-download")) { process.stdout.write("DLOM_ARCHIVE youtube ${ID}\\n"); process.exit(0); } } else { - process.stderr.write("ERROR: [youtube] ${ID}: ${SUB_429}\\n"); + process.stderr.write("ERROR: [youtube] ${ID}: " + SUB_FAIL + "\\n"); process.exit(1); } } @@ -53,10 +54,11 @@ process.stdout.write("DLOM_ARCHIVE youtube ${ID}\\n"); process.exit(0); `; -async function run(config: Partial<ChannelConfig>): Promise<{ +async function run(config: Partial<ChannelConfig>, subFail?: string): Promise<{ record: Awaited<ReturnType<typeof downloadOneManaged>>; spawns: string[][]; audio: boolean; + archived: boolean; transcript: boolean; log: string; }> { @@ -67,6 +69,8 @@ async function run(config: Partial<ChannelConfig>): Promise<{ await chmod(bin, 0o755); const logFile = path.join(root, "argv.log"); process.env.FAKE_LOG = logFile; + if (subFail) process.env.FAKE_SUB_FAIL = subFail; + else delete process.env.FAKE_SUB_FAIL; const paths = { channelsDir: path.join(root, "channels"), ytdlpBin: bin, @@ -95,11 +99,13 @@ async function run(config: Partial<ChannelConfig>): Promise<{ record, spawns, audio: existsSync(path.join(dir, "audio.mp3")), + archived: existsSync(path.join(paths.channelsDir, "c", "archive")), transcript: existsSync(path.join(dir, "transcript.en.vtt")), log, }; } finally { delete process.env.FAKE_LOG; + delete process.env.FAKE_SUB_FAIL; await rm(root, { recursive: true, force: true }); } } @@ -145,3 +151,25 @@ test("a primary that died on its subtitles alone is run once more without them", // The refusals come after the channel's own --write-subs (last flag wins). assert.ok(again.lastIndexOf("--no-write-subs") > again.lastIndexOf("--write-subs")); }); + +// Review H1: --ignore-errors turns EVERY subtitle failure into a WARNING and +// exit 0. Only a 429 takes the new path; anything else is a failed attempt, +// exactly as before — never `ok` with no transcript, no media and an archive line. +for (const [name, fail, cls] of [ + ["a subtitle 403", "Unable to download video subtitles for 'en': HTTP Error 403: Forbidden", "network"], + ["a failed live_chat replay", "Unable to download video subtitles for 'live_chat': HTTP Error 404: Not Found", "unknown"], +] as const) { + test(`youtube handling: ${name} under --ignore-errors is still a failed attempt`, async () => { + const r = await run({}, fail); + assert.equal(r.record.status, "failed"); + assert.equal(r.record.failureClass, cls); + assert.equal(r.audio, false); + assert.equal(r.archived, false); + assert.deepEqual( + r.record.attempts.map((a) => a.kind), + ["metadata-prefetch", "primary"], + ); + assert.match(r.record.attempts[1].error ?? "", /Unable to download video subtitles/); + assert.equal(r.spawns.length, 2); + }); +}