commit 177fb06b810326545cf73428f39f04fd4a3145b7
parent fb6f6cb96eeea02f1100d3e1ed45b3ea2a528d50
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 18 May 2026 12:22:59 -0400
.part bucket and resume, fix INVALID_STATE
Diffstat:
12 files changed, 467 insertions(+), 52 deletions(-)
diff --git a/common/controller/channelSnapshot.ts b/common/controller/channelSnapshot.ts
@@ -51,6 +51,7 @@ export type ChannelSnapshot = {
failedListed: string[];
missingFromArchive: string[];
duplicateDirs: string[];
+ partialDownloads: string[];
};
undownloadedIds: string[];
excludedFromDownload?: ExcludedFromDownload;
@@ -214,6 +215,7 @@ export async function generateChannelSnapshot(
const multipleAudioFormats: string[] = [];
const untranscribable: string[] = [];
const noMetadata: string[] = [];
+ const partialDownloads: string[] = [];
let transcribed = 0;
let downloaded = 0;
for (const { id, files } of perVideo) {
@@ -234,6 +236,13 @@ export async function generateChannelSnapshot(
) {
multipleAudioFormats.push(id);
}
+ if (
+ files.audioFiles.length === 0 &&
+ files.partAudioFiles.length > 0 &&
+ !excludedById.has(id)
+ ) {
+ partialDownloads.push(id);
+ }
if (files.isUntranscribable) {
untranscribable.push(id);
continue;
@@ -302,6 +311,7 @@ export async function generateChannelSnapshot(
failedListed: failedListed.filter((id) => !excludedById.has(id)),
missingFromArchive: missingFromArchive.sort(),
duplicateDirs: [],
+ partialDownloads: partialDownloads.sort(),
},
undownloadedIds,
excludedFromDownload,
diff --git a/common/jobs/streamCommand.ts b/common/jobs/streamCommand.ts
@@ -4,6 +4,7 @@ import { createWriteStream, type WriteStream } from "node:fs";
import { execa } from "execa";
import { getRegistry, newJobId, type JobRecord } from "./registry";
import type { Paths } from "../lib/paths";
+import { makeSafeController } from "../lib/safeStreamController";
export type StreamActionResult =
| { ok: true; jobId: string; stream: ReadableStream<string> }
@@ -58,45 +59,6 @@ function makeJob(
return { id, logPath, record };
}
-// Safety contract shared by both stream creators. The consumer (RSC encoder)
-// can close the stream at any time when the client disconnects; once closed,
-// any further enqueue/close on the controller throws ERR_INVALID_STATE. The
-// throw escapes our local try/catch because it surfaces inside the RSC
-// encoder's own pipeline, not at our call site. Tracking a `closed` flag and
-// gating all controller ops on it ensures we stop producing the moment the
-// downstream goes away. Returns helpers plus a `markClosed()` for the
-// cancel() / finally() paths.
-function makeSafeController() {
- let controller: ReadableStreamDefaultController<string> | null = null;
- let closed = false;
- const setController = (c: ReadableStreamDefaultController<string>) => {
- controller = c;
- };
- const safeEnqueue = (text: string) => {
- if (closed) return;
- try {
- controller?.enqueue(text);
- } catch (err) {
- if (
- (err as NodeJS.ErrnoException | undefined)?.code === "ERR_INVALID_STATE"
- ) {
- closed = true;
- }
- }
- };
- const safeClose = () => {
- if (closed) return;
- closed = true;
- try {
- controller?.close();
- } catch {}
- };
- const markClosed = () => {
- closed = true;
- };
- return { setController, safeEnqueue, safeClose, markClosed };
-}
-
// Best-effort error listener for the log WriteStream: writes can fail (disk
// full, permissions, etc.) and would otherwise become uncaughtExceptions.
function ignoreFileStreamErrors(stream: WriteStream): void {
@@ -116,7 +78,7 @@ export async function runManagedCommand(
opts.videoId,
);
- const safe = makeSafeController();
+ const safe = makeSafeController<string>();
let fileStream: WriteStream | null = null;
let cancelledBeforeStart = false;
@@ -202,7 +164,7 @@ export async function runManagedFunction(
opts.videoId,
);
- const safe = makeSafeController();
+ const safe = makeSafeController<string>();
let fileStream: WriteStream | null = null;
let cancelledBeforeStart = false;
diff --git a/common/lib/safeStreamController.ts b/common/lib/safeStreamController.ts
@@ -0,0 +1,60 @@
+// Shared safety wrapper around a WHATWG ReadableStream controller.
+//
+// The consumer (RSC encoder, fetch response body, browser <audio>/<video>)
+// can close the stream at any time. Once that happens, calling enqueue/
+// close/error on the controller throws ERR_INVALID_STATE — and because the
+// throw surfaces inside the encoder's own pipeline, not at our call site,
+// it leaks as an uncaughtException. Tracking a `closed` flag and gating all
+// controller ops on it ensures we stop producing the moment the downstream
+// goes away.
+//
+// `markClosed()` is for the cancel() / finally() paths where we already know
+// the stream is gone and don't want to attempt close().
+
+export type SafeStreamController<T> = {
+ setController: (c: ReadableStreamDefaultController<T>) => void;
+ safeEnqueue: (chunk: T) => void;
+ safeClose: () => void;
+ safeError: (err: unknown) => void;
+ markClosed: () => void;
+ isClosed: () => boolean;
+};
+
+export function makeSafeController<T>(): SafeStreamController<T> {
+ let controller: ReadableStreamDefaultController<T> | null = null;
+ let closed = false;
+ const setController = (c: ReadableStreamDefaultController<T>) => {
+ controller = c;
+ };
+ const safeEnqueue = (chunk: T) => {
+ if (closed) return;
+ try {
+ controller?.enqueue(chunk);
+ } catch (err) {
+ if (
+ (err as NodeJS.ErrnoException | undefined)?.code === "ERR_INVALID_STATE"
+ ) {
+ closed = true;
+ }
+ }
+ };
+ const safeClose = () => {
+ if (closed) return;
+ closed = true;
+ try {
+ controller?.close();
+ } catch {}
+ };
+ const safeError = (err: unknown) => {
+ if (closed) return;
+ closed = true;
+ try {
+ controller?.error(err);
+ } catch {}
+ };
+ const markClosed = () => {
+ closed = true;
+ };
+ const isClosed = () => closed;
+ return { setController, safeEnqueue, safeClose, safeError, markClosed, isClosed };
+}
diff --git a/common/lib/videoStatus.ts b/common/lib/videoStatus.ts
@@ -8,6 +8,10 @@ export type VideoFiles = {
hasCuesJson: boolean;
isUntranscribable: boolean;
audioFiles: string[];
+ // yt-dlp leaves audio.<ext>.part on disk when a download is interrupted.
+ // Surfaced separately so the UI can offer a Resume action; audio-check's
+ // own internal snapshots (.part.good, .part.testing) are excluded.
+ partAudioFiles: string[];
};
export type IndexTranscript =
@@ -59,7 +63,18 @@ export async function readVideoFiles(
!e.endsWith(".info.json") &&
// yt-dlp writes audio.<ext>.part while downloading; treat those as
// incomplete so prefilters re-invoke yt-dlp (which resumes via -c).
- !e.endsWith(".part"),
+ !e.endsWith(".part") &&
+ // audio-check internals — not user-visible audio outputs.
+ !e.endsWith(".part.good") &&
+ !e.endsWith(".part.testing"),
+ );
+ const partAudioFiles = entries.filter(
+ (e) =>
+ e.startsWith("audio.") &&
+ !e.includes(".tmp-") &&
+ e.endsWith(".part") &&
+ !e.endsWith(".part.good") &&
+ !e.endsWith(".part.testing"),
);
let isUntranscribable = false;
if (hasWhisper && opts.checkUntranscribable) {
@@ -84,6 +99,7 @@ export async function readVideoFiles(
hasCuesJson,
isUntranscribable,
audioFiles,
+ partAudioFiles,
};
}
diff --git a/common/ytdlp/audioCheckedDownload.ts b/common/ytdlp/audioCheckedDownload.ts
@@ -188,10 +188,22 @@ async function pathExists(p: string): Promise<boolean> {
async function prepareDataTree(
channelDir: string,
onLog: (s: string) => void,
+ precheck?: {
+ paths: Paths;
+ signal: AbortSignal;
+ onCheckpoint: (rec: CheckpointRecord) => void;
+ },
): Promise<void> {
// Drop any leftover .testing snapshots from crashed prior runs, and
// promote any orphan .good back to .part so yt-dlp resume picks up where
// we left off. Scoped to <channelDir>/data/*.
+ //
+ // When `precheck` is supplied, also probe each pre-existing .part (i.e.
+ // one we did NOT just restore from a .good — the .good was already
+ // probe-validated at snapshot time). On malformed verdict: roll back to
+ // .good if present, else discard so yt-dlp restarts from 0. This catches
+ // corruption from a prior interrupted attempt that yt-dlp's -c would
+ // otherwise extend rather than detect.
const dataDir = path.join(channelDir, "data");
let videoDirs: string[] = [];
try {
@@ -225,10 +237,60 @@ async function prepareDataTree(
partFile = path.join(dir, e);
}
}
+ let preExisting = partFile;
if (!partFile && goodFile) {
const restored = goodFile.slice(0, -".good".length);
await rename(goodFile, restored);
onLog(`Restored prior validated snapshot: ${restored}\n`);
+ // Don't probe the restored snapshot — .good is clean by construction.
+ preExisting = null;
+ }
+
+ if (precheck && preExisting && !precheck.signal.aborted) {
+ let bytes = 0;
+ try {
+ const st = await stat(preExisting);
+ bytes = st.size;
+ } catch {}
+ const probe = await probeAudioStream({
+ ffmpegBin: precheck.paths.ffmpegBin,
+ file: preExisting,
+ signal: precheck.signal,
+ onLog,
+ });
+ if (precheck.signal.aborted) continue;
+ if (probe.verdict === "clean" || probe.verdict === "partial") {
+ onLog(
+ `Pre-check OK at ${bytes} bytes (verdict=${probe.verdict}): ${preExisting}\n`,
+ );
+ } else {
+ const good = goodPath(preExisting);
+ const haveGood = await pathExists(good);
+ if (haveGood) {
+ await rm(preExisting, { force: true });
+ await rename(good, preExisting);
+ onLog(
+ `Pre-check found malformed .part; rolled back to .good: ${preExisting}\n`,
+ );
+ precheck.onCheckpoint({
+ at: new Date().toISOString(),
+ bytes,
+ verdict: probe.verdict,
+ action: "rollback",
+ });
+ } else {
+ await rm(preExisting, { force: true });
+ onLog(
+ `Pre-check found malformed .part; discarded (no .good baseline): ${preExisting}\n`,
+ );
+ precheck.onCheckpoint({
+ at: new Date().toISOString(),
+ bytes,
+ verdict: probe.verdict,
+ action: "restart",
+ });
+ }
+ }
}
}
}
@@ -385,7 +447,11 @@ export async function runAudioCheckedYtdlp(
// Sticky across rollback/restart loops since the id stays the same.
let resolvedVideoDir: string | null = null;
- await prepareDataTree(opts.channelDir, opts.onLog);
+ await prepareDataTree(opts.channelDir, opts.onLog, {
+ paths: opts.paths,
+ signal: opts.signal,
+ onCheckpoint: (rec) => checkpoints.push(rec),
+ });
// Main loop: each iteration = one yt-dlp launch. Decides what to do based
// on whether the launch ended in success, rollback, restart, or error.
diff --git a/editor/app/api/channels/[slug]/videos/[id]/files/[name]/route.ts b/editor/app/api/channels/[slug]/videos/[id]/files/[name]/route.ts
@@ -1,9 +1,9 @@
import path from "node:path";
-import { createReadStream } from "node:fs";
+import { createReadStream, type ReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { NextResponse } from "next/server";
-import { Readable } from "node:stream";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { makeSafeController } from "yt-dlp-transcript-common/lib/safeStreamController";
export const dynamic = "force-dynamic";
@@ -31,6 +31,40 @@ function contentTypeFor(name: string): string {
return MIME_BY_EXT[ext] ?? "application/octet-stream";
}
+// Wrap a Node ReadStream as a WHATWG ReadableStream that survives the browser
+// aborting the response mid-flight. The browser does this constantly while
+// scrubbing an <audio>/<video> element — each seek cancels the prior range
+// request. `Readable.toWeb(...)` was previously here, but it can call enqueue
+// on the WHATWG controller AFTER the consumer cancelled, surfacing
+// `ERR_INVALID_STATE: Controller is already closed` as an uncaughtException.
+function nodeStreamToResponseBody(
+ node: ReadStream,
+): ReadableStream<Uint8Array> {
+ const safe = makeSafeController<Uint8Array>();
+ return new ReadableStream<Uint8Array>({
+ start(controller) {
+ safe.setController(controller);
+ node.on("data", (chunk: Buffer | string) => {
+ const bytes =
+ typeof chunk === "string"
+ ? new TextEncoder().encode(chunk)
+ : new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
+ safe.safeEnqueue(bytes);
+ });
+ node.on("end", () => {
+ safe.safeClose();
+ });
+ node.on("error", (err) => {
+ safe.safeError(err);
+ });
+ },
+ cancel() {
+ safe.markClosed();
+ node.destroy();
+ },
+ });
+}
+
function parseRange(
header: string | null,
size: number,
@@ -107,9 +141,9 @@ export async function GET(
if (range) {
const { start, end } = range;
const length = end - start + 1;
- const stream = Readable.toWeb(
+ const stream = nodeStreamToResponseBody(
createReadStream(fullPath, { start, end }),
- ) as ReadableStream<Uint8Array>;
+ );
return new Response(stream, {
status: 206,
headers: {
@@ -122,9 +156,7 @@ export async function GET(
});
}
- const stream = Readable.toWeb(
- createReadStream(fullPath),
- ) as ReadableStream<Uint8Array>;
+ const stream = nodeStreamToResponseBody(createReadStream(fullPath));
return new Response(stream, {
status: 200,
headers: {
diff --git a/editor/app/channels/[slug]/components/stages/DownloadStage.tsx b/editor/app/channels/[slug]/components/stages/DownloadStage.tsx
@@ -26,6 +26,7 @@ type Props = {
undownloadedIds: string[];
excludedFromDownload: ExcludedFromDownload;
noTranscriptIds: string[];
+ partialDownloadIds: string[];
missingShard: ShardConfigSummary | null;
};
@@ -37,6 +38,7 @@ export function DownloadStage({
undownloadedIds,
excludedFromDownload,
noTranscriptIds,
+ partialDownloadIds,
missingShard,
}: Props) {
const [downloadQueue, setDownloadQueue] = useState(defaultQueueKey);
@@ -208,6 +210,12 @@ export function DownloadStage({
defaultQueueKey={defaultQueueKey}
existingQueues={existingQueues}
/>
+ <PartialDownloadsList
+ slug={slug}
+ ids={partialDownloadIds}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ />
</div>
);
}
@@ -361,6 +369,50 @@ function NoTranscriptList({
);
}
+function PartialDownloadsList({
+ slug,
+ ids,
+ defaultQueueKey,
+ existingQueues,
+}: {
+ slug: string;
+ ids: string[];
+ defaultQueueKey: string;
+ existingQueues: string[];
+}) {
+ if (ids.length === 0) return null;
+ return (
+ <div className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3">
+ <div>
+ <h4 className="text-sm font-semibold">
+ Partial downloads ({ids.length})
+ </h4>
+ <p className="text-xs text-zinc-500">
+ Video directory has an <code>audio.<ext>.part</code> file but
+ no completed download. Resume picks up via yt-dlp <code>-c</code>;
+ on audio-integrity channels the <code>.part</code> is probed first
+ and rolled back or restarted if malformed.
+ </p>
+ </div>
+ <VideoIdList
+ slug={slug}
+ ids={ids}
+ ariaLabel="partial downloads list"
+ emptyAriaLabel="partial downloads empty"
+ emptyMessage="None"
+ itemAriaLabel={(id) => `partial download ${id}`}
+ />
+ <RetryBucketControl
+ slug={slug}
+ ids={ids}
+ actionLabel="resume partial downloads"
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ />
+ </div>
+ );
+}
+
function Heading({ title, desc }: { title: string; desc: string }) {
return (
<div>
diff --git a/editor/app/channels/[slug]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts
@@ -22,6 +22,7 @@ export function normalizeBuckets(
failedListed: raw?.failedListed ?? [],
missingFromArchive: raw?.missingFromArchive ?? [],
duplicateDirs: raw?.duplicateDirs ?? [],
+ partialDownloads: raw?.partialDownloads ?? [],
};
}
@@ -121,7 +122,9 @@ export function computeStageStatuses(
config.handling === "transcribe" && !!config.audioFormat;
const downloadPending =
- undownloadedIds.length + actionableNoTranscript.length;
+ undownloadedIds.length +
+ actionableNoTranscript.length +
+ buckets.partialDownloads.length;
const transcodePending = transcodeApplies ? buckets.untranscoded.length : 0;
const transcodeFailed = transcodeApplies ? failedTranscodingIds.length : 0;
const transcribePending = buckets.downloadedNoTranscript.length;
@@ -181,6 +184,15 @@ export function computeStageStatuses(
),
);
}
+ if (buckets.partialDownloads.length > 0) {
+ downloadParts.push(
+ pluralize(
+ buckets.partialDownloads.length,
+ "partial download",
+ "partial downloads",
+ ),
+ );
+ }
const download: StageStatus = {
id: "download",
title: "Download",
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -173,6 +173,7 @@ export default async function ChannelDetailPage({
undownloadedIds={undownloadedIds}
excludedFromDownload={excludedFromDownload}
noTranscriptIds={buckets.noTranscript}
+ partialDownloadIds={buckets.partialDownloads}
missingShard={downloadMissingShard}
/>
),
diff --git a/editor/e2e/audio-check-scenarios.spec.ts b/editor/e2e/audio-check-scenarios.spec.ts
@@ -1,4 +1,4 @@
-import { writeFile } from "node:fs/promises";
+import { mkdir, writeFile } from "node:fs/promises";
import { test, expect } from "@playwright/test";
import { pathExists, readJson, resetData, resolvePath } from "./helpers";
import type {
@@ -231,4 +231,75 @@ test.describe("audio-checked download scenarios", () => {
).toBeGreaterThanOrEqual(1);
expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`)).toBe(true);
});
+
+ test("pre-check rolls back to .good when an existing .part is malformed", async ({
+ page,
+ }) => {
+ await resetData("audio-check-channel");
+ // Seed a malformed .part (contains the fake-ffmpeg CORRUPT_MARKER) and
+ // a clean .good baseline so the pre-check has something to fall back to.
+ const dir = resolvePath(`${CHANNEL_ROOT}/data/${VIDEO_ID}`);
+ await mkdir(dir, { recursive: true });
+ await writeFile(
+ `${dir}/audio.mp4.part`,
+ "__CORRUPT__ leftover bytes from a prior interrupted attempt",
+ );
+ await writeFile(
+ `${dir}/audio.mp4.part.good`,
+ "clean validated bytes from a prior checkpoint",
+ );
+ await writeFakeConfig({
+ mode: "happy",
+ recoverOnResume: true,
+ totalChunks: 4,
+ chunkDelayMs: 100,
+ });
+ await triggerDownload(page);
+
+ const log = page.getByLabel("Download videos output");
+ await expect(log).toContainText("rolled back to .good", {
+ timeout: 30_000,
+ });
+
+ const outcome = await waitForOutcome(
+ (o) => o.status === "ok-audio-checked",
+ "pre-check rollback outcome",
+ );
+ // The pre-check appends a rollback checkpoint before yt-dlp launches.
+ expect(outcome.attempts[0].audioCheck?.checkpoints ?? 0)
+ .toBeGreaterThanOrEqual(1);
+ expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`))
+ .toBe(true);
+ });
+
+ test("pre-check discards a malformed .part with no .good baseline", async ({
+ page,
+ }) => {
+ await resetData("audio-check-channel");
+ const dir = resolvePath(`${CHANNEL_ROOT}/data/${VIDEO_ID}`);
+ await mkdir(dir, { recursive: true });
+ await writeFile(
+ `${dir}/audio.mp4.part`,
+ "__CORRUPT__ leftover bytes with no clean baseline to roll back to",
+ );
+ await writeFakeConfig({
+ mode: "happy",
+ recoverOnResume: true,
+ totalChunks: 4,
+ chunkDelayMs: 100,
+ });
+ await triggerDownload(page);
+
+ const log = page.getByLabel("Download videos output");
+ await expect(log).toContainText("discarded", { timeout: 30_000 });
+
+ const outcome = await waitForOutcome(
+ (o) => o.status === "ok-audio-checked",
+ "pre-check discard outcome",
+ );
+ expect(outcome.attempts[0].audioCheck?.checkpoints ?? 0)
+ .toBeGreaterThanOrEqual(1);
+ expect(await pathExists(`${CHANNEL_ROOT}/data/${VIDEO_ID}/audio.mp3`))
+ .toBe(true);
+ });
});
diff --git a/editor/e2e/media-file-abort.spec.ts b/editor/e2e/media-file-abort.spec.ts
@@ -0,0 +1,67 @@
+// Regression: the media file route used `Readable.toWeb(createReadStream(...))`
+// which can call enqueue on the WHATWG controller AFTER the consumer aborts,
+// surfacing `Invalid state: Controller is already closed` as an
+// uncaughtException. Browsers issue range requests with rapid aborts while
+// scrubbing <audio>/<video>, so opening the single-video view alone could
+// trip this.
+
+import { test, expect } from "@playwright/test";
+import { resetData } from "./helpers";
+
+const baseUrl = "http://localhost:3011";
+
+async function readUncaughtCount(): Promise<{
+ uncaught: number;
+ unhandled: number;
+ messages: string[];
+}> {
+ const res = await fetch(`${baseUrl}/api/test/uncaught-count`);
+ return res.json() as Promise<{
+ uncaught: number;
+ unhandled: number;
+ messages: string[];
+ }>;
+}
+
+async function clearUncaughtCount(): Promise<void> {
+ await fetch(`${baseUrl}/api/test/uncaught-count`, { method: "DELETE" });
+}
+
+test("aborting media file requests does not crash with 'Controller is already closed'", async () => {
+ test.setTimeout(30_000);
+ await resetData("one-transcribe-channel-with-audio");
+ await clearUncaughtCount();
+
+ const url = `${baseUrl}/api/channels/test-transcribe/videos/vidA/files/audio.m4a`;
+
+ // Fire a burst of overlapping range requests, each aborted shortly after
+ // they start. This is what a browser does when the user scrubs through an
+ // <audio> element — every seek cancels the prior range request.
+ const aborts: Promise<unknown>[] = [];
+ for (let i = 0; i < 25; i++) {
+ const ac = new AbortController();
+ const start = i * 64;
+ const end = start + 4096;
+ aborts.push(
+ fetch(url, {
+ headers: { Range: `bytes=${start}-${end}` },
+ signal: ac.signal,
+ })
+ .then((res) => res.body?.getReader().read())
+ .catch(() => {}),
+ );
+ // Abort almost immediately so the consumer disappears while data may
+ // still be in flight on the server side.
+ setTimeout(() => ac.abort(), 5);
+ }
+ await Promise.all(aborts);
+
+ // Let any post-cancel async cleanup fire its error path before we check.
+ await new Promise<void>((resolve) => setTimeout(resolve, 750));
+
+ const counts = await readUncaughtCount();
+ expect(
+ counts.messages.filter((m) => m.includes("Controller is already closed")),
+ ).toEqual([]);
+ expect(counts.uncaught).toBe(0);
+});
diff --git a/editor/e2e/partial-downloads-bucket.spec.ts b/editor/e2e/partial-downloads-bucket.spec.ts
@@ -0,0 +1,66 @@
+// A video whose only audio file is `audio.<ext>.part` should surface in a
+// "Partial downloads" bucket on the channel page, with a Resume action that
+// kicks off the standard retry-bucket flow. Resume relies on yt-dlp's
+// implicit `-c` behavior to pick up where the prior attempt left off.
+
+import { rename, writeFile } from "node:fs/promises";
+import { test, expect } from "@playwright/test";
+import { pathExists, resetData, resolvePath } from "./helpers";
+
+const CHANNEL = "test-transcribe";
+const CHANNEL_ROOT = `test-transcripts/channels/${CHANNEL}`;
+
+test("partial download surfaces in a bucket on the channel page", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ // Strand vidA mid-download: only a .part file, no completed audio.
+ await rename(
+ resolvePath(`${CHANNEL_ROOT}/data/vidA/audio.m4a`),
+ resolvePath(`${CHANNEL_ROOT}/data/vidA/audio.m4a.part`),
+ );
+
+ await page.goto(`/channels/${CHANNEL}`);
+
+ await expect(
+ page.getByRole("heading", { name: /Partial downloads \(1\)/ }),
+ ).toBeVisible();
+ await expect(
+ page.getByLabel("partial downloads list").getByLabel("partial download vidA"),
+ ).toBeVisible();
+});
+
+test("resume action re-downloads the partial video", async ({ page }) => {
+ test.setTimeout(60_000);
+ await resetData("one-transcribe-channel-with-audio");
+ await rename(
+ resolvePath(`${CHANNEL_ROOT}/data/vidA/audio.m4a`),
+ resolvePath(`${CHANNEL_ROOT}/data/vidA/audio.m4a.part`),
+ );
+ // retry-bucket reads the channel playlist file to know which URL to fetch.
+ // The slug-style URL keeps extractVideoId() === "vidA" without needing an
+ // Odysee-style claim id.
+ await writeFile(
+ resolvePath(`${CHANNEL_ROOT}/playlist`),
+ "https://www.youtube.com/watch?v=vidA\n",
+ );
+
+ await page.goto(`/channels/${CHANNEL}`);
+
+ await page
+ .getByLabel("retry resume partial downloads bucket")
+ .getByRole("button", { name: /^Retry \(1\)$/ })
+ .click();
+
+ const log = page.getByLabel("Retry resume partial downloads output");
+ await expect(log).toContainText("Retry allowlist: kept 1 of 1", {
+ timeout: 30_000,
+ });
+ await expect(log).toContainText("Managed download complete", {
+ timeout: 30_000,
+ });
+
+ expect(
+ await pathExists(`${CHANNEL_ROOT}/data/vidA/audio.m4a`),
+ ).toBe(true);
+});