Archilyzer · Source

archilyzer

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

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:
Mcommon/controller/channelSnapshot.ts | 10++++++++++
Mcommon/jobs/streamCommand.ts | 44+++-----------------------------------------
Acommon/lib/safeStreamController.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/videoStatus.ts | 18+++++++++++++++++-
Mcommon/ytdlp/audioCheckedDownload.ts | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/api/channels/[slug]/videos/[id]/files/[name]/route.ts | 46+++++++++++++++++++++++++++++++++++++++-------
Meditor/app/channels/[slug]/components/stages/DownloadStage.tsx | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/lib/stageStatus.ts | 14+++++++++++++-
Meditor/app/channels/[slug]/page.tsx | 1+
Meditor/e2e/audio-check-scenarios.spec.ts | 73++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aeditor/e2e/media-file-abort.spec.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/partial-downloads-bucket.spec.ts | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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.&lt;ext&gt;.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); +});