Archilyzer · Source

archilyzer

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

commit c10c87a8b613d072a1ccfb2fe900fb13b1555120
parent 3b54ab45e68025ba50ba7711025284fe451c3290
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 26 Sep 2026 03:25:19 -0400

common: a rate-limited metadata prefetch ends the download there (review fix)

A failed prefetch fell through to the real download whatever the failure,
so a 429, a bot check or YouTube's soft block cost one more request into
the same refusal before the batch could back off. When the last prefetch
attempt classifies `rate_limit`, `runManagedDownload` now logs one line and
returns `failed` / `rate_limit` without attempt 1; the batch's cooldown and
abort and the runner's deferral follow from the record as before. Every
other failure (a 403, a removed video, an auth gate) keeps today's flow.

The sidecar + availability-history tail moves into `writeOutcome`, which
both exits call, so the record is written one way.

prefetchRateLimit.test.ts (4, a temp yt-dlp script that counts spawns):
soft block → 1 spawn, 1 attempt, sidecar `rate_limit`; 429 → 1 spawn;
403 → 2 spawns (prefetch + primary, `network`); a removed video → 2 spawns
(`per_video`). The first two fail with the early exit disabled.

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

Diffstat:
Mcommon/ytdlp/downloadOneManaged.ts | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Acommon/ytdlp/prefetchRateLimit.test.ts | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 178 insertions(+), 5 deletions(-)

diff --git a/common/ytdlp/downloadOneManaged.ts b/common/ytdlp/downloadOneManaged.ts @@ -719,6 +719,35 @@ async function runManagedDownload( } } + // A RATE-LIMITED PREFETCH ENDS THE VIDEO HERE (release 10, L2 review). + // Every failed prefetch used to fall through to the real download, so a + // 429, a bot check or YouTube's soft block ("…isn't available, try again + // later") cost one more request into the same refusal before the batch + // could back off. That request is only skipped for a `rate_limit` class: + // any other failure (a 403, a removed video, an auth gate) keeps today's + // flow, where attempt 1 and its own auth retry still get their chance. The + // record carries `failureClass: "rate_limit"`, so the batch's cooldown and + // abort and the runner's per-video deferral follow exactly as before. + const lastPrefetch = attempts.at(-1); + if ( + lastPrefetch && + !attemptSucceeded(lastPrefetch.ytdlpExitCode) && + classifyDownloadFailure(lastFullTail, lastPrefetch.availabilityClass) === + "rate_limit" + ) { + opts.onLog( + `Metadata prefetch for ${canonicalId} was rate-limited by the source; ` + + `not attempting the download (the platform backs off instead).\n`, + ); + return writeOutcome(opts, videoDir, { + videoId: canonicalId, + status: "failed", + startedAt, + attempts, + lastFullTail, + }); + } + const metaPath = path.join(videoDir, "metadata.info.json"); const metadata = await loadRawMetadata(metaPath); // Only wire --load-info-json into the real attempts when we actually have @@ -1364,24 +1393,53 @@ async function runManagedDownload( } // ---------- Sidecar ---------- + return writeOutcome(opts, videoDir, { + videoId, + status, + startedAt, + attempts, + lastFullTail, + fellBackToTranscribe, + shortAudio: shortAudioInfo, + }); +} + +// THE SIDECAR AND THE AVAILABILITY HISTORY, written one way by every exit that +// ends a download's attempts: the end of the main path, and a prefetch the +// source rate-limited (release 10, L2 review). A filter-skip writes its own +// record and has no failure class; it does not come through here. +async function writeOutcome( + opts: ManagedDownloadOpts, + videoDir: string, + o: { + videoId: string; + status: DownloadOutcomeStatus; + startedAt: string; + attempts: DownloadAttempt[]; + lastFullTail: string; + fellBackToTranscribe?: boolean; + shortAudio?: DownloadOutcomeRecord["shortAudio"]; + }, +): Promise<DownloadOutcomeRecord> { + const { status, attempts } = o; const finishedAt = new Date().toISOString(); // Classify a failed download against the FULL stderr tail of the last attempt // so a rate-limit logged as a WARNING (then masked by a different final error) // is still caught. Skipped for successes and filter-skips. const isFailure = status === "failed" || status === "failed-corrupt-source"; const failureClass = isFailure - ? classifyDownloadFailure(lastFullTail, attempts.at(-1)?.availabilityClass) + ? classifyDownloadFailure(o.lastFullTail, attempts.at(-1)?.availabilityClass) : undefined; const record: DownloadOutcomeRecord = { - videoId, + videoId: o.videoId, webpageUrl: opts.videoUrl, status, - startedAt, + startedAt: o.startedAt, finishedAt, attempts, ...(failureClass ? { failureClass } : {}), - ...(fellBackToTranscribe ? { fellBackToTranscribe: true } : {}), - ...(shortAudioInfo ? { shortAudio: shortAudioInfo } : {}), + ...(o.fellBackToTranscribe ? { fellBackToTranscribe: true } : {}), + ...(o.shortAudio ? { shortAudio: o.shortAudio } : {}), }; // Only write the sidecar if we know which dir to put it in. If the very first // attempt failed before metadata could be written, the data/<id> dir may not diff --git a/common/ytdlp/prefetchRateLimit.test.ts b/common/ytdlp/prefetchRateLimit.test.ts @@ -0,0 +1,115 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +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/prefetchRateLimit.test.ts +// +// A RATE-LIMITED PREFETCH ENDS THE VIDEO (release 10, L2 review). The per-video +// download runs a metadata prefetch first; a failed prefetch used to fall +// through to the real download whatever the failure, so a soft block cost one +// more request into it. Now a `rate_limit` prefetch stops there, and every +// other failure keeps today's flow. The yt-dlp here is a temp script that +// counts its spawns and always fails with the given line — no network, no +// real yt-dlp. + +const VIDEO = "https://www.youtube.com/watch?v=H64QQZuw-aA"; + +async function runWith(stderrLine: string): Promise<{ + spawns: number; + record: Awaited<ReturnType<typeof downloadOneManaged>>; + outcomeOnDisk: { status: string; failureClass?: string }; + log: string; +}> { + const root = await mkdtemp(path.join(tmpdir(), "prefetch-rl-")); + try { + const counter = path.join(root, "spawns"); + const bin = path.join(root, "fake-ytdlp.sh"); + await writeFile( + bin, + `#!/bin/sh\necho x >> '${counter}'\necho ${JSON.stringify(stderrLine)} >&2\nexit 1\n`, + ); + await chmod(bin, 0o755); + 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", + } as ChannelConfig, + paths, + videoUrl: VIDEO, + onLog: (s) => { + log += s; + }, + signal: new AbortController().signal, + }); + const spawns = (await readFile(counter, "utf8").catch(() => "")) + .split("\n") + .filter(Boolean).length; + const outcomeOnDisk = JSON.parse( + await readFile( + path.join(paths.channelsDir, "c", "data", "H64QQZuw-aA", "download-outcome.json"), + "utf8", + ), + ); + return { spawns, record, outcomeOnDisk, log }; + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +test("a soft-blocked prefetch is the only request: no download attempt follows", async () => { + const r = await runWith( + "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.", + ); + assert.equal(r.spawns, 1); + assert.equal(r.record.attempts.length, 1); + assert.equal(r.record.attempts[0].kind, "metadata-prefetch"); + assert.equal(r.record.status, "failed"); + assert.equal(r.record.failureClass, "rate_limit"); + // The sidecar is written the same way as on the main path. + assert.equal(r.outcomeOnDisk.status, "failed"); + assert.equal(r.outcomeOnDisk.failureClass, "rate_limit"); + assert.match(r.log, /rate-limited by the source; not attempting the download/); +}); + +test("an HTTP 429 at the prefetch stops the same way", async () => { + const r = await runWith( + "ERROR: [youtube] H64QQZuw-aA: Unable to download webpage: HTTP Error 429: Too Many Requests", + ); + assert.equal(r.spawns, 1); + assert.equal(r.record.failureClass, "rate_limit"); +}); + +test("any other prefetch failure still reaches the download attempt (a 403: two requests)", async () => { + const r = await runWith( + "ERROR: [youtube] H64QQZuw-aA: Unable to download webpage: HTTP Error 403: Forbidden", + ); + assert.equal(r.spawns, 2); + assert.deepEqual( + r.record.attempts.map((a) => a.kind), + ["metadata-prefetch", "primary"], + ); + assert.equal(r.record.status, "failed"); + assert.equal(r.record.failureClass, "network"); + assert.doesNotMatch(r.log, /not attempting the download/); +}); + +test("a removed video still reaches the download attempt too (per-video flow unchanged)", async () => { + const r = await runWith( + "ERROR: [youtube] H64QQZuw-aA: Video unavailable. This video has been removed by the uploader", + ); + assert.equal(r.spawns, 2); + assert.equal(r.record.failureClass, "per_video"); +});