commit 41bbb51efcaa5917fcaae18bce0a51147417bae8
parent d1c783cde9dc61ae5af5a588b517a4d669f612b2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 29 Jun 2026 22:07:59 -0400
Merge feat/bulk-fix-incomplete-transcripts: bulk clear + batch re-fix for truncated transcripts
Diffstat:
14 files changed, 744 insertions(+), 46 deletions(-)
diff --git a/common/jobs/jobKinds.ts b/common/jobs/jobKinds.ts
@@ -77,6 +77,13 @@ const JOB_KINDS: Record<string, JobKindMeta> = {
bookmarkable: true,
queueKeyStrategy: "custom",
},
+ "redownload-incomplete-bucket": {
+ kind: "redownload-incomplete-bucket",
+ label: "Re-download truncated transcripts",
+ drainable: true,
+ bookmarkable: true,
+ queueKeyStrategy: "custom",
+ },
"download-from-playlist": {
kind: "download-from-playlist",
label: "Download from playlist",
diff --git a/common/jobs/jobSpec.ts b/common/jobs/jobSpec.ts
@@ -18,7 +18,8 @@
export type ReplayBucket =
| "partialDownloads"
| "noTranscript"
- | "downloadedNoTranscript";
+ | "downloadedNoTranscript"
+ | "incompleteTranscript";
export type JobSpec = {
kind: string;
@@ -36,6 +37,7 @@ const REPLAY_BUCKETS: ReadonlySet<string> = new Set<ReplayBucket>([
"partialDownloads",
"noTranscript",
"downloadedNoTranscript",
+ "incompleteTranscript",
]);
// Defensive parse for a spec read back from JSON (a sidecar or the bookmarks
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -3,6 +3,7 @@
## [Unreleased]
- **The Deploy page is reworked around a clearer build/deploy lifecycle, with one-click build-then-deploy and batch multi-site builds.** The page now reads top-to-bottom as you'd actually ship: **Release notes** (the `## [Unreleased]` changelog preview + Cut release) → **Build & deploy** → optional **Individual steps** → **Build multiple sites**. A new **Build & deploy** button runs the build and, only if it succeeds (and wasn't cancelled), deploys it — as a single managed job with one combined streamed log and one Cancel (`buildAndDeployAction`, a composite `runManagedFunction`; cancelling mid-build skips the deploy). The new **Build multiple sites** panel kicks off a build (optionally build+deploy) for several sites at once, each rendered as its own live status-chipped log lane (`BuildSitesPanel` + `JobLane`); in Basic mode the jobs serialize on the shared build/deploy queue (the `export/` output tree is shared), with a note that true parallelism arrives with Docker mode. A **Build mode** toggle (Basic | Docker) on the page persists the choice as the default (`settings.buildPipeline`, also editable on Settings); Docker mode is a follow-up and currently falls back to a basic build with an inline notice. The build/deploy commands now share a child-streaming helper (`common/jobs/runChild.ts`) and mode-routing core (`editor/app/deploy/buildDeployCore.ts`). See `editor/app/deploy/{page.tsx,buildAction usage,components/*}`, `editor/app/build/buildAction.ts`, and `common/lib/settings.ts`.
- **Truncated transcripts are now detected and flagged for re-download.** When an audio download silently stops early (yt-dlp exits `ok`, `download-outcome.json` records success), whisper transcribes only the few minutes that landed — so a 2h22m video ends up with a ~7-minute transcript and nothing warns you. A new coverage check (last cue end ÷ video duration) flags any non-livestream video ≥10min whose transcript covers <50% of its runtime. The single source of truth is `common/lib/transcriptCoverage.ts` (`transcriptCoverage` + `isIncompleteTranscript`, with named thresholds), read from each video's `transcript.cues.json` so the existing corpus is flagged with no migration. Surfaced everywhere: a new **`incompleteTranscript`** channel-snapshot bucket → an **"Incomplete transcript"** filter chip and an **amber transcribed-dot** in the per-channel video list; a warning banner on the video page ("Transcript covers 6:52 of 2:22:21 (4.8%)…") with a one-click **Re-download & re-transcribe** button; and an **"Channels with incomplete (truncated) transcripts"** section on `/actionable`. The fix action (`redownloadIncompleteTranscriptAction`) deletes the truncated audio first, then re-downloads and re-transcribes — re-running whisper alone would just reproduce the short transcript. See `common/controller/channelSnapshot.ts`, `editor/app/channels/[slug]/{lib/videoRows.ts,lib/videoRowsServer.ts,lib/stageStatus.ts,components/VideoListPane.tsx,videos/[id]/{components/VideoPanel.tsx,videoActions.ts,page.tsx},page.tsx}`, and `editor/app/actionable/{lib/loadActionable.ts,page.tsx}`.
+- **Fix truncated transcripts in bulk — two buttons, in three places.** The per-video fix now has channel-wide and cross-channel counterparts, each offered as a **batch re-fix** (queues one job that removes the truncated audio → re-downloads → re-transcribes every flagged video in place; the transcript is never gapped) **and** a **clear & re-queue** (deletes the truncated audio + transcript so the videos drop back into the normal *undownloaded → needs-transcript* pipeline, then enables + starts the auto-download/auto-transcribe runners so they reprocess automatically). Both appear on the **`/actionable`** "incomplete transcripts" section — per-channel **Re-download & re-transcribe** / **Clear & re-queue** buttons (replacing the old "Review"-only link) plus a section-header **Re-fix all** / **Clear & re-queue all** that acts across every affected channel — and on the **channel page bulk bar** as two new Action options with a new **Select incomplete** quick-select. The clear path needs no archive pruning: `undownloadedIds` is derived purely from on-disk artifacts, and a single-video re-download isn't archive-gated. Destructive clears are confirm-gated everywhere; enabling the runners is disclosed in the confirm (note: the auto-queue policy must cover the channel for auto-reprocessing — cleared videos also surface in the existing "Download missing" / "Transcribe pending" sections as a fallback). New shared helper `editor/app/channels/[slug]/lib/fixIncompleteTranscript.ts` is the single source of truth for the per-video fix/clear, reused by the per-video action, the new `redownload-incomplete-bucket` batch job (bookmarkable; re-derives the live `incompleteTranscript` bucket), the bulk-bar wrappers, and the global actions. See `editor/app/channels/[slug]/{incompleteTranscriptActions.ts,bulkVideoActions.ts,components/VideoListPane.tsx}`, `editor/app/actionable/{actions.ts,page.tsx,components/{InlineActionButton.tsx,FixAllIncompleteButton.tsx}}`, `common/jobs/{jobKinds.ts,jobSpec.ts}`, `editor/app/jobs/jobReplayRegistry.ts`, and `editor/e2e/incomplete-transcript.spec.ts`.
- **Auto-queue rules with no bucket now draw from *all* of a runner's buckets, and auto-download can resume partial downloads.** A policy-tree rule left at the **"all buckets (default)"** setting (previously just labeled *default*) now draws from the **union** of every bucket that runner kind tracks — deduped, in priority order — instead of only the single primary bucket. This fixes channels (e.g. an Odysee channel mid-download) that quietly stopped being auto-downloaded once their remaining work drifted entirely into **partially-downloaded** videos: those have a `.part` file but no completed audio, so they live in the `partialDownloads` bucket and were **absent from `undownloadedIds`** — the only bucket auto-download used to load. The download runner now loads `partialDownloads` alongside `undownloadedIds` (partials first, so in-progress downloads resume via `downloadOneManaged` before fresh ones start), and exposes `partialDownloads` as a selectable bucket in the policy editor so you can dedicate a high-priority rule to resuming partials. The per-kind bucket lists are consolidated behind a single `bucketsForKind` source of truth shared by the runner, the per-rule pending-count helper, and the editor's bucket picker (so they can't drift). Note: a *bucketless* auto-transcribe rule now also drains `failedListed` after `downloadedNoTranscript` (it already loaded both); platform rate-limit backoff is unchanged and remains an independent reason a throttled platform may pause. See `common/jobs/autoQueuePolicy.ts` (`buildPendingByLeaf` + `bucketsForKind` + unit tests), `common/controller/autoRunner.ts`, and `editor/app/auto-queue/{page.tsx,components/PolicyTreeEditor.tsx}`.
- **Hub homepage redesigned into a cross-site landing; the homepage page-creator is removed.** The hub's home page is now a single mobile-first cross-site landing (headline KPIs and one stacked activity chart with Metric [Transcribed/Downloaded] · Breakdown [By site/By channel] · Bucket [Week/Month/Cumulative] · Range [90d/12mo/All] · Display [Share/Counts] controls, plus a metric-aware site-links grid with sparklines and a "#1 this month" badge), built from a small `homepage-summary.json` pre-computed by `compose-homepage`. The separate `/stats` dashboard route folds into it. Consequently the hub's **Markdown-pages subsystem is dropped**: **Manage → Homepage** now edits only branding (the Pages list, New-page, and the page editor are gone), and the homepage config no longer carries a `nav`. The page server actions (`saveHomepagePageAction`/`deleteHomepagePageAction`), `editor/app/homepage/pages/*`, `PageEditor.tsx`, `common/lib/{homepagePages,homepageConstants}.ts`, and `paths.homepagePagesDir` are removed. See `editor/app/homepage/{page.tsx,actions.ts}`, `common/bin/compose-homepage.ts`, `common/lib/{homepageSummary,homepageChart}.ts`, and the `homepage/` package. (Re-addable later if needed.)
- **One-click Retry for failed jobs (plus "Retry all failed").** A failed job that carries a replay descriptor (any bookmarkable kind — sync, download-missing, transcribe-all, retry-bucket, …) now shows a **Retry** button on the Jobs history table, and the page header gains a **Retry all failed** button whenever at least one such job is listed. Retry re-runs the job from its stored spec exactly like a bookmark re-run (so bucket jobs re-derive from the channel's *current* state), and the re-run **jumps ahead of other queued work** (it's promoted to the front of its queue, reusing the new reorder machinery) so a fix-and-retry runs next rather than at the back of the line. The spec is resolved from the live registry or, for an evicted/archived job, from its on-disk `<id>.meta.json` sidecar — so even a failure the 100-job cap has dropped is still retryable. Kinds with no replay descriptor (e.g. `import-one`) intentionally offer no Retry. See `editor/app/jobs/actions.ts` (`retryJobAction` / `retryAllFailedAction`), the new `RetryJobButton` / `RetryAllFailedButton`, and `editor/e2e/jobs-retry.spec.ts`.
diff --git a/editor/app/actionable/actions.ts b/editor/app/actionable/actions.ts
@@ -11,6 +11,12 @@ import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { runManagedFunction } from "yt-dlp-transcript-common/jobs/streamCommand";
import { drainStream } from "yt-dlp-transcript-common/jobs/drainStream";
import { detectDuplicateShorts } from "yt-dlp-transcript-common/controller/duplicateShorts";
+import {
+ clearIncompleteTranscriptsAction,
+ enableAutoRunners,
+ redownloadIncompleteBucketAction,
+} from "../channels/[slug]/incompleteTranscriptActions";
+import { loadActionableSummary } from "./lib/loadActionable";
export type RefreshAllResult = {
queued: string[];
@@ -123,3 +129,53 @@ export async function runDuplicateDetectionAction(
revalidatePath("/actionable");
return { ok: true, clusters, videosInClusters };
}
+
+export type GlobalIncompleteResult =
+ | { ok: true; channels: number; affected: number }
+ | { ok: false; error: string };
+
+// All channels with at least one truncated transcript right now.
+async function affectedIncompleteSlugs(): Promise<string[]> {
+ const summary = await loadActionableSummary(getPaths());
+ return summary.incompleteTranscripts.map((r) => r.channel.slug);
+}
+
+// Clear & re-queue every truncated transcript across all channels, then enable
+// the auto-runners once. Destructive — the caller confirms first.
+export async function clearAllIncompleteTranscriptsAction(): Promise<GlobalIncompleteResult> {
+ const slugs = await affectedIncompleteSlugs();
+ if (slugs.length === 0) {
+ return { ok: false, error: "No incomplete transcripts to clear." };
+ }
+ let cleared = 0;
+ for (const slug of slugs) {
+ // Defer enabling the runners until the end so settings flip only once.
+ const r = await clearIncompleteTranscriptsAction(slug, undefined, {
+ enableRunners: false,
+ });
+ cleared += r.succeeded;
+ }
+ await enableAutoRunners();
+ revalidatePath("/actionable");
+ return { ok: true, channels: slugs.length, affected: cleared };
+}
+
+// Queue one batch re-fix job per affected channel (fire-and-forget — the jobs
+// keep running and surface in /jobs; cancel each stream so we don't hold them
+// open).
+export async function redownloadAllIncompleteTranscriptsAction(): Promise<GlobalIncompleteResult> {
+ const slugs = await affectedIncompleteSlugs();
+ if (slugs.length === 0) {
+ return { ok: false, error: "No incomplete transcripts to fix." };
+ }
+ let queued = 0;
+ for (const slug of slugs) {
+ const result = await redownloadIncompleteBucketAction(slug);
+ if (result.ok) {
+ void result.stream.cancel();
+ queued++;
+ }
+ }
+ revalidatePath("/actionable");
+ return { ok: true, channels: slugs.length, affected: queued };
+}
diff --git a/editor/app/actionable/components/FixAllIncompleteButton.tsx b/editor/app/actionable/components/FixAllIncompleteButton.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import {
+ clearAllIncompleteTranscriptsAction,
+ redownloadAllIncompleteTranscriptsAction,
+ type GlobalIncompleteResult,
+} from "../actions";
+
+type Mode = "clear" | "redownload";
+
+type Status =
+ | { kind: "idle" }
+ | { kind: "running"; mode: Mode }
+ | { kind: "done"; mode: Mode; result: GlobalIncompleteResult }
+ | { kind: "error"; message: string };
+
+export function FixAllIncompleteButton() {
+ const [status, setStatus] = useState<Status>({ kind: "idle" });
+
+ async function run(mode: Mode) {
+ if (
+ mode === "clear" &&
+ !window.confirm(
+ "Delete the truncated audio + transcript for every flagged video across ALL channels and enable the auto-download/transcribe runners? The current partial transcripts are removed and re-fetched. This cannot be undone.",
+ )
+ ) {
+ return;
+ }
+ setStatus({ kind: "running", mode });
+ try {
+ const result =
+ mode === "clear"
+ ? await clearAllIncompleteTranscriptsAction()
+ : await redownloadAllIncompleteTranscriptsAction();
+ setStatus({ kind: "done", mode, result });
+ } catch (e) {
+ setStatus({ kind: "error", message: (e as Error).message });
+ }
+ }
+
+ const running = status.kind === "running";
+ return (
+ <div className="flex items-center gap-2 flex-wrap">
+ <button
+ type="button"
+ onClick={() => run("redownload")}
+ disabled={running}
+ aria-label="re-download all incomplete transcripts"
+ className="px-2.5 py-1 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-xs font-medium hover:opacity-90 disabled:opacity-50 whitespace-nowrap"
+ >
+ {status.kind === "running" && status.mode === "redownload"
+ ? "Queuing…"
+ : "Re-fix all"}
+ </button>
+ <button
+ type="button"
+ onClick={() => run("clear")}
+ disabled={running}
+ aria-label="clear all incomplete transcripts"
+ className="px-2.5 py-1 rounded-md border border-zinc-300 dark:border-zinc-700 text-xs font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50 whitespace-nowrap"
+ >
+ {status.kind === "running" && status.mode === "clear"
+ ? "Clearing…"
+ : "Clear & re-queue all"}
+ </button>
+ {status.kind === "done" && (
+ <span
+ aria-label="fix all incomplete transcripts result"
+ className="text-xs text-zinc-500"
+ >
+ {status.result.ok ? (
+ <>
+ {status.mode === "clear" ? "Cleared" : "Queued"}{" "}
+ {status.result.affected} across {status.result.channels} channel
+ {status.result.channels === 1 ? "" : "s"} ·{" "}
+ <Link
+ href="/jobs"
+ className="underline hover:text-zinc-900 dark:hover:text-zinc-100"
+ >
+ view jobs
+ </Link>
+ </>
+ ) : (
+ status.result.error
+ )}
+ </span>
+ )}
+ {status.kind === "error" && (
+ <span
+ role="alert"
+ aria-label="fix all incomplete transcripts error"
+ className="text-xs text-red-700 dark:text-red-400"
+ >
+ {status.message}
+ </span>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/actionable/components/InlineActionButton.tsx b/editor/app/actionable/components/InlineActionButton.tsx
@@ -9,11 +9,17 @@ import {
cleanExtraAudioFormatsAction,
transcribeMissingAction,
} from "../../channels/[slug]/whisperActions";
+import {
+ clearIncompleteTranscriptsAction,
+ redownloadIncompleteBucketAction,
+} from "../../channels/[slug]/incompleteTranscriptActions";
import { refreshChannelSnapshotAction } from "../../channels/actions";
type Variant =
| { kind: "downloadMissing"; slug: string }
| { kind: "transcribeMissing"; slug: string; audioFormat?: AudioFormat }
+ | { kind: "redownloadIncomplete"; slug: string }
+ | { kind: "clearIncomplete"; slug: string }
| { kind: "cleanTranscribedAudio"; slug: string }
| { kind: "cleanExtraFormats"; slug: string }
| { kind: "refreshReport"; slug: string };
@@ -22,20 +28,25 @@ type Status =
| { kind: "idle" }
| { kind: "running" }
| { kind: "queued"; jobId: string }
- | { kind: "done" }
+ | { kind: "done"; message?: string }
| { kind: "error"; message: string };
const LABEL: Record<Variant["kind"], { idle: string; running: string }> = {
downloadMissing: { idle: "Download missing", running: "Queuing…" },
transcribeMissing: { idle: "Transcribe pending", running: "Queuing…" },
+ redownloadIncomplete: { idle: "Re-download & re-transcribe", running: "Queuing…" },
+ clearIncomplete: { idle: "Clear & re-queue", running: "Clearing…" },
cleanTranscribedAudio: { idle: "Clean audio", running: "Queuing…" },
cleanExtraFormats: { idle: "Clean extra formats", running: "Queuing…" },
refreshReport: { idle: "Refresh report", running: "Refreshing…" },
};
-// Cleanup actions delete files, so they pop a confirm() before queuing.
-// Download/transcribe/refresh are non-destructive and stay unguarded.
+// Destructive actions pop a confirm() before running. Download/transcribe/
+// refresh (and the non-destructive re-download, which overwrites in place) stay
+// unguarded.
const CONFIRM: Partial<Record<Variant["kind"], (slug: string) => string>> = {
+ clearIncomplete: (slug) =>
+ `Delete the truncated audio + transcript for every flagged video in "${slug}" and enable the auto-download/transcribe runners? The current partial transcripts are removed and re-fetched. This cannot be undone.`,
cleanTranscribedAudio: (slug) =>
`Delete redundant audio from every transcribed video in "${slug}"? Videos marked "do not clean" are skipped. This cannot be undone.`,
cleanExtraFormats: (slug) =>
@@ -54,6 +65,9 @@ async function runAction(variant: Variant): Promise<StreamActionResult> {
variant.audioFormat,
);
}
+ if (variant.kind === "redownloadIncomplete") {
+ return redownloadIncompleteBucketAction(variant.slug);
+ }
if (variant.kind === "cleanTranscribedAudio") {
return cleanAudioAction(variant.slug);
}
@@ -92,6 +106,16 @@ export function InlineActionButton({
setStatus({ kind: "running" });
startTransition(async () => {
try {
+ // clearIncomplete is a synchronous fs op returning a per-id summary, not
+ // a queued job — show the cleared count rather than a job id.
+ if (variant.kind === "clearIncomplete") {
+ const summary = await clearIncompleteTranscriptsAction(slug);
+ setStatus({
+ kind: "done",
+ message: `cleared ${summary.succeeded}/${summary.attempted}`,
+ });
+ return;
+ }
const result = await runAction(variant);
if (!result.ok) {
setStatus({ kind: "error", message: result.error });
@@ -136,7 +160,7 @@ export function InlineActionButton({
aria-label={`${ariaLabel} done`}
className="text-xs text-zinc-500"
>
- done
+ {status.message ?? "done"}
</span>
)}
{status.kind === "error" && (
diff --git a/editor/app/actionable/page.tsx b/editor/app/actionable/page.tsx
@@ -14,6 +14,7 @@ import {
type ActionableRow,
} from "./lib/loadActionable";
import { InlineActionButton } from "./components/InlineActionButton";
+import { FixAllIncompleteButton } from "./components/FixAllIncompleteButton";
import { RefreshAllReportsButton } from "./components/RefreshAllReportsButton";
import { RunDuplicateDetectionButton } from "./components/RunDuplicateDetectionButton";
import type {
@@ -39,6 +40,8 @@ type SectionConfig = {
getValue: (row: ActionableRow) => string;
};
primaryAction: (row: ActionableRow) => React.ReactNode | null;
+ // Optional control rendered in the section header (e.g. an act-on-all button).
+ headerAction?: React.ReactNode;
};
export default async function ActionablePage() {
@@ -96,18 +99,27 @@ export default async function ActionablePage() {
id: "incomplete-transcripts",
title: "Channels with incomplete (truncated) transcripts",
description:
- "Transcribed videos whose transcript covers only a small fraction of the runtime — the audio download stopped early. Open the channel (filtered) to re-download & re-transcribe the affected videos.",
+ "Transcribed videos whose transcript covers only a small fraction of the runtime — the audio download stopped early. “Re-download & re-transcribe” queues a batch fix; “Clear & re-queue” deletes the truncated audio + transcript and hands them to the auto-runners.",
countLabel: "truncated",
emptyLabel: "None detected.",
getCount: actionableIncompleteTranscriptCount,
+ headerAction: <FixAllIncompleteButton />,
primaryAction: (r) => (
- <Link
- href={`/channels/${r.channel.slug}?filter=incomplete_transcript`}
- aria-label={`review incomplete transcripts for ${r.channel.slug}`}
- className="inline-flex items-center px-2.5 py-1 rounded-md border border-zinc-300 dark:border-zinc-700 text-xs font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800 whitespace-nowrap"
- >
- Review
- </Link>
+ <span className="inline-flex items-center justify-end gap-2 flex-wrap">
+ <InlineActionButton
+ variant={{ kind: "redownloadIncomplete", slug: r.channel.slug }}
+ />
+ <InlineActionButton
+ variant={{ kind: "clearIncomplete", slug: r.channel.slug }}
+ />
+ <Link
+ href={`/channels/${r.channel.slug}?filter=incomplete_transcript`}
+ aria-label={`review incomplete transcripts for ${r.channel.slug}`}
+ className="inline-flex items-center px-2.5 py-1 rounded-md border border-zinc-300 dark:border-zinc-700 text-xs font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800 whitespace-nowrap"
+ >
+ Review
+ </Link>
+ </span>
),
},
rows: summary.incompleteTranscripts,
@@ -305,7 +317,10 @@ function Section({
aria-label={config.id}
className="flex flex-col gap-2"
>
- <h2 className="text-lg font-semibold">{config.title}</h2>
+ <div className="flex items-center justify-between flex-wrap gap-2">
+ <h2 className="text-lg font-semibold">{config.title}</h2>
+ {config.headerAction}
+ </div>
<p className="text-sm text-zinc-500">{config.description}</p>
{rows.length === 0 ? (
<p
diff --git a/editor/app/channels/[slug]/bulkVideoActions.ts b/editor/app/channels/[slug]/bulkVideoActions.ts
@@ -16,6 +16,10 @@ import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"
import { transcribeBucketAction } from "./whisperActions";
import { retryBucketAction } from "./pipelineActions";
import {
+ clearIncompleteTranscriptsAction,
+ redownloadIncompleteBucketAction,
+} from "./incompleteTranscriptActions";
+import {
deleteOneVideoDir,
markVideoUntranscribableAction,
removeAudioFilesForVideo,
@@ -50,6 +54,27 @@ export async function bulkRetryDownloadAction(
return retryBucketAction(slug, videoIds, queueKey, abortOnError);
}
+// Bulk re-fix truncated transcripts: queues ONE batch job that re-downloads +
+// re-transcribes each selected video in place (same one-job-per-bulk convention
+// as bulkTranscribeAction).
+export async function bulkRedownloadIncompleteAction(
+ slug: string,
+ videoIds: string[],
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ return redownloadIncompleteBucketAction(slug, videoIds, queueKey);
+}
+
+// Bulk clear truncated transcripts: synchronous fs op (delete audio + transcript)
+// that enables the auto-runners, so it reports a per-id summary like the other
+// clear/remove bulk actions. Destructive — the caller confirms first.
+export async function bulkClearIncompleteAction(
+ slug: string,
+ videoIds: string[],
+): Promise<BulkActionSummary> {
+ return clearIncompleteTranscriptsAction(slug, videoIds);
+}
+
// Marking videos untranscribable is an instant metadata write, not a queued
// batch, so it stays synchronous and reports a per-id summary.
export async function bulkMarkUntranscribableAction(
diff --git a/editor/app/channels/[slug]/components/VideoListPane.tsx b/editor/app/channels/[slug]/components/VideoListPane.tsx
@@ -9,8 +9,10 @@ import { filterRows, serializeFilters } from "../lib/videoRows";
import { QueueControl } from "../../../components/QueueControl";
import {
bulkClearFailedMarkersAction,
+ bulkClearIncompleteAction,
bulkDeleteVideoDirsAction,
bulkMarkUntranscribableAction,
+ bulkRedownloadIncompleteAction,
bulkRemoveAudioAction,
bulkRemoveWrongFormatAudioAction,
bulkRetryDownloadAction,
@@ -21,6 +23,8 @@ import {
type BulkAction =
| "transcribe"
| "retry"
+ | "redownload_incomplete"
+ | "clear_incomplete"
| "untranscribable"
| "clear_failed"
| "remove_audio"
@@ -30,6 +34,8 @@ type BulkAction =
const BULK_ACTION_OPTIONS: { value: BulkAction; label: string }[] = [
{ value: "transcribe", label: "Transcribe" },
{ value: "retry", label: "Retry download" },
+ { value: "redownload_incomplete", label: "Re-download & re-transcribe" },
+ { value: "clear_incomplete", label: "Clear incomplete (audio+transcript)" },
{ value: "untranscribable", label: "Mark untranscribable" },
{ value: "clear_failed", label: "Clear failed markers" },
{ value: "remove_audio", label: "Remove audio files" },
@@ -100,6 +106,9 @@ export function VideoListPane({
const [error, setError] = useState<string | null>(null);
const [transcribeQueue, setTranscribeQueue] = useState(defaultTranscribeQueue);
const [retryQueue, setRetryQueue] = useState(defaultDownloadQueue);
+ // Re-download & re-transcribe runs download+whisper inline; queue it on the
+ // transcription queue (same default as bulk transcribe).
+ const [incompleteQueue, setIncompleteQueue] = useState(defaultTranscribeQueue);
const [retryAbortOnError, setRetryAbortOnError] = useState(false);
const [action, setAction] = useState<BulkAction>("transcribe");
const [deleteConfirm, setDeleteConfirm] = useState("");
@@ -265,6 +274,22 @@ export function VideoListPane({
});
}
+ // Videos whose transcript is truncated (covers a fraction of the runtime) —
+ // the targets of the re-download/clear actions.
+ const hasIncomplete = useMemo(
+ () => rows.some((r) => r.incompleteTranscript),
+ [rows],
+ );
+ function selectIncomplete() {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ for (const r of rows) {
+ if (r.incompleteTranscript) next.add(r.id);
+ }
+ return next;
+ });
+ }
+
const deleteArmed = deleteConfirm.trim().toLowerCase() === "delete";
const applyDisabled = pending || (action === "delete" && !deleteArmed);
@@ -280,6 +305,20 @@ export function VideoListPane({
bulkRetryDownloadAction(s, ids, retryQueue, retryAbortOnError),
);
break;
+ case "redownload_incomplete":
+ doStreamingBulk((s, ids) =>
+ bulkRedownloadIncompleteAction(s, ids, incompleteQueue),
+ );
+ break;
+ case "clear_incomplete":
+ if (
+ !confirm(
+ `Clear the truncated audio + transcript for ${selected.size} video${selected.size === 1 ? "" : "s"} and enable the auto-download/transcribe runners? The current partial transcripts are removed and re-fetched. This cannot be undone.`,
+ )
+ )
+ return;
+ doSummaryBulk(bulkClearIncompleteAction);
+ break;
case "untranscribable":
if (
!confirm(
@@ -381,6 +420,15 @@ export function VideoListPane({
Select wrong-format
</button>
)}
+ {hasIncomplete && (
+ <button
+ type="button"
+ onClick={selectIncomplete}
+ className="rounded border border-zinc-200 dark:border-zinc-800 px-2 py-0.5 hover:bg-zinc-100 dark:hover:bg-zinc-800"
+ >
+ Select incomplete
+ </button>
+ )}
{visibleRows.length > 0 && (
<>
<button
@@ -520,6 +568,15 @@ export function VideoListPane({
</label>
</>
)}
+ {action === "redownload_incomplete" && (
+ <QueueControl
+ value={incompleteQueue}
+ onChange={setIncompleteQueue}
+ defaultQueueKey={defaultTranscribeQueue}
+ existingQueues={existingQueues}
+ actionLabel="bulk re-download incomplete"
+ />
+ )}
{action === "delete" && (
<label className="flex items-center gap-1.5 text-xs text-red-700 dark:text-red-400">
<span>
@@ -545,7 +602,8 @@ export function VideoListPane({
? "border-red-300 dark:border-red-800 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950"
: action === "untranscribable" ||
action === "remove_audio" ||
- action === "remove_wrong_format"
+ action === "remove_wrong_format" ||
+ action === "clear_incomplete"
? "border-amber-300 dark:border-amber-800 text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-950"
: "border-zinc-300 dark:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800"
}`}
diff --git a/editor/app/channels/[slug]/incompleteTranscriptActions.ts b/editor/app/channels/[slug]/incompleteTranscriptActions.ts
@@ -0,0 +1,152 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ TRANSCRIPTION_QUEUE,
+ resolveQueueKey,
+} from "yt-dlp-transcript-common/lib/queueKeys";
+import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
+import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings";
+import { startAutoRunner } from "yt-dlp-transcript-common/controller/autoRunner";
+import {
+ runManagedFunction,
+ type StreamActionResult,
+} from "yt-dlp-transcript-common/jobs/streamCommand";
+import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler";
+import { makeTaskTracker } from "yt-dlp-transcript-common/jobs/taskHooks";
+import {
+ clearIncompleteTranscriptOne,
+ fixIncompleteTranscriptOne,
+ incompleteIdsForChannel,
+} from "./lib/fixIncompleteTranscript";
+import type { BulkActionSummary } from "./bulkVideoActions";
+
+function dedupeIds(ids: string[]): string[] {
+ return Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean)));
+}
+
+// Turn on + start both auto-queue runners so cleared videos get reprocessed
+// automatically. Enabling persists across restarts (settings.json); starting
+// brings the runner up now without a server restart (same path the auto-queue
+// admin page and /api/auto-queue/control use). NOTE: the runner only picks up a
+// channel its policy tree actually matches — cleared videos also surface in the
+// manual "Download missing" / "Transcribe pending" actionable sections as a
+// fallback.
+export async function enableAutoRunners(): Promise<void> {
+ const current = getSettings();
+ if (
+ !current.autoQueue.transcription.enabled ||
+ !current.autoQueue.download.enabled
+ ) {
+ await writeSettings({
+ ...current,
+ autoQueue: {
+ ...current.autoQueue,
+ transcription: { ...current.autoQueue.transcription, enabled: true },
+ download: { ...current.autoQueue.download, enabled: true },
+ },
+ });
+ }
+ await startAutoRunner("transcription");
+ await startAutoRunner("download");
+}
+
+// One-shot batch re-fix: queue a single managed job that removes the truncated
+// audio, re-downloads, and re-transcribes each flagged video in place (the bulk
+// version of the per-video redownloadIncompleteTranscriptAction). ids default to
+// the channel's current incompleteTranscript bucket; bookmarkable so a re-run
+// re-derives the live bucket.
+export async function redownloadIncompleteBucketAction(
+ slug: string,
+ ids?: string[],
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ const config = await readChannelConfig(paths, slug);
+ if (!config) return { ok: false, error: `Channel "${slug}" not found` };
+ const source = ids && ids.length ? ids : await incompleteIdsForChannel(slug, paths);
+ const cleaned = dedupeIds(source);
+ if (cleaned.length === 0) {
+ return { ok: false, error: "No incomplete transcripts to fix.", info: true };
+ }
+ return runManagedFunction({
+ kind: "redownload-incomplete-bucket",
+ queueKey: resolveQueueKey(TRANSCRIPTION_QUEUE, queueKey),
+ paths,
+ channelSlug: slug,
+ spec: {
+ kind: "redownload-incomplete-bucket",
+ slug,
+ bucket: "incompleteTranscript",
+ params: { queueKey },
+ },
+ fn: async (onLog, signal, _setProgress, ctx) => {
+ const tracker = makeTaskTracker(ctx, onLog);
+ let succeeded = 0;
+ let failed = 0;
+ for (const id of cleaned) {
+ if (ctx.drainSignal?.aborted) {
+ onLog(`Drain requested; stopping before ${id}.`);
+ break;
+ }
+ try {
+ onLog(`Re-downloading & re-transcribing ${id}…`);
+ await fixIncompleteTranscriptOne({
+ slug,
+ videoId: id,
+ config,
+ paths,
+ onLog,
+ signal,
+ tracker,
+ });
+ succeeded++;
+ } catch (e) {
+ failed++;
+ onLog(`Failed ${id}: ${(e as Error).message}`);
+ }
+ }
+ onLog(
+ `Re-download incomplete: ${succeeded} fixed, ${failed} failed of ${cleaned.length}.`,
+ );
+ revalidatePath(`/channels/${slug}`);
+ },
+ });
+}
+
+// Clear & release: synchronously delete the truncated audio + transcript for the
+// flagged videos so they fall back into the normal pending pipeline, then enable
+// the auto-runners so they reprocess automatically. Destructive — gate every
+// entry point with a confirm. ids default to the channel's incompleteTranscript
+// bucket.
+export async function clearIncompleteTranscriptsAction(
+ slug: string,
+ ids?: string[],
+ opts?: { enableRunners?: boolean },
+): Promise<BulkActionSummary> {
+ const paths = getPaths();
+ const source = ids && ids.length ? ids : await incompleteIdsForChannel(slug, paths);
+ const cleaned = dedupeIds(source);
+ const failures: BulkActionSummary["failures"] = [];
+ let succeeded = 0;
+ for (const id of cleaned) {
+ try {
+ await clearIncompleteTranscriptOne({ slug, videoId: id, paths });
+ succeeded++;
+ } catch (e) {
+ failures.push({ videoId: id, error: (e as Error).message });
+ }
+ }
+ if (opts?.enableRunners !== false && cleaned.length > 0) {
+ await enableAutoRunners();
+ }
+ revalidatePath(`/channels/${slug}`);
+ requestChannelSnapshot(paths, slug);
+ return {
+ ok: failures.length === 0,
+ attempted: cleaned.length,
+ succeeded,
+ failures,
+ };
+}
diff --git a/editor/app/channels/[slug]/lib/fixIncompleteTranscript.ts b/editor/app/channels/[slug]/lib/fixIncompleteTranscript.ts
@@ -0,0 +1,126 @@
+// Shared per-video building blocks for fixing truncated ("incomplete")
+// transcripts, reused by the per-video action, the channel-level batch job, the
+// bulk-bar wrappers, and the global all-channels actions. Server-only (it does
+// fs + spawns yt-dlp/whisper) but NOT a "use server" module — it exports plain
+// helpers that take onLog/signal/tracker, which aren't serializable across the
+// server-action boundary.
+//
+// A truncated transcript means the audio download silently stopped early: the
+// audio on disk is itself short, so re-running whisper on it just reproduces the
+// short transcript. The fix MUST re-fetch the audio first.
+
+import path from "node:path";
+import { readdir, rm } from "node:fs/promises";
+import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
+import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ isRealAudioFile,
+ isTranscriptVtt,
+} from "yt-dlp-transcript-common/lib/videoStatus";
+import { transcribeWithWorker } from "yt-dlp-transcript-common/controller/transcribeOne";
+import { findVideoSourceUrl } from "yt-dlp-transcript-common/controller/undownloadedVideos";
+import { readChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
+import { runYtdlp } from "yt-dlp-transcript-common/ytdlp/runYtdlp";
+import type { TaskTracker } from "yt-dlp-transcript-common/jobs/taskHooks";
+
+function videoDirOf(paths: Paths, slug: string, videoId: string): string {
+ return path.join(paths.channelsDir, slug, "data", videoId);
+}
+
+// The current snapshot's truncated-transcript ids for a channel. Empty when the
+// snapshot is missing or has no flagged videos (older snapshots lack the bucket).
+export async function incompleteIdsForChannel(
+ slug: string,
+ paths: Paths = getPaths(),
+): Promise<string[]> {
+ const snap = await readChannelSnapshot(paths, slug);
+ const ids = snap?.buckets?.incompleteTranscript;
+ return Array.isArray(ids) ? ids : [];
+}
+
+// Re-download the full audio and re-transcribe one video in place. The new
+// transcript overwrites transcript.json (transcribeWithWorker) and normalize
+// regenerates transcript.cues.json, so there is never a window with no
+// transcript. Throws on failure so the batch loop can record it per-id.
+export async function fixIncompleteTranscriptOne(opts: {
+ slug: string;
+ videoId: string;
+ config: ChannelConfig;
+ paths: Paths;
+ onLog: (line: string) => void;
+ signal: AbortSignal;
+ tracker?: TaskTracker;
+}): Promise<void> {
+ const { slug, videoId, config, paths, onLog, signal, tracker } = opts;
+ const videoDir = videoDirOf(paths, slug, videoId);
+ const audioFormat = config.audioFormat ?? "mp3";
+ const url = await findVideoSourceUrl(paths, slug, videoId, config);
+ if (!url) {
+ throw new Error(
+ `Could not determine the video URL for ${videoId}: no metadata.info.json and the playlist does not contain a matching entry.`,
+ );
+ }
+ // Remove the truncated audio so the download re-fetches the full file rather
+ // than seeing it as already present.
+ const entries = await readdir(videoDir).catch(() => [] as string[]);
+ for (const name of entries.filter(isRealAudioFile)) {
+ await rm(path.join(videoDir, name), { force: true });
+ onLog(`Removed truncated audio ${name}.`);
+ }
+ onLog(`Re-downloading audio for ${videoId}…`);
+ await runYtdlp({
+ channelSlug: slug,
+ mode: "download-one-audio",
+ channelConfig: config,
+ paths,
+ onLog,
+ signal,
+ singleVideoUrl: url,
+ audioFormatOverride: audioFormat,
+ });
+ await transcribeWithWorker({
+ paths,
+ videoDir,
+ videoId,
+ audioFilename: `audio.${audioFormat}`,
+ tracker,
+ onLog,
+ signal,
+ });
+}
+
+// Clear a truncated transcript so the video drops back into the normal pending
+// pipeline: delete every real audio file, the whisper transcript.json + derived
+// transcript.cues.json, and any transcript VTT track. With no remaining artifact
+// the snapshot re-buckets it as undownloaded → (after re-download)
+// downloadedNoTranscript, where auto-download/auto-transcribe (or the manual
+// "Download missing" / "Transcribe pending" actions) reprocess it. Keeps
+// metadata.info.json (needed to resolve the URL on re-download).
+export async function clearIncompleteTranscriptOne(opts: {
+ slug: string;
+ videoId: string;
+ paths: Paths;
+}): Promise<{ removed: number }> {
+ const { slug, videoId, paths } = opts;
+ const videoDir = videoDirOf(paths, slug, videoId);
+ const dataDir = path.resolve(paths.channelsDir, slug, "data");
+ const resolved = path.resolve(videoDir);
+ // Belt-and-suspenders: never delete outside the channel's data dir.
+ if (path.dirname(resolved) !== dataDir) {
+ throw new Error(
+ `Refusing to clear: video path resolved outside the data dir (${videoId})`,
+ );
+ }
+ const entries = await readdir(resolved).catch(() => [] as string[]);
+ const toRemove = entries.filter(
+ (name) =>
+ isRealAudioFile(name) ||
+ isTranscriptVtt(name) ||
+ name === "transcript.json" ||
+ name === "transcript.cues.json",
+ );
+ for (const name of toRemove) {
+ await rm(path.join(resolved, name), { force: true });
+ }
+ return { removed: toRemove.length };
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts
@@ -38,6 +38,7 @@ import {
} from "yt-dlp-transcript-common/jobs/streamCommand";
import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler";
import { makeTaskTracker } from "yt-dlp-transcript-common/jobs/taskHooks";
+import { fixIncompleteTranscriptOne } from "../../lib/fixIncompleteTranscript";
function videoQueueKey(config: ChannelConfig, override: string | undefined): string {
return resolveQueueKey(downloadQueueKey(config), override);
@@ -318,7 +319,6 @@ export async function redownloadIncompleteTranscriptAction(
const r = await loadConfigOrError(slug);
if (!r.ok) return r;
const paths = getPaths();
- const videoDir = videoDirOf(slug, videoId);
return runManagedFunction({
kind: "whisper-video",
queueKey: videoQueueKey(r.config, queueKey),
@@ -326,39 +326,14 @@ export async function redownloadIncompleteTranscriptAction(
channelSlug: slug,
videoId,
fn: async (onLog, signal, _setProgress, ctx) => {
- const audioFormat = r.config.audioFormat ?? "mp3";
- const url = await findVideoSourceUrl(paths, slug, videoId, r.config);
- if (!url) {
- throw new Error(
- "Could not determine the video URL: no metadata.info.json and the playlist does not contain a matching entry.",
- );
- }
- // Remove the truncated audio so the download below re-fetches the full
- // file rather than seeing it as already present.
- const entries = await readdir(videoDir).catch(() => [] as string[]);
- for (const name of entries.filter(isRealAudioFile)) {
- await rm(path.join(videoDir, name), { force: true });
- onLog(`Removed truncated audio ${name}.`);
- }
- onLog(`Re-downloading audio for ${videoId}…`);
- await runYtdlp({
- channelSlug: slug,
- mode: "download-one-audio",
- channelConfig: r.config,
+ await fixIncompleteTranscriptOne({
+ slug,
+ videoId,
+ config: r.config,
paths,
onLog,
signal,
- singleVideoUrl: url,
- audioFormatOverride: audioFormat,
- });
- await transcribeWithWorker({
- paths,
- videoDir,
- videoId,
- audioFilename: `audio.${audioFormat}`,
tracker: makeTaskTracker(ctx, onLog),
- onLog,
- signal,
});
revalidatePath(`/channels/${slug}/videos/${videoId}`);
revalidatePath(`/channels/${slug}`);
diff --git a/editor/app/jobs/jobReplayRegistry.ts b/editor/app/jobs/jobReplayRegistry.ts
@@ -34,6 +34,7 @@ import {
transcribeMissingAction,
} from "../channels/[slug]/whisperActions";
import { persistKeptAction } from "../channels/[slug]/persistActions";
+import { redownloadIncompleteBucketAction } from "../channels/[slug]/incompleteTranscriptActions";
export type ReplayHandler = (spec: JobSpec) => Promise<StreamActionResult>;
@@ -94,6 +95,15 @@ export const JOB_REPLAY_HANDLERS: Record<string, ReplayHandler> = {
spec.bucket,
);
},
+ "redownload-incomplete-bucket": async (spec) => {
+ const { queueKey } = params(spec);
+ if (!spec.bucket) return { ok: false, error: "Bookmark is missing its bucket." };
+ const ids = await idsForBucket(spec.slug, spec.bucket);
+ if (ids.length === 0) {
+ return { ok: false, error: "No incomplete transcripts right now.", info: true };
+ }
+ return redownloadIncompleteBucketAction(spec.slug, ids, queueKey);
+ },
"retry-bucket": async (spec) => {
const { p, queueKey } = params(spec);
if (!spec.bucket) return { ok: false, error: "Bookmark is missing its bucket." };
diff --git a/editor/e2e/incomplete-transcript.spec.ts b/editor/e2e/incomplete-transcript.spec.ts
@@ -9,7 +9,7 @@
import { mkdir, writeFile } from "node:fs/promises";
import { test, expect } from "@playwright/test";
-import { resetData, resolvePath } from "./helpers";
+import { pathExists, readJson, resetData, resolvePath } from "./helpers";
const CHANNEL = "test-transcribe";
const DATA = `test-transcripts/channels/${CHANNEL}/data`;
@@ -126,3 +126,149 @@ test("incomplete-transcript filter, glyph, panel banner, and actionable", async
section.getByLabel(`incomplete-transcripts row ${CHANNEL}`),
).toBeVisible();
});
+
+test("channel bulk bar: clear incomplete resets the video and enables auto-runners", async ({
+ page,
+}) => {
+ await seed();
+ await page.goto(`/channels/${CHANNEL}`);
+
+ // Select the flagged video via the new quick-select, then clear it.
+ await page
+ .getByRole("button", { name: "Select incomplete", exact: true })
+ .click();
+ const bar = page.getByLabel("bulk action bar");
+ await expect(bar).toBeVisible();
+ await page.getByLabel("bulk action", { exact: true }).selectOption("clear_incomplete");
+ page.once("dialog", (d) => d.accept());
+ await page.getByLabel("apply bulk action").click();
+ // Selection clears on success → the bar hides.
+ await expect(bar).toBeHidden();
+
+ // The truncated audio + transcript are gone on disk.
+ await expect
+ .poll(() => pathExists(`${DATA}/vidTrunc/audio.m4a`))
+ .toBe(false);
+ await expect
+ .poll(() => pathExists(`${DATA}/vidTrunc/transcript.json`))
+ .toBe(false);
+ await expect
+ .poll(() => pathExists(`${DATA}/vidTrunc/transcript.cues.json`))
+ .toBe(false);
+
+ // The auto-download + auto-transcribe runners are now enabled.
+ const settings = await readJson<{
+ autoQueue: {
+ transcription: { enabled: boolean };
+ download: { enabled: boolean };
+ };
+ }>("test-settings.json");
+ expect(settings.autoQueue.transcription.enabled).toBe(true);
+ expect(settings.autoQueue.download.enabled).toBe(true);
+
+ // The video is no longer flagged and now reads as undownloaded ("No audio").
+ // The channel snapshot regenerates on a ~1s debounce after the clear, and the
+ // channel page serves the persisted snapshot, so re-navigate until it's fresh.
+ const list = page.getByLabel("videos", { exact: true });
+ await expect
+ .poll(
+ async () => {
+ await page.goto(`/channels/${CHANNEL}?filter=incomplete_transcript`);
+ return list.getByLabel("open vidTrunc").count();
+ },
+ { timeout: 15000 },
+ )
+ .toBe(0);
+ await page.goto(`/channels/${CHANNEL}?filter=no_audio`);
+ await expect(list.getByLabel("open vidTrunc")).toBeVisible();
+});
+
+test("channel bulk bar: re-download & re-transcribe queues a batch fix", async ({
+ page,
+}) => {
+ await seed();
+ await page.goto(`/channels/${CHANNEL}`);
+
+ await page
+ .getByRole("button", { name: "Select incomplete", exact: true })
+ .click();
+ const bar = page.getByLabel("bulk action bar");
+ await expect(bar).toBeVisible();
+ await page.getByLabel("bulk action", { exact: true }).selectOption("redownload_incomplete");
+ await page.getByLabel("apply bulk action").click();
+ // A streaming bulk action clears the selection on success (the batch job runs
+ // in the background) → the bar hides with no error surfaced.
+ await expect(bar).toBeHidden();
+ await expect(page.getByLabel("bulk action error")).toBeHidden();
+});
+
+test("actionable: section exposes per-channel + global fix buttons; per-channel re-download queues a job", async ({
+ page,
+}) => {
+ await seed();
+ // Visiting the channel materializes its snapshot so /actionable lists it.
+ await page.goto(`/channels/${CHANNEL}`);
+ await page.goto(`/actionable`);
+ const section = page.getByRole("region", {
+ name: "incomplete-transcripts",
+ exact: true,
+ });
+ await expect(section).toBeVisible();
+
+ // Per-channel and global buttons are all present.
+ await expect(
+ section.getByLabel(`re-download & re-transcribe ${CHANNEL}`),
+ ).toBeVisible();
+ await expect(
+ section.getByLabel(`clear & re-queue ${CHANNEL}`),
+ ).toBeVisible();
+ await expect(
+ section.getByLabel("re-download all incomplete transcripts"),
+ ).toBeVisible();
+ await expect(
+ section.getByLabel("clear all incomplete transcripts"),
+ ).toBeVisible();
+
+ // Per-channel re-download queues a job (the job id is returned synchronously).
+ await section.getByLabel(`re-download & re-transcribe ${CHANNEL}`).click();
+ await expect(
+ section.getByLabel(`re-download & re-transcribe ${CHANNEL} job`),
+ ).toBeVisible();
+});
+
+test("actionable: global clear-all clears every flagged video and empties the section", async ({
+ page,
+}) => {
+ await seed();
+ // Visiting the channel materializes its snapshot so /actionable lists it.
+ await page.goto(`/channels/${CHANNEL}`);
+ await page.goto(`/actionable`);
+ const section = page.getByRole("region", {
+ name: "incomplete-transcripts",
+ exact: true,
+ });
+ await expect(section).toBeVisible();
+
+ // Global clear-all clears every flagged video across all channels (synchronous
+ // fs op — no background job to race the assertions below).
+ page.once("dialog", (d) => d.accept());
+ await section.getByLabel("clear all incomplete transcripts").click();
+ await expect(
+ section.getByLabel("fix all incomplete transcripts result"),
+ ).toContainText(/Cleared 1/);
+ await expect
+ .poll(() => pathExists(`${DATA}/vidTrunc/transcript.cues.json`))
+ .toBe(false);
+
+ // The snapshot regenerates on a ~1s debounce after the clear; /actionable
+ // reads the persisted snapshot, so re-navigate until the section is empty.
+ await expect
+ .poll(
+ async () => {
+ await page.goto(`/actionable`);
+ return page.getByLabel("incomplete-transcripts empty").count();
+ },
+ { timeout: 15000 },
+ )
+ .toBe(1);
+});