// The backup command, rebuilt on the library API (issue #51). // // `lib.backup()` waits for a completed refresh of the library (a failed one // fails the backup before any file is touched), then, for every file in scope, // puts its original at its save path under `downloadDirectory`, as // `Photo.download()` does, and rebuilds the derived views (per-file sidecars, // per-collection symlink trees, per-collection JSON) from the model. The // on-disk layout: // // / // YYYY/YYYY-MM/YYYY-MM-DD/ // YYYY-MM-DD.. the decrypted bytes (the save path) // YYYY-MM-DD..json per-file metadata sidecar // collections// symlink to the original // collections/<name>.json per-collection metadata // failures.json durable ledger of unresolved failures // // A live photo's original is its image and its video, each with its own // extension, beside `YYYY-MM-DD.<fileID>.livephoto.json` naming them; its album // folders link both. // // Crash-safety rests on two properties. Bytes are present-means-complete: an // original appears at its save path 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 looking at the save paths. 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 to 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 { dirname, extname, join, relative, resolve } from "node:path"; import { removeLeftoverTempFiles } from "./download/index.js"; import { sanitizeFileName, withExtension } from "./filename.js"; import { copyAtomic, placeOriginal, savePath, storedAtSavePath, } from "./library/content.js"; import { representative } from "./library/records.js"; import type { Collection, EnteFile } from "./model/types.js"; export type ProgressCallback = (message: string) => void; export interface BackupOptions { // Where the backup tree lives. `lib.backup()` defaults it to the library's // download directory; `runBackup` with none throws before any network // traffic. 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 at their save path 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: `destination` when they were fetched now, // otherwise wherever they already were (the cache, or the library's save // path). A live photo lands as its image and its video, fetched now beside // `destination`. original( fileID: number, destination: string, ): Promise<{ path: string; videoPath?: 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; // 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); // 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, in entry order. 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 uniqueNames = ( entries: { id: number; name: string }[], beforeExtension: boolean, ): 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 = entries.map((e) => e.name); const suffixed = new Set<number>(); for (;;) { const counts = new Map<string, number>(); for (const name of names) { const key = name.toLowerCase(); counts.set(key, (counts.get(key) ?? 0) + 1); } let changed = false; for (const [i, { id, name }] of entries.entries()) { if (suffixed.has(i)) continue; if (counts.get(name.toLowerCase()) === 1) continue; names[i] = withID(id, name); suffixed.add(i); changed = true; } if (!changed) return names; } }; // The links a file gets in its album's folder: one named after its title, to // its original if that is stored. A stored live photo gets two, to its image // and its video, each named after the title with that file's extension. const linksFor = ( file: EnteFile, stored: { path: string; videoPath?: string } | undefined, ): { id: number; name: string; file: EnteFile; target?: string }[] => { const name = sanitizeFileName(file.metadata.title, `file-${file.id}`); if (stored?.videoPath === undefined) { return [{ id: file.id, name, file, target: stored?.path }]; } return [stored.path, stored.videoPath].map((target) => ({ id: file.id, name: withExtension(name, extname(target)), file, target, })); }; // Every date folder (`YYYY/YYYY-MM/YYYY-MM-DD/`) under `root`, whether or not a // file in this backup is saved there. A folder that cannot be read is skipped. const dateFolders = (root: string): string[] => { const subfolders = (dir: string, name: RegExp): string[] => { try { return readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory() && name.test(e.name)) .map((e) => join(dir, e.name)); } catch { return []; } }; return subfolders(root, /^\d{4}$/) .flatMap((year) => subfolders(year, /^\d{4}-\d\d$/)) .flatMap((month) => subfolders(month, /^\d{4}-\d\d-\d\d$/)); }; // Whether the entry at `path` is a symlink a backup to `root` made: one to an // original in a `YYYY/YYYY-MM/YYYY-MM-DD/` folder of `root`. const linksToOriginal = (path: string, root: string): boolean => { if (!lstatSync(path).isSymbolicLink()) return false; const target = relative(root, resolve(dirname(path), readlinkSync(path))); return /^\d{4}\/\d{4}-\d\d\/\d{4}-\d\d-\d\d\/[^/]+$/.test(target); }; // Remove the symlinks in the album directory `dir` that point to an original // in `root` 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>, root: string, ): void => { for (const name of readdirSync(dir)) { if (keep.has(name)) continue; const path = join(dir, name); if (linksToOriginal(path, root)) 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 to originals in `root` 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>, root: 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(), root); 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 collectionsDir = join(downloadDirectory, "collections"); const thumbnailsDir = join(downloadDirectory, "thumbnails"); mkdirSync(collectionsDir, { recursive: true }); if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true }); removeLeftoverTempFiles(thumbnailsDir); for (const dir of dateFolders(downloadDirectory)) { removeLeftoverTempFiles(dir); } 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). Each file is the membership // `representative` picks from all of its albums, in scope or not, so it is // saved at the path `photo.savePath` names. 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 allCollections) collectionName.set(c.id, c.name); const memberships = new Map<number, EnteFile[]>(); const filesByCollection = new Map<number, EnteFile[]>(); for (const c of allCollections) { const files = lib.listFiles(c.id); filesByCollection.set(c.id, files); for (const f of files) { const arr = memberships.get(f.id); if (arr) arr.push(f); else memberships.set(f.id, [f]); } } const distinct = new Map<number, EnteFile>(); for (const c of collections) { for (const f of filesByCollection.get(c.id)!) { if (!distinct.has(f.id)) { distinct.set(f.id, representative(memberships.get(f.id)!)); } } } 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. Put each pending original at its save path // through the content cache/pools, as `Photo.download()` does, and fetch // the optional thumbnails; a present file is left as is. if (includeOriginals) { for (const [fileID, file] of distinct) { if (storedAtSavePath(downloadDirectory, file) !== undefined) { skipped++; continue; } try { log(`Fetching original ${file.metadata.title} (${fileID})...`); // A fetched original is written straight to its save path (a // live photo beside it); only one that was already cached // elsewhere is copied. await placeOriginal(downloadDirectory, file, (dest) => lib.original(fileID, 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 file of distinct.values()) { if (storedAtSavePath(downloadDirectory, file) !== undefined) { const path = savePath(downloadDirectory, file); writeSidecar(withExtension(path, ".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 dirNames = uniqueNames( allCollections.map((c) => ({ id: c.id, name: sanitizeFileName(c.name, `collection-${c.id}`), })), false, ); const albumDirNames = new Map( allCollections.map((c, i) => [c.id, dirNames[i]!]), ); try { removeStaleAlbumDirs( collectionsDir, new Set(dirNames), downloadDirectory, ); } 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 }); // Every album links the one original, saved from the file's entry in // `distinct`. const files = filesByCollection.get(c.id) ?? []; const links = files.flatMap((f) => linksFor( f, storedAtSavePath(downloadDirectory, distinct.get(f.id)!), ), ); const linkNames = uniqueNames(links, true); try { removeStaleLinks(colDir, new Set(linkNames), downloadDirectory); } catch (err) { log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`); } const metaFiles = files.map((f) => ({ id: f.id, metadata: f.metadata, })); for (const [i, link] of links.entries()) { if (!includeOriginals || link.target === undefined) continue; const linkName = linkNames[i]!; try { rebuildSymlink( join(colDir, linkName), relative(colDir, link.target), ); } catch (err) { log( `FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`, ); recordFailure(link.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, }; };