Add configurable backup schedule with admin UI

- Make backup frequency (6h/12h/1d/7d), time, and day-of-week
  configurable via settings, stored in appSettings key-value table
- Add rescheduleBackup() to dynamically update cron job at runtime
- Differentiate manual (sofa-manual-*) vs scheduled (sofa-scheduled-*)
  backup filenames with source icons and tooltips in the backup list
- Pruning now only targets scheduled backups, leaving manual ones intact
- Merge backup-actions.ts into actions.ts, use shadcn DropdownMenu for
  all selectors (consistent with dashboard stats cards)
- Fix hydration mismatch on relative backup timestamps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 13:43:40 -05:00
co-authored by Claude Opus 4.6
parent b17f54f6db
commit 6cfe3a0439
6 changed files with 465 additions and 129 deletions
+45 -2
View File
@@ -29,6 +29,8 @@ import {
import { getSetting } from "@/lib/services/settings";
import { getTvDetails } from "@/lib/tmdb/client";
export type BackupFrequency = "6h" | "12h" | "1d" | "7d";
const log = createLogger("cron");
const DAY = 24 * 60 * 60 * 1000;
@@ -229,17 +231,58 @@ async function scheduledBackupJob() {
}
await ensureBackupDir();
await createBackup();
await createBackup("sofa-scheduled");
const maxStr = getSetting("maxBackupRetention");
const max = maxStr ? Number.parseInt(maxStr, 10) : 7;
await pruneBackups(max);
}
export function buildBackupCron(
frequency: BackupFrequency = "1d",
time = "02:00",
dayOfWeek = 0,
): string {
const [hour, minute] = time.split(":").map(Number);
const h = Number.isFinite(hour) ? hour : 2;
const m = Number.isFinite(minute) ? minute : 0;
const dow = dayOfWeek >= 0 && dayOfWeek <= 6 ? dayOfWeek : 0;
switch (frequency) {
case "6h":
return `${m} */6 * * *`;
case "12h":
return `${m} ${h},${(h + 12) % 24} * * *`;
case "7d":
return `${m} ${h} * * ${dow}`;
default:
return `${m} ${h} * * *`;
}
}
function getBackupCronFromSettings(): string {
const frequency = (getSetting("backupScheduleFrequency") ??
"1d") as BackupFrequency;
const time = getSetting("backupScheduleTime") ?? "02:00";
const dayOfWeek = Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10);
return buildBackupCron(frequency, time, dayOfWeek);
}
export function rescheduleBackup() {
const existing = jobs.get("scheduledBackup");
if (existing) {
existing.stop();
jobs.delete("scheduledBackup");
}
const cron = getBackupCronFromSettings();
log.info(`Rescheduling backup job with cron: ${cron}`);
schedule("scheduledBackup", cron, scheduledBackupJob);
}
export function startJobs() {
if (jobs.size > 0) return;
schedule("scheduledBackup", "0 2 * * *", scheduledBackupJob);
schedule("scheduledBackup", getBackupCronFromSettings(), scheduledBackupJob);
schedule("nightlyRefreshLibrary", "0 3 * * *", nightlyRefreshLibrary);
schedule("refreshAvailability", "0 */6 * * *", refreshAvailabilityJob);
schedule("refreshRecommendations", "0 */12 * * *", refreshRecommendationsJob);
+25 -9
View File
@@ -13,13 +13,31 @@ const DATABASE_URL =
process.env.DATABASE_URL || path.join(DATA_DIR, "sqlite.db");
const BACKUP_DIR = path.join(DATA_DIR, "backups");
const BACKUP_PATTERN = /^sofa-backup-\d{4}-\d{2}-\d{2}-\d{6}\.db$/;
const MANUAL_PATTERN = /^sofa-manual-\d{4}-\d{2}-\d{2}-\d{6}\.db$/;
const SCHEDULED_PATTERN = /^sofa-scheduled-\d{4}-\d{2}-\d{2}-\d{6}\.db$/;
const PRE_RESTORE_PATTERN = /^pre-restore-\d{4}-\d{2}-\d{2}-\d{6}\.db$/;
export type BackupSource = "manual" | "scheduled" | "pre-restore";
export interface BackupInfo {
filename: string;
sizeBytes: number;
createdAt: string;
source: BackupSource;
}
function getBackupSource(filename: string): BackupSource {
if (SCHEDULED_PATTERN.test(filename)) return "scheduled";
if (PRE_RESTORE_PATTERN.test(filename)) return "pre-restore";
return "manual";
}
function isKnownBackup(filename: string): boolean {
return (
MANUAL_PATTERN.test(filename) ||
SCHEDULED_PATTERN.test(filename) ||
PRE_RESTORE_PATTERN.test(filename)
);
}
export async function ensureBackupDir() {
@@ -29,14 +47,12 @@ export async function ensureBackupDir() {
function isValidBackupFilename(filename: string): boolean {
const base = path.basename(filename);
return (
base === filename &&
!filename.includes("..") &&
(BACKUP_PATTERN.test(filename) || PRE_RESTORE_PATTERN.test(filename))
base === filename && !filename.includes("..") && isKnownBackup(filename)
);
}
export async function createBackup(
prefix = "sofa-backup",
prefix = "sofa-manual",
): Promise<BackupInfo> {
await ensureBackupDir();
@@ -54,15 +70,14 @@ export async function createBackup(
filename,
sizeBytes: s.size,
createdAt: s.mtime.toISOString(),
source: getBackupSource(filename),
};
}
export async function listBackups(): Promise<BackupInfo[]> {
await ensureBackupDir();
const files = (await readdir(BACKUP_DIR)).filter(
(f) => BACKUP_PATTERN.test(f) || PRE_RESTORE_PATTERN.test(f),
);
const files = (await readdir(BACKUP_DIR)).filter((f) => isKnownBackup(f));
const results: BackupInfo[] = [];
for (const filename of files) {
@@ -71,6 +86,7 @@ export async function listBackups(): Promise<BackupInfo[]> {
filename,
sizeBytes: s.size,
createdAt: s.mtime.toISOString(),
source: getBackupSource(filename),
});
}
@@ -164,7 +180,7 @@ export async function restoreFromBackup(buffer: Buffer): Promise<void> {
export async function pruneBackups(maxKeep: number): Promise<void> {
const backups = (await listBackups()).filter((b) =>
BACKUP_PATTERN.test(b.filename),
SCHEDULED_PATTERN.test(b.filename),
);
if (backups.length <= maxKeep) return;