commit 09a87666efe8507825b228937fce9e49ae8f14c7 parent 672fd92a01ebbb79e1e031d538c56fbef3eef35a Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st> Date: Thu, 18 Jun 2026 14:45:03 -0400 Add bulk audio removal + wrong-format audio sweep to channel selector Two new bulk-menu actions (pure fs ops, no queued job, no transcode): - Remove audio files: deletes finalized audio.<ext> for selected videos, keeping transcripts/metadata and .part partials. - Remove wrong-format audio: deletes audio not in the channel's target format (e.g. cornbreadman audio.m4a/audio.mp4 leftovers that failed yt-dlp's extract step), even when it's the only audio. A "Select wrong-format" quick-select derives from the snapshot's existing untranscoded/multipleAudioFormats buckets (no new disk scans). Channel-wide sweep: a "Remove wrong-format audio" section in the Cleanup stage removes all off-target audio across dirs, including the orphans cleanExtraAudioFormats skips. It respects the per-video do-not-clean marker (the bulk actions, being explicit selections, override it). Adds cleanupBytes.foreignAudio for the reclaim estimate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Diffstat:
14 files changed, 528 insertions(+), 3 deletions(-)
diff --git a/common/controller/channelSnapshot.ts b/common/controller/channelSnapshot.ts @@ -85,6 +85,11 @@ export type ChannelSnapshot = { cleanupBytes?: { transcribedWithAudio: number; multipleAudioFormats: number; + // Sum of every non-target audio.* file across dirs that have one (orphans + // with no target file + dirs with target plus extras) — what the + // "remove wrong-format audio" sweep reclaims. Optional: older snapshots + // lack it; readers must default to 0. + foreignAudio?: number; }; }; @@ -295,6 +300,7 @@ export async function generateChannelSnapshot( const skippedByFilter: string[] = []; let transcribedWithAudioBytes = 0; let multipleAudioFormatsBytes = 0; + let foreignAudioBytes = 0; let transcribed = 0; let downloaded = 0; for (const { id, files, audioSizes, outcome } of perVideo) { @@ -316,6 +322,16 @@ export async function generateChannelSnapshot( ) { untranscoded.push(id); } + // Reclaim estimate for the wrong-format sweep: every non-target audio file + // in a cleanable (not do-not-clean) dir. Covers orphans (no target) and the + // extras counted in multipleAudioFormats — the sweep removes them all. + if (targetAudioFile && !doNotCleanIds.has(id)) { + for (const name of files.audioFiles) { + if (name !== targetAudioFile) { + foreignAudioBytes += audioSizes[name] ?? 0; + } + } + } if ( targetAudioFile && files.audioFiles.includes(targetAudioFile) && @@ -434,6 +450,7 @@ export async function generateChannelSnapshot( cleanupBytes: { transcribedWithAudio: transcribedWithAudioBytes, multipleAudioFormats: multipleAudioFormatsBytes, + foreignAudio: foreignAudioBytes, }, }; diff --git a/common/controller/removeWrongFormatAudio.ts b/common/controller/removeWrongFormatAudio.ts @@ -0,0 +1,85 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { Paths } from "../lib/paths"; +import { isDoNotClean } from "../lib/doNotClean-server"; +import { audioFilesToRemove } from "../lib/videoStatus"; +import { readChannelConfig } from "./channels"; + +const { pathExists, readdir, remove } = fs; + +export type RemoveWrongFormatAudioOptions = { + channelSlug: string; + paths: Paths; + onLog?: (msg: string) => void; + signal?: AbortSignal; +}; + +export type RemoveWrongFormatAudioResult = { + inspected: number; + cleanedDirs: number; + removedFiles: number; + skipped: number; +}; + +// Delete EVERY finalized audio file that isn't the channel's target format, +// across all video dirs — even when the dir has no target file at all (the +// failed-extract orphans cleanExtraAudioFormats deliberately leaves alone). Used +// to clear pre-audio-integrity downloads so they re-download cleanly. Respects +// the per-video "do not clean" marker, like the sibling cleanup controllers. +export async function removeWrongFormatAudio({ + channelSlug, + paths, + onLog, + signal, +}: RemoveWrongFormatAudioOptions): Promise<RemoveWrongFormatAudioResult> { + const log = onLog ?? ((m: string) => console.log(m)); + const config = await readChannelConfig(paths, channelSlug); + if (!config?.audioFormat) { + log( + `No audioFormat configured for ${channelSlug}; nothing to clean (set the channel's audio format first).`, + ); + return { inspected: 0, cleanedDirs: 0, removedFiles: 0, skipped: 0 }; + } + const targetAudioFile = `audio.${config.audioFormat}`; + const dataDir = path.join(paths.channelsDir, channelSlug, "data"); + if (!(await pathExists(dataDir))) { + log(`No data directory for ${channelSlug}`); + return { inspected: 0, cleanedDirs: 0, removedFiles: 0, skipped: 0 }; + } + const dirs = await readdir(dataDir); + + let cleanedDirs = 0; + let removedFiles = 0; + let skipped = 0; + + for (const id of dirs) { + if (signal?.aborted) { + log("Cancelled"); + break; + } + const videoDir = path.join(dataDir, id); + const entries = await readdir(videoDir).catch(() => [] as string[]); + const wrongFormat = audioFilesToRemove(entries, { + targetAudioFile, + wrongFormatOnly: true, + }); + if (wrongFormat.length === 0) continue; + if (await isDoNotClean(videoDir)) { + log(`Skipped ${id} (marked do not clean)`); + skipped++; + continue; + } + for (const f of wrongFormat) { + await remove(path.join(videoDir, f)); + log(`Removed ${id}/${f}`); + removedFiles++; + } + cleanedDirs++; + } + + const skippedNote = skipped > 0 ? ` Skipped ${skipped} (do not clean).` : ""; + log( + `Removed ${removedFiles} wrong-format audio file(s) from ${cleanedDirs} of ${dirs.length} video dir(s) (target ${targetAudioFile}).${skippedNote}`, + ); + return { inspected: dirs.length, cleanedDirs, removedFiles, skipped }; +} diff --git a/common/lib/videoStatus.ts b/common/lib/videoStatus.ts @@ -52,6 +52,23 @@ export function isRealAudioFile(name: string): boolean { return true; } +// The finalized audio files to delete from a video dir. Operates on the raw +// readdir() entries: keeps only real audio (excludes .part partials, snapshots, +// sidecars, temp files via isRealAudioFile). With wrongFormatOnly the channel's +// target audio file is preserved and only off-target formats are returned — +// this is how "remove wrong-format audio" strips the cornbreadman-style +// audio.m4a/audio.mp4 leftovers that failed yt-dlp's extract-to-mp3 step. +export function audioFilesToRemove( + entries: string[], + opts: { targetAudioFile?: string; wrongFormatOnly?: boolean } = {}, +): string[] { + let files = entries.filter(isRealAudioFile); + if (opts.wrongFormatOnly) { + files = files.filter((name) => name !== opts.targetAudioFile); + } + return files; +} + // A genuine resumable partial: an interrupted audio download, NOT a live-chat // sidecar that merely ends in .part. export function isPartAudioFile(name: string): boolean { diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,7 +1,9 @@ # Changelog ## [Unreleased] -- **New read-only monitor widget (`/widget`) plus a builder to compose and embed it.** A compact, chrome-less page shows worker status (a colored idle/busy/draining/disabled/degraded dot per worker) and active-job progress bars at a glance — no sidebar, no command palette, and no action controls — so it fits in a small pinned window or an `<iframe>` for at-a-glance monitoring. It reuses the existing `/api/jobs/active` and `/api/workers` endpoints (polled live), and its initial paint is server-rendered for no flicker. What it shows is driven entirely by GET params: `jobs`/`workers` (toggle each section), `channel` (filter active jobs to one slug), `poll` (refresh seconds), `compact` (drop per-task detail), `titles` (section headers), and `idle=hide` (collapse to a tiny "Idle" line when nothing is active). A new **Monitor** page under the sidebar's **Pool** group (`/widget/builder`) exposes all of those as form controls, builds the shareable link with a **Copy** button, and live-previews the real widget in a sized iframe. To strip the app shell on exactly the widget route, the root layout now renders its sidebar/command-palette/auto-refresh through a small `AppFrame` client wrapper that hides them when the path is `/widget` (the builder keeps the normal shell). The per-worker payload builder shared by the Workers page and `/api/workers` was extracted to `buildWorkersPayload()` so the widget reuses it too. The selector pane's bulk bar (channel page → video list) previously stacked separate Transcribe / Retry / Mark-untranscribable buttons; it's now a single **Action** dropdown + **Apply** button that also exposes two new operations. **Delete directories** removes each checked video's directory outright (`fs.rm` recursive) — a pure filesystem op that queues no job, so it never triggers a transcode the way leaving failed downloads in place can; this is the quick way to clear a batch of failed downloads. It's gated by an inline *type `delete` to confirm* box (the Apply button stays disabled until matched), mirroring the Clean-extra-formats pattern. **Clear failed markers** prunes the selected ids from the channel's `failed-transcriptions` and `failed-transcodings` files so they're retried on the next pass. Two quick-select helpers, **Select failed** (every video listed in either failure file) and **Select filtered** (every row matching the current filter + search), make the cleanup one flow: filter **Failed** → **Select failed** → action **Delete directories** → type `delete` → **Apply**. Both new actions report a per-id success/failure summary and refresh the channel report like the existing bulk actions. +- **New read-only monitor widget (`/widget`) plus a builder to compose and embed it.** A compact, chrome-less page shows worker status (a colored idle/busy/draining/disabled/degraded dot per worker) and active-job progress bars at a glance — no sidebar, no command palette, and no action controls — so it fits in a small pinned window or an `<iframe>` for at-a-glance monitoring. It reuses the existing `/api/jobs/active` and `/api/workers` endpoints (polled live), and its initial paint is server-rendered for no flicker. What it shows is driven entirely by GET params: `jobs`/`workers` (toggle each section), `channel` (filter active jobs to one slug), `poll` (refresh seconds), `compact` (drop per-task detail), `titles` (section headers), and `idle=hide` (collapse to a tiny "Idle" line when nothing is active). A new **Monitor** page under the sidebar's **Pool** group (`/widget/builder`) exposes all of those as form controls, builds the shareable link with a **Copy** button, and live-previews the real widget in a sized iframe. To strip the app shell on exactly the widget route, the root layout now renders its sidebar/command-palette/auto-refresh through a small `AppFrame` client wrapper that hides them when the path is `/widget` (the builder keeps the normal shell). The per-worker payload builder shared by the Workers page and `/api/workers` was extracted to `buildWorkersPayload()` so the widget reuses it too. +- **The channel video selector can bulk-remove audio files and wrong-format audio, and a new channel-wide sweep clears wrong-format audio in one click.** The selector pane's bulk action picker (channel page → video list) gained two operations, both pure filesystem ops that queue no job (so they never trigger a transcode). **Remove audio files** deletes each checked video's finalized `audio.<ext>` files while keeping transcripts, metadata, and any in-progress `.part` download (which can still resume). **Remove wrong-format audio** deletes only audio files that aren't the channel's target format — e.g. the `audio.m4a` / `audio.mp4` leftovers from downloads that failed yt-dlp's extract-to-`mp3` step *before* audio-integrity checking existed — even when that's a video's only audio, so it re-downloads cleanly. A matching **Select wrong-format** quick-select (shown when any such videos exist) checks exactly those videos, so the cleanup is one flow: **Select wrong-format** → action **Remove wrong-format audio** → **Apply** (each is confirmed first). For whole-channel cleanup, the channel page's **Cleanup** stage gained a **Remove wrong-format audio** section: a `type "remove" to confirm` sweep that walks every video dir and deletes all non-target audio — including the failed-extract orphans the existing **Clean extra audio formats** deliberately skips (it only de-dupes extras when the target file already exists). The sweep respects per-video **do not clean** markers and shows a reclaim estimate; the bulk action, being an explicit selection, removes regardless of the marker. +- **The channel video selector can bulk-delete directories and clear failure markers, and its action bar is now an action picker.** The selector pane's bulk bar (channel page → video list) previously stacked separate Transcribe / Retry / Mark-untranscribable buttons; it's now a single **Action** dropdown + **Apply** button that also exposes two new operations. **Delete directories** removes each checked video's directory outright (`fs.rm` recursive) — a pure filesystem op that queues no job, so it never triggers a transcode the way leaving failed downloads in place can; this is the quick way to clear a batch of failed downloads. It's gated by an inline *type `delete` to confirm* box (the Apply button stays disabled until matched), mirroring the Clean-extra-formats pattern. **Clear failed markers** prunes the selected ids from the channel's `failed-transcriptions` and `failed-transcodings` files so they're retried on the next pass. Two quick-select helpers, **Select failed** (every video listed in either failure file) and **Select filtered** (every row matching the current filter + search), make the cleanup one flow: filter **Failed** → **Select failed** → action **Delete directories** → type `delete` → **Apply**. Both new actions report a per-id success/failure summary and refresh the channel report like the existing bulk actions. - **Audio-integrity checks now pause the download while they run, cutting re-downloaded bytes and HTTP 429 risk.** With audio-integrity checking enabled, the downloader periodically snapshots the in-progress `.part` and validates it with ffmpeg. Previously yt-dlp was only paused for the brief *copy* of that snapshot and then resumed immediately, so it kept downloading throughout the (longer) ffmpeg probe — and if the probe came back malformed, every byte pulled during the probe, plus everything back to the last good checkpoint, was discarded and had to be re-fetched. That wasted, repeated fetching is a prime driver of rate-limit (429) responses. Now yt-dlp stays suspended (SIGSTOP) across the whole probe and only resumes on a clean verdict; on a corrupt verdict it's killed while still stopped and rolled back, having downloaded zero throwaway bytes. The trade-off is a briefly idle source connection during each probe (probes are seconds; if a held connection is ever dropped, yt-dlp's own `-c` resume recovers on the next launch). This is the new default; a per-channel **Resume during probe (legacy)** checkbox (channel editor → Audio-integrity checking, `audioCheck.resumeDuringProbe` in `config.json`) restores the old resume-immediately behavior for comparison, and the `AUDIO_CHECK_RESUME_DURING_PROBE` env var overrides it for one-off runs. - **Channels can sync automatically on a schedule.** Each channel gained an **Auto-sync** setting (channel editor → Source): *Default* (inherit the global cadence), *Off*, or a concrete interval (every 10m / 30m / hourly / 6h / 12h / daily / weekly), stored as `syncIntervalMinutes` in `config.json`. Inspired by the Laravel scheduler, a single lightweight cron heartbeat (`pnpm sync:tick`, an ~30-line client) POSTs to the editor's new `/api/scheduler/tick`, and the **server** decides which channels are due — a channel is due when `now − lastSyncedAt ≥ its interval`, so a missed tick (server down, machine asleep) simply runs at the next one with no catch-up storm. All work runs **inside the editor** through the existing job queue and per-channel lock, so a scheduled sync can't collide with a manual **Sync** click, shows up live on `/jobs`, and feeds the same transcription worker pool — no second process, no new file locks. Global controls live in **Settings → Sync scheduler**: a master **enable** (off by default), a **default interval**, a **max concurrent syncs** cap (a tick queues at most `cap − running` channels, most-overdue first, rolling the rest to the next tick — which both bounds load and staggers a large due-batch so it doesn't hit the source all at once), an optional **quiet-hours** window, and **failure backoff** (after N consecutive failures a channel waits `base·2^(N-1)` minutes, capped, before retrying). Channels already marked **Exclude from sync** never auto-sync. A new **Schedule** page (`/scheduler`) shows each channel's interval, last sync, next-due time, last outcome, and any active backoff, plus a recent-ticks log and a **Run scheduler now** button; the same data is at `GET /api/scheduler/status`. The cron client targets the editor's port (3001) by default and is hardenable with a `SYNC_TICK_TOKEN` bearer token for installs that expose the editor — see `SCHEDULED_SYNC.md`. - **Sites can link to each other.** A site's form gained a **Public URL** field (the absolute URL it's served at, e.g. `https://jeralyzer.com`) and a **Related sites** section. The export footer automatically links to every *other* site that has a Public URL, so filling these in is all that's needed for cross-site links; a site left without a URL is simply omitted from the lists. The **Related sites** editor lets a site pull closely-related siblings to the front under named groups (e.g. Jeralyzer featuring Rekietalyzer under "MTG drama") — add a group, give it an optional heading, and check which sibling sites belong; everything you don't feature falls into a trailing "Other sites" group on its own. Groups reorder with ↑/↓. The picker only lists sites that actually exist, and featured ids for sites that were since deleted are dropped on save (with a heads-up note). It's a subtle, secondary feature — see the matching note in the export changelog for how it renders. diff --git a/editor/app/channels/[slug]/bulkVideoActions.ts b/editor/app/channels/[slug]/bulkVideoActions.ts @@ -12,11 +12,13 @@ import { failedTranscodingsFile, pruneFailedTranscodings, } from "yt-dlp-transcript-common/controller/failedTranscodings"; +import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; import { transcribeBucketAction } from "./whisperActions"; import { retryBucketAction } from "./pipelineActions"; import { deleteOneVideoDir, markVideoUntranscribableAction, + removeAudioFilesForVideo, } from "./videos/[id]/videoActions"; export type BulkActionSummary = { @@ -94,6 +96,72 @@ export async function bulkDeleteVideoDirsAction( }; } +// Removing audio files is a pure filesystem op (no queued job): finalized +// audio.<ext> is deleted, .part partials/transcripts/metadata are kept. Reports +// a per-id summary; never triggers a transcode. +export async function bulkRemoveAudioAction( + slug: string, + videoIds: string[], +): Promise<BulkActionSummary> { + const failures: BulkActionSummary["failures"] = []; + let succeeded = 0; + for (const id of videoIds) { + const r = await removeAudioFilesForVideo(slug, id); + if (r.ok) succeeded++; + else failures.push({ videoId: id, error: r.error }); + } + revalidatePath(`/channels/${slug}`); + requestChannelSnapshot(getPaths(), slug); + return { + ok: failures.length === 0, + attempted: videoIds.length, + succeeded, + failures, + }; +} + +// Removes finalized audio files whose format isn't the channel's target (e.g. +// the cornbreadman audio.m4a/audio.mp4 leftovers that failed yt-dlp's +// extract-to-mp3 step), even when it's a video's only audio. Pure filesystem op; +// queues no job. Requires the channel to have a configured audioFormat. +export async function bulkRemoveWrongFormatAudioAction( + slug: string, + videoIds: string[], +): Promise<BulkActionSummary> { + const paths = getPaths(); + const config = await readChannelConfig(paths, slug); + if (!config?.audioFormat) { + return { + ok: false, + attempted: videoIds.length, + succeeded: 0, + failures: videoIds.map((videoId) => ({ + videoId, + error: "No target audio format configured for this channel", + })), + }; + } + const targetAudioFile = `audio.${config.audioFormat}`; + const failures: BulkActionSummary["failures"] = []; + let succeeded = 0; + for (const id of videoIds) { + const r = await removeAudioFilesForVideo(slug, id, { + wrongFormatOnly: true, + targetAudioFile, + }); + if (r.ok) succeeded++; + else failures.push({ videoId: id, error: r.error }); + } + revalidatePath(`/channels/${slug}`); + requestChannelSnapshot(paths, slug); + return { + ok: failures.length === 0, + attempted: videoIds.length, + succeeded, + failures, + }; +} + // Clearing failure markers is a set operation over the two failure-list files, // so `succeeded` reports how many ids were actually removed from either list // (some selected ids may not have been marked failed). diff --git a/editor/app/channels/[slug]/components/VideoListPane.tsx b/editor/app/channels/[slug]/components/VideoListPane.tsx @@ -11,6 +11,8 @@ import { bulkClearFailedMarkersAction, bulkDeleteVideoDirsAction, bulkMarkUntranscribableAction, + bulkRemoveAudioAction, + bulkRemoveWrongFormatAudioAction, bulkRetryDownloadAction, bulkTranscribeAction, type BulkActionSummary, @@ -21,6 +23,8 @@ type BulkAction = | "retry" | "untranscribable" | "clear_failed" + | "remove_audio" + | "remove_wrong_format" | "delete"; const BULK_ACTION_OPTIONS: { value: BulkAction; label: string }[] = [ @@ -28,6 +32,8 @@ const BULK_ACTION_OPTIONS: { value: BulkAction; label: string }[] = [ { value: "retry", label: "Retry download" }, { value: "untranscribable", label: "Mark untranscribable" }, { value: "clear_failed", label: "Clear failed markers" }, + { value: "remove_audio", label: "Remove audio files" }, + { value: "remove_wrong_format", label: "Remove wrong-format audio" }, { value: "delete", label: "Delete directories" }, ]; @@ -218,6 +224,22 @@ export function VideoListPane({ }); } + // Videos with a finalized audio file not in the channel's target format — + // the failed-extract leftovers the wrong-format removal targets. + const hasWrongFormat = useMemo( + () => rows.some((r) => r.wrongFormatAudio), + [rows], + ); + function selectWrongFormat() { + setSelected((prev) => { + const next = new Set(prev); + for (const r of rows) { + if (r.wrongFormatAudio) next.add(r.id); + } + return next; + }); + } + const deleteArmed = deleteConfirm.trim().toLowerCase() === "delete"; const applyDisabled = pending || (action === "delete" && !deleteArmed); @@ -245,6 +267,24 @@ export function VideoListPane({ case "clear_failed": doSummaryBulk(bulkClearFailedMarkersAction); break; + case "remove_audio": + if ( + !confirm( + `Remove audio files from ${selected.size} video${selected.size === 1 ? "" : "s"}? Transcripts and metadata are kept; .part downloads are left to resume.`, + ) + ) + return; + doSummaryBulk(bulkRemoveAudioAction); + break; + case "remove_wrong_format": + if ( + !confirm( + `Remove wrong-format audio from ${selected.size} video${selected.size === 1 ? "" : "s"}? Only audio files not in the channel's target format are deleted.`, + ) + ) + return; + doSummaryBulk(bulkRemoveWrongFormatAudioAction); + break; case "delete": if (!deleteArmed) return; doSummaryBulk(bulkDeleteVideoDirsAction, () => setDeleteConfirm("")); @@ -295,6 +335,15 @@ export function VideoListPane({ > Select failed </button> + {hasWrongFormat && ( + <button + type="button" + onClick={selectWrongFormat} + 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 wrong-format + </button> + )} {visibleRows.length > 0 && ( <> <button @@ -457,7 +506,9 @@ export function VideoListPane({ className={`px-3 py-1 rounded border text-xs disabled:opacity-50 ${ action === "delete" ? "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 === "untranscribable" || + action === "remove_audio" || + action === "remove_wrong_format" ? "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]/components/stages/CleanupStage.tsx b/editor/app/channels/[slug]/components/stages/CleanupStage.tsx @@ -8,6 +8,7 @@ import { cancelJobAction } from "../../../../jobs/actions"; import { cleanAudioAction, cleanExtraAudioFormatsAction, + removeWrongFormatAudioAction, } from "../../whisperActions"; import { VideoIdList } from "../VideoIdList"; @@ -15,19 +16,23 @@ type Props = { slug: string; existingQueues: string[]; multipleAudioFormatIds: string[]; + foreignAudioIds: string[]; transcodeApplies: boolean; // Estimated bytes each cleanup would reclaim, as of the last report. transcribedAudioBytes: number; extraFormatsBytes: number; + foreignAudioBytes: number; }; export function CleanupStage({ slug, existingQueues, multipleAudioFormatIds, + foreignAudioIds, transcodeApplies, transcribedAudioBytes, extraFormatsBytes, + foreignAudioBytes, }: Props) { const defaultQueueKey = `channel:${slug}`; const [cleanQueue, setCleanQueue] = useState(defaultQueueKey); @@ -71,6 +76,96 @@ export function CleanupStage({ /> </div> )} + {transcodeApplies && ( + <div className="flex flex-col gap-2"> + <Heading + title="Remove wrong-format audio" + desc="Delete every audio.* file that isn't the channel's target format across all video dirs — even dirs with no target file at all (the failed-extract leftovers 'Clean extra audio formats' skips). Use this to clear pre-audio-integrity downloads so they re-download cleanly. The list tracks affected dirs as of the last report." + /> + <WrongFormatAudioSection + slug={slug} + ids={foreignAudioIds} + bytes={foreignAudioBytes} + existingQueues={existingQueues} + defaultQueueKey={defaultQueueKey} + /> + </div> + )} + </div> + ); +} + +function WrongFormatAudioSection({ + slug, + ids, + bytes, + existingQueues, + defaultQueueKey, +}: { + slug: string; + ids: string[]; + bytes: number; + existingQueues: string[]; + defaultQueueKey: string; +}) { + const [cleanQueue, setCleanQueue] = useState(defaultQueueKey); + const [confirmText, setConfirmText] = useState(""); + const armed = confirmText === "remove"; + return ( + <div + aria-label="wrong-format audio section" + className="flex flex-col gap-2 rounded border border-zinc-200 dark:border-zinc-800 p-3" + > + <div> + <h4 className="text-sm font-semibold"> + Dirs with wrong-format audio ({ids.length}) + </h4> + <p className="text-xs text-zinc-500"> + Click <em>Refresh report</em> above to recompute. The sweep walks every + video dir on disk regardless of this list — the count below reflects the + last snapshot only. Dirs marked <em>do not clean</em> are skipped. + </p> + <ReclaimEstimate bytes={bytes} /> + </div> + <VideoIdList + slug={slug} + ids={ids} + ariaLabel="wrong-format audio list" + emptyAriaLabel="wrong-format audio empty" + emptyMessage="No dirs with wrong-format audio in the last report." + itemAriaLabel={(id) => `wrong-format audio ${id}`} + /> + <div className="flex flex-col gap-2"> + <label className="flex items-center gap-2 text-xs text-zinc-600 dark:text-zinc-400"> + Type + <span className="font-mono">remove</span> + to confirm + <input + type="text" + value={confirmText} + onChange={(e) => setConfirmText(e.target.value)} + aria-label="confirm remove wrong-format audio" + className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-0.5 font-mono" + /> + </label> + <StreamActionLog + trigger={() => removeWrongFormatAudioAction(slug, cleanQueue)} + cancelAction={cancelJobAction} + buttonLabel="Remove wrong-format audio" + runningLabel="Removing…" + label="Remove wrong-format audio" + disabled={!armed} + extraControls={ + <QueueControl + value={cleanQueue} + onChange={setCleanQueue} + defaultQueueKey={defaultQueueKey} + existingQueues={existingQueues} + actionLabel="Remove wrong-format audio" + /> + } + /> + </div> </div> ); } diff --git a/editor/app/channels/[slug]/lib/stageStatus.ts b/editor/app/channels/[slug]/lib/stageStatus.ts @@ -68,6 +68,7 @@ const JOB_KIND_TO_STAGE: Record<string, StageId> = { "whisper-video": "transcribe", "clean-audio-transcribed": "cleanup", "clean-extra-audio-formats": "cleanup", + "remove-wrong-format-audio": "cleanup", "check-availability": "diagnostics", }; diff --git a/editor/app/channels/[slug]/lib/videoRows.ts b/editor/app/channels/[slug]/lib/videoRows.ts @@ -17,6 +17,11 @@ export type VideoRow = { partial: boolean; failedTranscription: boolean; failedTranscoding: boolean; + // Has at least one finalized audio file not in the channel's target format — + // the failed-extract orphans (audio.m4a with no audio.mp3) plus dirs with the + // target alongside extra formats. Drives the "Select wrong-format" quick-select + // and matches what the wrong-format-audio removal deletes. + wrongFormatAudio: boolean; // Members-only / deleted / private — listed in the channel but unactionable. excluded: boolean; running: boolean; diff --git a/editor/app/channels/[slug]/lib/videoRowsServer.ts b/editor/app/channels/[slug]/lib/videoRowsServer.ts @@ -37,6 +37,7 @@ export function computeVideoRows(input: ComputeRowsInput): VideoRow[] { const noTranscript = new Set(buckets.noTranscript); const downloadedNoTranscript = new Set(buckets.downloadedNoTranscript); const untranscoded = new Set(buckets.untranscoded); + const multipleAudioFormats = new Set(buckets.multipleAudioFormats); const untranscribable = new Set(buckets.untranscribable); const partial = new Set(buckets.partialDownloads); const failedTranscription = new Set(input.failedTranscriptionIds); @@ -85,6 +86,8 @@ export function computeVideoRows(input: ComputeRowsInput): VideoRow[] { partial: isPartial, failedTranscription: isFailedT, failedTranscoding: isFailedX, + wrongFormatAudio: + untranscoded.has(id) || multipleAudioFormats.has(id), excluded, running: runningIds.has(id), status, diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -253,9 +253,16 @@ export default async function ChannelDetailPage({ slug={slug} existingQueues={existingQueues} multipleAudioFormatIds={buckets.multipleAudioFormats} + foreignAudioIds={[ + ...new Set([ + ...buckets.untranscoded, + ...buckets.multipleAudioFormats, + ]), + ].sort()} transcodeApplies={transcodeApplies} transcribedAudioBytes={snapshot.cleanupBytes?.transcribedWithAudio ?? 0} extraFormatsBytes={snapshot.cleanupBytes?.multipleAudioFormats ?? 0} + foreignAudioBytes={snapshot.cleanupBytes?.foreignAudio ?? 0} /> ), diagnostics: ( diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts @@ -11,6 +11,7 @@ import type { import { AUDIO_FORMAT_VALUES } from "yt-dlp-transcript-common/lib/channelConfig"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { + audioFilesToRemove, isRealAudioFile, isTranscriptVtt, VTT_FILENAME, @@ -330,6 +331,37 @@ export async function deleteOneVideoDir( return { ok: true }; } +// Validated removal of finalized audio files from one video dir, shared by the +// bulk "Remove audio files" and "Remove wrong-format audio" actions. Keeps .part +// partials, transcripts, and metadata. With wrongFormatOnly, only audio files +// other than the channel's target format are removed. Pure filesystem op — never +// queues a job, so it can't trigger a transcode. Bulk selection is explicit, so +// this deliberately ignores the "do not clean" marker. +export async function removeAudioFilesForVideo( + slug: string, + videoId: string, + opts: { wrongFormatOnly?: boolean; targetAudioFile?: string } = {}, +): Promise<{ ok: true; removed: number } | { ok: false; error: string }> { + const videoDir = videoDirOf(slug, videoId); + const dataDir = path.resolve(getPaths().channelsDir, slug, "data"); + const resolved = path.resolve(videoDir); + if (path.dirname(resolved) !== dataDir) { + return { + ok: false, + error: "Refusing to remove: video path resolved outside the data dir", + }; + } + const entries = await readdir(resolved).catch(() => [] as string[]); + const toRemove = audioFilesToRemove(entries, { + targetAudioFile: opts.targetAudioFile, + wrongFormatOnly: opts.wrongFormatOnly, + }); + for (const name of toRemove) { + await rm(path.join(resolved, name), { force: true }); + } + return { ok: true, removed: toRemove.length }; +} + export async function deleteVideoDirAction( slug: string, videoId: string, diff --git a/editor/app/channels/[slug]/whisperActions.ts b/editor/app/channels/[slug]/whisperActions.ts @@ -26,6 +26,7 @@ import { import { clearFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions"; import { cleanAudioFromTranscribed } from "yt-dlp-transcript-common/controller/cleanAudioFromTranscribed"; import { cleanExtraAudioFormats } from "yt-dlp-transcript-common/controller/cleanExtraAudioFormats"; +import { removeWrongFormatAudio } from "yt-dlp-transcript-common/controller/removeWrongFormatAudio"; import { verifyTranscripts } from "yt-dlp-transcript-common/controller/verifyTranscripts"; import { runManagedFunction, @@ -279,6 +280,34 @@ export async function cleanExtraAudioFormatsAction( }); } +// Channel-wide sweep: delete every finalized audio file not in the target +// format, including the failed-extract orphans cleanExtraAudioFormats skips +// (dirs with no target file). Respects the per-video "do not clean" marker. +export async function removeWrongFormatAudioAction( + slug: string, + queueKey?: string, +): Promise<StreamActionResult> { + const paths = getPaths(); + return runManagedFunction({ + kind: "remove-wrong-format-audio", + queueKey: resolveQueueKey(channelQueueKey(slug), queueKey), + paths, + channelSlug: slug, + fn: async (onLog, signal) => { + const result = await removeWrongFormatAudio({ + channelSlug: slug, + paths, + onLog, + signal, + }); + onLog( + `Remove wrong-format audio: ${result.removedFiles} file(s) removed across ${result.cleanedDirs} dir(s); inspected ${result.inspected}.`, + ); + revalidatePath(`/channels/${slug}`); + }, + }); +} + export async function cleanAudioAction( slug: string, queueKey?: string, diff --git a/editor/e2e/bulk-actions.spec.ts b/editor/e2e/bulk-actions.spec.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, rename, writeFile } from "node:fs/promises"; import { test, expect } from "@playwright/test"; import { pathExists, resetData, resolvePath } from "./helpers"; @@ -185,3 +185,116 @@ test("Clear failed markers prunes both failure files and clears the glyphs", asy "vidB", ); }); + +// Remove audio files / Remove wrong-format audio are synchronous, summary-style +// actions (no queued job) gated by a window.confirm — the point is to clear +// audio WITHOUT triggering a transcode. Accept the confirm dialog before Apply. + +test("Select wrong-format + Remove wrong-format audio removes off-target files and queues no job", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + // The channel's target format is m4a. Turn vidA's audio.m4a into audio.webm so + // vidA is a wrong-format orphan (off-target audio, no audio.m4a at all). + await rename( + resolvePath(`${dataDir}/vidA/audio.m4a`), + resolvePath(`${dataDir}/vidA/audio.webm`), + ); + + await page.goto("/channels/test-transcribe"); + + // The quick-select helper grabs exactly the wrong-format video. + await page + .getByRole("button", { name: "Select wrong-format", exact: true }) + .click(); + await expect(page.getByLabel("bulk action bar")).toBeVisible(); + await expect(page.getByText("1 selected", { exact: true })).toBeVisible(); + + page.once("dialog", (dialog) => dialog.accept()); + await chooseAction(page, "Remove wrong-format audio"); + await clickApply(page); + await expectSubmitted(page); + + // vidA's off-target file is gone; the on-target files elsewhere are untouched. + expect(await pathExists(`${dataDir}/vidA/audio.webm`)).toBe(false); + expect(await pathExists(`${dataDir}/vidB/audio.m4a`)).toBe(true); + expect(await pathExists(`${dataDir}/vidC/audio.m4a`)).toBe(true); + + // Crucially: NO managed job was queued (no transcode/download triggered). + await page.goto("/jobs"); + await expect( + page.getByRole("row").filter({ hasText: "test-transcribe" }), + ).toHaveCount(0); +}); + +test("Remove audio files deletes finalized audio but keeps .part partials, queues no job", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + // vidB has a finalized audio.m4a plus an in-progress .part download. + await writeFile(resolvePath(`${dataDir}/vidB/audio.m4a.part`), "partial\n"); + + await page.goto("/channels/test-transcribe"); + + await page.getByLabel("select vidB").check(); + await expect(page.getByText("1 selected", { exact: true })).toBeVisible(); + + page.once("dialog", (dialog) => dialog.accept()); + await chooseAction(page, "Remove audio files"); + await clickApply(page); + await expectSubmitted(page); + + // Finalized audio removed; the resumable .part is kept. + expect(await pathExists(`${dataDir}/vidB/audio.m4a`)).toBe(false); + expect(await pathExists(`${dataDir}/vidB/audio.m4a.part`)).toBe(true); + // Other videos' audio is untouched. + expect(await pathExists(`${dataDir}/vidA/audio.m4a`)).toBe(true); + + // No managed job was queued. + await page.goto("/jobs"); + await expect( + page.getByRole("row").filter({ hasText: "test-transcribe" }), + ).toHaveCount(0); +}); + +// The channel-wide sweep lives in the Cleanup stage and runs as a managed job. +// It removes wrong-format orphans across every dir — including dirs with no +// target file, which "Clean extra audio formats" skips — but respects the +// per-video do-not-clean marker. +test("Cleanup stage 'Remove wrong-format audio' sweeps orphans but skips do-not-clean dirs", async ({ + page, +}) => { + await resetData("one-transcribe-channel-with-audio"); + // vidA: wrong-format orphan that should be swept. + await rename( + resolvePath(`${dataDir}/vidA/audio.m4a`), + resolvePath(`${dataDir}/vidA/audio.webm`), + ); + // vidB: wrong-format orphan protected by do-not-clean — must be kept. + await rename( + resolvePath(`${dataDir}/vidB/audio.m4a`), + resolvePath(`${dataDir}/vidB/audio.webm`), + ); + await writeFile( + resolvePath(`${dataDir}/vidB/do-not-clean.json`), + JSON.stringify({ setAt: "2026-06-18T00:00:00.000Z" }), + ); + // vidC keeps its on-target audio.m4a untouched. + + // The #stage-cleanup hash selects + expands the Cleanup stage. + await page.goto("/channels/test-transcribe#stage-cleanup"); + const section = page.getByLabel("wrong-format audio section"); + await expect(section).toBeVisible(); + await section.getByLabel("confirm remove wrong-format audio").fill("remove"); + await section + .getByRole("button", { name: "Remove wrong-format audio", exact: true }) + .click(); + + // The managed sweep removes vidA's orphan; vidB (do-not-clean) and vidC's + // on-target audio are kept. + await expect + .poll(() => pathExists(`${dataDir}/vidA/audio.webm`), { timeout: 15_000 }) + .toBe(false); + expect(await pathExists(`${dataDir}/vidB/audio.webm`)).toBe(true); + expect(await pathExists(`${dataDir}/vidC/audio.m4a`)).toBe(true); +});