Archilyzer · Source

archilyzer

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

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:
Acommon/controller/persistKept.test.ts | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/persistKept.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/[slug]/components/stages/CleanupStage.tsx | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/page.tsx | 11+++++++++++
Aeditor/app/channels/[slug]/persistActions.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/videos/[id]/page.tsx | 3+++
Meditor/app/channels/[slug]/videos/[id]/videoActions.ts | 20++++++++++++++++++++
Meditor/app/channels/components/ChannelForm.tsx | 45+++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/components/parseChannelForm.ts | 33+++++++++++++++++++++++++++++++++
Meditor/app/jobs/jobKindLabels.ts | 1+
Meditor/app/jobs/runJobSpec.ts | 3+++
Meditor/app/layout.tsx | 1+
Aeditor/app/saved-videos/backupActions.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/saved-videos/components/SavedVideosControls.tsx | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/saved-videos/page.tsx | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Deditor/app/savedVideos/backupActions.ts | 80-------------------------------------------------------------------------------
Meditor/app/scheduler/runTick.ts | 2+-
Aeditor/e2e/saved-videos.spec.ts | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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 &amp; 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&apos;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&apos;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&apos;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&apos;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&apos;s keep-latest window and + per-channel store dir under that channel&apos;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&apos;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(); +});