One album or file quak cannot decrypt no longer stops a refresh or a backup (closes #181)

collectionsSince and filesSince put a row that does not decrypt or parse in
an undecryptable list with the reason instead of throwing. The refresh
leaves it out, an album with its files, and the store keeps the list in
metadata.json until the server sends a copy that decrypts or deletes it,
since the next refresh asks only for changes. lib.backup() records each
such file in failures.json as "could not be decrypted: <reason>", unless a
copy in another album decrypted, logs one line per such album and lists
their IDs in undecryptableAlbums; quak backup exits 1 while any remain.
Tests use a stand-in Ente server with real encryption.

Model: opus-5-5
This commit is contained in:
2026-10-14 01:41:38 +00:00
parent 54133cfa5f
commit a0a97f1019
10 changed files with 787 additions and 74 deletions
+59 -18
View File
@@ -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,54 @@ 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 recorded as failed, with an empty title as
// its metadata is not known, unless a copy of it in another album
// decrypted.
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) &&
!memberships.has(record.fileID)
) {
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 +667,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 +708,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 +726,8 @@ const runLockedBackup = async (
await copyAtomic(path, dest);
} catch (err) {
recordFailure(
file,
file.id,
file.metadata.title,
collectionName.get(file.collectionID) ?? "",
err,
);
@@ -733,7 +767,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 +841,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 +877,7 @@ const runLockedBackup = async (
unchecked,
failed: ledger.size,
errors,
undecryptableAlbums,
};
};