check / check (push) Successful in 20s
No retry on 4xx, backoff on 5xx and transport failures, and a deadline on every request. Before this, one transient 503 or TCP reset failed a file for good, and a CDN connection that went quiet after accepting the request blocked `quak backup` forever, because there was no timeout anywhere. src/retry.ts holds the policy: a classifier that decides whether another attempt could produce a different answer, and a loop that acts on it with exponential backoff and full jitter. Retried: 5xx, 408, 429, transport failures (the errno is read out of the cause chain, which is where Node's fetch puts it), deadline aborts, and truncated transfers. Not retried: every other 4xx, and anything unrecognised — a wrongly retried permanent failure delays every remaining file, while a wrongly abandoned transient one costs a single file the next run picks up. Attempt count, delays, sleep and jitter source are all configurable through ApiClientOptions; sleep being injectable is what lets the suite exercise the policy without waiting. Truncation needed a type before it could be classified. streamDecrypt threw plain Errors whose messages began "download: stream truncated", and classifying on message text would mean the next reword silently turned every truncated download into a permanent failure. It now throws TruncatedStreamError, which lives in src/errors.ts alongside ApiError so the classifier can recognise both without importing the modules that import it; api/client.ts re-exports ApiError, so it stays one class and every existing import path still resolves. Downloads retry the request, the stream consumption and the decryption together. Only the first of those happens inside ApiClient: a socket reset after the headers arrived throws in streamDecrypt, and retrying the request alone would never see it. The client's own retry is switched off for those two calls so the budgets do not multiply into sixteen requests per file, and the atomic write stays outside the loop so a download that took three attempts still performs one write and one rename. Non-idempotent requests are not blindly replayed. postJSON and putJSON reach create-session, two-factor/verify — which burns one of a few second-factor attempts — and files/thumbnail, so they retry only when the connection was never established and the server provably never saw the request. putFile is exempt and retries fully: a presigned PUT stores one whole object at one key, with no partial state to damage. It now throws ApiError with the status, as do the two null-body paths, which previously threw bare Errors that nothing could classify. Timeouts come from AbortSignal.timeout(), renewed per attempt: 30s for JSON and upload calls, 10 minutes for file bodies, since a value short enough to keep a hung API call from stalling a backup would cancel a legitimate multi-gigabyte download. The download deadline is enforced over the body rather than only the headers, by racing each read against the signal, so the guarantee does not depend on the fetch implementation tearing down a stream it already handed over. listMissingThumbnails now separates a genuine 404 from an exhausted retry. Its bare catch reported both as missing, which after this change would have let a few minutes of 500s talk fix-missing-thumbnails into regenerating and re-uploading thumbnails that were fine. runBackup and runMetadataBackup are untouched: the retry sits below them and their per-file resilience is unchanged.
260 lines
8.7 KiB
TypeScript
260 lines
8.7 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import * as jpeg from "jpeg-js";
|
|
import type { Client } from "./client.js";
|
|
import { ApiError } from "./api/client.js";
|
|
import { encryptBlob, toBase64 } from "./crypto/index.js";
|
|
import { downloadFile } from "./download/index.js";
|
|
import type { EnteFile } from "./model/types.js";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
|
|
const THUMB_MAX_DIMENSION = 720;
|
|
const THUMB_JPEG_QUALITY = 50;
|
|
|
|
export interface MissingThumbnailInfo {
|
|
fileID: number;
|
|
title: string;
|
|
collection: string;
|
|
reason: string;
|
|
}
|
|
|
|
export interface ThumbnailFixResult {
|
|
fileID: number;
|
|
title: string;
|
|
collection: string;
|
|
success: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
export type ProgressCallback = (message: string) => void;
|
|
|
|
export const listMissingThumbnails = async (
|
|
client: Client,
|
|
onProgress?: ProgressCallback,
|
|
): Promise<MissingThumbnailInfo[]> => {
|
|
const log = onProgress ?? (() => {});
|
|
const missing: MissingThumbnailInfo[] = [];
|
|
const seen = new Set<number>();
|
|
|
|
const collections = await client.listCollections();
|
|
for (const col of collections) {
|
|
log(`[${col.name}] Checking thumbnails...`);
|
|
const files = await client.listFiles(col.id, col.key);
|
|
for (const file of files) {
|
|
if (seen.has(file.id)) continue;
|
|
seen.add(file.id);
|
|
try {
|
|
const api = client.getApiClient();
|
|
const stream = await api.getThumbnailStream(file.id);
|
|
const reader = stream.getReader();
|
|
let totalBytes = 0;
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (value) totalBytes += value.length;
|
|
if (done) break;
|
|
}
|
|
if (totalBytes === 0) {
|
|
missing.push({
|
|
fileID: file.id,
|
|
title: file.metadata.title,
|
|
collection: col.name,
|
|
reason: "empty thumbnail (0 bytes)",
|
|
});
|
|
}
|
|
} catch (err) {
|
|
// A 404 is the server stating the thumbnail is not there:
|
|
// that, and an empty body, are the only two answers that mean
|
|
// "missing". Anything else reaching this point is a failure
|
|
// that already exhausted its retries — a failing server, a
|
|
// dropped connection, a deadline — and says nothing about
|
|
// whether the thumbnail exists.
|
|
//
|
|
// The distinction is what stops `helper
|
|
// fix-missing-thumbnails` from downloading originals,
|
|
// regenerating thumbnails and uploading them over thumbnails
|
|
// that were fine all along, because the CDN was briefly
|
|
// returning 500s while this ran.
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
missing.push({
|
|
fileID: file.id,
|
|
title: file.metadata.title,
|
|
collection: col.name,
|
|
reason: "thumbnail not found (HTTP 404)",
|
|
});
|
|
} else {
|
|
log(
|
|
`[${col.name}] Could not check ${file.metadata.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return missing;
|
|
};
|
|
|
|
// Bilinear resize of RGBA pixel buffer
|
|
const resizeRGBA = (
|
|
src: Uint8Array,
|
|
srcW: number,
|
|
srcH: number,
|
|
dstW: number,
|
|
dstH: number,
|
|
): Uint8Array => {
|
|
const dst = new Uint8Array(dstW * dstH * 4);
|
|
const xRatio = srcW / dstW;
|
|
const yRatio = srcH / dstH;
|
|
for (let y = 0; y < dstH; y++) {
|
|
const srcY = y * yRatio;
|
|
const y0 = Math.floor(srcY);
|
|
const y1 = Math.min(y0 + 1, srcH - 1);
|
|
const fy = srcY - y0;
|
|
for (let x = 0; x < dstW; x++) {
|
|
const srcX = x * xRatio;
|
|
const x0 = Math.floor(srcX);
|
|
const x1 = Math.min(x0 + 1, srcW - 1);
|
|
const fx = srcX - x0;
|
|
const i00 = (y0 * srcW + x0) * 4;
|
|
const i10 = (y0 * srcW + x1) * 4;
|
|
const i01 = (y1 * srcW + x0) * 4;
|
|
const i11 = (y1 * srcW + x1) * 4;
|
|
const di = (y * dstW + x) * 4;
|
|
for (let c = 0; c < 4; c++) {
|
|
dst[di + c] = Math.round(
|
|
src[i00 + c]! * (1 - fx) * (1 - fy) +
|
|
src[i10 + c]! * fx * (1 - fy) +
|
|
src[i01 + c]! * (1 - fx) * fy +
|
|
src[i11 + c]! * fx * fy,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return dst;
|
|
};
|
|
|
|
const generateThumbnail = (fileBytes: Uint8Array): Uint8Array => {
|
|
const decoded = jpeg.decode(fileBytes, {
|
|
useTArray: true,
|
|
formatAsRGBA: true,
|
|
});
|
|
const { width: srcW, height: srcH } = decoded;
|
|
const scale = Math.min(
|
|
THUMB_MAX_DIMENSION / srcW,
|
|
THUMB_MAX_DIMENSION / srcH,
|
|
1,
|
|
);
|
|
const dstW = Math.round(srcW * scale);
|
|
const dstH = Math.round(srcH * scale);
|
|
|
|
let pixels: Uint8Array;
|
|
if (scale < 1) {
|
|
pixels = resizeRGBA(decoded.data, srcW, srcH, dstW, dstH);
|
|
} else {
|
|
pixels = decoded.data;
|
|
}
|
|
|
|
const encoded = jpeg.encode(
|
|
{ data: pixels, width: dstW, height: dstH },
|
|
THUMB_JPEG_QUALITY,
|
|
);
|
|
return new Uint8Array(encoded.data);
|
|
};
|
|
|
|
export const fixMissingThumbnails = async (
|
|
client: Client,
|
|
fileIDs: number[],
|
|
onProgress?: ProgressCallback,
|
|
): Promise<ThumbnailFixResult[]> => {
|
|
const log = onProgress ?? (() => {});
|
|
const results: ThumbnailFixResult[] = [];
|
|
const api = client.getApiClient();
|
|
|
|
const collections = await client.listCollections();
|
|
const fileMap = new Map<
|
|
number,
|
|
{ file: EnteFile; collectionName: string }
|
|
>();
|
|
|
|
for (const col of collections) {
|
|
const files = await client.listFiles(col.id, col.key);
|
|
for (const file of files) {
|
|
if (fileIDs.includes(file.id) && !fileMap.has(file.id)) {
|
|
fileMap.set(file.id, {
|
|
file,
|
|
collectionName: col.name,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const fileID of fileIDs) {
|
|
const entry = fileMap.get(fileID);
|
|
if (!entry) {
|
|
results.push({
|
|
fileID,
|
|
title: "unknown",
|
|
collection: "unknown",
|
|
success: false,
|
|
error: "file not found in any collection",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const { file, collectionName } = entry;
|
|
const tmpDir = mkdtempSync(join(tmpdir(), "quak-thumb-"));
|
|
|
|
try {
|
|
log(
|
|
`[${collectionName}] Downloading ${file.metadata.title} for thumbnail generation...`,
|
|
);
|
|
const origPath = join(tmpDir, "original");
|
|
await downloadFile(api, file, origPath);
|
|
|
|
log(
|
|
`[${collectionName}] Generating thumbnail for ${file.metadata.title}...`,
|
|
);
|
|
const fileBytes = readFileSync(origPath);
|
|
const thumbJpeg = generateThumbnail(new Uint8Array(fileBytes));
|
|
|
|
log(
|
|
`[${collectionName}] Encrypting and uploading thumbnail (${thumbJpeg.length} bytes)...`,
|
|
);
|
|
const { header, ciphertext } = encryptBlob(thumbJpeg, file.key);
|
|
|
|
const md5 = createHash("md5").update(ciphertext).digest("base64");
|
|
const { objectKey, url } = await api.getUploadURL(
|
|
ciphertext.length,
|
|
md5,
|
|
);
|
|
await api.putFile(url, ciphertext);
|
|
await api.updateThumbnail(file.id, objectKey, toBase64(header));
|
|
|
|
log(
|
|
`[${collectionName}] Thumbnail uploaded for ${file.metadata.title}`,
|
|
);
|
|
results.push({
|
|
fileID,
|
|
title: file.metadata.title,
|
|
collection: collectionName,
|
|
success: true,
|
|
});
|
|
} catch (err) {
|
|
log(
|
|
`[${collectionName}] FAILED ${file.metadata.title}: ${err instanceof Error ? err.message : err}`,
|
|
);
|
|
results.push({
|
|
fileID,
|
|
title: file.metadata.title,
|
|
collection: collectionName,
|
|
success: false,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
} finally {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
return results;
|
|
};
|