One album or file quak cannot decrypt no longer stops a refresh or a backup (closes #181)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A file or album whose key, metadata or name does not decrypt used to throw out of the refresh, so quak backup stopped before storing anything, every run. Client.collectionsSince and filesSince now return such a row in undecryptable with the reason, the refresh leaves it out, and the store keeps the list in metadata.json until a later refresh decrypts or deletes the record. lib.backup() records each such file in failures.json unless a copy in another album decrypted, in which case it stores the original from that copy. Each such album is listed in BackupResult.undecryptableAlbums and printed as one FAILED album line. quak backup exits 1 while either remains. An album or file that no longer decrypts loses its album folder, JSON and links; its original stays. Model: opus-5-5
This commit was merged in pull request #188.
This commit is contained in:
+61
-18
@@ -47,6 +47,9 @@
|
||||
// download, a failed symlink, or ML data missing because the ML data fetch
|
||||
// failed is caught, recorded in `failures.json` with a classification, a
|
||||
// running attempt count, and the last-tried time, and the run continues.
|
||||
// A file the library could not decrypt is recorded there too, unless a copy of
|
||||
// it in another album decrypted; an album it could not decrypt is logged by ID
|
||||
// and listed in `result.undecryptableAlbums`, as its name is not known.
|
||||
// `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
|
||||
@@ -86,6 +89,7 @@ import {
|
||||
storedAtSavePath,
|
||||
} from "./library/content.js";
|
||||
import { representative } from "./library/records.js";
|
||||
import type { Undecryptable } from "./library/store.js";
|
||||
import { extractImageMetadata } from "./metadata-backup.js";
|
||||
import type { MLData } from "./mldata-fetch.js";
|
||||
import type { Collection, EnteFile, FileMetadata } from "./model/types.js";
|
||||
@@ -141,6 +145,9 @@ export interface BackupResult {
|
||||
failed: number;
|
||||
// This run's per-file errors, in encounter order.
|
||||
errors: BackupError[];
|
||||
// The IDs of the albums the library could not decrypt, whose files it
|
||||
// could not list; the CLI exits non-zero while there are any.
|
||||
undecryptableAlbums: number[];
|
||||
}
|
||||
|
||||
// The slice of the library that backup drives. `Library` implements it; a test
|
||||
@@ -151,6 +158,9 @@ export interface BackupLibrary {
|
||||
refresh(): Promise<void>;
|
||||
listCollections(): Collection[];
|
||||
listFiles(collectionID: number): EnteFile[];
|
||||
// The albums, and files in albums, that the library could not decrypt,
|
||||
// which `listCollections` and `listFiles` leave out.
|
||||
undecryptable(): Undecryptable[];
|
||||
// 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
|
||||
@@ -574,33 +584,56 @@ const runLockedBackup = async (
|
||||
let unchecked = 0;
|
||||
|
||||
const recordFailure = (
|
||||
file: EnteFile,
|
||||
fileID: number,
|
||||
title: string,
|
||||
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;
|
||||
if (failedThisRun.has(fileID)) 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,
|
||||
errors.push({ fileID, title, collection, error });
|
||||
const prior = ledger.get(fileID);
|
||||
ledger.set(fileID, {
|
||||
fileID,
|
||||
title,
|
||||
classification: classify(err),
|
||||
attempts: (prior?.attempts ?? 0) + 1,
|
||||
lastTriedAt: now,
|
||||
error,
|
||||
});
|
||||
failedThisRun.add(file.id);
|
||||
failedThisRun.add(fileID);
|
||||
};
|
||||
|
||||
// What the library could not decrypt. An album is logged by its ID, as
|
||||
// its name is not known, so a run limited to some albums logs it too. A
|
||||
// file in an album in scope is backed up from a copy of it in another
|
||||
// album that decrypted, even an album out of scope. With no such copy it
|
||||
// is recorded as failed, with an empty title as its metadata is not known.
|
||||
const scope = new Set(collections.map((c) => c.id));
|
||||
const undecryptableAlbums: number[] = [];
|
||||
for (const record of lib.undecryptable()) {
|
||||
const error = `could not be decrypted: ${record.error}`;
|
||||
if (record.fileID === undefined) {
|
||||
log(`FAILED album ${record.collectionID}: ${error}`);
|
||||
undecryptableAlbums.push(record.collectionID);
|
||||
} else if (scope.has(record.collectionID)) {
|
||||
const copies = memberships.get(record.fileID);
|
||||
if (copies) {
|
||||
distinct.set(record.fileID, representative(copies));
|
||||
} else {
|
||||
recordFailure(
|
||||
record.fileID,
|
||||
"",
|
||||
collectionName.get(record.collectionID) ?? "",
|
||||
new Error(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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. With `verify`, a
|
||||
@@ -636,7 +669,8 @@ const runLockedBackup = async (
|
||||
`FAILED verifying original ${file.metadata.title}: ${errorMessage(err)}`,
|
||||
);
|
||||
recordFailure(
|
||||
file,
|
||||
file.id,
|
||||
file.metadata.title,
|
||||
collectionName.get(file.collectionID) ?? "",
|
||||
err,
|
||||
);
|
||||
@@ -676,7 +710,8 @@ const runLockedBackup = async (
|
||||
`FAILED original ${file.metadata.title}: ${errorMessage(err)}`,
|
||||
);
|
||||
recordFailure(
|
||||
file,
|
||||
file.id,
|
||||
file.metadata.title,
|
||||
collectionName.get(file.collectionID) ?? "",
|
||||
err,
|
||||
);
|
||||
@@ -693,7 +728,8 @@ const runLockedBackup = async (
|
||||
await copyAtomic(path, dest);
|
||||
} catch (err) {
|
||||
recordFailure(
|
||||
file,
|
||||
file.id,
|
||||
file.metadata.title,
|
||||
collectionName.get(file.collectionID) ?? "",
|
||||
err,
|
||||
);
|
||||
@@ -733,7 +769,8 @@ const runLockedBackup = async (
|
||||
if (mlData === undefined && mlDataError !== undefined) {
|
||||
writeSidecar(path, file, { mlDataError }, image);
|
||||
recordFailure(
|
||||
file,
|
||||
file.id,
|
||||
file.metadata.title,
|
||||
collectionName.get(file.collectionID) ?? "",
|
||||
new Error(`ML data: ${mlDataError}`),
|
||||
);
|
||||
@@ -806,7 +843,12 @@ const runLockedBackup = async (
|
||||
log(
|
||||
`FAILED symlink ${c.name}/${linkName}: ${errorMessage(err)}`,
|
||||
);
|
||||
recordFailure(link.file, c.name, err);
|
||||
recordFailure(
|
||||
link.file.id,
|
||||
link.file.metadata.title,
|
||||
c.name,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,6 +879,7 @@ const runLockedBackup = async (
|
||||
unchecked,
|
||||
failed: ledger.size,
|
||||
errors,
|
||||
undecryptableAlbums,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user