Archilyzer · Source

archilyzer

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

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:
Aeditor/app/actionable/components/FixAllIncompleteButton.tsx | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/actionable/components/InlineActionButton.tsx | 32++++++++++++++++++++++++++++----
Meditor/app/actionable/page.tsx | 33++++++++++++++++++++++++---------
Meditor/app/channels/[slug]/components/VideoListPane.tsx | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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" }`}