commit 5687dc29b5297927305f89fc9d9f9ab8778e5dfd
parent 8a4c523345a7ed7e0597ac7598b26867345ab9dd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 30 Jun 2026 23:54:00 -0400
Merge feat/cleanup-page-and-colorful-status: dedicated Cleanup page + colorful semantic badges/alerts + per-video truncated-check opt-out + widget cleaning indicator
Diffstat:
29 files changed, 1368 insertions(+), 37 deletions(-)
diff --git a/common/components/ui/alert.tsx b/common/components/ui/alert.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "../../lib/utils"
+
+// A token-driven Alert primitive replacing the family's ad-hoc role="alert" /
+// role="status" notice divs. Each variant is a soft-filled, colored-border block
+// that recolors correctly across every theme family (light + dark) via the
+// semantic tokens. Callers keep control of the a11y role (default "alert"; pass
+// role="status" for non-urgent notices) so existing e2e querying by role stays
+// green. An optional leading icon is supported via the grid layout below.
+const alertVariants = cva(
+ "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-md border px-3 py-2 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5",
+ {
+ variants: {
+ variant: {
+ default: "border-border bg-muted text-muted-foreground [&>svg]:text-muted-foreground",
+ success:
+ "border-success/40 bg-success-soft text-success [&>svg]:text-success",
+ warning:
+ "border-warning/40 bg-warning-soft text-warning [&>svg]:text-warning",
+ info: "border-info/40 bg-info-soft text-info [&>svg]:text-info",
+ destructive:
+ "border-destructive/40 bg-destructive-soft text-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant = "default",
+ role = "alert",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
+ return (
+ <div
+ data-slot="alert"
+ data-variant={variant}
+ role={role}
+ className={cn(alertVariants({ variant }), className)}
+ {...props}
+ />
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="alert-title"
+ className={cn(
+ "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="alert-description"
+ className={cn(
+ "col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription, alertVariants }
diff --git a/common/components/ui/badge.tsx b/common/components/ui/badge.tsx
@@ -18,6 +18,15 @@ const badgeVariants = cva(
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
+ // Soft-filled semantic tones — colorful but calm, legible across every
+ // theme family in light and dark (token hues tuned as text-on-soft).
+ success:
+ "border-success/30 bg-success-soft text-success [a&]:hover:bg-success/15",
+ warning:
+ "border-warning/30 bg-warning-soft text-warning [a&]:hover:bg-warning/15",
+ info: "border-info/30 bg-info-soft text-info [a&]:hover:bg-info/15",
+ brand:
+ "border-brand/30 bg-brand-soft text-brand [a&]:hover:bg-brand/15",
},
},
defaultVariants: {
diff --git a/common/controller/channelSnapshot.ts b/common/controller/channelSnapshot.ts
@@ -20,6 +20,7 @@ import {
resolveEffectiveAvailability,
} from "../lib/availability-server";
import { isDoNotClean } from "../lib/doNotClean-server";
+import { isExcludedFromTruncatedCheck } from "../lib/excludeTruncatedCheck-server";
import { loadDownloadOutcome } from "../lib/downloadOutcome-server";
import type { Paths } from "../lib/paths";
import { extractVideoId } from "../ytdlp/runYtdlp";
@@ -298,6 +299,8 @@ export async function generateChannelSnapshot(
const availability = await loadAvailability(dir);
const effectiveAvailability = await resolveEffectiveAvailability(dir);
const doNotClean = await isDoNotClean(dir);
+ const excludedFromTruncatedCheck =
+ await isExcludedFromTruncatedCheck(dir);
const outcome = await loadDownloadOutcome(dir);
// Only transcribed (non-untranscribable) dirs can have a truncated
// transcript; skip the cues.json read for everything else.
@@ -313,6 +316,7 @@ export async function generateChannelSnapshot(
availability,
effectiveAvailability,
doNotClean,
+ excludedFromTruncatedCheck,
outcome,
coverage,
};
@@ -358,6 +362,14 @@ export async function generateChannelSnapshot(
// will actually remove.
const protectedFromCleanup = new Set<string>([...doNotCleanIds, ...keptIds]);
+ // Videos the user has opted out of the truncated/incomplete-transcript check
+ // (e.g. legitimately have no speech for the back half). Suppressed from the
+ // incompleteTranscript + shortAudio detection buckets below.
+ const excludedTruncatedIds = new Set<string>();
+ for (const v of perVideo) {
+ if (v.excludedFromTruncatedCheck) excludedTruncatedIds.add(v.id);
+ }
+
const noTranscript: string[] = [];
const downloadedNoTranscript: string[] = [];
const untranscoded: string[] = [];
@@ -396,7 +408,7 @@ export async function generateChannelSnapshot(
// the wrong-format cleanup (don't delete the kept file). If the user later
// transcribes it anyway, it falls through to the incompleteTranscript path.
if (outcome?.status === "failed-short-audio" && !isVideoTranscribed(files)) {
- shortAudio.push(id);
+ if (!excludedTruncatedIds.has(id)) shortAudio.push(id);
continue;
}
if (!files.hasMeta && !excludedById.has(id)) noMetadata.push(id);
@@ -404,6 +416,7 @@ export async function generateChannelSnapshot(
// download truncated silently. Threshold lives in transcriptCoverage.
if (
coverage &&
+ !excludedTruncatedIds.has(id) &&
isIncompleteTranscript(coverage.cov, {
isLivestream: coverage.isLivestream,
})
diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts
@@ -74,6 +74,12 @@ export type ChannelConfig = {
lastFullDownloadAt?: string;
excludeFromBuild?: boolean;
excludeFromSync?: boolean;
+ // Opt this channel OUT of the aggregate "cleanable data" total shown on the
+ // /cleanup page and its sidebar badge. The per-channel cleanup sweeps remain
+ // fully available; this flag only removes the channel's reclaimable bytes from
+ // the running total (e.g. keep a finicky-to-redownload channel's audio around
+ // for now without it nagging in the badge). Toggled from the cleanup ledger.
+ excludeFromCleanup?: boolean;
// Auto-sync cadence for the scheduled sync system (common/jobs/syncScheduler.ts).
// The cron-driven scheduler syncs this channel when
// `now - lastSyncedAt >= syncIntervalMinutes`. Semantics:
@@ -197,6 +203,9 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null {
if (typeof r.excludeFromSync === "boolean") {
config.excludeFromSync = r.excludeFromSync;
}
+ if (typeof r.excludeFromCleanup === "boolean") {
+ config.excludeFromCleanup = r.excludeFromCleanup;
+ }
if (
typeof r.syncIntervalMinutes === "number" &&
Number.isFinite(r.syncIntervalMinutes) &&
diff --git a/common/lib/excludeTruncatedCheck-server.ts b/common/lib/excludeTruncatedCheck-server.ts
@@ -0,0 +1,53 @@
+import path from "node:path";
+import { readFile, rename, rm, writeFile } from "node:fs/promises";
+import {
+ EXCLUDE_TRUNCATED_CHECK_FILENAME,
+ type ExcludeTruncatedCheckRecord,
+} from "./excludeTruncatedCheck";
+
+export function excludeTruncatedCheckPath(videoDir: string): string {
+ return path.join(videoDir, EXCLUDE_TRUNCATED_CHECK_FILENAME);
+}
+
+export async function loadExcludeTruncatedCheck(
+ videoDir: string,
+): Promise<ExcludeTruncatedCheckRecord | null> {
+ try {
+ const raw = await readFile(excludeTruncatedCheckPath(videoDir), "utf8");
+ const parsed = JSON.parse(raw) as Partial<ExcludeTruncatedCheckRecord>;
+ if (typeof parsed?.setAt === "string") {
+ return parsed as ExcludeTruncatedCheckRecord;
+ }
+ // Parseable-but-malformed still means "excluded".
+ return { setAt: "" };
+ } catch {
+ return null;
+ }
+}
+
+// Presence of a valid sidecar = excluded from the truncated check. Cheap
+// existence check for the snapshot generator's per-dir loop.
+export async function isExcludedFromTruncatedCheck(
+ videoDir: string,
+): Promise<boolean> {
+ return (await loadExcludeTruncatedCheck(videoDir)) !== null;
+}
+
+export async function setExcludedFromTruncatedCheck(
+ videoDir: string,
+ enabled: boolean,
+ note?: string,
+): Promise<void> {
+ const file = excludeTruncatedCheckPath(videoDir);
+ if (!enabled) {
+ await rm(file, { force: true });
+ return;
+ }
+ const record: ExcludeTruncatedCheckRecord = {
+ setAt: new Date().toISOString(),
+ ...(note ? { note } : {}),
+ };
+ const tmp = `${file}.tmp-${process.pid}`;
+ await writeFile(tmp, JSON.stringify(record, null, 2) + "\n");
+ await rename(tmp, file);
+}
diff --git a/common/lib/excludeTruncatedCheck.ts b/common/lib/excludeTruncatedCheck.ts
@@ -0,0 +1,20 @@
+// Client-safe types and constants. No node-only imports — the editor's video
+// panel is a "use client" file that pulls this in for the toggle UI. Server-only
+// I/O lives in excludeTruncatedCheck-server.ts. Mirrors the do-not-clean marker.
+
+// Per-video "exclude from truncated check" marker: when this sidecar exists in a
+// video dir, the incomplete/short-audio detectors (isIncompleteTranscript /
+// shortAudio) treat the video as fine — it's dropped from the snapshot's
+// incompleteTranscript + shortAudio buckets and the per-video panel suppresses
+// the "looks truncated" banner. For videos that legitimately have no speech for
+// the back half (e.g. long ambient/music tails) so coverage is genuinely low.
+//
+// Marker semantics: presence of the file = excluded. No stored `enabled: false`
+// state — toggling off removes the file — so the read is a simple existence
+// check for the snapshot generator's hot loop.
+export type ExcludeTruncatedCheckRecord = {
+ setAt: string;
+ note?: string;
+};
+
+export const EXCLUDE_TRUNCATED_CHECK_FILENAME = "exclude-truncated-check.json";
diff --git a/common/styles/tokens.css b/common/styles/tokens.css
@@ -45,10 +45,24 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
+ --color-destructive-soft: var(--destructive-soft);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
+ /* Semantic status — success / warning / info, each with -foreground + -soft.
+ Soft = a low-alpha tint of the hue for badge/alert fills (like --brand-soft);
+ the base color is tuned to read as TEXT on that soft fill in each family. */
+ --color-success: var(--success);
+ --color-success-foreground: var(--success-foreground);
+ --color-success-soft: var(--success-soft);
+ --color-warning: var(--warning);
+ --color-warning-foreground: var(--warning-foreground);
+ --color-warning-soft: var(--warning-soft);
+ --color-info: var(--info);
+ --color-info-foreground: var(--info-foreground);
+ --color-info-soft: var(--info-soft);
+
/* Family brand (brass / blue) — power-layer accent, distinct from shadcn accent */
--color-brand: var(--brand);
--color-brand-strong: var(--brand-strong);
@@ -98,10 +112,21 @@
--accent-foreground: #18181b;
--destructive: #dc2626;
--destructive-foreground: #fafafa;
+ --destructive-soft: rgba(220, 38, 38, 0.12);
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #a1a1aa;
+ --success: #15803d;
+ --success-foreground: #ffffff;
+ --success-soft: rgba(22, 163, 74, 0.12);
+ --warning: #b45309;
+ --warning-foreground: #ffffff;
+ --warning-soft: rgba(217, 119, 6, 0.14);
+ --info: #1d4ed8;
+ --info-foreground: #ffffff;
+ --info-soft: rgba(37, 99, 235, 0.12);
+
--brand: #2563eb;
--brand-strong: #1d4ed8;
--brand-soft: rgba(37, 99, 235, 0.12);
@@ -134,10 +159,21 @@
--accent-foreground: #fafafa;
--destructive: #f87171;
--destructive-foreground: #fafafa;
+ --destructive-soft: rgba(248, 113, 113, 0.16);
--border: #27272a;
--input: rgba(255, 255, 255, 0.12);
--ring: #71717a;
+ --success: #4ade80;
+ --success-foreground: #052e16;
+ --success-soft: rgba(74, 222, 128, 0.16);
+ --warning: #fbbf24;
+ --warning-foreground: #2a1a02;
+ --warning-soft: rgba(251, 191, 36, 0.16);
+ --info: #60a5fa;
+ --info-foreground: #04122e;
+ --info-soft: rgba(96, 165, 250, 0.16);
+
--brand: #60a5fa;
--brand-strong: #93c5fd;
--brand-soft: rgba(96, 165, 250, 0.16);
@@ -186,6 +222,16 @@
--panel: rgba(255, 252, 244, 0.7);
--panel-2: rgba(244, 236, 219, 0.7);
+ --success: #2e7d32;
+ --success-foreground: #fff7e7;
+ --success-soft: rgba(46, 125, 50, 0.14);
+ --warning: #a85b00;
+ --warning-foreground: #fff7e7;
+ --warning-soft: rgba(168, 91, 0, 0.14);
+ --info: #1f6feb;
+ --info-foreground: #fff7e7;
+ --info-soft: rgba(31, 111, 235, 0.12);
+
--brand: #95661a;
--brand-strong: #714d10;
--brand-soft: rgba(149, 102, 26, 0.14);
@@ -229,6 +275,16 @@
--panel: rgba(28, 23, 15, 0.55);
--panel-2: rgba(40, 33, 21, 0.5);
+ --success: #6cc777;
+ --success-foreground: #0c1a0d;
+ --success-soft: rgba(108, 199, 119, 0.16);
+ --warning: #f0a93c;
+ --warning-foreground: #1a1305;
+ --warning-soft: rgba(240, 169, 60, 0.16);
+ --info: #6ea8ff;
+ --info-foreground: #04122e;
+ --info-soft: rgba(110, 168, 255, 0.16);
+
--brand: #e3b15c;
--brand-strong: #f3c977;
--brand-soft: rgba(227, 177, 92, 0.14);
@@ -276,6 +332,16 @@
--panel: rgba(255, 255, 255, 0.7);
--panel-2: rgba(231, 240, 243, 0.7);
+ --success: #047857;
+ --success-foreground: #ecfeff;
+ --success-soft: rgba(4, 120, 87, 0.14);
+ --warning: #b45309;
+ --warning-foreground: #ecfeff;
+ --warning-soft: rgba(217, 119, 6, 0.14);
+ --info: #0369a1;
+ --info-foreground: #ecfeff;
+ --info-soft: rgba(2, 132, 199, 0.14);
+
--brand: #0891b2;
--brand-strong: #0e7490;
--brand-soft: rgba(8, 145, 178, 0.14);
@@ -319,6 +385,16 @@
--panel: rgba(12, 26, 33, 0.6);
--panel-2: rgba(18, 38, 46, 0.5);
+ --success: #34d399;
+ --success-foreground: #04141a;
+ --success-soft: rgba(52, 211, 153, 0.16);
+ --warning: #fbbf24;
+ --warning-foreground: #04141a;
+ --warning-soft: rgba(251, 191, 36, 0.16);
+ --info: #38bdf8;
+ --info-foreground: #04141a;
+ --info-soft: rgba(56, 189, 248, 0.16);
+
--brand: #22d3ee;
--brand-strong: #67e8f9;
--brand-soft: rgba(34, 211, 238, 0.16);
@@ -366,6 +442,16 @@
--panel: rgba(255, 255, 255, 0.85);
--panel-2: rgba(245, 245, 245, 0.85);
+ --success: #1a7d3a;
+ --success-foreground: #ffffff;
+ --success-soft: rgba(26, 125, 58, 0.12);
+ --warning: #9a6700;
+ --warning-foreground: #ffffff;
+ --warning-soft: rgba(154, 103, 0, 0.12);
+ --info: #1d4ed8;
+ --info-foreground: #ffffff;
+ --info-soft: rgba(29, 78, 216, 0.1);
+
--brand: #d4001a;
--brand-strong: #b00016;
--brand-soft: rgba(212, 0, 26, 0.1);
@@ -409,6 +495,16 @@
--panel: rgba(20, 20, 20, 0.6);
--panel-2: rgba(31, 31, 31, 0.5);
+ --success: #46c46a;
+ --success-foreground: #0a0a0a;
+ --success-soft: rgba(70, 196, 106, 0.16);
+ --warning: #d9a13a;
+ --warning-foreground: #0a0a0a;
+ --warning-soft: rgba(217, 161, 58, 0.16);
+ --info: #60a5fa;
+ --info-foreground: #0a0a0a;
+ --info-soft: rgba(96, 165, 250, 0.16);
+
--brand: #ff2b3e;
--brand-strong: #ff5a6a;
--brand-soft: rgba(255, 43, 62, 0.16);
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,10 @@
# Changelog
## [Unreleased]
+- **A dedicated Cleanup page with a live "reclaimable" sidebar badge and per-channel include/exclude.** Space-reclaim cleaning now has its own home (`/cleanup`, in the Pool nav) instead of being scattered across each channel's detail page. It opens with a serif **reclamation console** readout — the total reclaimable audio across channels, a token-colored breakdown bar (transcribed audio / extra formats / wrong-format), and a per-channel ledger that runs the same `cleanAudioAction` / `cleanExtraAudioFormatsAction` / `removeWrongFormatAudioAction` sweeps (plus failed-list housekeeping) the channel page does — the per-channel `CleanupStage` stays put. The sidebar **Cleanup** item carries an amber badge with the running reclaimable total (e.g. `12.4 GB`), recomputed on each auto-refresh tick. A per-channel **Counted / Excluded** toggle (new `ChannelConfig.excludeFromCleanup`, persisted in `config.json` like `excludeFromBuild`/`excludeFromSync`) holds a channel's space back from the total without disabling its sweeps — e.g. keep a finicky-to-redownload channel's audio around for now. The headline total uses the primary "clean audio" reclaim only (the three sweeps overlap, so they aren't summed). See `editor/app/cleanup/{page.tsx,lib/loadCleanup.ts,components/{ChannelCleanupCard,ChannelCleanupToggle}.tsx}`, `editor/app/channels/actions.ts` (`toggleChannelCleanupInclusionAction`), `editor/app/{lib/nav.ts,layout.tsx}`, and `common/lib/channelConfig.ts`.
+- **Badges and alerts are now colorful and theme-aware in every theme.** The design system only tokenized red (`--destructive`); every green/amber/blue status was hardcoded Tailwind palette that ignored the four theme families. New semantic tokens — **`--success` / `--warning` / `--info`** (each with `-foreground` + a soft fill partner) plus `--destructive-soft` — are defined across all eight family blocks (base/archive/terminal/swiss × light/dark) and registered in `@theme inline`. The shared kit `Badge` gains `success` / `warning` / `info` / `brand` soft-filled variants, and a **new `Alert` component** (`common/components/ui/alert.tsx`) replaces ad-hoc notice divs. High-traffic status UI is migrated onto the tokens: channel stage badges (`StageBadge`), worker state badges + dots (`WorkersView`, `MonitorWidget`), the scheduler status column (`SchedulerView`), and the widget disk strip — so each recolors correctly in Archive, Terminal, and Swiss, light and dark. See `common/styles/tokens.css`, `common/components/ui/{badge,alert}.tsx`.
+- **Per-video "Exclude from truncated check".** Videos that legitimately have no speech for their back half (e.g. long ambient/music tails) kept getting false-flagged as truncated/incomplete transcripts. A new per-video toggle on the video panel writes an `exclude-truncated-check.json` marker (mirroring the `do-not-clean.json` pattern) that suppresses the flag everywhere it surfaces — the snapshot's `incompleteTranscript` + `shortAudio` buckets (so bulk actions, the `/actionable` lists, and channel row dots all skip it) **and** the per-video panel's "looks truncated" banner, which computes independently of the snapshot. The detection thresholds in `transcriptCoverage.ts` are unchanged (other videos unaffected). See `common/lib/excludeTruncatedCheck{,-server}.ts`, `common/controller/channelSnapshot.ts`, `editor/app/channels/[slug]/videos/[id]/{videoActions.ts,components/VideoPanel.tsx,page.tsx}`, and `editor/app/channels/[slug]/page.tsx`.
+- **Monitor widget: an optional cleanable-data indicator, and the builder remembers your last config.** The widget builder gains a **Show cleanable indicator** option (URL flag `clean=1`) that adds a strip showing total reclaimable audio (polled from a new `/api/widget/cleanable` route, honoring `excludeFromCleanup`), styled like the disk strip. The builder also now **persists its form to localStorage** (`ytdlp-tb:widget-config`, versioned, SSR-guarded — mirroring `jobsFilterStorage`), so it reopens with your last configuration; the shared/embedded `/widget` URL stays authoritative for what actually renders. See `editor/app/widget/{lib/config.ts,builder/{widgetConfigStorage.ts,components/WidgetBuilder.tsx},components/MonitorWidget.tsx}` and `editor/app/api/widget/cleanable/route.ts`.
- **Two more selectable themes + a theme picker.** A palette menu beside the light/dark toggle (in every app — editor, export, homepage) switches the theme family between **Archive**, **Terminal** (ice-cyan), **Swiss** (red/black/white), and **Base**, each in light/dark/system, persisted client-side. Each family is a pure CSS token swap in `common/styles/tokens.css` (a `[data-theme]` block) — no markup changes — wired through `common/components/{themeConfig.ts,ThemeMenu.tsx}`.
- **Per-site brand accent.** Each site's config (under Sites) gains an optional **Brand accent** field — a hex color that overrides the family brass on that site's public build. Validated on save; blank inherits the family accent. The long-running action logs (download, transcribe, build, deploy, …) are also restyled onto the shared kit (Button + design tokens), keeping their exact accessible labels. See `common/lib/accent.ts`, `common/lib/site.ts`, `common/components/StreamActionLog.tsx`, `editor/app/sites/{components/SiteForm.tsx,actions.ts}`, and `export/app/layout.tsx`.
- **Command-first cockpit: a ⌘K command palette that runs actions, plus a live dashboard.** Press **⌘K / Ctrl-K** anywhere to open a fast command palette (rebuilt on cmdk) that jumps to any page **and runs pool/site actions inline** — Sync all channels, Refresh all reports, Detect duplicate shorts, Retry all failed jobs, Drain the queue, Pause/Resume all workers — each firing the server action and reporting the result as a **toast** (Sonner). On a channel page it also offers the channel's status filters. The sidebar and palette now read from one shared nav source (`editor/app/lib/nav.ts`) so they never drift (the old palette listed a stale 5-route subset), and every nav item gains an icon. The **Dashboard** gains a **Live jobs** panel that polls running jobs in real time (reusing the active-jobs monitor), and the whole editor shell is restyled compact onto the shared design tokens/kit with the Source type family. See `editor/app/{layout.tsx,page.tsx,lib/nav.ts,components/{CommandPalette.tsx,ChangelogNavLink.tsx}}` and `common/components/ui/sonner.tsx`.
diff --git a/editor/app/api/widget/cleanable/route.ts b/editor/app/api/widget/cleanable/route.ts
@@ -0,0 +1,15 @@
+import { NextResponse } from "next/server";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { cleanableTotalBytes } from "../../../cleanup/lib/loadCleanup";
+
+export const dynamic = "force-dynamic";
+
+export type CleanablePayload = { bytes: number };
+
+// Backs the monitor widget's optional cleanable-data indicator. Sums the
+// primary "clean audio" reclaim across channels not excluded from the cleanup
+// total (honors the per-channel excludeFromCleanup flag).
+export async function GET() {
+ const bytes = await cleanableTotalBytes(getPaths());
+ return NextResponse.json({ bytes } satisfies CleanablePayload);
+}
diff --git a/editor/app/channels/[slug]/components/StageBadge.tsx b/editor/app/channels/[slug]/components/StageBadge.tsx
@@ -1,15 +1,15 @@
import type { StageStatus, StageTone } from "../lib/stageStatus";
+// Semantic, token-driven tones so stage badges recolor with every theme family.
const TONE_CLASS: Record<StageTone, string> = {
neutral:
- "border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 hover:border-zinc-400",
+ "border-border bg-card text-muted-foreground hover:border-muted-foreground/50",
attention:
- "border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950 text-amber-800 dark:text-amber-200 hover:border-amber-500",
+ "border-warning/30 bg-warning-soft text-warning hover:border-warning/60",
danger:
- "border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-950 text-red-800 dark:text-red-200 hover:border-red-500",
- running:
- "border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-950 text-blue-800 dark:text-blue-200 hover:border-blue-500",
- ok: "border-emerald-300 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-950 text-emerald-800 dark:text-emerald-200 hover:border-emerald-500",
+ "border-destructive/30 bg-destructive-soft text-destructive hover:border-destructive/60",
+ running: "border-info/30 bg-info-soft text-info hover:border-info/60",
+ ok: "border-success/30 bg-success-soft text-success hover:border-success/60",
};
export function StageBadge({ status }: { status: StageStatus }) {
@@ -23,7 +23,7 @@ export function StageBadge({ status }: { status: StageStatus }) {
{status.running && (
<span
aria-hidden="true"
- className="inline-block w-2 h-2 rounded-full bg-blue-500 animate-pulse"
+ className="inline-block w-2 h-2 rounded-full bg-info animate-pulse"
/>
)}
<span>{status.title}</span>
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -28,6 +28,7 @@ import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { resolvePrimaryVtt } from "yt-dlp-transcript-common/lib/videoStatus";
import { readTranscriptCoverage } from "yt-dlp-transcript-common/controller/normalizeTranscript";
import { isIncompleteTranscript } from "yt-dlp-transcript-common/lib/transcriptCoverage";
+import { isExcludedFromTruncatedCheck } from "yt-dlp-transcript-common/lib/excludeTruncatedCheck-server";
import {
platformQueueKey,
queueKeyForUrl,
@@ -354,13 +355,16 @@ export default async function ChannelDetailPage({
let selectedPanel: ReactNode = null;
let selectedTitle: string | null = null;
if (selectedVideoId) {
- const [dirData, title, outcome, availabilityRecord, cov] =
+ const [dirData, title, outcome, availabilityRecord, cov, excludedTrunc] =
await Promise.all([
loadVideoDir(channelDataDir, selectedVideoId),
loadVideoTitle(channelDataDir, selectedVideoId),
loadDownloadOutcome(path.join(channelDataDir, selectedVideoId)),
loadAvailability(path.join(channelDataDir, selectedVideoId)),
readTranscriptCoverage(path.join(channelDataDir, selectedVideoId)),
+ isExcludedFromTruncatedCheck(
+ path.join(channelDataDir, selectedVideoId),
+ ),
]);
selectedTitle = title;
const coverage = cov
@@ -368,9 +372,11 @@ export default async function ChannelDetailPage({
lastCueEnd: cov.cov.lastCueEnd,
duration: cov.cov.duration,
coverage: cov.cov.coverage,
- incomplete: isIncompleteTranscript(cov.cov, {
- isLivestream: cov.isLivestream,
- }),
+ incomplete:
+ !excludedTrunc &&
+ isIncompleteTranscript(cov.cov, {
+ isLivestream: cov.isLivestream,
+ }),
}
: null;
const prevRow =
@@ -391,6 +397,7 @@ export default async function ChannelDetailPage({
downloadOutcome={outcome}
availabilityHistory={availabilityRecord?.history ?? []}
channelAudioFormat={config.audioFormat}
+ excludedFromTruncatedCheck={excludedTrunc}
coverage={coverage}
prevHref={prevRow ? buildVideoHref(prevRow.id) : undefined}
nextHref={nextRow ? buildVideoHref(nextRow.id) : undefined}
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -26,6 +26,7 @@ import {
redownloadIncompleteTranscriptAction,
redownloadToArchiveAction,
toggleDoNotCleanAction,
+ toggleExcludeTruncatedCheckAction,
transcodeAudioAction,
transcribeOneAction,
unpersistVideoAction,
@@ -64,6 +65,10 @@ type Props = {
availabilityHistory: AvailabilityHistoryEntry[];
channelAudioFormat?: AudioFormat;
doNotClean?: boolean;
+ // When true the user has opted this video out of the truncated/incomplete
+ // check, so the "looks truncated" banner is suppressed and the toggle reads
+ // as on. Drives ExcludeTruncatedCheckSection.
+ excludedFromTruncatedCheck?: boolean;
// This video's saved-video pointer when its source container is persisted to
// the store, else null. Drives the Source-video persistence card (Phase 5).
savedVideo?: SavedVideoPointer | null;
@@ -153,6 +158,7 @@ export function VideoPanel({
availabilityHistory,
channelAudioFormat,
doNotClean = false,
+ excludedFromTruncatedCheck = false,
savedVideo = null,
coverage = null,
prevHref,
@@ -217,7 +223,8 @@ export function VideoPanel({
existingQueues={existingQueues}
/>
)}
- {downloadOutcome?.status === "failed-short-audio" && (
+ {downloadOutcome?.status === "failed-short-audio" &&
+ !excludedFromTruncatedCheck && (
<ShortAudioBanner
slug={slug}
videoId={videoId}
@@ -403,6 +410,24 @@ export function VideoPanel({
</PipelineStageCard>
<PipelineStageCard
+ id="truncated-check"
+ title="Truncated check"
+ summary={
+ excludedFromTruncatedCheck
+ ? "Excluded — this video won't be flagged as truncated."
+ : "Exclude this video from the truncated-transcript check."
+ }
+ defaultOpen={false}
+ tone={excludedFromTruncatedCheck ? "ok" : "neutral"}
+ >
+ <ExcludeTruncatedCheckSection
+ slug={slug}
+ videoId={videoId}
+ excluded={excludedFromTruncatedCheck}
+ />
+ </PipelineStageCard>
+
+ <PipelineStageCard
id="files"
title="Files"
summary={`${files.length} file${files.length === 1 ? "" : "s"} in this directory.`}
@@ -1127,6 +1152,64 @@ function DoNotCleanSection({
);
}
+function ExcludeTruncatedCheckSection({
+ slug,
+ videoId,
+ excluded,
+}: {
+ slug: string;
+ videoId: string;
+ excluded: boolean;
+}) {
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+ const next = !excluded;
+ return (
+ <div className="flex flex-col gap-2">
+ <p className="text-sm text-muted-foreground">
+ {excluded
+ ? "This video is excluded from the truncated-transcript check. It won't be flagged as incomplete/short even when its transcript covers only part of the runtime. Reversible at any time."
+ : "Exclude this video from the truncated-transcript check — useful when the back half legitimately has no speech (so low coverage is expected, not a broken download). Reversible at any time."}
+ </p>
+ <button
+ type="button"
+ disabled={pending}
+ aria-label={
+ next
+ ? `exclude video ${videoId} from truncated check`
+ : `include video ${videoId} in truncated check`
+ }
+ onClick={() => {
+ setError(null);
+ startTransition(async () => {
+ const res = await toggleExcludeTruncatedCheckAction(
+ slug,
+ videoId,
+ next,
+ );
+ if (!res.ok) setError(res.error);
+ });
+ }}
+ className="self-start px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {pending
+ ? "Saving…"
+ : excluded
+ ? "Re-enable check"
+ : "Exclude from check"}
+ </button>
+ {error && (
+ <span
+ className="text-sm text-destructive"
+ aria-label="truncated check error"
+ >
+ {error}
+ </span>
+ )}
+ </div>
+ );
+}
+
function DeleteFileButton({
slug,
videoId,
diff --git a/editor/app/channels/[slug]/videos/[id]/page.tsx b/editor/app/channels/[slug]/videos/[id]/page.tsx
@@ -8,6 +8,7 @@ import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"
import { loadDownloadOutcome } from "yt-dlp-transcript-common/lib/downloadOutcome-server";
import { loadAvailability } from "yt-dlp-transcript-common/lib/availability-server";
import { isDoNotClean } from "yt-dlp-transcript-common/lib/doNotClean-server";
+import { isExcludedFromTruncatedCheck } from "yt-dlp-transcript-common/lib/excludeTruncatedCheck-server";
import { loadSavedVideo } from "yt-dlp-transcript-common/lib/savedVideo-server";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { resolvePrimaryVtt } from "yt-dlp-transcript-common/lib/videoStatus";
@@ -104,6 +105,8 @@ export default async function VideoDetailPage({
const availabilityRecord = await loadAvailability(videoDir);
const availabilityHistory = availabilityRecord?.history ?? [];
const doNotClean = await isDoNotClean(videoDir);
+ const excludedFromTruncatedCheck =
+ await isExcludedFromTruncatedCheck(videoDir);
const savedVideo = await loadSavedVideo(videoDir);
const cov = await readTranscriptCoverage(videoDir);
const coverage = cov
@@ -111,9 +114,11 @@ export default async function VideoDetailPage({
lastCueEnd: cov.cov.lastCueEnd,
duration: cov.cov.duration,
coverage: cov.cov.coverage,
- incomplete: isIncompleteTranscript(cov.cov, {
- isLivestream: cov.isLivestream,
- }),
+ incomplete:
+ !excludedFromTruncatedCheck &&
+ isIncompleteTranscript(cov.cov, {
+ isLivestream: cov.isLivestream,
+ }),
}
: null;
@@ -194,6 +199,7 @@ export default async function VideoDetailPage({
availabilityHistory={availabilityHistory}
channelAudioFormat={config.audioFormat}
doNotClean={doNotClean}
+ excludedFromTruncatedCheck={excludedFromTruncatedCheck}
savedVideo={savedVideo}
coverage={coverage}
/>
diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts
@@ -29,6 +29,7 @@ import {
} from "yt-dlp-transcript-common/lib/queueKeys";
import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
import { setDoNotClean } from "yt-dlp-transcript-common/lib/doNotClean-server";
+import { setExcludedFromTruncatedCheck } from "yt-dlp-transcript-common/lib/excludeTruncatedCheck-server";
import { pruneFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions";
import { transcodeAudio } from "yt-dlp-transcript-common/controller/transcode";
import { transcribeWithWorker } from "yt-dlp-transcript-common/controller/transcribeOne";
@@ -572,6 +573,31 @@ export async function toggleDoNotCleanAction(
return { ok: true };
}
+// Opt this video in/out of the truncated/incomplete-transcript check. When
+// enabled, the snapshot's incompleteTranscript + shortAudio buckets and the
+// video panel's "looks truncated" banner suppress this id (for videos that
+// legitimately have little speech). Mirrors toggleDoNotCleanAction.
+export async function toggleExcludeTruncatedCheckAction(
+ slug: string,
+ videoId: string,
+ enabled: boolean,
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ const videoDir = videoDirOf(slug, videoId);
+ try {
+ const s = await stat(videoDir);
+ if (!s.isDirectory()) {
+ return { ok: false, error: `Video directory not found: ${videoId}` };
+ }
+ } catch {
+ return { ok: false, error: `Video directory not found: ${videoId}` };
+ }
+ await setExcludedFromTruncatedCheck(videoDir, enabled);
+ revalidatePath(`/channels/${slug}/videos/${videoId}`);
+ revalidatePath(`/channels/${slug}`);
+ requestChannelSnapshot(getPaths(), slug);
+ return { ok: true };
+}
+
// Reverse persistence: move this video's stored source container back into its
// data dir and drop the pointer (Phase 5). The companion "persist" direction is
// redownloadToArchiveAction, which re-fetches the container when it's not on
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -158,6 +158,28 @@ export async function toggleChannelSyncInclusionAction(
return undefined;
}
+// Toggle whether this channel's reclaimable bytes count toward the aggregate
+// "cleanable data" total on the /cleanup page (and its sidebar badge). The
+// cleanup sweeps themselves stay available regardless; this only flips the
+// channel's inclusion in the running total.
+export async function toggleChannelCleanupInclusionAction(
+ slug: string,
+): Promise<ActionResult> {
+ const paths = getPaths();
+ const existing = await readChannelConfig(paths, slug);
+ if (!existing) return { error: `Channel "${slug}" not found` };
+ const next = { ...existing };
+ if (existing.excludeFromCleanup) {
+ delete next.excludeFromCleanup;
+ } else {
+ next.excludeFromCleanup = true;
+ }
+ await writeChannelConfig(paths, slug, next);
+ revalidatePath("/cleanup");
+ revalidatePath("/channels");
+ return undefined;
+}
+
export type SyncAllResult = {
queued: string[];
skipped: { slug: string; reason: string }[];
diff --git a/editor/app/cleanup/components/ChannelCleanupCard.tsx b/editor/app/cleanup/components/ChannelCleanupCard.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
+import { Badge } from "yt-dlp-transcript-common/components/ui/badge";
+import { cancelJobAction } from "../../jobs/actions";
+import {
+ cleanAudioAction,
+ cleanExtraAudioFormatsAction,
+ removeWrongFormatAudioAction,
+ clearFailedTranscriptionsAction,
+ clearFailedTranscodingsAction,
+} from "../../channels/[slug]/whisperActions";
+import { ChannelCleanupToggle } from "./ChannelCleanupToggle";
+
+export type ChannelCleanupCardProps = {
+ slug: string;
+ name: string;
+ included: boolean;
+ transcribedBytes: number;
+ extraFormatsBytes: number;
+ foreignBytes: number;
+ hasTargetFormat: boolean;
+ failedTranscriptions: number;
+ failedTranscodings: number;
+};
+
+export function ChannelCleanupCard({
+ slug,
+ name,
+ included,
+ transcribedBytes,
+ extraFormatsBytes,
+ foreignBytes,
+ hasTargetFormat,
+ failedTranscriptions,
+ failedTranscodings,
+}: ChannelCleanupCardProps) {
+ const queueKey = `channel:${slug}`;
+ const [armed, setArmed] = useState(false);
+ const nothingToReclaim =
+ transcribedBytes <= 0 && extraFormatsBytes <= 0 && foreignBytes <= 0;
+ const noHousekeeping = failedTranscriptions === 0 && failedTranscodings === 0;
+
+ return (
+ <section
+ aria-label={`cleanup ${slug}`}
+ data-included={included ? "true" : "false"}
+ className={
+ "flex flex-col gap-3 rounded-lg border border-border bg-card p-4 transition-opacity " +
+ (included ? "" : "opacity-60")
+ }
+ >
+ <header className="flex flex-wrap items-center gap-x-3 gap-y-2">
+ <Link
+ href={`/channels/${slug}`}
+ className="font-display text-base font-semibold text-foreground hover:text-brand"
+ >
+ {name}
+ </Link>
+ {!included && (
+ <Badge variant="warning" aria-label={`${slug} excluded from total`}>
+ Excluded from total
+ </Badge>
+ )}
+ <span
+ className="ml-auto font-mono text-sm tabular-nums text-foreground"
+ aria-label={`${slug} reclaimable`}
+ >
+ {transcribedBytes > 0 ? `~${formatBytes(transcribedBytes)}` : "—"}
+ </span>
+ <ChannelCleanupToggle slug={slug} excluded={!included} />
+ </header>
+
+ {nothingToReclaim && noHousekeeping ? (
+ <p className="text-sm text-muted-foreground">
+ Nothing to reclaim in the last report.
+ </p>
+ ) : (
+ <div className="flex flex-col gap-3">
+ {transcribedBytes > 0 && (
+ <SweepRow
+ estimate={transcribedBytes}
+ caption="Audio in transcribed video dirs"
+ >
+ <StreamActionLog
+ trigger={() => cleanAudioAction(slug, queueKey)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Clean audio"
+ runningLabel="Cleaning…"
+ label={`Clean audio ${slug}`}
+ />
+ </SweepRow>
+ )}
+
+ {hasTargetFormat && extraFormatsBytes > 0 && (
+ <SweepRow
+ estimate={extraFormatsBytes}
+ caption="Non-target audio in dirs that keep the target format"
+ >
+ <StreamActionLog
+ trigger={() => cleanExtraAudioFormatsAction(slug, queueKey)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Clean extra formats"
+ runningLabel="Cleaning…"
+ label={`Clean extra audio formats ${slug}`}
+ />
+ </SweepRow>
+ )}
+
+ {hasTargetFormat && foreignBytes > 0 && (
+ <SweepRow
+ estimate={foreignBytes}
+ caption="All wrong-format audio, including failed-extract orphans"
+ >
+ <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
+ <input
+ type="checkbox"
+ checked={armed}
+ onChange={(e) => setArmed(e.target.checked)}
+ aria-label={`confirm remove wrong-format audio ${slug}`}
+ />
+ Confirm
+ </label>
+ <StreamActionLog
+ trigger={() => removeWrongFormatAudioAction(slug, queueKey)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Remove wrong-format"
+ runningLabel="Removing…"
+ label={`Remove wrong-format audio ${slug}`}
+ disabled={!armed}
+ />
+ </SweepRow>
+ )}
+
+ {!noHousekeeping && (
+ <div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
+ <span className="font-mono text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
+ Housekeeping
+ </span>
+ {failedTranscriptions > 0 && (
+ <StreamActionLog
+ trigger={() => clearFailedTranscriptionsAction(slug, queueKey)}
+ cancelAction={cancelJobAction}
+ buttonLabel={`Clear failed transcriptions (${failedTranscriptions})`}
+ runningLabel="Clearing…"
+ label={`Clear failed transcriptions ${slug}`}
+ />
+ )}
+ {failedTranscodings > 0 && (
+ <StreamActionLog
+ trigger={() => clearFailedTranscodingsAction(slug, queueKey)}
+ cancelAction={cancelJobAction}
+ buttonLabel={`Clear failed transcodings (${failedTranscodings})`}
+ runningLabel="Clearing…"
+ label={`Clear failed transcodings ${slug}`}
+ />
+ )}
+ </div>
+ )}
+ </div>
+ )}
+ </section>
+ );
+}
+
+function SweepRow({
+ estimate,
+ caption,
+ children,
+}: {
+ estimate: number;
+ caption: string;
+ children: React.ReactNode;
+}) {
+ return (
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
+ <div className="min-w-0 flex-1">
+ <p className="text-sm text-foreground">{caption}</p>
+ <p className="font-mono text-xs tabular-nums text-muted-foreground">
+ ~{formatBytes(estimate)}
+ </p>
+ </div>
+ {children}
+ </div>
+ );
+}
diff --git a/editor/app/cleanup/components/ChannelCleanupToggle.tsx b/editor/app/cleanup/components/ChannelCleanupToggle.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { toggleChannelCleanupInclusionAction } from "../../channels/actions";
+
+// Per-channel include/exclude switch for the aggregate cleanable-data total.
+// Token-driven (semantic success / muted) so it recolors with every theme.
+export function ChannelCleanupToggle({
+ slug,
+ excluded,
+}: {
+ slug: string;
+ excluded: boolean;
+}) {
+ return (
+ <form
+ action={async () => {
+ await toggleChannelCleanupInclusionAction(slug);
+ }}
+ >
+ <button
+ type="submit"
+ aria-label={`toggle cleanup-total inclusion for ${slug}`}
+ aria-pressed={!excluded}
+ title={
+ excluded
+ ? "Excluded from the cleanable-data total. Click to include."
+ : "Counted in the cleanable-data total. Click to exclude."
+ }
+ className={
+ "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium border transition-colors " +
+ (excluded
+ ? "border-border text-muted-foreground hover:bg-accent"
+ : "border-success/30 bg-success-soft text-success hover:bg-success/15")
+ }
+ >
+ <span
+ aria-hidden="true"
+ className={
+ "inline-block h-1.5 w-1.5 rounded-full " +
+ (excluded ? "bg-muted-foreground/50" : "bg-success")
+ }
+ />
+ {excluded ? "Excluded" : "Counted"}
+ </button>
+ </form>
+ );
+}
diff --git a/editor/app/cleanup/lib/loadCleanup.ts b/editor/app/cleanup/lib/loadCleanup.ts
@@ -0,0 +1,135 @@
+import type { Paths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ listChannels,
+ type ChannelStat,
+} from "yt-dlp-transcript-common/controller/channels";
+import {
+ readChannelSnapshot,
+ type ChannelSnapshot,
+} from "yt-dlp-transcript-common/controller/channelSnapshot";
+import { loadFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions";
+import { loadFailedTranscodings } from "yt-dlp-transcript-common/controller/failedTranscodings";
+
+// One channel's row in the cleanup ledger. The three reclaim estimates come
+// straight from the snapshot's precomputed `cleanupBytes` (already net of
+// do-not-clean + keep-latest protection). They OVERLAP — they're alternative
+// sweeps, not additive — so the headline total uses `transcribedBytes` (the
+// primary, safe "Clean audio for transcribed videos" sweep) only.
+export type CleanupRow = {
+ channel: ChannelStat;
+ snapshot: ChannelSnapshot | null;
+ included: boolean;
+ transcribedBytes: number;
+ extraFormatsBytes: number;
+ foreignBytes: number;
+ // Gating for the extra-format / wrong-format sweeps (need a target format).
+ hasTargetFormat: boolean;
+ // Housekeeping list sizes (failed transcriptions / transcodings).
+ failedTranscriptions: number;
+ failedTranscodings: number;
+};
+
+export type CleanupSummary = {
+ rows: CleanupRow[];
+ // Reclaimable across INCLUDED channels (the badge/hero number = primary sweep).
+ includedReclaimBytes: number;
+ // The same primary number across ALL channels (so the UI can show what's
+ // excluded). includedReclaimBytes <= totalReclaimBytes.
+ totalReclaimBytes: number;
+ // Secondary reclaim across included channels, for the breakdown bar. These
+ // overlap with the primary and each other, so they're shown as separate
+ // available sweeps, never summed into the headline.
+ includedExtraFormatsBytes: number;
+ includedForeignBytes: number;
+ includedCount: number;
+ excludedCount: number;
+};
+
+function transcribedBytesOf(snap: ChannelSnapshot | null): number {
+ return snap?.cleanupBytes?.transcribedWithAudio ?? 0;
+}
+
+function rowOf(
+ channel: ChannelStat,
+ snapshot: ChannelSnapshot | null,
+ failedTranscriptions: number,
+ failedTranscodings: number,
+): CleanupRow {
+ return {
+ channel,
+ snapshot,
+ included: !channel.config.excludeFromCleanup,
+ transcribedBytes: transcribedBytesOf(snapshot),
+ extraFormatsBytes: snapshot?.cleanupBytes?.multipleAudioFormats ?? 0,
+ foreignBytes: snapshot?.cleanupBytes?.foreignAudio ?? 0,
+ hasTargetFormat: Boolean(channel.config.audioFormat),
+ failedTranscriptions,
+ failedTranscodings,
+ };
+}
+
+export async function loadCleanupSummary(
+ paths: Paths,
+): Promise<CleanupSummary> {
+ const channels = await listChannels(paths);
+ const rows = await Promise.all(
+ channels.map(async (channel) => {
+ const [snapshot, failedT, failedX] = await Promise.all([
+ readChannelSnapshot(paths, channel.slug),
+ loadFailedTranscriptions(paths, channel.slug),
+ loadFailedTranscodings(paths, channel.slug),
+ ]);
+ return rowOf(channel, snapshot, failedT.length, failedX.length);
+ }),
+ );
+
+ // Sort: channels with the most reclaimable first; excluded sink to the bottom.
+ rows.sort((a, b) => {
+ if (a.included !== b.included) return a.included ? -1 : 1;
+ return b.transcribedBytes - a.transcribedBytes;
+ });
+
+ let includedReclaimBytes = 0;
+ let totalReclaimBytes = 0;
+ let includedExtraFormatsBytes = 0;
+ let includedForeignBytes = 0;
+ let includedCount = 0;
+ let excludedCount = 0;
+ for (const r of rows) {
+ totalReclaimBytes += r.transcribedBytes;
+ if (r.included) {
+ includedCount++;
+ includedReclaimBytes += r.transcribedBytes;
+ includedExtraFormatsBytes += r.extraFormatsBytes;
+ includedForeignBytes += r.foreignBytes;
+ } else {
+ excludedCount++;
+ }
+ }
+
+ return {
+ rows,
+ includedReclaimBytes,
+ totalReclaimBytes,
+ includedExtraFormatsBytes,
+ includedForeignBytes,
+ includedCount,
+ excludedCount,
+ };
+}
+
+// Lean total for the sidebar badge and the widget API — sums only the primary
+// "clean audio" reclaim over channels NOT excluded from the cleanup total. Reads
+// the small snapshot JSONs (no failed-list reads), so it's cheap on the
+// AutoRefresh cadence / per widget poll.
+export async function cleanableTotalBytes(paths: Paths): Promise<number> {
+ const channels = await listChannels(paths);
+ const perChannel = await Promise.all(
+ channels.map(async (channel) => {
+ if (channel.config.excludeFromCleanup) return 0;
+ const snap = await readChannelSnapshot(paths, channel.slug);
+ return transcribedBytesOf(snap);
+ }),
+ );
+ return perChannel.reduce((a, b) => a + b, 0);
+}
diff --git a/editor/app/cleanup/page.tsx b/editor/app/cleanup/page.tsx
@@ -0,0 +1,167 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
+import { Alert, AlertDescription } from "yt-dlp-transcript-common/components/ui/alert";
+import { loadCleanupSummary } from "./lib/loadCleanup";
+import { ChannelCleanupCard } from "./components/ChannelCleanupCard";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = { title: "Cleanup" };
+
+export default async function CleanupPage() {
+ const paths = getPaths();
+ const summary = await loadCleanupSummary(paths);
+ const {
+ rows,
+ includedReclaimBytes,
+ totalReclaimBytes,
+ includedExtraFormatsBytes,
+ includedForeignBytes,
+ includedCount,
+ excludedCount,
+ } = summary;
+
+ const excludedReclaimBytes = totalReclaimBytes - includedReclaimBytes;
+ const hasAnything = rows.length > 0;
+
+ // Breakdown bar segments (share of the three reclaimable estimates across
+ // INCLUDED channels). They overlap — alternative sweeps, not additive — so the
+ // bar is a relative sense of where reclaimable audio sits, not a sum.
+ const segTotal =
+ includedReclaimBytes + includedExtraFormatsBytes + includedForeignBytes;
+ const seg = (n: number) => (segTotal > 0 ? (n / segTotal) * 100 : 0);
+
+ return (
+ <div className="flex flex-col gap-6">
+ <div className="flex items-center justify-between flex-wrap gap-2">
+ <h1 className="text-2xl font-semibold">Cleanup</h1>
+ </div>
+
+ {/* Hero readout — the reclamation console. */}
+ <section
+ aria-label="reclaimable total"
+ className="flex flex-col gap-4 rounded-xl border border-border bg-card p-5"
+ >
+ <div className="flex flex-wrap items-end justify-between gap-3">
+ <div>
+ <p className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
+ Reclaimable across {includedCount} channel
+ {includedCount === 1 ? "" : "s"}
+ </p>
+ <p
+ className="font-display text-5xl font-semibold tabular-nums text-foreground"
+ aria-label="total reclaimable bytes"
+ >
+ {formatBytes(includedReclaimBytes)}
+ </p>
+ </div>
+ {excludedCount > 0 && (
+ <p className="font-mono text-xs text-muted-foreground">
+ {excludedCount} channel{excludedCount === 1 ? "" : "s"} excluded
+ {excludedReclaimBytes > 0 && (
+ <> · ~{formatBytes(excludedReclaimBytes)} held back</>
+ )}
+ </p>
+ )}
+ </div>
+
+ {segTotal > 0 && (
+ <div className="flex flex-col gap-1.5">
+ <div
+ className="flex h-2 w-full overflow-hidden rounded-full bg-muted"
+ role="img"
+ aria-label="reclaimable audio breakdown"
+ >
+ <span
+ className="h-full bg-[var(--chart-1)]"
+ style={{ width: `${seg(includedReclaimBytes)}%` }}
+ />
+ <span
+ className="h-full bg-[var(--chart-3)]"
+ style={{ width: `${seg(includedExtraFormatsBytes)}%` }}
+ />
+ <span
+ className="h-full bg-[var(--chart-5)]"
+ style={{ width: `${seg(includedForeignBytes)}%` }}
+ />
+ </div>
+ <div className="flex flex-wrap gap-x-4 gap-y-1 font-mono text-[11px] text-muted-foreground">
+ <Legend swatch="var(--chart-1)" label="Transcribed audio" value={includedReclaimBytes} />
+ <Legend swatch="var(--chart-3)" label="Extra formats" value={includedExtraFormatsBytes} />
+ <Legend swatch="var(--chart-5)" label="Wrong-format" value={includedForeignBytes} />
+ </div>
+ </div>
+ )}
+
+ <Alert role="status">
+ <AlertDescription>
+ The total is the primary <strong>Clean audio</strong> reclaim (audio
+ kept beside finished transcripts). Extra-format and wrong-format
+ sweeps overlap it, so they're shown per channel rather than
+ added in. Per-video <em>do-not-clean</em> pins and the keep-latest
+ window are already excluded. Toggle a channel to hold its space back
+ from the total without losing the sweep.
+ </AlertDescription>
+ </Alert>
+ </section>
+
+ {!hasAnything ? (
+ <p className="text-sm text-muted-foreground border border-dashed border-border rounded p-4">
+ No channels yet.
+ </p>
+ ) : (
+ <section aria-label="channel cleanup ledger" className="flex flex-col gap-3">
+ {rows.map((r) => (
+ <ChannelCleanupCard
+ key={r.channel.slug}
+ slug={r.channel.slug}
+ name={r.channel.config.name ?? r.channel.slug}
+ included={r.included}
+ transcribedBytes={r.transcribedBytes}
+ extraFormatsBytes={r.extraFormatsBytes}
+ foreignBytes={r.foreignBytes}
+ hasTargetFormat={r.hasTargetFormat}
+ failedTranscriptions={r.failedTranscriptions}
+ failedTranscodings={r.failedTranscodings}
+ />
+ ))}
+ </section>
+ )}
+
+ <p className="text-xs text-muted-foreground">
+ Estimates come from each channel's last report — run{" "}
+ <Link href="/actionable" className="underline hover:text-foreground">
+ Refresh reports
+ </Link>{" "}
+ to recompute. Whole-channel and per-video deletion stay on the{" "}
+ <Link href="/channels" className="underline hover:text-foreground">
+ channel
+ </Link>{" "}
+ pages.
+ </p>
+ </div>
+ );
+}
+
+function Legend({
+ swatch,
+ label,
+ value,
+}: {
+ swatch: string;
+ label: string;
+ value: number;
+}) {
+ return (
+ <span className="inline-flex items-center gap-1.5">
+ <span
+ aria-hidden="true"
+ className="inline-block h-2 w-2 rounded-[2px]"
+ style={{ background: swatch }}
+ />
+ {label} · {value > 0 ? `~${formatBytes(value)}` : "—"}
+ </span>
+ );
+}
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -4,6 +4,8 @@ import type { Metadata } from "next";
import { Suspense } from "react";
import Link from "next/link";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { getLatestChangelogDate } from "yt-dlp-transcript-common/lib/changelog";
import { listSites } from "yt-dlp-transcript-common/lib/site";
@@ -19,6 +21,7 @@ import { ChangelogNavLink } from "./components/ChangelogNavLink";
import { SiteScopeSelect } from "./components/SiteScopeSelect";
import { Toaster } from "yt-dlp-transcript-common/components/ui/sonner";
import { NAV_GROUPS, type NavLink } from "./lib/nav";
+import { cleanableTotalBytes } from "./cleanup/lib/loadCleanup";
import "./globals.css";
export async function generateMetadata(): Promise<Metadata> {
@@ -58,6 +61,9 @@ export default async function RootLayout({
(j) => j.status === "running" || j.status === "queued",
).length;
const runningJobs = allJobs.filter((j) => j.status === "running").length;
+ // Aggregate reclaimable disk across channels not excluded from the total.
+ // Recomputes on each AutoRefresh tick (same cadence as the job pills).
+ const cleanableBytes = await cleanableTotalBytes(getPaths());
const latestChangelogDate = readChangelogLatestDate();
const sites = listSites().map((s) => ({
siteId: s.siteId,
@@ -84,6 +90,22 @@ export default async function RootLayout({
/>
);
}
+ if (link.badgeKey === "cleanable") {
+ return (
+ <Link key={link.href} href={link.href} className={navItemClass}>
+ <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
+ <span>{link.label}</span>
+ {cleanableBytes > 0 && (
+ <span
+ aria-label={`${formatBytes(cleanableBytes)} reclaimable`}
+ className="ml-auto text-xs rounded-full border border-warning/30 bg-warning-soft text-warning px-2 py-0.5 leading-none tabular-nums"
+ >
+ {formatBytes(cleanableBytes)}
+ </span>
+ )}
+ </Link>
+ );
+ }
const badgeCount =
link.badgeKey === "jobs"
? activeJobs
diff --git a/editor/app/lib/nav.ts b/editor/app/lib/nav.ts
@@ -17,6 +17,7 @@ import {
TriangleAlert,
Tv,
Bookmark,
+ Trash2,
} from "lucide-react";
// Single source of truth for the editor's primary navigation. Consumed by BOTH
@@ -25,7 +26,7 @@ import {
// palette hard-coded a stale 5-item subset. Icons are used by the palette and
// the compact sidebar; `badgeKey` drives the live count pills / changelog dot.
-export type NavBadgeKey = "jobs" | "running" | "changelog";
+export type NavBadgeKey = "jobs" | "running" | "changelog" | "cleanable";
export type NavLink = {
href: string;
@@ -62,6 +63,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ href: "/auto-queue", label: "Auto-queue", icon: ListPlus, keywords: "auto runner policy" },
{ href: "/build", label: "Build", icon: Hammer, keywords: "static export" },
{ href: "/actionable", label: "Actionable", icon: TriangleAlert, keywords: "needs attention todo" },
+ { href: "/cleanup", label: "Cleanup", icon: Trash2, badgeKey: "cleanable", keywords: "clean reclaim disk space prune audio" },
{ href: "/saved-videos", label: "Saved videos", icon: Bookmark, keywords: "backup" },
],
},
diff --git a/editor/app/scheduler/components/SchedulerView.tsx b/editor/app/scheduler/components/SchedulerView.tsx
@@ -193,23 +193,23 @@ type ChannelRow = SchedulerStatusPayload["channels"][number];
function statusBadge(c: ChannelRow) {
if (c.intervalMinutes === 0 || !c.autoSyncEligible) {
- return <span className="text-zinc-400">—</span>;
+ return <span className="text-muted-foreground">—</span>;
}
if (c.nextEligibleAt != null && c.nextEligibleAt > Date.now()) {
return (
- <span className="text-amber-700 dark:text-amber-300">
+ <span className="text-warning">
backoff ({c.consecutiveFailures} fail
{c.consecutiveFailures === 1 ? "" : "s"})
</span>
);
}
if (c.lastOutcome === "failed") {
- return <span className="text-red-700 dark:text-red-300">last failed</span>;
+ return <span className="text-destructive">last failed</span>;
}
if (c.overdue) {
- return <span className="text-blue-700 dark:text-blue-300">due</span>;
+ return <span className="text-info">due</span>;
}
- return <span className="text-green-700 dark:text-green-300">ok</span>;
+ return <span className="text-success">ok</span>;
}
function nextDueLabel(c: ChannelRow, now: number): string {
diff --git a/editor/app/widget/builder/components/WidgetBuilder.tsx b/editor/app/widget/builder/components/WidgetBuilder.tsx
@@ -6,6 +6,7 @@ import {
WIDGET_DEFAULTS,
type WidgetConfig,
} from "../../lib/config";
+import { loadWidgetConfig, saveWidgetConfig } from "../widgetConfigStorage";
const SIZE_PRESETS: { label: string; width: number; height: number }[] = [
{ label: "Small", width: 320, height: 200 },
@@ -18,12 +19,28 @@ export function WidgetBuilder() {
const [size, setSize] = useState(SIZE_PRESETS[0]);
const [origin, setOrigin] = useState("");
const [copied, setCopied] = useState(false);
+ // Gate persistence until the stored config has loaded, so the initial
+ // defaults render (SSR-stable) isn't written over the saved value.
+ const [loaded, setLoaded] = useState(false);
// Resolve the absolute origin on the client to avoid an SSR/CSR mismatch.
useEffect(() => {
setOrigin(window.location.origin);
}, []);
+ // Restore the last-used builder config from localStorage on mount (client
+ // only — defaults render first on both server and client to avoid a hydration
+ // mismatch, then this overlays the saved form state).
+ useEffect(() => {
+ setConfig(loadWidgetConfig());
+ setLoaded(true);
+ }, []);
+
+ // Persist the form on every change once the saved value has been restored.
+ useEffect(() => {
+ if (loaded) saveWidgetConfig(config);
+ }, [config, loaded]);
+
const query = useMemo(() => buildWidgetQuery(config), [config]);
const relativeUrl = query ? `/widget?${query}` : "/widget";
const fullUrl = origin ? `${origin}${relativeUrl}` : relativeUrl;
@@ -98,6 +115,11 @@ export function WidgetBuilder() {
onChange={(v) => patch({ disk: v })}
/>
<Check
+ label="Show cleanable indicator"
+ checked={config.cleanable}
+ onChange={(v) => patch({ cleanable: v })}
+ />
+ <Check
label="Show worker names"
checked={config.workerLabels}
onChange={(v) => patch({ workerLabels: v })}
diff --git a/editor/app/widget/builder/widgetConfigStorage.ts b/editor/app/widget/builder/widgetConfigStorage.ts
@@ -0,0 +1,87 @@
+// localStorage layer for the /widget/builder form so it reopens with your last
+// configuration. Mirrors editor/app/jobs/jobsFilterStorage.ts (versioned key,
+// SSR-guarded, reset on malformed/version-mismatch). This persists ONLY the
+// builder's form state — the shared/embedded /widget URL stays authoritative for
+// what actually renders (see buildWidgetQuery).
+
+import { WIDGET_DEFAULTS, type WidgetConfig } from "../lib/config";
+
+const KEY = "ytdlp-tb:widget-config";
+const VERSION = 1;
+
+type StoredWidgetConfig = { v: 1; config: WidgetConfig };
+
+// Overlay validated fields onto the defaults so the saved form survives the
+// config gaining new fields (the new field simply takes its default).
+function parseStored(raw: string): WidgetConfig | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (!parsed || typeof parsed !== "object") return null;
+ const outer = parsed as Record<string, unknown>;
+ if (outer.v !== VERSION) return null;
+ const c = outer.config;
+ if (!c || typeof c !== "object") return null;
+ const r = c as Record<string, unknown>;
+ const next: WidgetConfig = { ...WIDGET_DEFAULTS };
+ for (const key of Object.keys(WIDGET_DEFAULTS) as (keyof WidgetConfig)[]) {
+ const v = r[key];
+ if (key === "channel") {
+ if (typeof v === "string") next.channel = v || undefined;
+ continue;
+ }
+ if (key === "pollSeconds") {
+ if (typeof v === "number" && Number.isFinite(v) && v >= 1) {
+ next.pollSeconds = Math.min(3600, Math.round(v));
+ }
+ continue;
+ }
+ if (typeof v === "boolean") {
+ (next[key] as boolean) = v;
+ }
+ }
+ return next;
+}
+
+export function loadWidgetConfig(): WidgetConfig {
+ if (typeof window === "undefined") return { ...WIDGET_DEFAULTS };
+ let raw: string | null = null;
+ try {
+ raw = window.localStorage.getItem(KEY);
+ } catch {
+ return { ...WIDGET_DEFAULTS };
+ }
+ if (raw === null) return { ...WIDGET_DEFAULTS };
+ const parsed = parseStored(raw);
+ if (!parsed) {
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ /* ignore */
+ }
+ return { ...WIDGET_DEFAULTS };
+ }
+ return parsed;
+}
+
+export function saveWidgetConfig(config: WidgetConfig): void {
+ if (typeof window === "undefined") return;
+ try {
+ const payload: StoredWidgetConfig = { v: VERSION, config };
+ window.localStorage.setItem(KEY, JSON.stringify(payload));
+ } catch {
+ /* quota/serialization errors are non-fatal */
+ }
+}
+
+export function clearWidgetConfig(): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ /* ignore */
+ }
+}
diff --git a/editor/app/widget/components/MonitorWidget.tsx b/editor/app/widget/components/MonitorWidget.tsx
@@ -96,6 +96,12 @@ export function MonitorWidget({
pollMs,
initialWorkers,
);
+ const { data: cleanablePayload } = usePolledPayload<CleanablePayload>(
+ "/api/widget/cleanable",
+ config.cleanable,
+ pollMs,
+ null,
+ );
const jobs = (jobsPayload?.jobs ?? []).filter(
(j) => !config.channel || j.channelSlug === config.channel,
@@ -127,6 +133,9 @@ export function MonitorWidget({
/>
)}
{config.disk && disk?.enabled && <DiskStrip disk={disk} />}
+ {config.cleanable && cleanablePayload && (
+ <CleanableStrip bytes={cleanablePayload.bytes} />
+ )}
{config.workers && (
<WorkersStrip
workers={workers}
@@ -151,14 +160,14 @@ export function MonitorWidget({
// Compact state → dot color, mirroring stateBadge() in WorkersView.
function workerDotClass(w: WorkerView): string {
- if (w.degraded) return "bg-red-500";
+ if (w.degraded) return "bg-destructive";
switch (w.state) {
case "enabled":
- return w.busy ? "bg-green-500" : "bg-green-500/40";
+ return w.busy ? "bg-success" : "bg-success/40";
case "draining":
- return "bg-amber-500";
+ return "bg-warning";
default:
- return "bg-zinc-400 dark:bg-zinc-600";
+ return "bg-muted-foreground/50";
}
}
@@ -234,13 +243,13 @@ function DiskStrip({ disk }: { disk: DiskStatusView }) {
aria-label="Disk space"
className={`flex items-center gap-2 rounded border px-2 py-1 text-xs ${
disk.low
- ? "border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-950 text-red-800 dark:text-red-200"
- : "border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-600 dark:text-zinc-300"
+ ? "border-destructive/30 bg-destructive-soft text-destructive"
+ : "border-border bg-card text-muted-foreground"
}`}
>
<span
className={`inline-block h-2 w-2 shrink-0 rounded-full ${
- disk.low ? "bg-red-500" : "bg-green-500/60"
+ disk.low ? "bg-destructive" : "bg-success/60"
}`}
/>
<span>
@@ -256,6 +265,32 @@ function DiskStrip({ disk }: { disk: DiskStatusView }) {
);
}
+type CleanablePayload = { bytes: number };
+
+function CleanableStrip({ bytes }: { bytes: number }) {
+ return (
+ <section
+ aria-label="Cleanable data"
+ className={`flex items-center gap-2 rounded border px-2 py-1 text-xs ${
+ bytes > 0
+ ? "border-warning/30 bg-warning-soft text-warning"
+ : "border-border bg-card text-muted-foreground"
+ }`}
+ >
+ <span
+ className={`inline-block h-2 w-2 shrink-0 rounded-full ${
+ bytes > 0 ? "bg-warning" : "bg-muted-foreground/50"
+ }`}
+ />
+ <span className="tabular-nums">
+ {bytes > 0
+ ? `${formatBytes(bytes)} reclaimable`
+ : "Nothing to reclaim"}
+ </span>
+ </section>
+ );
+}
+
function ActiveJobsStrip({
jobs,
showTitle,
diff --git a/editor/app/widget/lib/config.ts b/editor/app/widget/lib/config.ts
@@ -28,6 +28,9 @@ export type WidgetConfig = {
eta: boolean;
// Show the free-disk indicator strip (when the disk gate is enabled).
disk: boolean;
+ // Show the cleanable-data indicator strip (total reclaimable audio across
+ // channels not excluded from the cleanup total). Off by default.
+ cleanable: boolean;
// Show worker names in the Workers strip. Off = colored dots only (denser).
workerLabels: boolean;
// Opt-in interactive controls (Pause/Resume Transcriptions, Drain all). Off by
@@ -47,6 +50,7 @@ export const WIDGET_DEFAULTS: WidgetConfig = {
headingProgress: false,
eta: true,
disk: true,
+ cleanable: false,
workerLabels: true,
controls: false,
};
@@ -89,6 +93,7 @@ export function parseWidgetConfig(params: RawParams): WidgetConfig {
headingProgress: parseBool(params.headtext, WIDGET_DEFAULTS.headingProgress),
eta: parseBool(params.eta, WIDGET_DEFAULTS.eta),
disk: parseBool(params.disk, WIDGET_DEFAULTS.disk),
+ cleanable: parseBool(params.clean, WIDGET_DEFAULTS.cleanable),
workerLabels: parseBool(params.wnames, WIDGET_DEFAULTS.workerLabels),
controls: parseBool(params.controls, WIDGET_DEFAULTS.controls),
};
@@ -116,6 +121,8 @@ export function buildWidgetQuery(config: WidgetConfig): string {
if (config.eta !== WIDGET_DEFAULTS.eta) sp.set("eta", config.eta ? "1" : "0");
if (config.disk !== WIDGET_DEFAULTS.disk)
sp.set("disk", config.disk ? "1" : "0");
+ if (config.cleanable !== WIDGET_DEFAULTS.cleanable)
+ sp.set("clean", config.cleanable ? "1" : "0");
if (config.workerLabels !== WIDGET_DEFAULTS.workerLabels)
sp.set("wnames", config.workerLabels ? "1" : "0");
if (config.controls !== WIDGET_DEFAULTS.controls)
diff --git a/editor/app/workers/components/WorkersView.tsx b/editor/app/workers/components/WorkersView.tsx
@@ -174,32 +174,29 @@ export function WorkersView({ initial }: { initial: WorkersPayload }) {
);
}
+// Token-driven semantic tones so worker state recolors with every theme family.
function stateBadge(w: WorkerView): { label: string; className: string } {
if (w.degraded) {
return {
label: "degraded",
- className:
- "bg-red-100 dark:bg-red-950 text-red-800 dark:text-red-200 border-red-300 dark:border-red-800",
+ className: "bg-destructive-soft text-destructive border-destructive/30",
};
}
switch (w.state) {
case "enabled":
return {
label: w.busy ? "busy" : "idle",
- className:
- "bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-200 border-green-300 dark:border-green-800",
+ className: "bg-success-soft text-success border-success/30",
};
case "draining":
return {
label: "draining",
- className:
- "bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-200 border-amber-300 dark:border-amber-800",
+ className: "bg-warning-soft text-warning border-warning/30",
};
default:
return {
label: "disabled",
- className:
- "bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300 border-zinc-300 dark:border-zinc-700",
+ className: "bg-muted text-muted-foreground border-border",
};
}
}
diff --git a/editor/e2e/cleanup-page.spec.ts b/editor/e2e/cleanup-page.spec.ts
@@ -0,0 +1,81 @@
+import { writeFile } from "node:fs/promises";
+import { test, expect } from "@playwright/test";
+import { readJson, resetData, resolvePath } from "./helpers";
+import { baseUrl } from "./baseUrl";
+import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig";
+
+// The dedicated /cleanup page aggregates each channel's reclaimable audio (from
+// the snapshot's cleanupBytes) into a headline total, and a per-channel toggle
+// flips ChannelConfig.excludeFromCleanup to hold a channel's bytes out of that
+// total without disabling its sweeps. See editor/app/cleanup/*.
+
+const SLUG = "test-transcribe";
+const CONFIG_REL = `test-transcripts/channels/${SLUG}/config.json`;
+
+function dataRel(videoId: string, file: string): string {
+ return `test-transcripts/channels/${SLUG}/data/${videoId}/${file}`;
+}
+
+async function seedTranscript(videoId: string): Promise<void> {
+ await writeFile(
+ resolvePath(dataRel(videoId, "transcript.json")),
+ '{"transcription":[]}\n',
+ );
+}
+
+test("cleanup page totals reclaimable audio and the per-channel toggle excludes it", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ await seedTranscript("vidA");
+ await seedTranscript("vidB");
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
+
+ // Visiting the channel page regenerates the snapshot (with cleanupBytes).
+ await page.goto(`/channels/${SLUG}`);
+
+ // The cleanup page hero counts the included channel.
+ await page.goto("/cleanup");
+ await expect(page.getByText("Reclaimable across 1 channel")).toBeVisible();
+
+ const toggle = page.getByRole("button", {
+ name: `toggle cleanup-total inclusion for ${SLUG}`,
+ });
+ await expect(toggle).toHaveText(/Counted/);
+ await expect(toggle).toHaveAttribute("aria-pressed", "true");
+
+ // Excluding the channel writes the flag and drops it from the total.
+ await toggle.click();
+ await expect(
+ page.getByRole("button", {
+ name: `toggle cleanup-total inclusion for ${SLUG}`,
+ }),
+ ).toHaveText(/Excluded/);
+ await expect(
+ page.getByLabel(`${SLUG} excluded from total`),
+ ).toBeVisible();
+ {
+ const cfg = await readJson<ChannelConfig>(CONFIG_REL);
+ expect(cfg.excludeFromCleanup).toBe(true);
+ }
+ await page.reload();
+ await expect(page.getByText("Reclaimable across 0 channels")).toBeVisible();
+
+ // Re-including restores it.
+ await page
+ .getByRole("button", {
+ name: `toggle cleanup-total inclusion for ${SLUG}`,
+ })
+ .click();
+ await expect(
+ page.getByRole("button", {
+ name: `toggle cleanup-total inclusion for ${SLUG}`,
+ }),
+ ).toHaveText(/Counted/);
+ {
+ const cfg = await readJson<ChannelConfig>(CONFIG_REL);
+ expect(cfg.excludeFromCleanup).toBeUndefined();
+ }
+ await page.reload();
+ await expect(page.getByText("Reclaimable across 1 channel")).toBeVisible();
+});
diff --git a/editor/e2e/truncated-check.spec.ts b/editor/e2e/truncated-check.spec.ts
@@ -0,0 +1,98 @@
+// The per-video "Exclude from truncated check" toggle writes an
+// exclude-truncated-check.json marker that suppresses the incomplete/short
+// detection everywhere it surfaces: the snapshot's incompleteTranscript bucket
+// AND the video panel's "looks truncated" banner (which computes independently
+// of the snapshot). For videos that legitimately have little speech. See
+// common/lib/excludeTruncatedCheck*.ts + common/controller/channelSnapshot.ts.
+
+import { mkdir, writeFile } from "node:fs/promises";
+import { test, expect } from "@playwright/test";
+import { pathExists, readJson, resetData, resolvePath } from "./helpers";
+import { baseUrl } from "./baseUrl";
+
+const CHANNEL = "test-transcribe";
+const DATA = `test-transcripts/channels/${CHANNEL}/data`;
+const SNAPSHOT_REL = `test-transcripts/channels/${CHANNEL}/snapshot.json`;
+const MARKER_REL = `${DATA}/vidTrunc/exclude-truncated-check.json`;
+
+const WHISPER = JSON.stringify({ transcription: [{ text: "hello" }] });
+
+async function writeFlaggedVideo(id: string): Promise<void> {
+ const dir = resolvePath(`${DATA}/${id}`);
+ await mkdir(dir, { recursive: true });
+ await writeFile(`${dir}/transcript.json`, WHISPER);
+ await writeFile(`${dir}/audio.m4a`, "fake-audio");
+ // 2h22m video, transcript stops at ~6:52 → 4.8% coverage → flagged.
+ await writeFile(
+ `${dir}/transcript.cues.json`,
+ JSON.stringify({
+ version: 1,
+ source: "whisper",
+ duration: 8541,
+ isLivestream: false,
+ cues: [{ start: 0, end: 412, text: "hello" }],
+ }),
+ );
+}
+
+test("excluding a video from the truncated check clears the banner and the snapshot bucket", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel");
+ await writeFlaggedVideo("vidTrunc");
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
+
+ // Snapshot flags it.
+ await page.goto(`/channels/${CHANNEL}`);
+ {
+ const snap = await readJson<{
+ buckets: { incompleteTranscript?: string[] };
+ }>(SNAPSHOT_REL);
+ expect(snap.buckets.incompleteTranscript).toContain("vidTrunc");
+ }
+
+ // The video panel shows the "looks truncated" banner.
+ await page.goto(`/channels/${CHANNEL}/videos/vidTrunc`);
+ await expect(page.getByLabel("incomplete transcript")).toBeVisible();
+
+ // Exclude it from the check (the toggle lives in a collapsed stage card).
+ await page
+ .getByLabel("Truncated check stage summary")
+ .click();
+ await page
+ .getByRole("button", {
+ name: "exclude video vidTrunc from truncated check",
+ })
+ .click();
+ // Banner clears (the panel recomputes after revalidation).
+ await expect(page.getByLabel("incomplete transcript")).toBeHidden();
+ expect(await pathExists(MARKER_REL)).toBe(true);
+
+ // Snapshot regen drops it from the bucket. The regen is debounced (~1s) after
+ // the mutation, so poll the on-disk snapshot rather than reading it once.
+ await page.goto(`/channels/${CHANNEL}`);
+ await expect
+ .poll(
+ async () => {
+ const snap = await readJson<{
+ buckets: { incompleteTranscript?: string[] };
+ }>(SNAPSHOT_REL);
+ return snap.buckets.incompleteTranscript ?? [];
+ },
+ { timeout: 15_000 },
+ )
+ .not.toContain("vidTrunc");
+
+ // Re-enabling the check restores the flag.
+ await page.goto(`/channels/${CHANNEL}/videos/vidTrunc`);
+ await page
+ .getByLabel("Truncated check stage summary")
+ .click();
+ await page
+ .getByRole("button", {
+ name: "include video vidTrunc in truncated check",
+ })
+ .click();
+ await expect(page.getByLabel("incomplete transcript")).toBeVisible();
+ expect(await pathExists(MARKER_REL)).toBe(false);
+});