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:
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);
+ });
+}