Archilyzer · Source

archilyzer

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

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:
Mcommon/jobs/jobKinds.ts | 7+++++++
Mcommon/jobs/jobSpec.ts | 4+++-
Meditor/CHANGELOG.md | 1+
Meditor/app/actionable/actions.ts | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/actionable/components/FixAllIncompleteButton.tsx | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/actionable/components/InlineActionButton.tsx | 32++++++++++++++++++++++++++++----
Meditor/app/actionable/page.tsx | 33++++++++++++++++++++++++---------
Meditor/app/channels/[slug]/bulkVideoActions.ts | 25+++++++++++++++++++++++++
Meditor/app/channels/[slug]/components/VideoListPane.tsx | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aeditor/app/channels/[slug]/incompleteTranscriptActions.ts | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/[slug]/lib/fixIncompleteTranscript.ts | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 35+++++------------------------------
Meditor/app/jobs/jobReplayRegistry.ts | 10++++++++++
Meditor/e2e/incomplete-transcript.spec.ts | 148++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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); +});