import { test } from "node:test"; import assert from "node:assert/strict"; import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import type { Paths } from "../lib/paths"; import type { ChannelConfig } from "../lib/channelConfig"; import { downloadOneManaged } from "./downloadOneManaged"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test ytdlp/subtitleRateLimit.test.ts // // A SUBTITLE 429 NEVER FAILS A DOWNLOAD (release 17, slice RL). The yt-dlp here // is a temp node script that plays the 2026-10-01 shape: the prefetch and the // media requests succeed, the subtitle fetch answers 429 — as a WARNING when // asked to --ignore-errors (yt-dlp's own semantics), as an ERROR otherwise. It // records every argv so the spawns can be counted and read. const ID = "H64QQZuw-aA"; const VIDEO = `https://www.youtube.com/watch?v=${ID}`; const SUB_429 = `Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests`; const FAKE = `#!/usr/bin/env node const fs = require("node:fs"); const path = require("node:path"); const argv = process.argv.slice(2); fs.appendFileSync(process.env.FAKE_LOG, JSON.stringify(argv) + "\\n"); const has = (f) => argv.includes(f); const dir = path.join("data", ${JSON.stringify(ID)}); fs.mkdirSync(dir, { recursive: true }); if (has("--no-write-auto-subs") && has("--write-info-json") && !has("-x") && !has("-f")) { // the metadata prefetch: captions are listed fs.writeFileSync(path.join(dir, "metadata.info.json"), JSON.stringify({ id: ${JSON.stringify(ID)}, title: "t", automatic_captions: { en: [{}] }, subtitles: {}, })); process.exit(0); } 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_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_FAIL + "\\n"); process.exit(1); } } // the media fs.writeFileSync(path.join(dir, "audio.mp3"), "audio"); process.stdout.write("DLOM_ARCHIVE youtube ${ID}\\n"); process.exit(0); `; async function run(config: Partial, subFail?: string): Promise<{ record: Awaited>; spawns: string[][]; audio: boolean; archived: boolean; transcript: boolean; log: string; }> { const root = await mkdtemp(path.join(tmpdir(), "subs-rl-")); try { const bin = path.join(root, "fake-ytdlp.cjs"); await writeFile(bin, FAKE); 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, } as Paths; let log = ""; const record = await downloadOneManaged({ channelSlug: "c", channelConfig: { handling: "youtube", url: "https://www.youtube.com/@c/videos", ...config, } as ChannelConfig, paths, videoUrl: VIDEO, onLog: (s) => { log += s; }, signal: new AbortController().signal, }); const spawns = (await readFile(logFile, "utf8")) .split("\n") .filter(Boolean) .map((l) => JSON.parse(l) as string[]); const dir = path.join(paths.channelsDir, "c", "data", ID); return { 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 }); } } test("youtube handling: the subtitle 429 is a warning, the media comes down, the record says subs_rate_limit", async () => { const r = await run({}); assert.equal(r.record.status, "ok"); assert.equal(r.record.failureClass, "subs_rate_limit"); assert.equal(r.record.fellBackToTranscribe, true); assert.equal(r.audio, true); assert.equal(r.transcript, false); assert.deepEqual( r.record.attempts.map((a) => a.kind), ["metadata-prefetch", "primary", "no-subs-fallback"], ); // The primary asked yt-dlp to carry on past a subtitle failure, and paced // the subtitle request at no less than `-t sleep`'s 5 s. const primary = r.spawns[1]; assert.ok(primary.includes("--ignore-errors")); const ss = primary.indexOf("--sleep-subtitles"); assert.ok(ss > primary.indexOf("sleep"), "--sleep-subtitles comes after -t sleep"); assert.equal(primary[ss + 1], "5"); // The media pass does not ask the throttled endpoint again. const media = r.spawns[2]; assert.ok(media.lastIndexOf("--no-write-subs") >= 0); assert.ok(media.lastIndexOf("--no-write-auto-subs") >= 0); assert.match(r.log, /subtitles for H64QQZuw-aA were rate-limited \(HTTP 429\); downloading the media anyway/); // Three spawns: prefetch, subtitles, media — the same as a video with no captions. assert.equal(r.spawns.length, 3); }); test("a primary that died on its subtitles alone is run once more without them", async () => { // A transcribe-handling channel whose own args ask for subtitles. const r = await run({ handling: "transcribe", ytdlpExtraArgs: ["--write-subs"] }); assert.equal(r.record.status, "ok"); assert.equal(r.record.failureClass, "subs_rate_limit"); assert.deepEqual( r.record.attempts.map((a) => a.kind), ["metadata-prefetch", "primary", "primary-without-subs"], ); assert.equal(r.audio, true); const again = r.spawns[2]; // 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); }); }