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:
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);