quak backup --verify re-hashes stored originals and downloads again any that do not match (closes #168)
check / check (push) Successful in 3m15s
check / check (push) Successful in 3m15s
`--verify`, or `lib.backup({ verify: true })`, hashes each original already
at its save path as the download check does, streamed, a live photo as
`<imageHash>:<videoHash>`. A mismatch is logged, removed and fetched again in
the same run; a failed fetch goes into `failures.json`. A file with no
recorded hash counts as unchecked. The result, the summary and `--json` gain
`verified`, `mismatched` and `unchecked`.
Judgement call: a stored original that cannot be read for hashing is recorded as failed and left in place.
Judgement call: the summary prints the three counts only with `--verify`.
Judgement call: the README's `BackupOptions` list does not name `verify`, to stay clear of #178's edit of that paragraph; Backup layout documents it.
Model: opus-5-5
This commit is contained in:
+85
-2
@@ -35,6 +35,10 @@
|
||||
// why they could not be read), so that a run does not read every stored
|
||||
// original again; a sidecar without them gets them read from the original.
|
||||
//
|
||||
// With `verify`, each original already at its save path is hashed as the
|
||||
// download check hashes it, and one that does not match the content hash its
|
||||
// metadata records is removed and fetched again in the same run.
|
||||
//
|
||||
// Resilience (issue #8): no per-file condition aborts the run. A failed
|
||||
// download, a failed symlink, or ML data missing because the ML data fetch
|
||||
// failed is caught, recorded in `failures.json` with a classification, a
|
||||
@@ -47,6 +51,7 @@
|
||||
// code.
|
||||
|
||||
import {
|
||||
createReadStream,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
@@ -61,6 +66,12 @@ import {
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, extname, join, relative, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
chunkHashFinal,
|
||||
chunkHashInit,
|
||||
chunkHashUpdate,
|
||||
init,
|
||||
} from "./crypto/index.js";
|
||||
import { removeLeftoverTempFiles } from "./download/index.js";
|
||||
import { sanitizeFileName, withExtension } from "./filename.js";
|
||||
import {
|
||||
@@ -88,6 +99,9 @@ export interface BackupOptions {
|
||||
includeThumbnails?: boolean;
|
||||
// Restrict the backup to albums with these names; others are left untouched.
|
||||
onlyAlbumNames?: string[];
|
||||
// Hash each original already at its save path, and fetch again any whose
|
||||
// bytes do not match the content hash its metadata records. Default false.
|
||||
verify?: boolean;
|
||||
onProgress?: ProgressCallback;
|
||||
}
|
||||
|
||||
@@ -105,6 +119,13 @@ export interface BackupResult {
|
||||
downloaded: number;
|
||||
// Originals already at their save path and left untouched.
|
||||
skipped: number;
|
||||
// With `verify`, the originals already at their save path whose hash
|
||||
// matched, those whose hash did not (each removed and fetched again), and
|
||||
// those whose metadata records no hash (left as they are). All three are
|
||||
// zero without `verify`.
|
||||
verified: number;
|
||||
mismatched: number;
|
||||
unchecked: 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.
|
||||
@@ -361,6 +382,26 @@ const saveLedger = (path: string, ledger: Map<number, FailureEntry>): void => {
|
||||
);
|
||||
};
|
||||
|
||||
// The content hash of the original stored at `stored`, computed as the download
|
||||
// check computes it: over each file's bytes, read in chunks, and for a live
|
||||
// photo `<imageHash>:<videoHash>`.
|
||||
const storedHash = async (stored: {
|
||||
path: string;
|
||||
videoPath?: string;
|
||||
}): Promise<string> => {
|
||||
await init();
|
||||
const hashFile = async (path: string): Promise<string> => {
|
||||
const state = chunkHashInit();
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
chunkHashUpdate(state, chunk as Buffer);
|
||||
}
|
||||
return chunkHashFinal(state);
|
||||
};
|
||||
const hash = await hashFile(stored.path);
|
||||
if (stored.videoPath === undefined) return hash;
|
||||
return `${hash}:${await hashFile(stored.videoPath)}`;
|
||||
};
|
||||
|
||||
// A file's EXIF, XMP and dimensions as its JSON holds them: what
|
||||
// `extractImageMetadata` found in its original, or why the original could not
|
||||
// be read.
|
||||
@@ -465,6 +506,7 @@ export const runBackup = async (
|
||||
const includeThumbnails = opts.includeThumbnails ?? false;
|
||||
const log = opts.onProgress ?? (() => {});
|
||||
const only = opts.onlyAlbumNames ? new Set(opts.onlyAlbumNames) : undefined;
|
||||
const verify = opts.verify ?? false;
|
||||
|
||||
log("Refreshing library...");
|
||||
await lib.refresh();
|
||||
@@ -523,6 +565,9 @@ export const runBackup = async (
|
||||
const storedThisRun = new Set<number>();
|
||||
let downloaded = 0;
|
||||
let skipped = 0;
|
||||
let verified = 0;
|
||||
let mismatched = 0;
|
||||
let unchecked = 0;
|
||||
|
||||
const recordFailure = (
|
||||
file: EnteFile,
|
||||
@@ -554,10 +599,45 @@ export const runBackup = async (
|
||||
|
||||
// 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.
|
||||
// the optional thumbnails; a present file is left as is. With `verify`, a
|
||||
// present original is hashed first, and one that does not match the hash
|
||||
// its metadata records is removed and fetched like a missing one. One
|
||||
// that cannot be read is recorded as failed and left where it is.
|
||||
if (includeOriginals) {
|
||||
for (const [fileID, file] of distinct) {
|
||||
if (storedAtSavePath(downloadDirectory, file) !== undefined) {
|
||||
let stored = storedAtSavePath(downloadDirectory, file);
|
||||
if (stored !== undefined && verify) {
|
||||
try {
|
||||
if (file.metadata.hash === undefined) {
|
||||
unchecked++;
|
||||
} else if (
|
||||
(await storedHash(stored)) === file.metadata.hash
|
||||
) {
|
||||
verified++;
|
||||
} else {
|
||||
log(
|
||||
`MISMATCH original ${file.metadata.title} (${fileID}): its bytes do not match its content hash`,
|
||||
);
|
||||
mismatched++;
|
||||
rmSync(stored.path);
|
||||
if (stored.videoPath !== undefined) {
|
||||
rmSync(stored.videoPath);
|
||||
}
|
||||
stored = undefined;
|
||||
}
|
||||
} catch (err) {
|
||||
log(
|
||||
`FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`,
|
||||
);
|
||||
recordFailure(
|
||||
file,
|
||||
collectionName.get(file.collectionID) ?? "",
|
||||
err,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (stored !== undefined) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
@@ -732,6 +812,9 @@ export const runBackup = async (
|
||||
totalFiles: distinct.size,
|
||||
downloaded,
|
||||
skipped,
|
||||
verified,
|
||||
mismatched,
|
||||
unchecked,
|
||||
failed: ledger.size,
|
||||
errors,
|
||||
};
|
||||
|
||||
+7
-1
@@ -401,7 +401,7 @@ export const backupMetadataCommand = async (
|
||||
export const backupCommand = async (
|
||||
ctx: CliContext,
|
||||
dir: string,
|
||||
opts: { json?: boolean },
|
||||
opts: { json?: boolean; verify?: boolean },
|
||||
): Promise<number> => {
|
||||
await init();
|
||||
const client = requireSession(ctx);
|
||||
@@ -420,6 +420,7 @@ export const backupCommand = async (
|
||||
try {
|
||||
const result = await lib.backup({
|
||||
downloadDirectory: dir,
|
||||
verify: opts.verify,
|
||||
onProgress: (msg) => {
|
||||
if (!opts.json) ctx.stderr.write(msg + "\n");
|
||||
},
|
||||
@@ -432,6 +433,11 @@ export const backupCommand = async (
|
||||
ctx.stderr.write(` Total files: ${result.totalFiles}\n`);
|
||||
ctx.stderr.write(` Downloaded: ${result.downloaded}\n`);
|
||||
ctx.stderr.write(` Skipped: ${result.skipped}\n`);
|
||||
if (opts.verify) {
|
||||
ctx.stderr.write(` Verified: ${result.verified}\n`);
|
||||
ctx.stderr.write(` Mismatched: ${result.mismatched}\n`);
|
||||
ctx.stderr.write(` Unchecked: ${result.unchecked}\n`);
|
||||
}
|
||||
ctx.stderr.write(` Failed: ${result.failed}\n`);
|
||||
if (result.errors.length > 0) {
|
||||
ctx.stderr.write("\nFailed files:\n");
|
||||
|
||||
Reference in New Issue
Block a user