Archilyzer · Source

archilyzer

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

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:
Acommon/components/ui/alert.tsx | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/ui/badge.tsx | 9+++++++++
Mcommon/controller/channelSnapshot.ts | 15++++++++++++++-
Mcommon/lib/channelConfig.ts | 9+++++++++
Acommon/lib/excludeTruncatedCheck-server.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/excludeTruncatedCheck.ts | 20++++++++++++++++++++
Mcommon/styles/tokens.css | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 4++++
Aeditor/app/api/widget/cleanable/route.ts | 15+++++++++++++++
Meditor/app/channels/[slug]/components/StageBadge.tsx | 14+++++++-------
Meditor/app/channels/[slug]/page.tsx | 15+++++++++++----
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/app/channels/[slug]/videos/[id]/page.tsx | 12+++++++++---
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 26++++++++++++++++++++++++++
Meditor/app/channels/actions.ts | 22++++++++++++++++++++++
Aeditor/app/cleanup/components/ChannelCleanupCard.tsx | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/cleanup/components/ChannelCleanupToggle.tsx | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/cleanup/lib/loadCleanup.ts | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/cleanup/page.tsx | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/layout.tsx | 22++++++++++++++++++++++
Meditor/app/lib/nav.ts | 4+++-
Meditor/app/scheduler/components/SchedulerView.tsx | 10+++++-----
Meditor/app/widget/builder/components/WidgetBuilder.tsx | 22++++++++++++++++++++++
Aeditor/app/widget/builder/widgetConfigStorage.ts | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/widget/components/MonitorWidget.tsx | 49++++++++++++++++++++++++++++++++++++++++++-------
Meditor/app/widget/lib/config.ts | 7+++++++
Meditor/app/workers/components/WorkersView.tsx | 13+++++--------
Aeditor/e2e/cleanup-page.spec.ts | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/truncated-check.spec.ts | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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&apos;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&apos;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); +});