// The backup command, rebuilt on the library API (issue #51). // // `lib.backup()` refreshes the library, then, for every file in scope, gets its // original bytes onto disk under `downloadDirectory` and rebuilds the derived // views (per-file sidecars, per-collection symlink trees, per-collection JSON) // from the model. The on-disk layout is the historical one, unchanged: // // / // originals/. the decrypted bytes // originals/.json per-file metadata sidecar // collections// symlink into ../../originals // collections/<name>.json per-collection metadata // failures.json durable ledger of unresolved failures // // Crash-safety rests on two properties. Bytes are present-means-complete: an // original appears under `originals/` only via the content layer's atomic // temp-then-rename, so a file that exists is whole and is never re-fetched — an // interrupted run resumes by listing the directory. The derived views hold no // unique state, so they are rebuilt every run; that repairs stale sidecars and // missing or broken symlinks left by an earlier crash. A rebuild also removes // the symlinks into originals/ that no longer belong to an album, and the // directories of albums that no longer exist. // // Resilience (issue #8): no per-file condition aborts the run. A failed // download or a failed symlink is caught, recorded in `failures.json` with a // classification, a running attempt count, and the last-tried time, and the run // continues. `result.failed` — and thus the CLI's exit code — stays non-zero // while any failure remains unresolved and clears once every one succeeds. Each // run reconciles the ledger against the files it attempted, so an entry for a // file that has since left the library (deleted) or this run's scope is dropped // rather than counted forever, which would poison a scheduled backup's exit code. import { lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync, } from "node:fs"; import { copyFile, rename, rm } from "node:fs/promises"; import { basename, dirname, extname, join, relative } from "node:path"; import { fsyncPath } from "./download/index.js"; import { safeExtension, sanitizeFileName } from "./filename.js"; import type { Collection, EnteFile } from "./model/types.js"; export type ProgressCallback = (message: string) => void; export interface BackupOptions { // Where the backup tree lives. Required: with none, `backup()` throws // before any network traffic. A library opened with a `downloadDirectory` // supplies the default. downloadDirectory?: string; // Fetch and store full-resolution originals. Default true. includeOriginals?: boolean; // Also fetch and store thumbnails under `thumbnails/<fileID>.jpg`. Default // false. includeThumbnails?: boolean; // Restrict the backup to albums with these names; others are left untouched. onlyAlbumNames?: string[]; onProgress?: ProgressCallback; } export interface BackupError { fileID: number; title: string; collection: string; error: string; } export interface BackupResult { // Distinct files in scope this run. totalFiles: number; // Originals fetched (or copied from the cache) this run. downloaded: number; // Originals already present and left untouched. skipped: number; // Files with an unresolved failure after this run (the ledger size); the // CLI exits non-zero while this is above zero. A file can be both // downloaded and failed if its bytes landed but its symlink did not. failed: number; // This run's per-file errors, in encounter order. errors: BackupError[]; } // The slice of the library that backup drives. `Library` implements it; a test // can drive backup with a stand-in. export interface BackupLibrary { refresh(): Promise<void>; listCollections(): Collection[]; listFiles(collectionID: number): EnteFile[]; // Get an original's bytes onto disk through the content cache/pools, // returning where they landed (the cache, or a prior backup). original(fileID: number): Promise<{ path: string }>; thumbnail(fileID: number): Promise<{ path: string }>; } type FailureClass = "transient" | "permanent" | "unknown"; interface FailureEntry { fileID: number; title: string; classification: FailureClass; attempts: number; lastTriedAt: number; error: string; } const LEDGER_VERSION = 1; // The originals/ filename for a file: `<id><ext>`, the extension taken from the // title (or `.bin`). Matches the content cache's own naming so a present check // lines up with what a fetch would write. const originalName = (file: EnteFile): string => `${file.id}${safeExtension(file.metadata.title)}`; // A regular file with content is treated as complete. A zero-byte file is not: // it is the shape an aborted write leaves and must be re-fetched. const isPresent = (path: string): boolean => { try { const s = statSync(path); return s.isFile() && s.size > 0; } catch { return false; } }; // Best-effort classification for the ledger. Retryable server/network problems // are transient; refusals and local filesystem/decrypt errors are permanent; // anything else is unknown. Both the error code and message are inspected. const classify = (err: unknown): FailureClass => { const e = err as NodeJS.ErrnoException; const text = `${e?.code ?? ""} ${err instanceof Error ? err.message : String(err)}`.toLowerCase(); if ( /timeout|timed out|econnreset|econnrefused|econnaborted|network|socket|eai_again|throttl|temporarily|429|500|502|503|504/.test( text, ) ) { return "transient"; } if ( /enoent|eacces|eperm|eexist|eisdir|enotempty|erofs|enospc|not found|forbidden|unauthor|decrypt|truncat|401|403|404/.test( text, ) ) { return "permanent"; } return "unknown"; }; const errorMessage = (err: unknown): string => err instanceof Error ? err.message : String(err); // Copy bytes into `dest` via a temp file in the same directory plus rename, so // `dest` appears only once it is whole ("present means complete"). As in the // download writer, the temp file is fsynced before the rename and the directory // after it, so a power cut cannot leave a correctly named but short original. // The temp name carries this process's ID so a later run can tell a leftover // from a copy still in progress (see `removeLeftoverTempFiles`). const copyAtomic = async (src: string, dest: string): Promise<void> => { if (src === dest) return; const tmp = join( dirname(dest), `.quak-backup-${basename(dest)}-${process.pid}-${Math.random() .toString(36) .slice(2)}.tmp`, ); try { await copyFile(src, tmp); await fsyncPath(tmp); // `rename` replaces the destination's directory entry: an existing // symlink at `dest` is replaced, not followed, and the new file has // the temp file's permissions (copied from `src`). await rename(tmp, dest); await fsyncPath(dirname(dest)); } finally { await rm(tmp, { force: true }); } }; // A process-ID check: signal 0 delivers nothing and only reports whether the // process exists. EPERM means it exists but belongs to another user. const isRunning = (pid: number): boolean => { try { process.kill(pid, 0); return true; } catch (err) { return (err as NodeJS.ErrnoException).code === "EPERM"; } }; // Delete the temp files `copyAtomic` leaves behind when a backup is killed // before its rename. Only files whose process is no longer running are // removed, so a backup running at the same time keeps its own. A reused // process ID can only keep a leftover a while longer, never remove a live one. const removeLeftoverTempFiles = (dir: string): void => { let names: string[]; try { names = readdirSync(dir); } catch { return; } for (const name of names) { const match = /^\.quak-backup-.*-(\d+)-[0-9a-z]*\.tmp$/.exec(name); if (match && !isRunning(Number(match[1]))) { rmSync(join(dir, name), { force: true }); } } }; // Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or // non-symlink entry. Throws on failure (a directory in the way, no permission) // so the caller records it and moves on rather than aborting the run. const rebuildSymlink = (linkPath: string, target: string): void => { try { const st = lstatSync(linkPath); if (st.isSymbolicLink() && readlinkSync(linkPath) === target) return; } catch { // Nothing there (or unreadable): fall through to create it. } // Remove a wrong symlink or stray file. `force` ignores a missing path but // still refuses a directory (no `recursive`), which surfaces as a failure. rmSync(linkPath, { force: true }); symlinkSync(target, linkPath); }; // The on-disk names for the entries of one directory, keyed by ID. Each name // is used as is unless another entry would get the same name, ignoring case // (two names that differ only in case are one entry on a case-insensitive // file system); then every entry sharing it gets ` (<id>)`, before the // extension when `beforeExtension` is set. A name with an ID added can match // another entry's own name (`IMG (6).JPG`), so this repeats until no name is // shared. IDs are stable, so the names are too. const namesByID = ( entries: { id: number; name: string }[], beforeExtension: boolean, ): Map<number, string> => { const withID = (id: number, name: string): string => { const ext = beforeExtension ? extname(name) : ""; const stem = name.slice(0, name.length - ext.length); return `${stem} (${id})${ext}`; }; const names = new Map<number, string>(); for (const { id, name } of entries) names.set(id, name); const suffixed = new Set<number>(); for (;;) { const counts = new Map<string, number>(); for (const name of names.values()) { const key = name.toLowerCase(); counts.set(key, (counts.get(key) ?? 0) + 1); } let changed = false; for (const { id, name } of entries) { if (suffixed.has(id)) continue; if (counts.get(name.toLowerCase()) === 1) continue; names.set(id, withID(id, name)); suffixed.add(id); changed = true; } if (!changed) return names; } }; // Remove the symlinks in the album directory `dir` that point into // `originalsDir` and are not named in `keep`. Nothing else in the directory // is touched: anything else there was put there by the user. const removeStaleLinks = ( dir: string, keep: Set<string>, originalsDir: string, ): void => { const target = relative(dir, originalsDir); for (const name of readdirSync(dir)) { if (keep.has(name)) continue; const path = join(dir, name); if ( lstatSync(path).isSymbolicLink() && dirname(readlinkSync(path)) === target ) { rmSync(path); } } }; // Remove the directories under `collectionsDir` that an earlier run wrote for // an album that is gone or renamed: a directory not named in `current` with a // `<name>.json` beside it holding an album ID, which is what a run writes. Its // symlinks into originals/ are removed; if that leaves it empty, it and its // JSON are deleted, otherwise both stay for what the user put there. const removeStaleAlbumDirs = ( collectionsDir: string, current: Set<string>, originalsDir: string, ): void => { for (const entry of readdirSync(collectionsDir, { withFileTypes: true })) { if (!entry.isDirectory() || current.has(entry.name)) continue; const jsonPath = join(collectionsDir, `${entry.name}.json`); try { const album = JSON.parse(readFileSync(jsonPath, "utf-8")) as { id?: unknown; }; if (typeof album.id !== "number") continue; } catch { continue; } const dir = join(collectionsDir, entry.name); removeStaleLinks(dir, new Set(), originalsDir); if (readdirSync(dir).length > 0) continue; rmdirSync(dir); rmSync(jsonPath); } }; const loadLedger = (path: string): Map<number, FailureEntry> => { const ledger = new Map<number, FailureEntry>(); try { const parsed = JSON.parse(readFileSync(path, "utf-8")) as { files?: Record<string, FailureEntry>; }; for (const entry of Object.values(parsed.files ?? {})) { if (entry && typeof entry.fileID === "number") { ledger.set(entry.fileID, entry); } } } catch { // No ledger yet, or an unreadable one: start clean. } return ledger; }; const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => { if (ledger.size === 0) { rmSync(path, { force: true }); return; } const files: Record<string, FailureEntry> = {}; for (const [fileID, entry] of ledger) files[String(fileID)] = entry; writeFileSync( path, JSON.stringify({ version: LEDGER_VERSION, files }, null, 2), ); }; const writeSidecar = (path: string, file: EnteFile): void => { const meta: Record<string, unknown> = { id: file.id, collectionID: file.collectionID, ownerID: file.ownerID, metadata: file.metadata, }; if (file.magicMetadata) meta.magicMetadata = file.magicMetadata; if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata; writeFileSync(path, JSON.stringify(meta, null, 2)); }; export const runBackup = async ( lib: BackupLibrary, opts: BackupOptions, ): Promise<BackupResult> => { const downloadDirectory = opts.downloadDirectory; if (!downloadDirectory) { throw new Error( "backup requires a downloadDirectory (pass one to backup() or " + "open the library with one)", ); } const includeOriginals = opts.includeOriginals ?? true; const includeThumbnails = opts.includeThumbnails ?? false; const log = opts.onProgress ?? (() => {}); const only = opts.onlyAlbumNames ? new Set(opts.onlyAlbumNames) : undefined; log("Refreshing library..."); await lib.refresh(); const originalsDir = join(downloadDirectory, "originals"); const collectionsDir = join(downloadDirectory, "collections"); const thumbnailsDir = join(downloadDirectory, "thumbnails"); mkdirSync(originalsDir, { recursive: true }); mkdirSync(collectionsDir, { recursive: true }); if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true }); removeLeftoverTempFiles(originalsDir); removeLeftoverTempFiles(thumbnailsDir); const ledgerPath = join(downloadDirectory, "failures.json"); const ledger = loadLedger(ledgerPath); const now = Date.now(); // Collections in scope, and the distinct files across them (a file shared // by two albums is one original). const allCollections = lib.listCollections(); const collections = allCollections.filter((c) => only ? only.has(c.name) : true, ); const collectionName = new Map<number, string>(); for (const c of collections) collectionName.set(c.id, c.name); const distinct = new Map<number, EnteFile>(); const filesByCollection = new Map<number, EnteFile[]>(); for (const c of collections) { const files = lib.listFiles(c.id); filesByCollection.set(c.id, files); for (const f of files) if (!distinct.has(f.id)) distinct.set(f.id, f); } const errors: BackupError[] = []; const failedThisRun = new Set<number>(); let downloaded = 0; let skipped = 0; const recordFailure = ( file: EnteFile, collection: string, err: unknown, ): void => { // Count at most one attempt per file per run: a file whose original // and thumbnail both fail this run must not double its attempt count // or appear twice in errors. if (failedThisRun.has(file.id)) return; const error = errorMessage(err); errors.push({ fileID: file.id, title: file.metadata.title, collection, error, }); const prior = ledger.get(file.id); ledger.set(file.id, { fileID: file.id, title: file.metadata.title, classification: classify(err), attempts: (prior?.attempts ?? 0) + 1, lastTriedAt: now, error, }); failedThisRun.add(file.id); }; // Phase 1: get the bytes. Fetch each pending original (and optional // thumbnail) through the content cache/pools and place it under the backup // tree; a present file is left as is. if (includeOriginals) { for (const [fileID, file] of distinct) { const dest = join(originalsDir, originalName(file)); if (isPresent(dest)) { skipped++; continue; } try { log(`Fetching original ${file.metadata.title} (${fileID})...`); const { path } = await lib.original(fileID); await copyAtomic(path, dest); downloaded++; } catch (err) { log( `FAILED original ${file.metadata.title}: ${errorMessage(err)}`, ); recordFailure( file, collectionName.get(file.collectionID) ?? "", err, ); } } } if (includeThumbnails) { for (const [fileID, file] of distinct) { const dest = join(thumbnailsDir, `${fileID}.jpg`); if (isPresent(dest)) continue; try { const { path } = await lib.thumbnail(fileID); await copyAtomic(path, dest); } catch (err) { recordFailure( file, collectionName.get(file.collectionID) ?? "", err, ); } } } // Phase 2: rebuild the derived views from the model. Sidecars first, for // every present original (this repairs stale ones). if (includeOriginals) { for (const [fileID, file] of distinct) { const orig = join(originalsDir, originalName(file)); if (isPresent(orig)) { writeSidecar(join(originalsDir, `${fileID}.json`), file); } } } // Then the per-collection symlink trees and JSON. Directory names are // chosen across every album, not just those in scope, so a scoped run // names an album the same as a full one and never takes the directory of // an album it skipped. Stale entries are removed before anything is // rebuilt, so on a case-insensitive file system removing an old name can // never remove the new one. const albumDirNames = namesByID( allCollections.map((c) => ({ id: c.id, name: sanitizeFileName(c.name, `collection-${c.id}`), })), false, ); try { removeStaleAlbumDirs( collectionsDir, new Set(albumDirNames.values()), originalsDir, ); } catch (err) { log(`FAILED removing old album directories: ${errorMessage(err)}`); } for (const c of collections) { const colDirName = albumDirNames.get(c.id)!; const colDir = join(collectionsDir, colDirName); mkdirSync(colDir, { recursive: true }); const files = filesByCollection.get(c.id) ?? []; const linkNames = namesByID( files.map((f) => ({ id: f.id, name: sanitizeFileName(f.metadata.title, `file-${f.id}`), })), true, ); try { removeStaleLinks(colDir, new Set(linkNames.values()), originalsDir); } catch (err) { log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`); } const metaFiles: { id: number; metadata: EnteFile["metadata"] }[] = []; for (const file of files) { metaFiles.push({ id: file.id, metadata: file.metadata }); if (!includeOriginals) continue; const orig = join(originalsDir, originalName(file)); if (!isPresent(orig)) continue; const linkName = linkNames.get(file.id)!; const linkPath = join(colDir, linkName); try { rebuildSymlink(linkPath, relative(colDir, orig)); } catch (err) { log( `FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`, ); recordFailure(file, c.name, err); } } writeFileSync( join(collectionsDir, `${colDirName}.json`), JSON.stringify( { id: c.id, name: c.name, type: c.type, files: metaFiles }, null, 2, ), ); } // Reconcile the ledger against what this run actually attempted: an entry // survives only for a file that failed this run. A file that succeeded had // its failure resolved; a file gone from the library (deleted) or outside // this run's scope is not something this run can resolve, so keeping its // stale entry would keep the exit code non-zero forever — a single // since-deleted photo would fail every future scheduled backup. for (const fileID of [...ledger.keys()]) { if (!failedThisRun.has(fileID)) ledger.delete(fileID); } saveLedger(ledgerPath, ledger); return { totalFiles: distinct.size, downloaded, skipped, failed: ledger.size, errors, }; };