commit b385a8fb849dc85c401a56583b4e69458a9a0e86
parent dffe8bb8cf2f045e78d39196ab4f7bdc9b143ba1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 29 Jun 2026 22:07:45 -0400
Bulk-fix incomplete transcripts: actionable + channel-page UI
Actionable "incomplete transcripts" section now offers per-channel
"Re-download & re-transcribe" and "Clear & re-queue" buttons (replacing the
Review-only link) plus a section-header "Re-fix all" / "Clear & re-queue all"
(FixAllIncompleteButton) that acts across every affected channel.
Channel-page bulk bar gains two Action options (redownload_incomplete,
clear_incomplete) with a "Select incomplete" quick-select. Destructive clears
are confirm-gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat:
4 files changed, 212 insertions(+), 14 deletions(-)
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]/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"
}`}