Archilyzer · Source

archilyzer

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

commit 1a3f53284fce5a35d35e48cb620ae8eba6fcba4b
parent a4b130153366ecedd86cf171d992fb6b26d620a2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 25 Jun 2026 01:12:17 -0400

Phase 4: saved-video backups (rsync mirror + manifest + verify)

Back up the saved-video store (phase 3) to a configured destination so the
large, often-irreplaceable source videos are recoverable. This is the phase-4
backend + scheduler concern; the Saved Videos UI lands in phase 5.

What's new:
- common/lib/savedVideoBackup.ts (pure): BackupManifest/BackupEntry types,
  parse, and <slug>/<videoId>/<file> rel-path helpers.
- common/controller/savedVideoInventory.ts: listSavedVideos / savedVideoTotals
  enumerate every saved-video pointer across all channels (or one), resolving
  per-channel store overrides via the pointer's absolute dir. Reused by backup
  and the upcoming UI counts.
- common/controller/backupSavedVideos.ts:
  - backupSavedVideos() rsync-mirrors each container into <dest>/<slug>/<id>/
    (-a --partial: incremental, resumable, additive — no deletes), streams a
    sha256, caches it back onto the pointer, and writes backup-manifest.json at
    the dest root.
  - verifySavedVideoBackup() reads the manifest back and reports drift:
    missing / sizeMismatch / checksumMismatch (re-hash) / extra.
- Settings: new global savedVideoBackup block { enabled, dest, intervalMinutes }
  (blank dest forces enabled off), sanitized like the other blocks; preserved on
  unrelated settings saves. paths.rsyncBin (env RSYNC_BIN, default "rsync").
- Scheduler: when enabled with a dest, runTick queues the backup on its own
  cadence (global job, suppressed in quiet hours), tracked via the new
  schedulerState.lastSavedVideoBackupAt.
- editor/app/savedVideos/backupActions.ts: backupSavedVideosAction /
  verifySavedVideoBackupAction (managed jobs on a dedicated saved-videos queue).
- savedVideo-server.updateSavedVideoChecksum() caches the backup hash on the
  pointer. Job-kind labels for the two new kinds.

The destination is treated as a local filesystem path (a mounted backup disk).

Tests: 4 new (inventory enumeration; backup mirror+manifest+hash+pointer cache;
verify drift across all four buckets; verify-without-manifest throws) — exercise
the real rsync binary. 20 saved-video unit tests pass; common + editor
typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/controller/backupSavedVideos.test.ts | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/backupSavedVideos.ts | 258+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/savedVideoInventory.test.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/savedVideoInventory.ts | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/jobs/syncSchedulerState.ts | 9+++++++--
Mcommon/lib/paths.ts | 5+++++
Mcommon/lib/savedVideo-server.ts | 12++++++++++++
Acommon/lib/savedVideoBackup.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/settings.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/jobs/jobKindLabels.ts | 2++
Aeditor/app/savedVideos/backupActions.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/scheduler/runTick.ts | 26++++++++++++++++++++++++++
Meditor/app/settings/actions.ts | 3+++
14 files changed, 830 insertions(+), 2 deletions(-)

diff --git a/common/controller/backupSavedVideos.test.ts b/common/controller/backupSavedVideos.test.ts @@ -0,0 +1,157 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + appendFile, + mkdir, + mkdtemp, + readFile, + rm, + stat, + 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 { loadSavedVideo } from "../lib/savedVideo-server"; +import { + BACKUP_MANIFEST_FILENAME, + parseBackupManifest, +} from "../lib/savedVideoBackup"; +import { + backupSavedVideos, + verifySavedVideoBackup, +} from "./backupSavedVideos"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common exec tsx --test controller/backupSavedVideos.test.ts +// +// These exercise the real rsync binary (resolved via paths.rsyncBin -> "rsync"). + +async function withPaths( + fn: (paths: Paths, dest: string) => Promise<void>, +): Promise<void> { + const dir = await mkdtemp(path.join(tmpdir(), "ttb-backup-")); + const paths = { + channelsDir: path.join(dir, "channels"), + savedVideosDir: path.join(dir, "saved"), + rsyncBin: "rsync", + } as Paths; + const dest = path.join(dir, "backup"); + try { + await fn(paths, dest); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +async function seed( + paths: Paths, + slug: string, + id: string, + content: 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"), content); + await persistSourceVideo({ + videoDir, + sourceFilename: "source-media.mp4", + storeDir: path.join(paths.savedVideosDir, slug, id), + keepReason: "keep-latest", + }); +} + +function sha256(s: string): string { + return createHash("sha256").update(s).digest("hex"); +} + +test("backup mirrors the store + writes a manifest with sizes and hashes", async () => { + await withPaths(async (paths, dest) => { + await seed(paths, "alpha", "v1", "hello video one"); + await seed(paths, "beta", "v2", "second"); + + const res = await backupSavedVideos({ paths, dest }); + assert.equal(res.entries, 2); + assert.equal(res.backedUp, 2); + + // Containers landed at <dest>/<slug>/<id>/source-media.mp4. + assert.equal( + await readFile(path.join(dest, "alpha", "v1", "source-media.mp4"), "utf8"), + "hello video one", + ); + assert.equal( + await readFile(path.join(dest, "beta", "v2", "source-media.mp4"), "utf8"), + "second", + ); + + // Manifest records each entry with the right size + streamed sha256. + const manifest = parseBackupManifest( + JSON.parse( + await readFile(path.join(dest, BACKUP_MANIFEST_FILENAME), "utf8"), + ), + ); + assert.ok(manifest); + assert.equal(manifest.entries.length, 2); + const alpha = manifest.entries.find((e) => e.slug === "alpha"); + assert.ok(alpha); + assert.equal(alpha.bytes, "hello video one".length); + assert.equal(alpha.sha256, sha256("hello video one")); + + // The checksum was cached back onto the live pointer. + const pointer = await loadSavedVideo( + path.join(paths.channelsDir, "alpha", "data", "v1"), + ); + assert.equal(pointer?.sha256, sha256("hello video one")); + + // A fresh verify is clean. + const verify = await verifySavedVideoBackup({ paths, dest }); + assert.equal(verify.ok, true); + assert.equal(verify.checked, 2); + }); +}); + +test("verify reports size, checksum, missing, and extra drift", async () => { + await withPaths(async (paths, dest) => { + await seed(paths, "alpha", "v1", "original-one"); + await seed(paths, "alpha", "v2", "original-two"); + await seed(paths, "beta", "v3", "original-three"); + await backupSavedVideos({ paths, dest }); + + // Grow one dest file -> size mismatch. + await appendFile(path.join(dest, "alpha", "v1", "source-media.mp4"), "X"); + // Same-size edit of another -> checksum mismatch (replace with equal length). + const v2 = path.join(dest, "alpha", "v2", "source-media.mp4"); + const v2bytes = (await stat(v2)).size; + await writeFile(v2, "Z".repeat(v2bytes)); + // Delete the third -> missing. + await rm(path.join(dest, "beta", "v3", "source-media.mp4")); + // Drop an unknown container into the dest -> extra. + await mkdir(path.join(dest, "ghost", "v9"), { recursive: true }); + await writeFile(path.join(dest, "ghost", "v9", "source-media.mp4"), "boo"); + + const drift = await verifySavedVideoBackup({ paths, dest }); + assert.equal(drift.ok, false); + assert.deepEqual( + drift.sizeMismatch.map((e) => e.videoId), + ["v1"], + ); + assert.deepEqual( + drift.checksumMismatch.map((e) => e.videoId), + ["v2"], + ); + assert.deepEqual( + drift.missing.map((e) => e.videoId), + ["v3"], + ); + assert.deepEqual(drift.extra, [path.join("ghost", "v9", "source-media.mp4")]); + }); +}); + +test("verify throws when no manifest is present", async () => { + await withPaths(async (paths, dest) => { + await mkdir(dest, { recursive: true }); + await assert.rejects(verifySavedVideoBackup({ paths, dest })); + }); +}); diff --git a/common/controller/backupSavedVideos.ts b/common/controller/backupSavedVideos.ts @@ -0,0 +1,258 @@ +import path from "node:path"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises"; +import { execa } from "execa"; +import type { Paths } from "../lib/paths"; +import { + BACKUP_MANIFEST_FILENAME, + backupEntryRelDir, + backupEntryRelPath, + parseBackupManifest, + type BackupEntry, + type BackupManifest, +} from "../lib/savedVideoBackup"; +import { listSavedVideos } from "./savedVideoInventory"; +import { updateSavedVideoChecksum } from "../lib/savedVideo-server"; + +// Backup tooling for the saved-video store (Phase 4 of the video-persistence +// feature). `dest` is treated as a local filesystem path (typically a mounted +// backup disk): containers are mirrored into it with rsync — incremental and +// resumable, so re-running only transfers changed/new files — and a manifest +// recording each container's size + streamed sha256 is written at the dest root. +// The verify step reads that manifest back and reports drift against the dest. + +// Stream a file through sha256 without buffering it whole (containers are large). +async function sha256File(file: string): Promise<string> { + const hash = createHash("sha256"); + await new Promise<void>((resolve, reject) => { + const stream = createReadStream(file); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve()); + }); + return hash.digest("hex"); +} + +export type BackupSavedVideosResult = { + // Saved containers considered (one per data-dir pointer). + entries: number; + // Containers rsynced to the destination (entries minus any that errored). + backedUp: number; + // Total bytes across the backed-up containers. + bytes: number; + // Absolute path to the manifest written at the destination root. + manifestPath: string; +}; + +export async function backupSavedVideos({ + paths, + dest, + onLog, + signal, +}: { + paths: Paths; + dest: string; + onLog?: (msg: string) => void; + signal?: AbortSignal; +}): Promise<BackupSavedVideosResult> { + const log = onLog ?? ((m: string) => console.log(m)); + const destRoot = dest.trim(); + if (!destRoot) throw new Error("No backup destination configured"); + + const saved = await listSavedVideos({ paths }); + log(`Backing up ${saved.length} saved video(s) to ${destRoot}`); + await mkdir(destRoot, { recursive: true }); + + const manifestEntries: BackupEntry[] = []; + let backedUp = 0; + let bytes = 0; + for (const entry of saved) { + if (signal?.aborted) break; + const destDir = path.join(destRoot, backupEntryRelDir(entry)); + try { + const st = await stat(entry.storedPath); + await mkdir(destDir, { recursive: true }); + // Trailing slash on destDir keeps rsync placing the file inside it. + const args = ["-a", "--partial", entry.storedPath, `${destDir}/`]; + log(`$ ${paths.rsyncBin} ${args.join(" ")}`); + const child = execa(paths.rsyncBin, args, { + cancelSignal: signal, + all: true, + buffer: false, + reject: false, + }); + child.all?.on("data", (c: Buffer) => log(c.toString("utf8"))); + const result = await child; + if (result.exitCode !== 0) { + if (signal?.aborted) break; + log( + `rsync failed for ${entry.slug}/${entry.videoId} (exit ${result.exitCode}); skipping`, + ); + continue; + } + const sha256 = await sha256File(entry.storedPath); + // Cache the checksum back onto the pointer for the store/UI. + await updateSavedVideoChecksum(entry.videoDir, sha256).catch(() => {}); + manifestEntries.push({ + slug: entry.slug, + videoId: entry.videoId, + file: entry.pointer.file, + bytes: st.size, + sha256, + }); + backedUp++; + bytes += st.size; + } catch (err) { + log( + `Failed to back up ${entry.slug}/${entry.videoId}: ${(err as Error).message}`, + ); + } + } + + const manifest: BackupManifest = { + backedUpAt: new Date().toISOString(), + entries: manifestEntries, + }; + const manifestPath = path.join(destRoot, BACKUP_MANIFEST_FILENAME); + const tmp = `${manifestPath}.tmp-${process.pid}`; + await writeFile(tmp, JSON.stringify(manifest, null, 2) + "\n"); + await rename(tmp, manifestPath); + log( + `Backup complete: ${backedUp}/${saved.length} container(s), ${bytes} bytes. Manifest at ${manifestPath}`, + ); + + return { entries: saved.length, backedUp, bytes, manifestPath }; +} + +export type BackupDrift = { + // Manifest entries whose file is absent from the destination. + missing: BackupEntry[]; + // Present at the destination but a different size than the manifest records. + sizeMismatch: BackupEntry[]; + // Present and right-sized but whose content hash no longer matches (only + // computed when `checksum` is true). + checksumMismatch: BackupEntry[]; + // Container files at the destination that the manifest doesn't know about + // (relative <slug>/<videoId>/<file> paths). + extra: string[]; +}; + +export type VerifyBackupResult = BackupDrift & { + // True when every drift bucket is empty. + ok: boolean; + backedUpAt: string; + checked: number; +}; + +// Walk the destination two levels deep (<slug>/<videoId>) collecting the +// relative paths of stored container files, ignoring the manifest itself. +async function listDestEntries(destRoot: string): Promise<Set<string>> { + const out = new Set<string>(); + let slugs: string[]; + try { + slugs = (await readdir(destRoot, { withFileTypes: true })) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + } catch { + return out; + } + for (const slug of slugs) { + const slugDir = path.join(destRoot, slug); + const ids = (await readdir(slugDir, { withFileTypes: true }).catch(() => [])) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + for (const id of ids) { + const files = await readdir(path.join(slugDir, id)).catch( + () => [] as string[], + ); + for (const f of files) out.add(path.join(slug, id, f)); + } + } + return out; +} + +export async function verifySavedVideoBackup({ + paths: _paths, + dest, + onLog, + signal, + checksum = true, +}: { + paths: Paths; + dest: string; + onLog?: (msg: string) => void; + signal?: AbortSignal; + // Re-hash each present, right-sized file and compare to the manifest. Off + // skips the (potentially expensive) hashing and only checks presence + size. + checksum?: boolean; +}): Promise<VerifyBackupResult> { + const log = onLog ?? ((m: string) => console.log(m)); + const destRoot = dest.trim(); + if (!destRoot) throw new Error("No backup destination configured"); + + let manifest: BackupManifest | null = null; + try { + manifest = parseBackupManifest( + JSON.parse( + await readFile(path.join(destRoot, BACKUP_MANIFEST_FILENAME), "utf8"), + ), + ); + } catch { + manifest = null; + } + if (!manifest) { + throw new Error(`No readable backup manifest at ${destRoot}`); + } + + const missing: BackupEntry[] = []; + const sizeMismatch: BackupEntry[] = []; + const checksumMismatch: BackupEntry[] = []; + const known = new Set<string>(); + let checked = 0; + + for (const entry of manifest.entries) { + if (signal?.aborted) break; + const rel = backupEntryRelPath(entry); + known.add(rel); + const file = path.join(destRoot, rel); + let st; + try { + st = await stat(file); + } catch { + missing.push(entry); + continue; + } + checked++; + if (st.size !== entry.bytes) { + sizeMismatch.push(entry); + continue; + } + if (checksum) { + const got = await sha256File(file); + if (got !== entry.sha256) checksumMismatch.push(entry); + } + } + + const destEntries = await listDestEntries(destRoot); + const extra = [...destEntries].filter((rel) => !known.has(rel)).sort(); + + const ok = + missing.length === 0 && + sizeMismatch.length === 0 && + checksumMismatch.length === 0 && + extra.length === 0; + log( + `Verify: checked ${checked}/${manifest.entries.length}; ${missing.length} missing, ${sizeMismatch.length} size-mismatch, ${checksumMismatch.length} checksum-mismatch, ${extra.length} extra.`, + ); + + return { + ok, + backedUpAt: manifest.backedUpAt, + checked, + missing, + sizeMismatch, + checksumMismatch, + extra, + }; +} diff --git a/common/controller/savedVideoInventory.test.ts b/common/controller/savedVideoInventory.test.ts @@ -0,0 +1,67 @@ +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 { listSavedVideos, savedVideoTotals } from "./savedVideoInventory"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common exec tsx --test controller/savedVideoInventory.test.ts + +async function withPaths(fn: (paths: Paths) => Promise<void>): Promise<void> { + const dir = await mkdtemp(path.join(tmpdir(), "ttb-inv-")); + 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 }); + } +} + +async function seed( + paths: Paths, + slug: string, + id: string, + content: 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"), content); + await persistSourceVideo({ + videoDir, + sourceFilename: "source-media.mp4", + storeDir: path.join(paths.savedVideosDir, slug, id), + keepReason: "keep-latest", + }); +} + +test("listSavedVideos enumerates every pointer across channels, sorted", async () => { + await withPaths(async (paths) => { + await seed(paths, "beta", "v2", "22"); + await seed(paths, "alpha", "v1", "1"); + await seed(paths, "alpha", "v0", "000"); + // A data dir with no pointer is ignored. + await mkdir(path.join(paths.channelsDir, "alpha", "data", "plain"), { + recursive: true, + }); + + const all = await listSavedVideos({ paths }); + assert.deepEqual( + all.map((e) => `${e.slug}/${e.videoId}`), + ["alpha/v0", "alpha/v1", "beta/v2"], + ); + assert.equal(all[0].pointer.file, "source-media.mp4"); + + const onlyAlpha = await listSavedVideos({ paths, channelSlug: "alpha" }); + assert.equal(onlyAlpha.length, 2); + + const totals = await savedVideoTotals({ paths }); + assert.equal(totals.count, 3); + assert.equal(totals.bytes, "22".length + "1".length + "000".length); + }); +}); diff --git a/common/controller/savedVideoInventory.ts b/common/controller/savedVideoInventory.ts @@ -0,0 +1,81 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { Paths } from "../lib/paths"; +import { loadSavedVideo } from "../lib/savedVideo-server"; +import { savedVideoPath, type SavedVideoPointer } from "../lib/savedVideo"; + +const { pathExists, readdir } = fs; + +// One persisted source video, located via its data-dir pointer. Shared by the +// backup/verify controllers (Phase 4) and the saved-videos UI counts (Phase 5). +export type SavedVideoEntry = { + slug: string; + videoId: string; + // The per-video data dir holding the pointer (channels/<slug>/data/<id>). + videoDir: string; + // Absolute path to the stored container in the saved-video store. + storedPath: string; + pointer: SavedVideoPointer; +}; + +async function listChannelSlugs(paths: Paths): Promise<string[]> { + try { + const entries = await readdir(paths.channelsDir, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch { + return []; + } +} + +// Enumerate every persisted source video, across all channels (or a single +// channel when channelSlug is given), by following the saved-video.json pointers +// in each data dir. The pointer's `dir` is absolute, so per-channel store +// overrides resolve correctly without consulting channel config here. +export async function listSavedVideos({ + paths, + channelSlug, +}: { + paths: Paths; + channelSlug?: string; +}): Promise<SavedVideoEntry[]> { + const slugs = channelSlug ? [channelSlug] : await listChannelSlugs(paths); + const out: SavedVideoEntry[] = []; + for (const slug of slugs) { + const dataDir = path.join(paths.channelsDir, slug, "data"); + if (!(await pathExists(dataDir))) continue; + const ids = await readdir(dataDir).catch(() => [] as string[]); + for (const videoId of ids) { + const videoDir = path.join(dataDir, videoId); + const pointer = await loadSavedVideo(videoDir); + if (!pointer) continue; + out.push({ + slug, + videoId, + videoDir, + storedPath: savedVideoPath(pointer), + pointer, + }); + } + } + out.sort( + (a, b) => a.slug.localeCompare(b.slug) || a.videoId.localeCompare(b.videoId), + ); + return out; +} + +export type SavedVideoTotals = { + count: number; + bytes: number; +}; + +// Aggregate counts/sizes for the saved-video store (or one channel). Cheap: it +// reads only the pointers, not the containers themselves. +export async function savedVideoTotals(opts: { + paths: Paths; + channelSlug?: string; +}): Promise<SavedVideoTotals> { + const entries = await listSavedVideos(opts); + let bytes = 0; + for (const e of entries) bytes += e.pointer.bytes; + return { count: entries.length, bytes }; +} diff --git a/common/jobs/syncSchedulerState.ts b/common/jobs/syncSchedulerState.ts @@ -42,6 +42,10 @@ export type SchedulerState = { channels: Record<string, ChannelSyncState>; // Newest-first, bounded to SCHEDULER_RUN_LOG_LIMIT entries. runs: SchedulerRun[]; + // Epoch ms the scheduler last queued a saved-video backup (a global, not + // per-channel, job). Drives the savedVideoBackup.intervalMinutes cadence. + // null = never run. See editor/app/scheduler/runTick.ts. + lastSavedVideoBackupAt: number | null; }; export const SCHEDULER_RUN_LOG_LIMIT = 50; @@ -59,7 +63,7 @@ export function emptyChannelSyncState(): ChannelSyncState { } export function emptySchedulerState(): SchedulerState { - return { channels: {}, runs: [] }; + return { channels: {}, runs: [], lastSavedVideoBackupAt: null }; } // Read the state file, tolerating a missing/corrupt file by returning an empty @@ -85,7 +89,7 @@ export async function readSchedulerState(paths: Paths): Promise<SchedulerState> const runs: SchedulerRun[] = Array.isArray(r.runs) ? r.runs.map(coerceRun).slice(0, SCHEDULER_RUN_LOG_LIMIT) : []; - return { channels, runs }; + return { channels, runs, lastSavedVideoBackupAt: num(r.lastSavedVideoBackupAt) }; } // Write the state atomically (tmp file + rename), creating the .scheduler dir on @@ -97,6 +101,7 @@ export async function writeSchedulerState( const out: SchedulerState = { channels: state.channels, runs: state.runs.slice(0, SCHEDULER_RUN_LOG_LIMIT), + lastSavedVideoBackupAt: state.lastSavedVideoBackupAt ?? null, }; await mkdir(path.dirname(paths.schedulerStateFile), { recursive: true }); const tmp = `${paths.schedulerStateFile}.tmp-${process.pid}`; diff --git a/common/lib/paths.ts b/common/lib/paths.ts @@ -75,6 +75,10 @@ export type Paths = { whisperBin: string; whisperModel: string; ffmpegBin: string; + // rsync binary used to mirror the saved-video store to a backup destination + // (Phase 4 of the video-persistence feature). See + // common/controller/backupSavedVideos.ts. + rsyncBin: string; // parakeet (overlapping-segment stitching) app. parakeetBin is the standalone // wrapper script invoked as the app binary; parakeetCliBin is the underlying // parakeet-cli it drives; parakeetModel is the default .gguf model. @@ -148,6 +152,7 @@ export function getPaths(): Paths { "ggml-base.en.bin", ), ffmpegBin: process.env.FFMPEG_BIN ?? "ffmpeg", + rsyncBin: process.env.RSYNC_BIN ?? "rsync", parakeetBin: process.env.PARAKEET_STITCH_BIN ?? path.join(monorepoRoot, "scripts", "parakeet-stitch.mjs"), diff --git a/common/lib/savedVideo-server.ts b/common/lib/savedVideo-server.ts @@ -105,6 +105,18 @@ export async function persistSourceVideo(opts: { return pointer; } +// Record a content hash on a video's pointer (populated by the backup step so +// the store + UI can show a verified checksum). Best-effort: a missing pointer +// is a no-op. +export async function updateSavedVideoChecksum( + videoDir: string, + sha256: string, +): Promise<void> { + const pointer = await loadSavedVideo(videoDir); + if (!pointer) return; + await writePointer(videoDir, { ...pointer, sha256 }); +} + // Best-effort removal of a now-empty store dir (and its empty <slug> parent). async function pruneEmptyStoreDirs(storeDir: string): Promise<void> { await rm(storeDir, { recursive: false, force: true }).catch(() => {}); diff --git a/common/lib/savedVideoBackup.ts b/common/lib/savedVideoBackup.ts @@ -0,0 +1,80 @@ +import path from "node:path"; + +// Backup manifest for the saved-video store (Phase 4 of the video-persistence +// feature). When the store is mirrored to a backup destination, a manifest is +// written at the destination root recording, for each saved container, its +// canonical <slug>/<videoId>/<file> location, its size, and a streamed sha256. +// The verify step compares this manifest against what's actually on the backup +// destination (and the live store) to report drift. +// +// This module is pure (types + parse + path math) so it's importable from both +// client and server; the filesystem/rsync work lives in +// common/controller/backupSavedVideos.ts. + +export const BACKUP_MANIFEST_FILENAME = "backup-manifest.json"; + +export type BackupEntry = { + // The channel slug the container belongs to. + slug: string; + // The video id within the channel. + videoId: string; + // Container basename (e.g. "source-media.mp4"). + file: string; + // Size in bytes at backup time. + bytes: number; + // sha256 of the container contents, streamed at backup time. + sha256: string; +}; + +export type BackupManifest = { + // ISO timestamp the backup that produced this manifest completed. + backedUpAt: string; + // One entry per saved container mirrored to the destination. + entries: BackupEntry[]; +}; + +// Canonical relative location of a backed-up container under the destination +// root: <slug>/<videoId>/<file>. The store mirrors this layout regardless of +// which physical root (global or per-channel override) the live copy lives on, +// so every saved video lands in one predictable place in the backup. +export function backupEntryRelDir(entry: Pick<BackupEntry, "slug" | "videoId">): string { + return path.join(entry.slug, entry.videoId); +} + +export function backupEntryRelPath( + entry: Pick<BackupEntry, "slug" | "videoId" | "file">, +): string { + return path.join(entry.slug, entry.videoId, entry.file); +} + +function parseEntry(raw: unknown): BackupEntry | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record<string, unknown>; + if (typeof r.slug !== "string" || r.slug === "") return null; + if (typeof r.videoId !== "string" || r.videoId === "") return null; + if (typeof r.file !== "string" || r.file === "") return null; + if (typeof r.bytes !== "number" || !Number.isFinite(r.bytes)) return null; + if (typeof r.sha256 !== "string" || r.sha256 === "") return null; + return { + slug: r.slug, + videoId: r.videoId, + file: r.file, + bytes: r.bytes, + sha256: r.sha256, + }; +} + +export function parseBackupManifest(raw: unknown): BackupManifest | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record<string, unknown>; + if (!Array.isArray(r.entries)) return null; + const entries: BackupEntry[] = []; + for (const e of r.entries) { + const parsed = parseEntry(e); + if (parsed) entries.push(parsed); + } + return { + backedUpAt: typeof r.backedUpAt === "string" ? r.backedUpAt : "", + entries, + }; +} diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -114,6 +114,24 @@ export type SiteSettings = { // common/lib/site.ts. The one presentation field that lives globally so a // shared footer doesn't have to be repeated per site. socialLinks: SocialLink[]; + // Backup configuration for the saved-video store (Phase 4 of the + // video-persistence feature). When enabled with a destination, the store is + // mirrored there (additively, no deletes) with a per-backup manifest, and the + // sync scheduler runs the backup on the configured cadence. See + // common/controller/backupSavedVideos.ts. + savedVideoBackup: SavedVideoBackupSettings; +}; + +export type SavedVideoBackupSettings = { + // Master switch for the scheduled backup. A backup can still be run manually + // when this is false, as long as a destination is set. + enabled: boolean; + // Destination root the store is mirrored into (a local path or any rsync + // target). Empty disables both scheduled and manual backups. + dest: string; + // Cadence (minutes) for the scheduled backup when enabled. Clamped into the + // sync-interval window; default daily. + intervalMinutes: number; }; export type SyncSchedulerSettings = { @@ -208,6 +226,7 @@ export const SYNC_SCHEDULER_MAX_CONCURRENT_MAX = 16; export const SYNC_SCHEDULER_BACKOFF_BASE_DEFAULT_MINUTES = 30; export const SYNC_SCHEDULER_BACKOFF_MAX_DEFAULT_MINUTES = 1440; export const KEEP_LATEST_CHECK_DEFAULT_INTERVAL_MINUTES = 1440; +export const SAVED_VIDEO_BACKUP_DEFAULT_INTERVAL_MINUTES = 1440; // Internal-heartbeat cadence bounds. 0 means "off" (use an external cron // heartbeat); any other value is clamped into [MIN, MAX] seconds. The floor @@ -307,6 +326,35 @@ export function sanitizeSyncScheduler(value: unknown): SyncSchedulerSettings { }; } +export function defaultSavedVideoBackup(): SavedVideoBackupSettings { + return { + enabled: false, + dest: "", + intervalMinutes: SAVED_VIDEO_BACKUP_DEFAULT_INTERVAL_MINUTES, + }; +} + +// Coerce a raw settings.savedVideoBackup value into a clean +// SavedVideoBackupSettings. A missing destination forces enabled off, since a +// backup with nowhere to go is meaningless. +export function sanitizeSavedVideoBackup( + value: unknown, +): SavedVideoBackupSettings { + const d = defaultSavedVideoBackup(); + if (!value || typeof value !== "object") return d; + const r = value as Record<string, unknown>; + const dest = typeof r.dest === "string" ? r.dest.trim() : ""; + return { + enabled: dest !== "" && r.enabled === true, + dest, + intervalMinutes: clampPositiveInt( + r.intervalMinutes, + d.intervalMinutes, + SYNC_INTERVAL_MAX_MINUTES, + ), + }; +} + function defaults(): SiteSettings { return { adminTitle: DEFAULT_ADMIN_TITLE, @@ -325,6 +373,7 @@ function defaults(): SiteSettings { syncScheduler: defaultSyncScheduler(), autoQueue: defaultAutoQueue(), socialLinks: [], + savedVideoBackup: defaultSavedVideoBackup(), }; } @@ -487,6 +536,7 @@ export function getSettings(): SiteSettings { merged.syncScheduler = sanitizeSyncScheduler(merged.syncScheduler); merged.autoQueue = sanitizeAutoQueue(merged.autoQueue); merged.socialLinks = parseSocialLinks(merged.socialLinks); + merged.savedVideoBackup = sanitizeSavedVideoBackup(merged.savedVideoBackup); // Workers. When the file predates the worker model (no `workers` key), // synthesize a default list from the (now-settled) active app + per-app // configs so existing installs behave identically. Otherwise sanitize the @@ -657,6 +707,7 @@ export async function writeSettings(next: SiteSettings): Promise<void> { syncScheduler: sanitizeSyncScheduler(next.syncScheduler), autoQueue: sanitizeAutoQueue(next.autoQueue), socialLinks, + savedVideoBackup: sanitizeSavedVideoBackup(next.savedVideoBackup), }; const tmp = `${file}.tmp-${process.pid}`; await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2) + "\n"); diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **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`. - **New per-channel "keep latest N" retention rule that protects recent videos from the Clean-audio sweep and pins any that get deleted from their source (backend; UI lands in a later change).** A channel can set `keepLatest` (in `config.json` for now) to shield its newest N videos — by upload date, a rolling window — from the **Clean audio** cleanup: those dirs are skipped just like a `do-not-clean` marker, and the snapshot's reclaim estimates/cleanup buckets exclude them (a new `keptCount` is recorded). Because a kept video can later be **deleted from its source** (YouTube etc.) and become irreplaceable, a new **kept-deletion check** re-probes just the kept window's availability (reusing `runAvailabilityCheck` with `onlyIds` + `recheck-non-deleted`) and **permanently pins** any video found `deleted`/`private`/`members_only` with a `do-not-clean` marker, so it survives even after it rolls out of the window. The check runs on demand via the new `check-kept-deleted` managed job (`checkKeptDeletedAction`, re-runnable/bookmarkable) and automatically from the sync scheduler on its own cadence (new `syncScheduler.keepLatestCheckIntervalMinutes`, default daily; per-channel `lastKeptCheckAt` state; suppressed during quiet hours, capped per tick, and skipped for a channel just synced this tick). This is phase 1 of a larger **video-persistence** subsystem (per-download persistence rules, a separate saved-video store, and backups follow). See `common/lib/channelConfig.ts` (`keepLatest`), the new `common/controller/keptVideos.ts` (`computeKeptVideoIds` + tests) and `common/controller/checkKeptDeleted.ts`, `common/controller/cleanAudioFromTranscribed.ts`, `common/controller/channelSnapshot.ts`, and the scheduler wiring in `common/lib/settings.ts`, `common/jobs/syncSchedulerState.ts`, and `editor/app/scheduler/runTick.ts`. diff --git a/editor/app/jobs/jobKindLabels.ts b/editor/app/jobs/jobKindLabels.ts @@ -17,6 +17,8 @@ const JOB_KIND_LABELS: Record<string, string> = { "retry-bucket": "Retry", "clean-audio-transcribed": "Clean audio", "check-kept-deleted": "Check kept videos", + "backup-saved-videos": "Back up saved videos", + "verify-saved-video-backup": "Verify saved-video backup", sync: "Sync", }; diff --git a/editor/app/savedVideos/backupActions.ts b/editor/app/savedVideos/backupActions.ts @@ -0,0 +1,80 @@ +"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,6 +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"; export type SchedulerTickResult = { ok: boolean; @@ -33,6 +34,8 @@ export type SchedulerTickResult = { queued: string[]; // Channels for which a keep-latest deletion check was queued this tick. keptChecksQueued: string[]; + // Whether a saved-video backup was queued this tick. + savedVideoBackupQueued: boolean; // Channels deliberately held back, with a reason. skipped: SchedulerSkip[]; }; @@ -62,6 +65,7 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> { running: 0, queued: [], keptChecksQueued: [], + savedVideoBackupQueued: false, skipped: [], }; } @@ -90,6 +94,7 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> { running, queued: [], keptChecksQueued: [], + savedVideoBackupQueued: false, skipped: [], }; } @@ -164,6 +169,26 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> { } } + // Saved-video backup: a single global job on its own cadence. Suppressed + // during quiet hours; runs at most once per intervalMinutes. Independent of + // the per-channel concurrency cap (it touches no source provider). + let savedVideoBackupQueued = false; + const backup = settings.savedVideoBackup; + if (!quiet && backup.enabled && backup.dest.trim()) { + const intervalMs = backup.intervalMinutes * 60_000; + const last = state.lastSavedVideoBackupAt ?? 0; + if (now - last >= intervalMs) { + const result = await backupSavedVideosAction(); + if (result.ok) { + state.lastSavedVideoBackupAt = now; + savedVideoBackupQueued = true; + void result.stream.cancel(); + } else { + skipped.push({ slug: "(saved-video backup)", reason: result.error }); + } + } + } + recordRun(state, { at: now, queued, skipped }); await writeSchedulerState(paths, state); @@ -174,6 +199,7 @@ export async function runSchedulerTick(): Promise<SchedulerTickResult> { running, queued, keptChecksQueued, + savedVideoBackupQueued, skipped, }; } finally { diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -198,6 +198,9 @@ export async function saveSettingsAction( // re-sanitizes it regardless. autoQueue: getSettings().autoQueue, socialLinks, + // Preserve the saved-video backup config on an unrelated settings save (the + // Saved Videos page edits it). writeSettings re-sanitizes it regardless. + savedVideoBackup: getSettings().savedVideoBackup, }; try { await writeSettings(next);