Archilyzer · Source

archilyzer

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

commit 3b2491cc00dc617930926b67f9fef0d5e36e04b3
parent 598e763442185bf180afe23d9dcaa73d5d3e5524
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 26 Sep 2026 02:28:35 -0400

common: YouTube's soft block ("try again later") is a rate limit, not a deleted video

"This content isn't available, try again later." — YouTube's answer to a
rate-limited session, which yt-dlp re-words as "The current session has been
rate-limited by YouTube for up to an hour" — matched the `deleted` group's
"content isn't available". So it read as a removed video: per_video, no
platform cooldown, the batch kept requesting into the block, and the video was
excluded from download as gone.

`isSoftBlock` (the reason's "isn't available, try again later", either
apostrophe) is checked first: `parseUnavailableFromStderr` answers "error"
(transient) and `classifyDownloadFailure` answers `rate_limit`, even over a
per-video class a caller already holds. The download batch then records the
platform cooldown and stops, the auto runner backs off and defers the video,
and the metadata scan stops its pass as a "soft-block" block instead of
recording a `deleted` error. A bare "Video unavailable", a removed or
terminated video and Rumble's 410 stay `deleted` / per_video.

Tests: availability.test.ts +3 (yt-dlp's own strings, both directions);
managedDownloadsSleep.test.ts +1 (the classifier through the batch loop:
the soft block backs off and aborts, a removed video does neither).

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

Diffstat:
Mcommon/lib/availability.test.ts | 93++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/lib/availability.ts | 34++++++++++++++++++++++++++++++++++
Mcommon/ytdlp/managedDownloadsSleep.test.ts | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcommon/ytdlp/metadataScan.ts | 12++++++++++--
Mcommon/ytdlp/runYtdlp.ts | 9++++++---
5 files changed, 228 insertions(+), 9 deletions(-)

diff --git a/common/lib/availability.test.ts b/common/lib/availability.test.ts @@ -1,6 +1,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { classifyDownloadFailure, parseUnavailableFromStderr } from "./availability"; +import { + classifyDownloadFailure, + isSoftBlock, + parseUnavailableFromStderr, +} from "./availability"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test lib/availability.test.ts @@ -35,3 +39,90 @@ test("HTTP 410 Gone is a removed video, not an error", () => { 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 <HTTPError 410: Gone>)", + ]; + 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"); +}); diff --git a/common/lib/availability.ts b/common/lib/availability.ts @@ -154,10 +154,39 @@ export type AvailabilityRecord = { export const AVAILABILITY_FILENAME = "availability.json"; +// YouTube's SOFT BLOCK. YouTube answers a session it is rate-limiting with the +// playability reason "This content isn't available, try again later." — worded +// like a removed video, and it is not one: the same video plays for anyone else +// and for this session an hour later. yt-dlp (since #12958, 2025-04) re-words +// it to say so: +// +// ERROR: [youtube] <id>: 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 +// +// ("Your account has been rate-limited …" when cookies were passed). Until +// release 10 its "content isn't available" matched the `deleted` group below, so +// the soft block read as a removed video: per_video, no platform cooldown, the +// batch kept going, and the video was excluded from download as gone. It is a +// rate limit, and it is classified as one: `parseUnavailableFromStderr` says +// "error" (transient) and `classifyDownloadFailure` says `rate_limit`. +// +// "try again later" is what marks it: a bare "Video unavailable" or "This +// content isn't available." with no retry advice is still read as removed. The +// apostrophe may be a right single quote, so match either. +export function isSoftBlock(stderr: string): boolean { + return /isn['’]?t available,? try again later/i.test(stderr); +} + // Map yt-dlp's stderr text to an Availability. yt-dlp does not expose a // stable machine-readable reason on failure, so we pattern-match the human // messages it emits across YouTube, Rumble, and a few other extractors. export function parseUnavailableFromStderr(stderr: string): Availability { + // Checked FIRST: the soft block's wording would otherwise match `deleted` + // below. It says nothing about the video, so it is an error, not a class. + if (isSoftBlock(stderr)) return "error"; const s = stderr.toLowerCase(); if ( /private video/.test(s) || @@ -235,6 +264,11 @@ export function classifyDownloadFailure( stderrTail: string, availabilityClass: Availability | undefined, ): DownloadFailureClass { + // The soft block wins over a per-video class, whichever parser produced it: + // a caller holding a `deleted` read from before release 10 (or from a tail + // that also names a removed video) must still back off. Erring this way costs + // one cooldown; erring the other way keeps requesting into the block. + if (isSoftBlock(stderrTail)) return "rate_limit"; if ( availabilityClass !== undefined && PER_VIDEO_CLASSES.includes(availabilityClass) diff --git a/common/ytdlp/managedDownloadsSleep.test.ts b/common/ytdlp/managedDownloadsSleep.test.ts @@ -115,13 +115,96 @@ test("a video the download filter declined does not sleep", async () => { }); // Release 9 review: a per-video failure KEEPS the pace, even one that never -// got past the prefetch. YouTube's soft block ("This content isn't available, -// try again later") classifies as deleted → per_video; skipping the sleep there -// would fire prefetches back to back into the block. +// got past the prefetch — a prefetch is still a request. (Release 9's example, +// YouTube's soft block, is no longer per-video: see the release 10 case below.) test("a per-video failure at the prefetch still sleeps", async () => { assert.equal(await sleepsFor([MEMBERS_ONLY, FETCHED]), 1); }); +// THE SOFT BLOCK BACKS OFF (release 10, L2). The outcome is built the way +// downloadOneManaged builds it — availabilityClass from parseUnavailableFromStderr, +// failureClass from classifyDownloadFailure over the same tail — from yt-dlp's +// real line, so this runs the classifier and the batch loop together. A soft +// block must record the platform cooldown and stop the batch; a video that is +// really gone must do neither (it is that video's property, the batch goes on). +const { classifyDownloadFailure, parseUnavailableFromStderr } = await import( + "../lib/availability" +); + +function failedPrefetch(stderr: string): DownloadOutcomeRecord { + const availabilityClass = parseUnavailableFromStderr(stderr); + return outcome( + "failed", + [{ ...PREFETCH, ytdlpExitCode: 1, availabilityClass, error: stderr }], + classifyDownloadFailure(stderr, availabilityClass), + ); +} + +async function batch(outcomes: DownloadOutcomeRecord[]): Promise<{ + downloads: number; + backoffs: string[]; + aborted: boolean; +}> { + let i = 0; + const backoffs: string[] = []; + const urls = outcomes.map( + (_, n) => + `https://www.youtube.com/watch?v=vid${String(n).padStart(8, "0")}`, + ); + const channelConfig = { + handling: "youtube", + url: "https://www.youtube.com/@c/videos", + } as never; + const res = await runManagedDownloads( + { + channelSlug: "c", + mode: "download-missing" as never, + channelConfig, + paths: getPaths(), + onLog: () => {}, + signal: new AbortController().signal, + // The production default: a batch-level failure stops the batch. + onPlatformBackoff: (cls) => { + backoffs.push(cls); + }, + }, + urls, + channelConfig, + undefined, + { + downloadOne: async () => outcomes[i++], + sleep: async () => {}, + }, + ); + return { downloads: i, backoffs, aborted: res.firstFailure !== null }; +} + +test("a soft block backs the platform off and stops the batch; a deleted video does not", async () => { + const softBlock = failedPrefetch( + "ERROR: [youtube] H64QQZuw-aA: 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", + ); + assert.equal(softBlock.failureClass, "rate_limit"); + assert.equal(softBlock.attempts[0].availabilityClass, "error"); + const blocked = await batch([softBlock, FETCHED, FETCHED]); + assert.deepEqual(blocked.backoffs, ["rate_limit"]); + assert.equal(blocked.aborted, true); + assert.equal(blocked.downloads, 1, "nothing after the soft block is requested"); + + const gone = failedPrefetch( + "ERROR: [youtube] dQw4w9WgXcQ: Video unavailable. This video has been removed by the uploader", + ); + assert.equal(gone.failureClass, "per_video"); + assert.equal(gone.attempts[0].availabilityClass, "deleted"); + const kept = await batch([gone, FETCHED, FETCHED]); + assert.deepEqual(kept.backoffs, []); + assert.equal(kept.aborted, false); + assert.equal(kept.downloads, 3, "a removed video does not stop the batch"); +}); + test("the last video never sleeps, whatever it was", async () => { assert.equal(await sleepsFor([FETCHED]), 0); assert.equal(await sleepsFor([FILTERED, FETCHED]), 0); diff --git a/common/ytdlp/metadataScan.ts b/common/ytdlp/metadataScan.ts @@ -21,6 +21,7 @@ import { execa } from "execa"; import { classifyDownloadFailure, isBotCheck, + isSoftBlock, parseUnavailableFromStderr, } from "../lib/availability"; import type { ChannelConfig } from "../lib/channelConfig"; @@ -484,10 +485,17 @@ export async function runMetadataScan( const failure = classifyDownloadFailure(trimmed, cls); // A batch-level signal: continuing would just hammer the source, and on a // bot check every subsequent entry fails the same way. Stop the pass and - // let the caller decide whether cookies are the answer. + // let the caller decide whether cookies are the answer. YouTube's + // explicit soft block ("…isn't available, try again later") lands here + // too since release 10 — before, it was recorded as a `deleted` error on + // its id and the pass kept going into it. if (failure === "rate_limit") { block = { - kind: isBotCheck(trimmed) ? "bot-check" : "rate-limit", + kind: isBotCheck(trimmed) + ? "bot-check" + : isSoftBlock(trimmed) + ? "soft-block" + : "rate-limit", message: trimmed.slice(0, 300), }; child.kill("SIGTERM"); diff --git a/common/ytdlp/runYtdlp.ts b/common/ytdlp/runYtdlp.ts @@ -901,9 +901,12 @@ export type ManagedDownloadsDeps = { // status skipped-filtered, and still asked). // // A per_video FAILURE is deliberately NOT here, even one that never got past -// the prefetch (release 9 review): YouTube's soft block ("This content isn't -// available, try again later") classifies as `deleted` → per_video, so -// skipping the sleep there would fire prefetches back to back into the block. +// the prefetch (release 9 review): a prefetch is still a request, and a run of +// "unavailable" answers is how a throttled source looks (see SOFT_BLOCK_STREAK +// in metadataScan.ts). Release 9's own reason — YouTube's explicit soft block +// ("This content isn't available, try again later") reading as `deleted` → +// per_video — is gone since release 10: it is `rate_limit` now (isSoftBlock), +// backs the platform off and stops the batch. The pace stays anyway. export function declinedWithoutMediaFetch( outcome: DownloadOutcomeRecord, ): boolean {