commit 6fe4d49dcca8df47d2d7cbccdcfe69bc546fad16
parent 1a3f53284fce5a35d35e48cb620ae8eba6fcba4b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 25 Jun 2026 01:34:31 -0400
Phase 5: first-class video-persistence UI (Saved Videos area, retention controls, per-video persist)
The final phase of the video-persistence subsystem makes phases 1-4
driveable end to end from the editor.
Saved Videos area (new /saved-videos route, in the Pool nav):
- Store summary (total count/size, channels, how many carry a backup
checksum), default store dir, last-backup time + scheduled cadence.
- Per-channel breakdown table linking back to each channel.
- Backup configuration form (dest / scheduled enable / interval) saved via
saveSavedVideoBackupAction, plus Back up now / Verify backup buttons
(disabled until a dest is set).
- Moved the saved-video actions from editor/app/savedVideos/ to
editor/app/saved-videos/ to match the route (runTick import updated).
Channel Cleanup stage - Retention & persistence section (shown when
keep-latest is on or the channel has saved videos): live keep-latest /
in-window / saved-count / saved-size stats and three buttons - Check kept
videos, Persist kept now, Back up saved videos.
Bulk persist pass: common/controller/persistKept.ts + persistKeptAction
re-fetch the source container (keepSourceVideoOverride, appendArchive) for
any in-window video whose source isn't saved yet, skipping already-saved /
no-URL videos; runs on the channel download queue with a disk-space gate.
Registered in runJobSpec (re-runnable from bookmarks) + job-kind label.
Channel settings form: new Retention & persistence section - keep latest
(window size), extraction mode (yt-dlp vs app-side ffmpeg), and a
per-channel saved-video store dir override. Parsed + cleared-on-edit via
parseChannelForm (added to CHANNEL_FORM_FIELDS).
Video page: Source video card shows persisted status (file, size, stored
time, keep reason, sha256, location) with an Unpersist control
(unpersistVideoAction -> unpersistSavedVideo, moves the container back to
the data dir), or a Persist source video button (redownloadToArchiveAction)
when not yet saved.
Verification: common + editor typecheck clean; 30 saved-video unit tests
pass incl. new common/controller/persistKept.test.ts (keep-latest-off and
all-already-saved orchestration paths); new editor/e2e/saved-videos.spec.ts
(4 tests: store listing, backup-config save, channel retention section,
video unpersist control) green.
Deferred: Actionable-page kept-check/persist rows (needs loadActionable
summary plumbing) and streaming the player directly from the store
(unpersist restores the container to the data dir to play it).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
20 files changed, 1206 insertions(+), 81 deletions(-)
diff --git a/common/controller/persistKept.test.ts b/common/controller/persistKept.test.ts
@@ -0,0 +1,84 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import type { Paths } from "../lib/paths";
+import { persistSourceVideo } from "../lib/savedVideo-server";
+import { persistKept } from "./persistKept";
+
+// Run with:
+// pnpm --filter yt-dlp-transcript-common exec tsx --test controller/persistKept.test.ts
+//
+// These cover the orchestration paths that don't reach downloadOneManaged (no
+// network): keep-latest off, and an all-already-saved window. The download path
+// is exercised via redownloadToArchiveAction in the editor e2e suite.
+
+async function withPaths(fn: (paths: Paths) => Promise<void>): Promise<void> {
+ const dir = await mkdtemp(path.join(tmpdir(), "ttb-persistkept-"));
+ const paths = {
+ channelsDir: path.join(dir, "channels"),
+ savedVideosDir: path.join(dir, "saved"),
+ } as Paths;
+ try {
+ await fn(paths);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
+// Seed a data dir whose dir name carries a YYYYMMDD_ recency prefix (so
+// computeKeptVideoIds can order it without metadata) and persist its container.
+async function seedSaved(
+ paths: Paths,
+ slug: string,
+ id: string,
+): Promise<void> {
+ const videoDir = path.join(paths.channelsDir, slug, "data", id);
+ await mkdir(videoDir, { recursive: true });
+ await writeFile(path.join(videoDir, "source-media.mp4"), id);
+ await persistSourceVideo({
+ videoDir,
+ sourceFilename: "source-media.mp4",
+ storeDir: path.join(paths.savedVideosDir, slug, id),
+ keepReason: "keep-latest",
+ });
+}
+
+test("persistKept is a no-op when keep-latest is off", async () => {
+ await withPaths(async (paths) => {
+ await seedSaved(paths, "alpha", "20240101_a");
+ const res = await persistKept({
+ paths,
+ channelSlug: "alpha",
+ channelConfig: { handling: "transcribe" },
+ });
+ assert.deepEqual(res, {
+ kept: 0,
+ alreadySaved: 0,
+ persisted: 0,
+ failed: 0,
+ skippedNoUrl: 0,
+ });
+ });
+});
+
+test("persistKept counts in-window videos already saved without re-downloading", async () => {
+ await withPaths(async (paths) => {
+ await seedSaved(paths, "alpha", "20240101_a");
+ await seedSaved(paths, "alpha", "20240202_b");
+ await seedSaved(paths, "alpha", "20240303_c");
+ // Window of 2 -> the two newest (c, b) are kept; both are already saved, so
+ // no download is attempted (which would fail in this offline test).
+ const res = await persistKept({
+ paths,
+ channelSlug: "alpha",
+ channelConfig: { handling: "transcribe", keepLatest: 2 },
+ });
+ assert.equal(res.kept, 2);
+ assert.equal(res.alreadySaved, 2);
+ assert.equal(res.persisted, 0);
+ assert.equal(res.failed, 0);
+ assert.equal(res.skippedNoUrl, 0);
+ });
+});
diff --git a/common/controller/persistKept.ts b/common/controller/persistKept.ts
@@ -0,0 +1,119 @@
+import path from "node:path";
+import type { Paths } from "../lib/paths";
+import type { ChannelConfig } from "../lib/channelConfig";
+import { getSettings } from "../lib/settings";
+import { isSavedVideo } from "../lib/savedVideo-server";
+import { computeKeptVideoIds } from "./keptVideos";
+import { findVideoSourceUrl } from "./undownloadedVideos";
+import { downloadOneManaged } from "../ytdlp/downloadOneManaged";
+
+// Bulk "persist kept now" pass (Phase 5). Ensures every video currently in the
+// channel's keep-latest window has its source container saved to the store, for
+// the catch-up case where the window was widened (or persistence was enabled)
+// after those videos had already been downloaded audio-only.
+//
+// For each kept video whose source isn't saved yet, it re-fetches the container
+// via downloadOneManaged with keepSourceVideoOverride (which app-extracts audio
+// and moves the container into the saved store), without disturbing the existing
+// transcript. Mirrors redownloadToArchiveAction (videoActions.ts) but loops over
+// the whole window. Videos already saved, or whose URL can't be resolved, are
+// skipped rather than failing the pass.
+
+export type PersistKeptResult = {
+ // Total videos in the keep-latest window.
+ kept: number;
+ // Already had a saved-video pointer; left untouched.
+ alreadySaved: number;
+ // Newly persisted this pass.
+ persisted: number;
+ // Re-download attempted but failed.
+ failed: number;
+ // No resolvable source URL (no metadata + not in the playlist).
+ skippedNoUrl: number;
+};
+
+export async function persistKept({
+ paths,
+ channelSlug,
+ channelConfig,
+ onLog,
+ signal,
+}: {
+ paths: Paths;
+ channelSlug: string;
+ channelConfig: ChannelConfig;
+ onLog?: (line: string) => void;
+ signal?: AbortSignal;
+}): Promise<PersistKeptResult> {
+ const log = (line: string) => onLog?.(line.endsWith("\n") ? line : `${line}\n`);
+ // downloadOneManaged requires a non-optional onLog/signal; supply inert
+ // fallbacks so persistKept stays callable without a managed-job context.
+ const downloadLog = (line: string) => onLog?.(line);
+ const downloadSignal = signal ?? new AbortController().signal;
+ const keepLatest = channelConfig.keepLatest ?? 0;
+ const keptIds = await computeKeptVideoIds({
+ paths,
+ channelSlug,
+ keepLatest,
+ });
+ const result: PersistKeptResult = {
+ kept: keptIds.size,
+ alreadySaved: 0,
+ persisted: 0,
+ failed: 0,
+ skippedNoUrl: 0,
+ };
+ if (keptIds.size === 0) {
+ log(
+ keepLatest > 0
+ ? "Persist kept: no videos in the keep-latest window yet."
+ : "Persist kept: keep-latest is off for this channel; nothing to do.",
+ );
+ return result;
+ }
+ const settings = getSettings();
+ const dataDir = path.join(paths.channelsDir, channelSlug, "data");
+ // Newest-first so the freshest videos are secured even if the pass is cancelled
+ // partway through. computeKeptVideoIds returns an unordered set; sort by id desc
+ // as a stable proxy (YYYYMMDD_-prefixed ids sort by recency, like the window).
+ const ordered = [...keptIds].sort((a, b) => b.localeCompare(a));
+ for (const videoId of ordered) {
+ if (signal?.aborted) break;
+ const videoDir = path.join(dataDir, videoId);
+ if (await isSavedVideo(videoDir)) {
+ result.alreadySaved += 1;
+ continue;
+ }
+ const url = await findVideoSourceUrl(paths, channelSlug, videoId, channelConfig);
+ if (!url) {
+ result.skippedNoUrl += 1;
+ log(` ${videoId}: no resolvable source URL, skipping.`);
+ continue;
+ }
+ log(` ${videoId}: re-fetching source container to persist…`);
+ try {
+ await downloadOneManaged({
+ channelSlug,
+ channelConfig,
+ paths,
+ videoUrl: url,
+ onLog: downloadLog,
+ signal: downloadSignal,
+ globalCookiesFromBrowser: settings.cookiesFromBrowser || undefined,
+ inlineTranscribeOnFallback: settings.inlineTranscribeOnFallback,
+ globalSkipLiveDownloads: settings.skipLiveDownloads,
+ appendArchive: true,
+ keepSourceVideoOverride: true,
+ });
+ result.persisted += 1;
+ } catch (e) {
+ result.failed += 1;
+ log(` ${videoId}: persist failed — ${(e as Error).message}`);
+ }
+ }
+ log(
+ `Persist kept: ${result.persisted} persisted, ${result.alreadySaved} already saved, ` +
+ `${result.failed} failed, ${result.skippedNoUrl} skipped (no URL) of ${result.kept} kept.`,
+ );
+ return result;
+}
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **First-class video-persistence UI (phase 5, the final phase): a Saved Videos area, per-channel retention controls, and per-video persist/unpersist.** The video-persistence subsystem built up over phases 1–4 is now driveable end to end from the editor. A new top-level **Saved videos** page (`/saved-videos`, in the Pool nav) summarizes the whole saved-video store — total count and size, per-channel breakdown (count, size, how many carry a backup checksum), the default store location, and the last backup time — and hosts the **backup configuration** (destination, scheduled on/off, interval) plus **Back up now** / **Verify backup** buttons. Each channel's **Cleanup stage** gains a **Retention & persistence** section (shown whenever keep-latest is on or the channel has saved videos) with live counts and three buttons: **Check kept videos** (re-probe the window for deleted-from-source videos and pin them), **Persist kept now** (a new bulk catch-up pass that re-fetches the source container for any in-window video whose source isn't saved yet — `persistKeptAction` / `common/controller/persistKept.ts`), and **Back up saved videos**. The **channel settings form** adds a Retention & persistence section: **keep latest** (window size), **extraction mode** (yt-dlp vs app-side ffmpeg), and a per-channel **saved-video store dir** override. Each **video page** gains a **Source video** card showing persisted status (file, size, stored time, keep reason, sha256, location) with an **Unpersist** control that moves the container back into the data dir, or a **Persist source video** button (re-fetch + archive) when it isn't saved yet. New job-kind label for `persist-kept`; `persist-kept` is re-runnable from bookmarks. The saved-video actions moved from `editor/app/savedVideos/` to `editor/app/saved-videos/` to match the route. Covered by `editor/e2e/saved-videos.spec.ts` and `common/controller/persistKept.test.ts`. (Deferred: surfacing kept-check/persist as Actionable-page rows, and streaming the player directly from the store — unpersist brings the container back to the data dir to play it.)
- **Backups for the saved-video store: rsync mirror + per-backup manifest + drift verification (phase 4 of the video-persistence subsystem; backend + scheduler, UI lands later).** The (large, often irreplaceable) saved source videos can now be **backed up to a configured destination**. A backup walks every saved-video pointer across all channels (so per-channel store overrides are covered automatically) and **`rsync`-mirrors each container** into `<dest>/<slug>/<videoId>/` — incremental and resumable (`-a --partial`), additive (no deletes), so re-running only transfers changed or new files. It writes a **`backup-manifest.json`** at the destination root recording each container's canonical location, byte size, and a **streamed sha256**, and caches that hash back onto the live pointer. A **verify** step reads the manifest back and reports drift in four buckets — `missing`, `sizeMismatch`, `checksumMismatch` (re-hashing each present file), and `extra` (containers at the destination the manifest doesn't know about). New global settings block **`savedVideoBackup`** (`{ enabled, dest, intervalMinutes }`; a blank `dest` forces `enabled` off) plus a **`RSYNC_BIN`** env override. When enabled with a destination, the **sync scheduler** runs the backup automatically on its own cadence (a global, not per-channel, job — suppressed during quiet hours, tracked via `lastSavedVideoBackupAt`). Backups can also be run/verified manually via `backupSavedVideosAction` / `verifySavedVideoBackupAction` (managed jobs on a dedicated `saved-videos` queue). The destination is treated as a local filesystem path (a mounted backup disk). See the new `common/lib/savedVideoBackup.ts` (manifest types/parse), `common/controller/{backupSavedVideos,savedVideoInventory}.ts` (+ tests), `common/lib/paths.ts` (`rsyncBin`), `common/lib/settings.ts` (`savedVideoBackup`), `common/jobs/syncSchedulerState.ts`, `editor/app/savedVideos/backupActions.ts`, and `editor/app/scheduler/runTick.ts`.
- **Saved-video store: persisted source videos move to a separate dir/disk, with retention pruning (phase 3 of the video-persistence subsystem; backend, UI lands later).** When the per-download persistence rule (phase 2) keeps a source video, the downloaded container is now **moved out of the per-video data dir into a separate saved-video store** — leaving only a small `saved-video.json` pointer behind — so the main data volume holds just audio + transcripts while the (large) source videos can live on another disk. The store root defaults to `<transcripts>/saved-videos`, is overridable globally via the **`SAVED_VIDEOS_DIR`** env var, and can be further overridden **per channel** (`savedVideosDir` in `config.json`); a video's container lands under `<root>/<slug>/<videoId>/`. The move is **cross-device-safe** (rename within a disk, copy-to-temp + atomic rename + unlink across disks) and **best-effort** — a failed move leaves the container in the data dir as `source-media.<ext>` (still persisted, just not relocated) rather than failing the download. **Transcription resolves from the store**: when no extracted `audio.*` exists, the transcribe fallback follows the pointer to the stored container (returned as a path relative to the video dir so both the local engine and the remote uploader read it correctly), so a kept-but-cleaned or archive-only video still transcribes. **Retention pruning** (the phase-2 follow-up) now bounds the store: the Clean-audio sweep also evicts any *keep-latest* container that has rolled out of the window — but **never** a manually-archived (`override`) or pinned/irreplaceable (`pin`/do-not-clean) one, distinguished by a `keepReason` recorded on each pointer. Reversible helpers ship for the upcoming UI: `unpersistSavedVideo` (move the container back) and `dropSavedVideo` (delete it). A reusable `checkDiskSpaceFor(dir, …)` lands so disk gating can target the store filesystem (used by the UI/backup phases). Note: source-video persistence is still skipped for audio-check channels (deferred), and saved-store counts aren't yet surfaced in the channel snapshot (lands with the phase-5 UI). See the new `common/lib/savedVideo.ts` (+ `savedVideo-server.ts` + tests), `common/controller/pruneSavedVideos.ts` (+ tests), `common/lib/paths.ts` (`savedVideosDir`), `common/lib/channelConfig.ts` (`savedVideosDir`), `common/lib/diskSpace.ts`, `common/ytdlp/{persistencePlan,downloadOneManaged}.ts`, `common/controller/{transcribeOne,cleanAudioFromTranscribed}.ts`.
- **Per-download persistence rule + app-side audio extraction (phase 2 of the video-persistence subsystem; backend, UI lands later).** Each individual download now consults the channel's keep-latest rule (plus any per-run overrides) to decide *what to keep*: a video inside the keep-latest window — or one carrying a `do-not-clean` pin — downloads its **full source video** (`bestvideo*+bestaudio/best`) and the app extracts `audio.<fmt>` from it with ffmpeg, keeping the container as `source-media.<ext>` (a deliberately distinct name from `audio.<ext>` so it's never mistaken for cleanable audio); everything else stays audio-only as before. Crucially the keep decision is made per video by its **upload date against the channel's Nth-newest cutoff** (computed once per run via the new `computeKeepWindow`/`isInKeepWindow`), so the newest videos — which aren't on disk yet at download time — are correctly persisted. A new **`extractionMode`** channel setting (`"ytdlp"` default | `"app"`) selects who extracts audio for audio-only downloads; persisting always forces app-side extraction. **Per-run overrides** thread through `download-missing` (and the shared managed-download path): `keepSourceVideoOverride` (force keep/discard), `extractImmediately` (extract now + discard the container even on a keep channel — the disk-saving backfill case), and `audioFormatOverride`. **Transcription falls back to the source container** when no extracted `audio.*` exists (parakeet ffmpeg-slices any container), so a kept-but-cleaned video or an archive-only download is still transcribable. New per-video **"Archive source video"** action (`redownloadToArchiveAction`) re-fetches an existing video purely to grab + keep its source container without disturbing the transcript. Legacy `"ytdlp"`-mode downloads produce byte-identical yt-dlp args to before (no behavior change for existing channels). Note: persisted source containers currently remain in the data dir and are not auto-pruned when they roll out of the window, and source-video persistence is skipped (with a log note) for audio-check channels — both addressed by the saved-video store in phase 3. See `common/ytdlp/persistencePlan.ts` (+ tests), `common/controller/keptVideos.ts` (keep-window), `common/ytdlp/downloadOneManaged.ts`, `common/ytdlp/runYtdlp.ts`, `common/controller/transcribeOne.ts`, `common/lib/videoStatus.ts` (`source-media`/`isVideoContainer`), and `editor/app/channels/[slug]/pipelineActions.ts` / `videos/[id]/videoActions.ts`.
diff --git a/editor/app/channels/[slug]/components/stages/CleanupStage.tsx b/editor/app/channels/[slug]/components/stages/CleanupStage.tsx
@@ -6,10 +6,13 @@ import { formatBytes } from "yt-dlp-transcript-common/lib/format";
import { QueueControl } from "../../../../components/QueueControl";
import { cancelJobAction } from "../../../../jobs/actions";
import {
+ checkKeptDeletedAction,
cleanAudioAction,
cleanExtraAudioFormatsAction,
removeWrongFormatAudioAction,
} from "../../whisperActions";
+import { persistKeptAction } from "../../persistActions";
+import { backupSavedVideosAction } from "../../../../saved-videos/backupActions";
import { VideoIdList } from "../VideoIdList";
type Props = {
@@ -22,6 +25,15 @@ type Props = {
transcribedAudioBytes: number;
extraFormatsBytes: number;
foreignAudioBytes: number;
+ // Retention & persistence (Phase 5). keepLatest is the configured window size
+ // (0 = off); keptCount is how many videos are currently in the window on disk;
+ // savedCount/savedBytes summarize this channel's persisted source videos.
+ // backupConfigured gates the "Back up saved videos" button.
+ keepLatest: number;
+ keptCount: number;
+ savedCount: number;
+ savedBytes: number;
+ backupConfigured: boolean;
};
export function CleanupStage({
@@ -33,11 +45,28 @@ export function CleanupStage({
transcribedAudioBytes,
extraFormatsBytes,
foreignAudioBytes,
+ keepLatest,
+ keptCount,
+ savedCount,
+ savedBytes,
+ backupConfigured,
}: Props) {
const defaultQueueKey = `channel:${slug}`;
const [cleanQueue, setCleanQueue] = useState(defaultQueueKey);
return (
<div className="flex flex-col gap-6">
+ {(keepLatest > 0 || savedCount > 0) && (
+ <RetentionSection
+ slug={slug}
+ keepLatest={keepLatest}
+ keptCount={keptCount}
+ savedCount={savedCount}
+ savedBytes={savedBytes}
+ backupConfigured={backupConfigured}
+ existingQueues={existingQueues}
+ defaultQueueKey={defaultQueueKey}
+ />
+ )}
<div className="flex flex-col gap-2">
<Heading
title="Clean audio for transcribed videos"
@@ -245,6 +274,130 @@ function ExtraAudioFormatsSection({
);
}
+function RetentionSection({
+ slug,
+ keepLatest,
+ keptCount,
+ savedCount,
+ savedBytes,
+ backupConfigured,
+ existingQueues,
+ defaultQueueKey,
+}: {
+ slug: string;
+ keepLatest: number;
+ keptCount: number;
+ savedCount: number;
+ savedBytes: number;
+ backupConfigured: boolean;
+ existingQueues: string[];
+ defaultQueueKey: string;
+}) {
+ const [queue, setQueue] = useState(defaultQueueKey);
+ return (
+ <div
+ aria-label="retention and persistence section"
+ className="flex flex-col gap-3 rounded border border-zinc-200 dark:border-zinc-800 p-3"
+ >
+ <div>
+ <h3 className="text-base font-semibold">Retention & persistence</h3>
+ <p className="text-sm text-zinc-500">
+ The keep-latest window protects the newest videos from cleanup and
+ persists their source containers to the saved-video store. Configure
+ the window size under{" "}
+ <a href={`/channels/${slug}/edit`} className="underline">
+ channel settings
+ </a>
+ .
+ </p>
+ </div>
+ <dl className="flex flex-wrap gap-x-6 gap-y-1 text-sm">
+ <Stat label="Keep latest" value={keepLatest > 0 ? String(keepLatest) : "off"} />
+ <Stat label="In window (on disk)" value={String(keptCount)} />
+ <Stat label="Saved source videos" value={String(savedCount)} />
+ <Stat
+ label="Saved store size"
+ value={savedBytes > 0 ? formatBytes(savedBytes) : "—"}
+ />
+ </dl>
+ <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:gap-4">
+ <StreamActionLog
+ trigger={() => checkKeptDeletedAction(slug, queue)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Check kept videos"
+ runningLabel="Checking…"
+ label="Check kept videos"
+ extraControls={
+ <QueueControl
+ value={queue}
+ onChange={setQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Check kept videos"
+ />
+ }
+ />
+ <StreamActionLog
+ trigger={() => persistKeptAction(slug, queue)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Persist kept now"
+ runningLabel="Persisting…"
+ label="Persist kept videos"
+ extraControls={
+ <QueueControl
+ value={queue}
+ onChange={setQueue}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel="Persist kept videos"
+ />
+ }
+ />
+ {backupConfigured && (
+ <StreamActionLog
+ trigger={() => backupSavedVideosAction()}
+ cancelAction={cancelJobAction}
+ buttonLabel="Back up saved videos"
+ runningLabel="Backing up…"
+ label="Back up saved videos"
+ />
+ )}
+ </div>
+ <p className="text-xs text-zinc-500">
+ <strong>Check kept videos</strong> re-probes the window for
+ deleted-from-source videos and pins them.{" "}
+ <strong>Persist kept now</strong> re-fetches the source container for any
+ in-window video whose source isn't saved yet.
+ {backupConfigured ? (
+ <>
+ {" "}
+ <strong>Back up saved videos</strong> mirrors the whole saved-video
+ store (all channels) to the configured destination.
+ </>
+ ) : (
+ <>
+ {" "}
+ Configure a backup destination under{" "}
+ <a href="/saved-videos" className="underline">
+ Saved videos
+ </a>{" "}
+ to enable backups.
+ </>
+ )}
+ </p>
+ </div>
+ );
+}
+
+function Stat({ label, value }: { label: string; value: string }) {
+ return (
+ <div className="flex flex-col">
+ <dt className="text-xs uppercase tracking-wide text-zinc-500">{label}</dt>
+ <dd className="font-medium tabular-nums">{value}</dd>
+ </div>
+ );
+}
+
function ReclaimEstimate({ bytes }: { bytes: number }) {
return (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -15,6 +15,8 @@ import {
} 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";
+import { savedVideoTotals } from "yt-dlp-transcript-common/controller/savedVideoInventory";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import {
loadShardConfig,
type ShardConfig,
@@ -144,6 +146,10 @@ export default async function ChannelDetailPage({
: queueKeyForUrl(config.url);
const existing = await readChannelSnapshot(paths, slug);
const snapshot = existing ?? (await generateChannelSnapshot(paths, slug));
+ // Saved-video store summary for this channel + whether backups are configured,
+ // for the Cleanup stage's Retention & persistence section (Phase 5).
+ const savedTotals = await savedVideoTotals({ paths, channelSlug: slug });
+ const backupConfigured = getSettings().savedVideoBackup.dest.trim() !== "";
// The retry-failures panel is interactive (a click mutates the file), so
// read it fresh on every render — the snapshot bucket only reflects state
// at refresh time.
@@ -266,6 +272,11 @@ export default async function ChannelDetailPage({
transcribedAudioBytes={snapshot.cleanupBytes?.transcribedWithAudio ?? 0}
extraFormatsBytes={snapshot.cleanupBytes?.multipleAudioFormats ?? 0}
foreignAudioBytes={snapshot.cleanupBytes?.foreignAudio ?? 0}
+ keepLatest={config.keepLatest ?? 0}
+ keptCount={snapshot.keptCount ?? 0}
+ savedCount={savedTotals.count}
+ savedBytes={savedTotals.bytes}
+ backupConfigured={backupConfigured}
/>
),
diagnostics: (
diff --git a/editor/app/channels/[slug]/persistActions.ts b/editor/app/channels/[slug]/persistActions.ts
@@ -0,0 +1,57 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { checkDiskSpace } from "yt-dlp-transcript-common/lib/diskSpace";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
+import {
+ downloadQueueKey,
+ resolveQueueKey,
+} from "yt-dlp-transcript-common/lib/queueKeys";
+import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels";
+import { persistKept } from "yt-dlp-transcript-common/controller/persistKept";
+import {
+ runManagedFunction,
+ type StreamActionResult,
+} from "yt-dlp-transcript-common/jobs/streamCommand";
+
+// Bulk catch-up: ensure every video in the channel's keep-latest window has its
+// source container saved to the store. Re-downloads only those not already
+// saved. Runs on the channel's download queue since it issues real downloads.
+export async function persistKeptAction(
+ slug: string,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ const config = await readChannelConfig(paths, slug);
+ if (!config) return { ok: false, error: `Channel "${slug}" not found` };
+ const disk = await checkDiskSpace(paths, getSettings());
+ if (!disk.ok) {
+ return {
+ ok: false,
+ error:
+ `Low disk space: ${formatBytes(disk.freeBytes)} free, ` +
+ `${formatBytes(disk.thresholdBytes)} required. Free up space or ` +
+ `lower the floor in Settings.`,
+ };
+ }
+ return runManagedFunction({
+ kind: "persist-kept",
+ queueKey: resolveQueueKey(downloadQueueKey(config), queueKey),
+ paths,
+ channelSlug: slug,
+ spec: { kind: "persist-kept", slug, params: { queueKey } },
+ fn: async (onLog, signal) => {
+ await persistKept({
+ paths,
+ channelSlug: slug,
+ channelConfig: config,
+ onLog,
+ signal,
+ });
+ revalidatePath(`/channels/${slug}`);
+ revalidatePath("/saved-videos");
+ },
+ });
+}
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -9,6 +9,7 @@ import {
type ChannelHandling,
} from "yt-dlp-transcript-common/lib/channelConfig";
import type { DownloadOutcomeRecord } from "yt-dlp-transcript-common/lib/downloadOutcome";
+import type { SavedVideoPointer } from "yt-dlp-transcript-common/lib/savedVideo";
import type { AvailabilityHistoryEntry } from "yt-dlp-transcript-common/lib/availability";
import { formatBytes } from "yt-dlp-transcript-common/lib/format";
import { QueueControl } from "../../../../../components/QueueControl";
@@ -22,9 +23,11 @@ import {
downloadVideoPipelineAction,
markVideoUntranscribableAction,
setPrimaryTranscriptAction,
+ redownloadToArchiveAction,
toggleDoNotCleanAction,
transcodeAudioAction,
transcribeOneAction,
+ unpersistVideoAction,
whisperVideoAction,
type DeleteDirActionResult,
} from "../videoActions";
@@ -56,6 +59,9 @@ type Props = {
availabilityHistory: AvailabilityHistoryEntry[];
channelAudioFormat?: AudioFormat;
doNotClean?: 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;
prevHref?: string;
nextHref?: string;
position?: { index: number; total: number };
@@ -132,6 +138,7 @@ export function VideoPanel({
availabilityHistory,
channelAudioFormat,
doNotClean = false,
+ savedVideo = null,
prevHref,
nextHref,
position,
@@ -306,6 +313,27 @@ export function VideoPanel({
)}
<PipelineStageCard
+ id="source-video-persistence"
+ title="Source video"
+ summary={
+ savedVideo
+ ? `Persisted to the saved-video store (${formatBytes(savedVideo.bytes)}).`
+ : "Persist the source video to the saved-video store."
+ }
+ defaultOpen={!!savedVideo}
+ tone={savedVideo ? "ok" : "neutral"}
+ >
+ <SourceVideoSection
+ slug={slug}
+ videoId={videoId}
+ handling={handling}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ savedVideo={savedVideo}
+ />
+ </PipelineStageCard>
+
+ <PipelineStageCard
id="archive-media"
title="Archive media"
summary={
@@ -848,6 +876,126 @@ function MarkUntranscribableSection({
);
}
+function SourceVideoSection({
+ slug,
+ videoId,
+ handling,
+ defaultQueueKey,
+ existingQueues,
+ savedVideo,
+}: {
+ slug: string;
+ videoId: string;
+ handling: ChannelHandling;
+ defaultQueueKey: string;
+ existingQueues: string[];
+ savedVideo: SavedVideoPointer | null;
+}) {
+ const [queueKey, setQueueKey] = useState(defaultQueueKey);
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState<string | null>(null);
+
+ if (handling !== "transcribe") {
+ return (
+ <p className="text-sm text-zinc-500">
+ Source-video persistence applies to transcribe-handling channels only.
+ </p>
+ );
+ }
+
+ if (savedVideo) {
+ return (
+ <div className="flex flex-col gap-3">
+ <p className="text-sm text-zinc-500">
+ The source container for this video is persisted to the saved-video
+ store, so the Clean-audio sweep can reclaim its <code>audio.*</code>{" "}
+ without losing the original. Transcription falls back to it when no
+ audio is on disk.
+ </p>
+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm">
+ <dt className="text-zinc-500">File</dt>
+ <dd className="font-mono break-all">{savedVideo.file}</dd>
+ <dt className="text-zinc-500">Size</dt>
+ <dd className="tabular-nums">{formatBytes(savedVideo.bytes)}</dd>
+ <dt className="text-zinc-500">Stored</dt>
+ <dd>{new Date(savedVideo.storedAt).toLocaleString()}</dd>
+ {savedVideo.keepReason && (
+ <>
+ <dt className="text-zinc-500">Reason</dt>
+ <dd>{savedVideo.keepReason}</dd>
+ </>
+ )}
+ {savedVideo.sha256 && (
+ <>
+ <dt className="text-zinc-500">sha256</dt>
+ <dd className="font-mono text-xs break-all">
+ {savedVideo.sha256}
+ </dd>
+ </>
+ )}
+ <dt className="text-zinc-500">Location</dt>
+ <dd className="font-mono text-xs break-all">{savedVideo.dir}</dd>
+ </dl>
+ <p className="text-xs text-zinc-500">
+ Unpersist moves the container back into this video's data dir
+ (where it appears under Files and plays inline) and removes the
+ pointer.
+ </p>
+ <button
+ type="button"
+ disabled={pending}
+ aria-label={`unpersist source video ${videoId}`}
+ onClick={() => {
+ setError(null);
+ startTransition(async () => {
+ const res = await unpersistVideoAction(slug, videoId);
+ if (!res.ok) setError(res.error);
+ });
+ }}
+ className="self-start px-3 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 text-sm font-medium hover:bg-zinc-100 dark:hover:bg-zinc-800 disabled:opacity-50"
+ >
+ {pending ? "Restoring…" : "Unpersist (move back to data dir)"}
+ </button>
+ {error && (
+ <span
+ className="text-sm text-red-600 dark:text-red-400"
+ aria-label="unpersist error"
+ >
+ {error}
+ </span>
+ )}
+ </div>
+ );
+ }
+
+ return (
+ <div className="flex flex-col gap-3">
+ <p className="text-sm text-zinc-500">
+ Re-fetch this video's full source container and move it into the
+ saved-video store, without disturbing the existing transcript. Use this
+ to archive a video that was downloaded audio-only. (Videos in a
+ channel's keep-latest window persist automatically on download.)
+ </p>
+ <StreamActionLog
+ trigger={() => redownloadToArchiveAction(slug, videoId, queueKey)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Persist source video"
+ runningLabel="Archiving…"
+ label={`Persist source video for ${videoId}`}
+ extraControls={
+ <QueueControl
+ value={queueKey}
+ onChange={setQueueKey}
+ defaultQueueKey={defaultQueueKey}
+ existingQueues={existingQueues}
+ actionLabel={`Persist source video for ${videoId}`}
+ />
+ }
+ />
+ </div>
+ );
+}
+
function DoNotCleanSection({
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 { 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";
import {
@@ -101,6 +102,7 @@ export default async function VideoDetailPage({
const availabilityRecord = await loadAvailability(videoDir);
const availabilityHistory = availabilityRecord?.history ?? [];
const doNotClean = await isDoNotClean(videoDir);
+ const savedVideo = await loadSavedVideo(videoDir);
const registry = getRegistry();
const existingQueues = registry.activeQueueNames();
@@ -179,6 +181,7 @@ export default async function VideoDetailPage({
availabilityHistory={availabilityHistory}
channelAudioFormat={config.audioFormat}
doNotClean={doNotClean}
+ savedVideo={savedVideo}
/>
</div>
);
diff --git a/editor/app/channels/[slug]/videos/[id]/videoActions.ts b/editor/app/channels/[slug]/videos/[id]/videoActions.ts
@@ -28,6 +28,7 @@ import { pruneFailedTranscriptions } from "yt-dlp-transcript-common/controller/f
import { transcodeAudio } from "yt-dlp-transcript-common/controller/transcode";
import { transcribeWithWorker } from "yt-dlp-transcript-common/controller/transcribeOne";
import { findVideoSourceUrl } from "yt-dlp-transcript-common/controller/undownloadedVideos";
+import { unpersistSavedVideo } from "yt-dlp-transcript-common/lib/savedVideo-server";
import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { downloadOneManaged } from "yt-dlp-transcript-common/ytdlp/downloadOneManaged";
import { runYtdlp } from "yt-dlp-transcript-common/ytdlp/runYtdlp";
@@ -519,3 +520,22 @@ export async function toggleDoNotCleanAction(
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
+// disk. Returns false-shaped error if there was nothing persisted to reverse.
+export async function unpersistVideoAction(
+ slug: string,
+ videoId: string,
+): Promise<{ ok: true } | { ok: false; error: string }> {
+ const videoDir = videoDirOf(slug, videoId);
+ const reversed = await unpersistSavedVideo(videoDir);
+ if (!reversed) {
+ return { ok: false, error: "This video has no persisted source to restore." };
+ }
+ revalidatePath(`/channels/${slug}/videos/${videoId}`);
+ revalidatePath(`/channels/${slug}`);
+ revalidatePath("/saved-videos");
+ return { ok: true };
+}
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -162,6 +162,51 @@ export function ChannelForm({
</label>
<AudioCheckFields config={c} />
</Section>
+ <Section title="Retention & persistence">
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Keep latest (source videos)</span>
+ <input
+ type="number"
+ name="keepLatest"
+ defaultValue={c?.keepLatest != null ? String(c.keepLatest) : ""}
+ min={0}
+ max={100000}
+ placeholder="0 (off)"
+ aria-label="keep latest"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm w-32"
+ />
+ <span className="text-xs text-zinc-500">
+ Protect the newest N videos from the Clean-audio sweep and persist
+ their source containers to the saved-video store. A kept video later
+ found deleted-from-source is pinned permanently. Blank or 0 disables.
+ </span>
+ </label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Extraction mode</span>
+ <select
+ name="extractionMode"
+ defaultValue={c?.extractionMode ?? ""}
+ aria-label="extraction mode"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ >
+ <option value="">Default (yt-dlp extracts)</option>
+ <option value="ytdlp">yt-dlp (-x postprocessor)</option>
+ <option value="app">App (download container, ffmpeg extracts)</option>
+ </select>
+ <span className="text-xs text-zinc-500">
+ Who extracts audio for transcribe-handling downloads. App mode keeps
+ the source container in hand so it can be persisted; the keep-latest
+ rule forces app mode for the videos it persists regardless.
+ </span>
+ </label>
+ <Field
+ label="Saved-video store dir"
+ name="savedVideosDir"
+ defaultValue={c?.savedVideosDir ?? ""}
+ placeholder="(global default)"
+ hint="Per-channel override for where this channel's persisted source videos live (e.g. a larger disk). Blank uses the global SAVED_VIDEOS_DIR default."
+ />
+ </Section>
<CollapsibleSection title="Advanced">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">
diff --git a/editor/app/channels/components/parseChannelForm.ts b/editor/app/channels/components/parseChannelForm.ts
@@ -6,9 +6,11 @@ import {
AUDIO_CHECK_INTERVAL_MIN_SECONDS,
AUDIO_CHECK_MAX_ROLLBACKS_MAX,
AUDIO_CHECK_MAX_ROLLBACKS_MIN,
+ KEEP_LATEST_MAX,
SYNC_INTERVAL_MAX_MINUTES,
SYNC_INTERVAL_MIN_MINUTES,
type AudioCheckConfig,
+ type ExtractionMode,
} from "yt-dlp-transcript-common/lib/channelConfig";
import {
PLATFORM_VALUES,
@@ -31,6 +33,9 @@ export const CHANNEL_FORM_FIELDS = [
"url",
"audioFormat",
"keepSourceVideo",
+ "keepLatest",
+ "extractionMode",
+ "savedVideosDir",
"ytdlpExtraArgs",
"syncIntervalMinutes",
"sleepBetweenDownloadsSeconds",
@@ -65,6 +70,31 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
? audioFormatRaw
: undefined;
const keepSourceVideo = formData.get("keepSourceVideo") != null;
+
+ // Keep-latest retention/persistence window. Blank = inherit-off (omit), so a
+ // cleared input clears the stored value. "0" is the explicit "disabled"
+ // sentinel; any positive value is the window size.
+ const keepLatestRaw = String(formData.get("keepLatest") ?? "").trim();
+ let keepLatest: number | undefined;
+ if (keepLatestRaw) {
+ const n = Number.parseInt(keepLatestRaw, 10);
+ if (!Number.isFinite(n) || n < 0 || n > KEEP_LATEST_MAX) {
+ throw new Error(`Keep latest must be 0 (off) or 1–${KEEP_LATEST_MAX}`);
+ }
+ keepLatest = n;
+ }
+
+ // Audio-extraction owner. "" = inherit the default ("ytdlp"); otherwise an
+ // explicit mode. Persisting a source video forces "app" at download time
+ // regardless of this setting.
+ const extractionModeRaw = String(formData.get("extractionMode") ?? "").trim();
+ const extractionMode: ExtractionMode | undefined =
+ extractionModeRaw === "ytdlp" || extractionModeRaw === "app"
+ ? extractionModeRaw
+ : undefined;
+
+ const savedVideosDir = stringOrUndef(formData, "savedVideosDir");
+
const argsBlob = String(formData.get("ytdlpExtraArgs") ?? "").trim();
const ytdlpExtraArgs = argsBlob
? argsBlob
@@ -175,6 +205,9 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
if (url) config.url = url;
if (audioFormat) config.audioFormat = audioFormat;
if (keepSourceVideo) config.keepSourceVideo = true;
+ if (keepLatest != null) config.keepLatest = keepLatest;
+ if (extractionMode) config.extractionMode = extractionMode;
+ if (savedVideosDir) config.savedVideosDir = savedVideosDir;
if (ytdlpExtraArgs?.length) config.ytdlpExtraArgs = ytdlpExtraArgs;
if (syncIntervalMinutes != null) {
config.syncIntervalMinutes = syncIntervalMinutes;
diff --git a/editor/app/jobs/jobKindLabels.ts b/editor/app/jobs/jobKindLabels.ts
@@ -17,6 +17,7 @@ const JOB_KIND_LABELS: Record<string, string> = {
"retry-bucket": "Retry",
"clean-audio-transcribed": "Clean audio",
"check-kept-deleted": "Check kept videos",
+ "persist-kept": "Persist kept videos",
"backup-saved-videos": "Back up saved videos",
"verify-saved-video-backup": "Verify saved-video backup",
sync: "Sync",
diff --git a/editor/app/jobs/runJobSpec.ts b/editor/app/jobs/runJobSpec.ts
@@ -26,6 +26,7 @@ import {
transcribeBucketAction,
transcribeMissingAction,
} from "../channels/[slug]/whisperActions";
+import { persistKeptAction } from "../channels/[slug]/persistActions";
// The single place that maps a stored JobSpec back to the server action that
// runs it. Bucket jobs re-derive their work from the channel's CURRENT snapshot
@@ -134,6 +135,8 @@ export async function runJobSpec(spec: JobSpec): Promise<StreamActionResult> {
return cleanAudioAction(spec.slug, queueKey);
case "check-kept-deleted":
return checkKeptDeletedAction(spec.slug, queueKey);
+ case "persist-kept":
+ return persistKeptAction(spec.slug, queueKey);
default:
return { ok: false, error: `Cannot re-run job kind: ${spec.kind}` };
}
diff --git a/editor/app/layout.tsx b/editor/app/layout.tsx
@@ -59,6 +59,7 @@ const NAV_GROUPS: NavGroup[] = [
{ href: "/auto-queue", label: "Auto-queue" },
{ href: "/build", label: "Build" },
{ href: "/actionable", label: "Actionable" },
+ { href: "/saved-videos", label: "Saved videos" },
],
},
{
diff --git a/editor/app/saved-videos/backupActions.ts b/editor/app/saved-videos/backupActions.ts
@@ -0,0 +1,119 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings";
+import { SYNC_INTERVAL_MAX_MINUTES } from "yt-dlp-transcript-common/lib/channelConfig";
+import {
+ backupSavedVideos,
+ verifySavedVideoBackup,
+} from "yt-dlp-transcript-common/controller/backupSavedVideos";
+import {
+ runManagedFunction,
+ type StreamActionResult,
+} from "yt-dlp-transcript-common/jobs/streamCommand";
+
+// Saved-video backups are a global (not per-channel) concern, so they share a
+// dedicated queue rather than a channel queue.
+const SAVED_VIDEOS_QUEUE = "saved-videos";
+
+// Mirror the saved-video store to the configured backup destination and write a
+// fresh manifest. The destination comes from settings (savedVideoBackup.dest);
+// an explicit `dest` overrides it for an ad-hoc run.
+export async function backupSavedVideosAction(
+ dest?: string,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ const settings = getSettings();
+ const target = (dest ?? settings.savedVideoBackup.dest).trim();
+ if (!target) {
+ return { ok: false, error: "No backup destination configured" };
+ }
+ return runManagedFunction({
+ kind: "backup-saved-videos",
+ queueKey: queueKey === undefined ? SAVED_VIDEOS_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ const result = await backupSavedVideos({
+ paths,
+ dest: target,
+ onLog,
+ signal,
+ });
+ onLog(
+ `Saved-video backup: ${result.backedUp}/${result.entries} container(s), ${result.bytes} bytes.`,
+ );
+ revalidatePath("/saved-videos");
+ },
+ });
+}
+
+// Verify the backup destination against its manifest and report drift.
+export async function verifySavedVideoBackupAction(
+ dest?: string,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ const settings = getSettings();
+ const target = (dest ?? settings.savedVideoBackup.dest).trim();
+ if (!target) {
+ return { ok: false, error: "No backup destination configured" };
+ }
+ return runManagedFunction({
+ kind: "verify-saved-video-backup",
+ queueKey: queueKey === undefined ? SAVED_VIDEOS_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ const drift = await verifySavedVideoBackup({
+ paths,
+ dest: target,
+ onLog,
+ signal,
+ });
+ onLog(
+ drift.ok
+ ? `Backup verified clean (checked ${drift.checked}).`
+ : `Backup drift: ${drift.missing.length} missing, ${drift.sizeMismatch.length} size-mismatch, ${drift.checksumMismatch.length} checksum-mismatch, ${drift.extra.length} extra.`,
+ );
+ },
+ });
+}
+
+export type SaveBackupConfigResult = { ok: true } | { ok: false; error: string };
+
+// Persist just the saved-video backup block (dest / enabled / interval) from the
+// Saved Videos page, leaving the rest of SiteSettings untouched. writeSettings
+// re-sanitizes (blank dest forces enabled off; interval clamped).
+export async function saveSavedVideoBackupAction(
+ _prev: SaveBackupConfigResult | undefined,
+ formData: FormData,
+): Promise<SaveBackupConfigResult> {
+ const dest = String(formData.get("backupDest") ?? "").trim();
+ const enabled = formData.get("backupEnabled") === "on";
+ const intervalRaw = String(formData.get("backupIntervalMinutes") ?? "").trim();
+ const intervalMinutes = Number.parseInt(intervalRaw, 10);
+ if (intervalRaw && (!Number.isFinite(intervalMinutes) || intervalMinutes < 1)) {
+ return {
+ ok: false,
+ error: `Backup interval must be 1–${SYNC_INTERVAL_MAX_MINUTES} minutes`,
+ };
+ }
+ const settings = getSettings();
+ try {
+ await writeSettings({
+ ...settings,
+ savedVideoBackup: {
+ enabled,
+ dest,
+ intervalMinutes: intervalRaw
+ ? intervalMinutes
+ : settings.savedVideoBackup.intervalMinutes,
+ },
+ });
+ } catch (e) {
+ return { ok: false, error: (e as Error).message };
+ }
+ revalidatePath("/saved-videos");
+ return { ok: true };
+}
diff --git a/editor/app/saved-videos/components/SavedVideosControls.tsx b/editor/app/saved-videos/components/SavedVideosControls.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { useActionState, useState } from "react";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { cancelJobAction } from "../../jobs/actions";
+import {
+ backupSavedVideosAction,
+ saveSavedVideoBackupAction,
+ verifySavedVideoBackupAction,
+ type SaveBackupConfigResult,
+} from "../backupActions";
+
+type Props = {
+ enabled: boolean;
+ dest: string;
+ intervalMinutes: number;
+};
+
+// Backup config form + run/verify buttons for the Saved Videos page. The
+// destination drives all three: with no dest, scheduled and manual backups are
+// both disabled.
+export function SavedVideosControls({ enabled, dest, intervalMinutes }: Props) {
+ const [state, formAction] = useActionState<
+ SaveBackupConfigResult | undefined,
+ FormData
+ >(saveSavedVideoBackupAction, undefined);
+ // Track the dest input live so the run/verify buttons enable/disable without a
+ // round-trip. Seeded from the saved value.
+ const [destInput, setDestInput] = useState(dest);
+ const hasDest = destInput.trim() !== "";
+
+ return (
+ <div className="flex flex-col gap-4">
+ <form
+ action={formAction}
+ className="flex flex-col gap-3 rounded border border-zinc-200 dark:border-zinc-800 p-4 max-w-xl"
+ aria-label="backup config"
+ >
+ <h2 className="text-base font-semibold">Backup configuration</h2>
+ {state?.ok === false && (
+ <div
+ role="alert"
+ className="rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-3 py-2 text-sm text-red-700 dark:text-red-300"
+ >
+ {state.error}
+ </div>
+ )}
+ {state?.ok === true && (
+ <div
+ aria-label="backup config saved"
+ className="rounded border border-green-300 bg-green-50 dark:border-green-800 dark:bg-green-950 px-3 py-2 text-sm text-green-700 dark:text-green-300"
+ >
+ Saved.
+ </div>
+ )}
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Backup destination</span>
+ <input
+ type="text"
+ name="backupDest"
+ value={destInput}
+ onChange={(e) => setDestInput(e.target.value)}
+ placeholder="/mnt/backup/saved-videos"
+ aria-label="backup destination"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm font-mono"
+ />
+ <span className="text-xs text-zinc-500">
+ Local filesystem path (a mounted backup disk) the store is mirrored
+ into. The mirror is additive — it never deletes. Blank disables both
+ scheduled and manual backups.
+ </span>
+ </label>
+ <label className="flex items-start gap-2 text-sm">
+ <input
+ type="checkbox"
+ name="backupEnabled"
+ defaultChecked={enabled}
+ aria-label="scheduled backup enabled"
+ className="mt-1"
+ />
+ <span className="flex flex-col gap-0.5">
+ <span className="font-medium">Scheduled backup</span>
+ <span className="text-xs text-zinc-500">
+ When on (and a destination is set), the sync scheduler runs the
+ backup on the cadence below. Manual backups work regardless.
+ </span>
+ </span>
+ </label>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Backup interval (minutes)</span>
+ <input
+ type="number"
+ name="backupIntervalMinutes"
+ defaultValue={String(intervalMinutes)}
+ min={1}
+ aria-label="backup interval minutes"
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm w-40"
+ />
+ </label>
+ <div>
+ <button
+ type="submit"
+ className="px-3 py-2 rounded-md bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 text-sm font-medium hover:opacity-90"
+ >
+ Save backup config
+ </button>
+ </div>
+ </form>
+ <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:gap-4">
+ <StreamActionLog
+ trigger={() => backupSavedVideosAction()}
+ cancelAction={cancelJobAction}
+ buttonLabel="Back up now"
+ runningLabel="Backing up…"
+ label="Back up saved videos"
+ disabled={!hasDest}
+ />
+ <StreamActionLog
+ trigger={() => verifySavedVideoBackupAction()}
+ cancelAction={cancelJobAction}
+ buttonLabel="Verify backup"
+ runningLabel="Verifying…"
+ label="Verify saved-video backup"
+ disabled={!hasDest}
+ />
+ </div>
+ {!hasDest && (
+ <p className="text-xs text-zinc-500">
+ Set a backup destination above and save to enable backups.
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/saved-videos/page.tsx b/editor/app/saved-videos/page.tsx
@@ -0,0 +1,163 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import { formatBytes } from "yt-dlp-transcript-common/lib/format";
+import { listSavedVideos } from "yt-dlp-transcript-common/controller/savedVideoInventory";
+import { readSchedulerState } from "yt-dlp-transcript-common/jobs/syncSchedulerState";
+import { SavedVideosControls } from "./components/SavedVideosControls";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = { title: "Saved videos" };
+
+type ChannelSummary = {
+ slug: string;
+ count: number;
+ bytes: number;
+ verified: number;
+};
+
+export default async function SavedVideosPage() {
+ const paths = getPaths();
+ const settings = getSettings();
+ const backup = settings.savedVideoBackup;
+ const entries = await listSavedVideos({ paths });
+ const scheduler = await readSchedulerState(paths);
+
+ const byChannel = new Map<string, ChannelSummary>();
+ let totalBytes = 0;
+ let totalVerified = 0;
+ for (const e of entries) {
+ const row =
+ byChannel.get(e.slug) ??
+ { slug: e.slug, count: 0, bytes: 0, verified: 0 };
+ row.count += 1;
+ row.bytes += e.pointer.bytes;
+ if (e.pointer.sha256) {
+ row.verified += 1;
+ totalVerified += 1;
+ }
+ byChannel.set(e.slug, row);
+ totalBytes += e.pointer.bytes;
+ }
+ const channels = [...byChannel.values()].sort((a, b) =>
+ a.slug.localeCompare(b.slug),
+ );
+ const lastBackup = scheduler.lastSavedVideoBackupAt
+ ? new Date(scheduler.lastSavedVideoBackupAt).toLocaleString()
+ : null;
+
+ return (
+ <main className="flex flex-col gap-8 p-6 max-w-4xl">
+ <header className="flex flex-col gap-1">
+ <h1 className="text-xl font-semibold">Saved videos</h1>
+ <p className="text-sm text-zinc-500">
+ Source video containers persisted by the keep-latest retention rule
+ live in a separate store, leaving the main data volume holding only
+ audio + transcripts. Configure a channel's keep-latest window and
+ per-channel store dir under that channel's settings.
+ </p>
+ </header>
+
+ <section
+ aria-label="store summary"
+ className="grid grid-cols-2 sm:grid-cols-4 gap-3"
+ >
+ <SummaryCard label="Saved videos" value={String(entries.length)} />
+ <SummaryCard label="Total size" value={formatBytes(totalBytes)} />
+ <SummaryCard label="Channels" value={String(channels.length)} />
+ <SummaryCard
+ label="With checksum"
+ value={`${totalVerified}/${entries.length}`}
+ />
+ </section>
+
+ <section aria-label="store location" className="flex flex-col gap-1 text-sm">
+ <div>
+ <span className="text-zinc-500">Default store dir: </span>
+ <span className="font-mono">{paths.savedVideosDir}</span>
+ </div>
+ <div>
+ <span className="text-zinc-500">Last backup: </span>
+ {lastBackup ? (
+ <span>{lastBackup}</span>
+ ) : (
+ <span className="text-zinc-500">never</span>
+ )}
+ {backup.enabled && backup.dest && (
+ <span className="text-zinc-500">
+ {" "}
+ · scheduled every {backup.intervalMinutes} min
+ </span>
+ )}
+ </div>
+ </section>
+
+ <section aria-label="per-channel saved videos" className="flex flex-col gap-2">
+ <h2 className="text-base font-semibold">By channel</h2>
+ {channels.length === 0 ? (
+ <p className="text-sm text-zinc-600 dark:text-zinc-400">
+ No saved videos yet. Set a channel's keep-latest window, then run
+ a sync (or <em>Persist kept now</em> from its Cleanup stage).
+ </p>
+ ) : (
+ <table className="text-sm border-collapse">
+ <thead>
+ <tr className="text-left text-zinc-500">
+ <th className="py-1 pr-6 font-medium">Channel</th>
+ <th className="py-1 pr-6 font-medium tabular-nums">Saved</th>
+ <th className="py-1 pr-6 font-medium tabular-nums">Size</th>
+ <th className="py-1 font-medium tabular-nums">Checksummed</th>
+ </tr>
+ </thead>
+ <tbody>
+ {channels.map((c) => (
+ <tr
+ key={c.slug}
+ className="border-t border-zinc-200 dark:border-zinc-800"
+ >
+ <td className="py-1 pr-6">
+ <Link
+ href={`/channels/${c.slug}`}
+ className="underline hover:no-underline"
+ >
+ {c.slug}
+ </Link>
+ </td>
+ <td className="py-1 pr-6 tabular-nums">{c.count}</td>
+ <td className="py-1 pr-6 tabular-nums">
+ {formatBytes(c.bytes)}
+ </td>
+ <td className="py-1 tabular-nums">
+ {c.verified}/{c.count}
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ )}
+ </section>
+
+ <section aria-label="backups" className="flex flex-col gap-2">
+ <h2 className="text-base font-semibold">Backups</h2>
+ <SavedVideosControls
+ enabled={backup.enabled}
+ dest={backup.dest}
+ intervalMinutes={backup.intervalMinutes}
+ />
+ </section>
+ </main>
+ );
+}
+
+function SummaryCard({ label, value }: { label: string; value: string }) {
+ return (
+ <div className="rounded border border-zinc-200 dark:border-zinc-800 p-3">
+ <div className="text-xs uppercase tracking-wide text-zinc-500">
+ {label}
+ </div>
+ <div className="text-lg font-semibold tabular-nums mt-0.5">{value}</div>
+ </div>
+ );
+}
diff --git a/editor/app/savedVideos/backupActions.ts b/editor/app/savedVideos/backupActions.ts
@@ -1,80 +0,0 @@
-"use server";
-
-import { revalidatePath } from "next/cache";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { getSettings } from "yt-dlp-transcript-common/lib/settings";
-import {
- backupSavedVideos,
- verifySavedVideoBackup,
-} from "yt-dlp-transcript-common/controller/backupSavedVideos";
-import {
- runManagedFunction,
- type StreamActionResult,
-} from "yt-dlp-transcript-common/jobs/streamCommand";
-
-// Saved-video backups are a global (not per-channel) concern, so they share a
-// dedicated queue rather than a channel queue.
-const SAVED_VIDEOS_QUEUE = "saved-videos";
-
-// Mirror the saved-video store to the configured backup destination and write a
-// fresh manifest. The destination comes from settings (savedVideoBackup.dest);
-// an explicit `dest` overrides it for an ad-hoc run.
-export async function backupSavedVideosAction(
- dest?: string,
- queueKey?: string,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- const settings = getSettings();
- const target = (dest ?? settings.savedVideoBackup.dest).trim();
- if (!target) {
- return { ok: false, error: "No backup destination configured" };
- }
- return runManagedFunction({
- kind: "backup-saved-videos",
- queueKey: queueKey === undefined ? SAVED_VIDEOS_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog, signal) => {
- const result = await backupSavedVideos({
- paths,
- dest: target,
- onLog,
- signal,
- });
- onLog(
- `Saved-video backup: ${result.backedUp}/${result.entries} container(s), ${result.bytes} bytes.`,
- );
- revalidatePath("/saved-videos");
- },
- });
-}
-
-// Verify the backup destination against its manifest and report drift.
-export async function verifySavedVideoBackupAction(
- dest?: string,
- queueKey?: string,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- const settings = getSettings();
- const target = (dest ?? settings.savedVideoBackup.dest).trim();
- if (!target) {
- return { ok: false, error: "No backup destination configured" };
- }
- return runManagedFunction({
- kind: "verify-saved-video-backup",
- queueKey: queueKey === undefined ? SAVED_VIDEOS_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog, signal) => {
- const drift = await verifySavedVideoBackup({
- paths,
- dest: target,
- onLog,
- signal,
- });
- onLog(
- drift.ok
- ? `Backup verified clean (checked ${drift.checked}).`
- : `Backup drift: ${drift.missing.length} missing, ${drift.sizeMismatch.length} size-mismatch, ${drift.checksumMismatch.length} checksum-mismatch, ${drift.extra.length} extra.`,
- );
- },
- });
-}
diff --git a/editor/app/scheduler/runTick.ts b/editor/app/scheduler/runTick.ts
@@ -20,7 +20,7 @@ import {
} from "yt-dlp-transcript-common/jobs/syncSchedulerState";
import { syncAction } from "../channels/[slug]/pipelineActions";
import { checkKeptDeletedAction } from "../channels/[slug]/whisperActions";
-import { backupSavedVideosAction } from "../savedVideos/backupActions";
+import { backupSavedVideosAction } from "../saved-videos/backupActions";
export type SchedulerTickResult = {
ok: boolean;
diff --git a/editor/e2e/saved-videos.spec.ts b/editor/e2e/saved-videos.spec.ts
@@ -0,0 +1,110 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { test, expect } from "@playwright/test";
+import { readJson, resetData, resolvePath } from "./helpers";
+import { baseUrl } from "./baseUrl";
+
+// The transcribe fixture's channel slug.
+const SLUG = "test-transcribe";
+
+// Persist a video's source container into the saved-video store: write the
+// container under test-transcripts/saved-videos/<slug>/<id>/ and a pointer
+// sidecar back in the data dir. Mirrors persistSourceVideo's on-disk result.
+async function seedSavedVideo(
+ videoId: string,
+ bytes: number,
+ sha256?: string,
+): Promise<void> {
+ const storeDir = resolvePath(
+ `test-transcripts/saved-videos/${SLUG}/${videoId}`,
+ );
+ await mkdir(storeDir, { recursive: true });
+ await writeFile(`${storeDir}/source-media.mp4`, "x".repeat(bytes));
+ const pointer = {
+ storedAt: "2026-06-01T00:00:00.000Z",
+ dir: storeDir,
+ file: "source-media.mp4",
+ bytes,
+ keepReason: "keep-latest",
+ ...(sha256 ? { sha256 } : {}),
+ };
+ await writeFile(
+ resolvePath(`test-transcripts/channels/${SLUG}/data/${videoId}/saved-video.json`),
+ JSON.stringify(pointer, null, 2) + "\n",
+ );
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
+}
+
+test("Saved Videos page lists persisted source videos per channel", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ await seedSavedVideo("vidA", 2048, "a".repeat(64));
+
+ await page.goto("/saved-videos");
+ await expect(
+ page.getByRole("heading", { name: "Saved videos", level: 1 }),
+ ).toBeVisible();
+
+ // Summary cards: one saved video, one channel, one with checksum.
+ const summary = page.getByLabel("store summary");
+ await expect(summary).toContainText("Saved videos");
+
+ // Per-channel table row links to the channel and reports the saved count.
+ const byChannel = page.getByLabel("per-channel saved videos");
+ await expect(byChannel.getByRole("link", { name: SLUG })).toBeVisible();
+ await expect(byChannel).toContainText("1/1"); // checksummed
+});
+
+test("Saved Videos page saves the backup configuration", async ({ page }) => {
+ await resetData("one-transcribe-channel-with-audio");
+
+ await page.goto("/saved-videos");
+ const dest = page.getByLabel("backup destination");
+ await dest.fill("/tmp/ttb-e2e-backup-dest");
+ await page.getByLabel("scheduled backup enabled").check();
+ await page.getByRole("button", { name: "Save backup config" }).click();
+ await expect(page.getByLabel("backup config saved")).toBeVisible();
+
+ // The setting persisted to test-settings.json.
+ const settings = await readJson<{
+ savedVideoBackup?: { dest?: string; enabled?: boolean };
+ }>("test-settings.json");
+ expect(settings.savedVideoBackup?.dest).toBe("/tmp/ttb-e2e-backup-dest");
+ expect(settings.savedVideoBackup?.enabled).toBe(true);
+
+ // The run/verify buttons become enabled once a dest is configured.
+ await expect(
+ page.getByRole("button", { name: "Back up now" }),
+ ).toBeEnabled();
+});
+
+test("channel Cleanup stage shows the retention & persistence section", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ await seedSavedVideo("vidA", 1024);
+
+ await page.goto(`/channels/${SLUG}`);
+ await page.getByRole("button", { name: "Cleanup stage summary" }).click();
+ const section = page.getByLabel("retention and persistence section");
+ await expect(section).toBeVisible();
+ await expect(section).toContainText("Saved source videos");
+ await expect(
+ section.getByRole("button", { name: "Check kept videos" }),
+ ).toBeVisible();
+ await expect(
+ section.getByRole("button", { name: "Persist kept now" }),
+ ).toBeVisible();
+});
+
+test("video page shows persisted source status and an unpersist control", async ({
+ page,
+}) => {
+ await resetData("one-transcribe-channel-with-audio");
+ await seedSavedVideo("vidA", 4096);
+
+ await page.goto(`/channels/${SLUG}/videos/vidA`);
+ await expect(
+ page.getByLabel("unpersist source video vidA"),
+ ).toBeVisible();
+});