commit 5df886be3bbe8b7d9eb4724752e8b8e7dd3a1c2b
parent 781493ed9b18aef9cd766ef040a9a8b5d834c871
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 01:42:57 -0400
common: a forced download whose media pass fails keeps the subtitle pass's status
A forceMedia pass over a transcript ("Persist source video", fetch_clip
full: true, Persist kept now) that failed set the whole download `failed`,
so the video page said "Download failed" over a transcript that is fine
(release 10 slice N, review L2). The status now stays the subtitle pass's;
only the failed media attempt (n: 3) is recorded, with its error, and one
log line says so and names yt-dlp's partial (left for a retry to resume).
sourceFetchFailure(record) says whether a download asked for the source got
it: the status for an outright failure, the last attempt for a failed forced
pass. archiveSourceVideo throws on it, so the Persist / whole-recording job
ends `failed` with yt-dlp's line in the log tail the poll returns, instead
of `done` with no file ("finished but named no file" in the MCP).
forceMedia.test.ts +2; the fake yt-dlp's `.fake-ytdlp-media-fail` knob;
fetch-window.spec.ts +1 (the full fetch fails with the line, outcome ok),
persist-youtube-handling.spec.ts +1 (the run fails, no failed badge).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
6 files changed, 284 insertions(+), 5 deletions(-)
diff --git a/common/ytdlp/downloadOneManaged.ts b/common/ytdlp/downloadOneManaged.ts
@@ -1,5 +1,5 @@
import path from "node:path";
-import { appendFile, mkdir, readdir, readFile, rm } from "node:fs/promises";
+import { appendFile, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
import { createWriteStream, type Dirent, type WriteStream } from "node:fs";
import { execa } from "execa";
import {
@@ -38,6 +38,7 @@ import {
type DownloadOutcomeStatus,
} from "../lib/downloadOutcome";
import { writeDownloadOutcome } from "../lib/downloadOutcome-server";
+import { formatBytes } from "../lib/format";
import { recordAvailability } from "../lib/availability-server";
import { withMetadataHistory } from "../lib/metadataHistory-server";
import {
@@ -517,6 +518,38 @@ function attemptSucceeded(exitCode: number | null): boolean {
return exitCode === 0 || exitCode === 101;
}
+// DID A DOWNLOAD THAT WAS ASKED FOR THE SOURCE GET IT? (release 11 slice O3.)
+// For "Persist source video" and the whole-recording fetch: null when nothing
+// failed, else yt-dlp's reason. Two ways to fail: the download failed outright
+// (the status says so), or the forced media pass over a transcript failed —
+// which leaves the status as the subtitle pass made it, so only the last
+// attempt (the n: 3 media pass) says so. Reading the status alone ended those
+// jobs `done` with no file.
+export function sourceFetchFailure(record: DownloadOutcomeRecord): string | null {
+ const last = record.attempts.at(-1);
+ const reason = (fallback: string) => last?.error?.trim() || fallback;
+ if (record.status === "failed" || record.status === "failed-corrupt-source") {
+ return reason(`the download ended ${record.status}`);
+ }
+ if (last?.kind === "no-subs-fallback" && !attemptSucceeded(last.ytdlpExitCode)) {
+ return reason(`yt-dlp exited ${last.ytdlpExitCode ?? "without a code"}`);
+ }
+ return null;
+}
+
+// yt-dlp's partial source container(s) in a video dir — `source-media.<fmt>.part`
+// and its fragments — with their sizes, for the failed-media log line.
+async function sourceMediaPartials(videoDir: string): Promise<string[]> {
+ const entries = await readdir(videoDir).catch(() => [] as string[]);
+ const out: string[] = [];
+ for (const name of entries.sort()) {
+ if (!name.startsWith("source-media.") || !/\.part(?:-Frag\d+)?$/.test(name)) continue;
+ const st = await stat(path.join(videoDir, name)).catch(() => null);
+ out.push(st ? `${name} (${formatBytes(st.size)})` : name);
+ }
+ return out;
+}
+
async function hasAnyTranscriptOnDisk(videoDir: string): Promise<boolean> {
const entries = await readdir(videoDir).catch(() => [] as string[]);
return entries.some((e) => {
@@ -1467,6 +1500,26 @@ async function runManagedDownload(
// fellBackToTranscribe flag distinguishes from a normal "ok"
// so the UI and downstream jobs can tell what happened.
}
+ } else if (keepTranscript) {
+ // THE MEDIA FAILED, THE DOWNLOAD DID NOT (release 11 slice O3). A
+ // persist-only pass over a transcript is an extra the caller asked for,
+ // not the download: the subtitle pass succeeded and the transcript is
+ // on disk, so the status stays the subtitle pass's and only the failed
+ // media attempt is recorded (n: 3 above, with its error). Marking the
+ // whole download failed put "Download failed" on a video whose
+ // transcript is fine. A caller that asked for the source reads the
+ // attempt through sourceFetchFailure. yt-dlp's partial container is
+ // left where it is — a retry resumes it — and named here, because
+ // nothing else surfaces a source-media partial.
+ const partials = await sourceMediaPartials(videoDir);
+ opts.onLog(
+ `forceMedia: the source download failed (yt-dlp exit ${fallbackRes.exitCode}); ` +
+ `the transcript on disk is untouched and the download stays ${status}.` +
+ (partials.length
+ ? ` Left for a retry to resume: ${partials.join(", ")}.`
+ : "") +
+ "\n",
+ );
} else {
status = "failed";
lastSucceeded = false;
diff --git a/common/ytdlp/forceMedia.test.ts b/common/ytdlp/forceMedia.test.ts
@@ -13,7 +13,12 @@ import { tmpdir } from "node:os";
import path from "node:path";
import type { Paths } from "../lib/paths";
import type { ChannelConfig } from "../lib/channelConfig";
-import { downloadOneManaged, type ManagedDownloadOpts } from "./downloadOneManaged";
+import {
+ downloadOneManaged,
+ sourceFetchFailure,
+ type ManagedDownloadOpts,
+} from "./downloadOneManaged";
+import type { DownloadOutcomeRecord } from "../lib/downloadOutcome";
// Run with:
// pnpm --filter yt-dlp-transcript-common exec tsx --test ytdlp/forceMedia.test.ts
@@ -56,6 +61,12 @@ if (has("--skip-download") && has("--write-auto-subs")) {
process.exit(0);
}
if ((arg("-o") || "").includes("source-media")) {
+ if (fs.existsSync(path.join(root, "media-fail"))) {
+ // A media pass that dies mid-download: the partial yt-dlp leaves, then a 403.
+ fs.writeFileSync(path.join(dir, "source-media.f137.mp4.part"), "half a container");
+ console.error("ERROR: [download] Got error: HTTP Error 403: Forbidden");
+ process.exit(1);
+ }
fs.writeFileSync(path.join(dir, "source-media.mp4"), "container bytes for " + id);
console.log("DLOM_ARCHIVE youtube " + id);
process.exit(0);
@@ -77,6 +88,8 @@ type Run = {
videoDir: string;
storeDir: string;
files: string[];
+ // The channel's download archive after the run.
+ archive: string;
};
async function runWith(opts: {
@@ -88,6 +101,9 @@ async function runWith(opts: {
priorMeta?: Record<string, unknown>;
config?: Partial<ChannelConfig>;
managed?: Partial<ManagedDownloadOpts>;
+ // The media pass (the source-media output) fails with a 403 after writing a
+ // partial.
+ mediaFail?: boolean;
}): Promise<Run & { cleanup: () => Promise<void> }> {
const root = await mkdtemp(path.join(tmpdir(), "force-media-"));
const ytdlp = path.join(root, "fake-ytdlp.cjs");
@@ -97,6 +113,7 @@ async function runWith(opts: {
await chmod(ytdlp, 0o755);
await chmod(ffmpeg, 0o755);
await writeFile(path.join(root, "meta.json"), JSON.stringify(opts.meta));
+ if (opts.mediaFail) await writeFile(path.join(root, "media-fail"), "");
process.env.FAKE_ROOT = root;
const paths = {
transcriptsDir: root,
@@ -144,6 +161,7 @@ async function runWith(opts: {
videoDir,
storeDir: path.join(paths.savedVideosDir, "c", ID),
files: (await readdir(videoDir)).sort(),
+ archive: await readFile(path.join(paths.channelsDir, "c", "archive"), "utf8").catch(() => ""),
cleanup: () => rm(root, { recursive: true, force: true }),
};
}
@@ -300,3 +318,86 @@ test("the prefetch's rewrite of metadata.info.json lands in metadata.history.jso
await r.cleanup();
}
});
+
+// A FAILED MEDIA PASS OVER A TRANSCRIPT IS NOT A FAILED DOWNLOAD (release 11
+// slice O3; release 10 slice N's review L2). The subtitle pass succeeded and the
+// transcript is on disk; the extra the caller asked for — the source — failed.
+// The status stays the subtitle pass's (the video page said "Download failed"
+// on a video whose transcript is fine), the failed attempt is recorded, and the
+// caller that asked for the source learns it through sourceFetchFailure.
+test("forceMedia over a transcript whose media pass fails: the download stays ok, only the media attempt failed", async () => {
+ const r = await runWith({ meta: META, transcript: TRANSCRIPT, managed: FORCED, mediaFail: true });
+ try {
+ assert.equal(r.argvs.length, 3);
+ assert.equal(r.record.status, "ok");
+ assert.equal(r.record.failureClass, undefined);
+ assert.deepEqual(
+ r.record.attempts.map((a) => [a.kind, a.ytdlpExitCode]),
+ [
+ ["metadata-prefetch", 0],
+ ["primary", 0],
+ ["no-subs-fallback", 1],
+ ],
+ );
+ assert.match(r.record.attempts[2].error ?? "", /HTTP Error 403: Forbidden/);
+ assert.match(sourceFetchFailure(r.record) ?? "", /HTTP Error 403: Forbidden/);
+ assert.match(
+ r.log,
+ /forceMedia: the source download failed \(yt-dlp exit 1\); the transcript on disk is untouched and the download stays ok\. Left for a retry to resume: source-media\.f137\.mp4\.part \(16 B\)\.\n/,
+ );
+ // The transcript is untouched and still counts: the video stays in the
+ // archive as the subtitle pass left it.
+ assert.equal(await readFile(path.join(r.videoDir, TRANSCRIPT.name), "utf8"), TRANSCRIPT.body);
+ assert.match(r.archive, new RegExp(`youtube ${ID}`));
+ // Nothing persisted; the partial is left for a retry to resume.
+ assert.ok(!r.files.includes("saved-video.json"));
+ assert.ok(r.files.includes("source-media.f137.mp4.part"), r.files.join(","));
+ // The sidecar on disk says the same as the returned record.
+ const onDisk = JSON.parse(
+ await readFile(path.join(r.videoDir, "download-outcome.json"), "utf8"),
+ ) as DownloadOutcomeRecord;
+ assert.equal(onDisk.status, "ok");
+ } finally {
+ await r.cleanup();
+ }
+});
+
+test("sourceFetchFailure: a failed download, a failed forced pass, and the clean cases", () => {
+ const base = { videoId: ID, startedAt: "t0", finishedAt: "t1" };
+ const attempt = (kind: DownloadOutcomeRecord["attempts"][number]["kind"], code: number | null, error?: string) => ({
+ n: 1,
+ kind,
+ handling: "youtube" as const,
+ usedCookies: false,
+ ytdlpExitCode: code,
+ ...(error ? { error } : {}),
+ });
+ assert.equal(
+ sourceFetchFailure({ ...base, status: "failed", attempts: [attempt("primary", 1, "ERROR: gone")] }),
+ "ERROR: gone",
+ );
+ assert.equal(
+ sourceFetchFailure({ ...base, status: "failed-corrupt-source", attempts: [] }),
+ "the download ended failed-corrupt-source",
+ );
+ assert.equal(
+ sourceFetchFailure({
+ ...base,
+ status: "ok",
+ attempts: [attempt("primary", 0), attempt("no-subs-fallback", 1)],
+ }),
+ "yt-dlp exited 1",
+ );
+ assert.equal(
+ sourceFetchFailure({
+ ...base,
+ status: "ok",
+ attempts: [attempt("primary", 0), attempt("no-subs-fallback", 0)],
+ }),
+ null,
+ );
+ assert.equal(
+ sourceFetchFailure({ ...base, status: "ok-with-cookies", attempts: [attempt("auth-retry", 0)] }),
+ null,
+ );
+});
diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts
@@ -70,7 +70,10 @@ import {
} from "yt-dlp-transcript-common/jobs/downloadBackoff";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { resolveCookiePolicy } from "yt-dlp-transcript-common/lib/cookiePolicy";
-import { downloadOneManaged } from "yt-dlp-transcript-common/ytdlp/downloadOneManaged";
+import {
+ downloadOneManaged,
+ sourceFetchFailure,
+} from "yt-dlp-transcript-common/ytdlp/downloadOneManaged";
import { runYtdlp } from "yt-dlp-transcript-common/ytdlp/runYtdlp";
import {
runManagedFunction,
@@ -291,7 +294,7 @@ async function archiveSourceVideo(
onLog(`${requesterLine(persistOrigin)}\n`);
}
onLog(`Re-downloading ${videoId} to archive its source video…\n`);
- await downloadOneManaged({
+ const record = await downloadOneManaged({
channelSlug: slug,
channelConfig: r.config,
paths,
@@ -314,6 +317,16 @@ async function archiveSourceVideo(
`/channels/${slug}/videos/${videoId}`,
`/channels/${slug}`,
]);
+ // THE JOB IS THE SOURCE, SO ITS STATUS IS THE SOURCE'S (release 11
+ // slice O3). A forced media pass that fails over a transcript leaves
+ // the download `ok` — the transcript is fine — so without this the job
+ // ended `done` with no file, and the whole-recording fetch's caller was
+ // told only that it "finished but named no file". Failing the job puts
+ // yt-dlp's line in the log tail the poll returns.
+ const failure = sourceFetchFailure(record);
+ if (failure) {
+ throw new Error(`The source video was not downloaded: ${failure}`);
+ }
} finally {
task.end();
}
diff --git a/editor/e2e/fetch-window.spec.ts b/editor/e2e/fetch-window.spec.ts
@@ -9,7 +9,7 @@
// Same token as /api/worker/* (the test server runs with
// WORKER_TOKEN=test-worker-token; see package.json dev:test).
-import { readdir, readFile, stat } from "node:fs/promises";
+import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import { test, expect, type APIRequestContext } from "@playwright/test";
import {
generateReport,
@@ -455,3 +455,57 @@ test("a full-source fetch on a youtube-handling video that already has a transcr
to: `Synthetic ${HAS_TRANSCRIPT}`,
});
});
+
+// …AND WHEN THAT DOWNLOAD FAILS, THE JOB SAYS SO (release 11 slice O3). The
+// forced media pass failing over a transcript is not a failed download — the
+// subtitle pass succeeded, the transcript is fine, so the outcome stays `ok` —
+// but the job exists for the source, so the job fails, with yt-dlp's line in
+// the log tail the poll returns. Before, it ended `done` with no file, and the
+// MCP could only say the job "finished but named no file".
+test("a full-source fetch whose media download fails ends failed with yt-dlp's line; the download stays ok", async ({
+ request,
+}) => {
+ test.setTimeout(90_000);
+ const HAS_TRANSCRIPT = "fake00000001";
+ await writeFile(resolvePath(rel(".fake-ytdlp-media-fail")), "");
+ const post = await request.post(`${baseUrl}/api/media/fetch-window`, {
+ headers: AUTH,
+ data: {
+ channelSlug: SLUG,
+ videoId: HAS_TRANSCRIPT,
+ full: true,
+ requestedBy: "mcp",
+ },
+ });
+ expect(post.status(), await post.text()).toBe(202);
+ const { jobId } = (await post.json()) as { jobId: string };
+
+ const finished = await pollJob(request, jobId);
+ expect(finished.status, JSON.stringify(finished)).toBe("failed");
+ expect(finished.file).toBeUndefined();
+ expect(String(finished.error)).toContain(
+ "The source video was not downloaded: ERROR: [download] Got error: HTTP Error 403: Forbidden",
+ );
+ expect(String(finished.error)).toContain(
+ "forceMedia: the source download failed (yt-dlp exit 1); the transcript on disk is untouched and the download stays ok.",
+ );
+
+ const outcome = await readJson<{
+ status: string;
+ attempts: Array<{ kind: string; ytdlpExitCode: number | null }>;
+ }>(rel(`data/${HAS_TRANSCRIPT}/download-outcome.json`));
+ expect(outcome.status).toBe("ok");
+ expect(outcome.attempts.map((a) => [a.kind, a.ytdlpExitCode])).toEqual([
+ ["metadata-prefetch", 0],
+ ["primary", 0],
+ ["no-subs-fallback", 1],
+ ]);
+ const files = await readdir(resolvePath(rel(`data/${HAS_TRANSCRIPT}`)));
+ expect(files).toContain("transcript.en.vtt");
+ expect(files).not.toContain("saved-video.json");
+ // The partial is left for a retry to resume, and the log named it.
+ expect(files).toContain("source-media.f137.mp4.part");
+ expect(String(finished.error)).toContain(
+ "Left for a retry to resume: source-media.f137.mp4.part (",
+ );
+});
diff --git a/editor/e2e/fixtures/bin/fake-ytdlp.mjs b/editor/e2e/fixtures/bin/fake-ytdlp.mjs
@@ -939,6 +939,19 @@ async function main() {
await writeMetadata(videoDir, id, urlSentinels(url));
}
process.stdout.write(`[download] Fetching ${id}\n`);
+ // `.fake-ytdlp-media-fail` in the channel root (the fake's cwd): the media
+ // download dies part-way — the partial yt-dlp leaves behind, then a 403 —
+ // for the forced-media failure specs (release 11 slice O3).
+ if (existsSync(".fake-ytdlp-media-fail")) {
+ await writeFile(
+ path.join(videoDir, "source-media.f137.mp4.part"),
+ `fake-ytdlp partial source container for ${id}\n`,
+ );
+ process.stderr.write(
+ `ERROR: [download] Got error: HTTP Error 403: Forbidden\n`,
+ );
+ process.exit(1);
+ }
await writeFile(
path.join(videoDir, "source-media.mp4"),
`fake-ytdlp synthesised source container for ${id}\n`,
diff --git a/editor/e2e/persist-youtube-handling.spec.ts b/editor/e2e/persist-youtube-handling.spec.ts
@@ -173,3 +173,48 @@ test("a download whose metadata changed upstream appends to metadata.history.jso
await expect(entries).toContainText("view_count");
await expect(entries).toContainText("100 → 150");
});
+
+// A FAILED MEDIA PASS IS NOT A FAILED DOWNLOAD (release 11 slice O3). With the
+// fake's media download dying part-way, the run fails — the operator asked for
+// the source and did not get it — but the download outcome stays the subtitle
+// pass's `ok`, so the video page no longer says "Download failed" over a
+// transcript that is fine.
+test("Persist source video whose media download fails: the run fails with yt-dlp's line, the video is not marked Download failed", async ({
+ page,
+}) => {
+ test.setTimeout(120_000);
+ await resetData("youtube-with-playlist");
+ await writeFile(resolvePath(rel(".fake-ytdlp-media-fail")), "");
+
+ await page.goto(`/channels/${SLUG}/videos/${HAS_TRANSCRIPT}`);
+ await page.getByLabel("Source video stage summary").click();
+ await page.getByRole("button", { name: "Persist source video", exact: true }).click();
+ const log = page.getByLabel(`Persist source video for ${HAS_TRANSCRIPT} output`);
+ await expect(log).toContainText(
+ "forceMedia: the source download failed (yt-dlp exit 1); the transcript on disk is untouched and the download stays ok.",
+ { timeout: 30_000 },
+ );
+ await expect(log).toContainText(
+ "[error] The source video was not downloaded: ERROR: [download] Got error: HTTP Error 403: Forbidden",
+ { timeout: 30_000 },
+ );
+
+ const outcome = await outcomeOf(HAS_TRANSCRIPT);
+ expect(outcome?.status).toBe("ok");
+ expect(outcome?.attempts.map((a) => [a.kind, a.ytdlpExitCode])).toEqual([
+ ["metadata-prefetch", 0],
+ ["primary", 0],
+ ["no-subs-fallback", 1],
+ ]);
+ // The transcript is there (the subtitle pass re-fetched it, as on any
+ // re-download); the failed media pass wrote none.
+ const files = await readdir(resolvePath(rel(`data/${HAS_TRANSCRIPT}`)));
+ expect(files).toContain("transcript.en.vtt");
+ expect(files).not.toContain("saved-video.json");
+
+ // The page, fresh: no failed-download badge, and nothing persisted.
+ await page.reload();
+ await expect(page.getByLabel("download outcome")).toHaveCount(0);
+ await page.getByLabel("Source video stage summary").click();
+ await expect(page.getByLabel(`unpersist source video ${HAS_TRANSCRIPT}`)).toHaveCount(0);
+});