import { test } from "node:test"; import assert from "node:assert/strict"; import { classifyDownloadFailure, hasNonRateLimitSubtitleFailure, hasSubtitleRateLimit, isSoftBlock, isSubtitleRateLimitOnly, parseUnavailableFromStderr, } from "./availability"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test lib/availability.test.ts test("a bare HTTP 403 is a network failure (backs the platform off)", () => { assert.equal( classifyDownloadFailure( "ERROR: [Rumble] v6abc: Unable to download webpage: HTTP Error 403: Forbidden", undefined, ), "network", ); // 429 still wins as rate_limit. assert.equal( classifyDownloadFailure("HTTP Error 429: Too Many Requests", undefined), "rate_limit", ); assert.equal(classifyDownloadFailure("ERROR: Unsupported URL", undefined), "unknown"); }); test("HTTP 410 Gone is a removed video, not an error", () => { // The exact line rekietalaw-rumble v7e07us's check has read since 2026-08-21. const stderr = "ERROR: [Rumble] v7e07us: Unable to download webpage: HTTP Error 410: Gone (caused by )"; assert.equal(parseUnavailableFromStderr(stderr), "deleted"); // …so a download that hits it skips the video rather than aborting the batch. assert.equal( classifyDownloadFailure(stderr, parseUnavailableFromStderr(stderr)), "per_video", ); // Other HTTP errors are unchanged. assert.equal(parseUnavailableFromStderr("HTTP Error 403: Forbidden"), "error"); assert.equal(parseUnavailableFromStderr("HTTP Error 500: Internal Server Error"), "error"); }); // YOUTUBE'S SOFT BLOCK (release 10, L2). The strings below are real: // - the bare line is the one reported in yt-dlp issue #11426 ("[YouTube] This // content isn't available, try again later.", after ~280 videos of a // playlist), which is what a yt-dlp older than #12958 prints; // - the long ones are what `yt_dlp/extractor/youtube/_video.py` builds from that // reason since #12958 (the build this editor runs, 2026.08.19) — "The current // session" without cookies, "Your account" with them. // yt-dlp's wiki (Extractors, "This content isn't available, try again later") // puts the limit at ~300 videos/hour for a guest session, ~2000 signed in. const SOFT_BLOCK_SESSION = "ERROR: [youtube] dQw4w9WgXcQ: This content isn't available, try again later. " + "The current session has been rate-limited by YouTube for up to an hour. " + "It is recommended to use `-t sleep` to add a delay between video requests to avoid " + "exceeding the rate limit. For more information, refer to " + "https://github.com/yt-dlp/yt-dlp/wiki/Extractors#this-content-isnt-available-try-again-later"; const SOFT_BLOCK_ACCOUNT = SOFT_BLOCK_SESSION.replace( "The current session", "Your account", ); const SOFT_BLOCK_BARE = "ERROR: [youtube] H64QQZuw-aA: This content isn't available, try again later."; // The same reason with a right single quote, in case YouTube sends one. const SOFT_BLOCK_CURLY = SOFT_BLOCK_BARE.replace("isn't", "isn’t"); test("the soft block ('try again later') is a rate limit, not a deleted video", () => { for (const stderr of [ SOFT_BLOCK_SESSION, SOFT_BLOCK_ACCOUNT, SOFT_BLOCK_BARE, SOFT_BLOCK_CURLY, ]) { assert.equal(isSoftBlock(stderr), true, stderr); // Not a property of the video: transient, so never excluded as gone. assert.equal(parseUnavailableFromStderr(stderr), "error", stderr); // …and a batch-level signal that backs the platform off. assert.equal( classifyDownloadFailure(stderr, parseUnavailableFromStderr(stderr)), "rate_limit", stderr, ); } // Inside a multi-line tail (a WARNING first, the ERROR last) it still counts. const tail = `WARNING: [youtube] dQw4w9WgXcQ: nsig extraction slow\n${SOFT_BLOCK_SESSION}\n`; assert.equal(classifyDownloadFailure(tail, parseUnavailableFromStderr(tail)), "rate_limit"); }); test("a soft block wins over a per-video class a caller already holds", () => { // A `deleted` read before release 10, or from another line of the same tail, // must not turn the soft block back into a per-video skip. assert.equal(classifyDownloadFailure(SOFT_BLOCK_BARE, "deleted"), "rate_limit"); assert.equal(classifyDownloadFailure(SOFT_BLOCK_SESSION, "members_only"), "rate_limit"); }); test("a genuinely removed or unavailable video is still deleted / per_video", () => { // yt-dlp's wording for videos that are really gone, soft-block-free. const gone = [ "ERROR: [youtube] dQw4w9WgXcQ: Video unavailable", "ERROR: [youtube] dQw4w9WgXcQ: Video unavailable. This video has been removed by the uploader", "ERROR: [youtube] dQw4w9WgXcQ: Video unavailable. This video is no longer available because the YouTube account associated with this video has been terminated.", "ERROR: [youtube] dQw4w9WgXcQ: This video has been removed for violating YouTube's Terms of Service", "ERROR: [Rumble] v7e07us: Unable to download webpage: HTTP Error 410: Gone (caused by )", ]; for (const stderr of gone) { assert.equal(isSoftBlock(stderr), false, stderr); assert.equal(parseUnavailableFromStderr(stderr), "deleted", stderr); assert.equal( classifyDownloadFailure(stderr, parseUnavailableFromStderr(stderr)), "per_video", stderr, ); } // The pattern's boundary: "content isn't available" with no retry advice // keeps the `deleted` reading it has had since the availability check began. const noAdvice = "ERROR: [youtube] dQw4w9WgXcQ: This content isn't available."; assert.equal(isSoftBlock(noAdvice), false); assert.equal(parseUnavailableFromStderr(noAdvice), "deleted"); // The other per-video classes are untouched by the soft-block check. const priv = "ERROR: [youtube] dQw4w9WgXcQ: Private video. Sign in if you've been granted access to this video"; assert.equal(parseUnavailableFromStderr(priv), "private"); assert.equal(classifyDownloadFailure(priv, "private"), "per_video"); const members = "ERROR: [youtube] dQw4w9WgXcQ: Join this channel to get access to members-only content like this video, and other exclusive perks."; assert.equal(parseUnavailableFromStderr(members), "members_only"); assert.equal(classifyDownloadFailure(members, "members_only"), "per_video"); }); // ── release 17, slice RL: a subtitle 429 is not a platform failure ────────── const SUB_429 = "ERROR: [youtube] abc: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests"; test("a subtitle 429 alone is subs_rate_limit — as an ERROR or a WARNING", () => { assert.equal(classifyDownloadFailure(SUB_429, "error"), "subs_rate_limit"); assert.equal( classifyDownloadFailure( "WARNING: Unable to download video subtitles for 'en-orig': HTTP Error 429: Too Many Requests", undefined, ), "subs_rate_limit", ); // yt-dlp's own re-extraction after a --load-info-json subtitle failure is a // WARNING; a second subtitle 429 from the URL is still subtitle-only. assert.equal( classifyDownloadFailure( [ SUB_429, "WARNING: The info failed to download: ERROR: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests; trying with URL https://www.youtube.com/watch?v=abc", SUB_429, ].join("\n"), "error", ), "subs_rate_limit", ); assert.equal(isSubtitleRateLimitOnly(SUB_429), true); }); test("a subtitle 429 beside any other ERROR is a platform rate limit", () => { const webpage429 = "ERROR: [youtube] abc: Unable to download webpage: HTTP Error 429: Too Many Requests"; assert.equal(classifyDownloadFailure(`${SUB_429}\n${webpage429}`, "error"), "rate_limit"); assert.equal(classifyDownloadFailure(webpage429, "error"), "rate_limit"); // The soft block and the bot check are platform signals, whatever else failed. assert.equal( classifyDownloadFailure( `${SUB_429}\nWARNING: This content isn't available, try again later.`, "error", ), "rate_limit", ); assert.equal( isSubtitleRateLimitOnly(`${SUB_429}\nSign in to confirm you’re not a bot`), false, ); }); test("a subtitle failure that is not a rate limit is not subs_rate_limit", () => { const sub404 = "ERROR: [youtube] abc: Unable to download video subtitles for 'en': HTTP Error 404: Not Found"; 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, ); }); 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", ); });