check / check (push) Canceled after 0s
Every line quak prints on stderr now goes through one log, in src/cli-log.ts, at one of six levels. The default, info, prints what quak printed before. -v adds each retry and the start and end of each refresh and ML data fetch; -vv adds each request with its result. -q prints only fatal lines, and is refused with -v; both are program options. Every non-zero exit logs one fatal line, so a run that exits 1 because files failed says how many and where to look. Every line reporting a failure is at error. --json changes stdout only, so backup and --json runs log their progress on stderr. withRetry and ApiClient report retries and requests through new callbacks. Model: opus-5-5
380 lines
14 KiB
TypeScript
380 lines
14 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 type { Library } from "./library/index.js";
|
|
import { ApiError } from "./api/client.js";
|
|
import { encryptBlob, toBase64 } from "./crypto/index.js";
|
|
import type { EnteFile } from "./model/types.js";
|
|
|
|
// The server refuses a thumbnail larger than the one it already records for the
|
|
// file (`thumbnail.size`, the encrypted size), so these encodings are tried
|
|
// from largest to smallest and the first that fits is uploaded.
|
|
const THUMB_ENCODINGS = [
|
|
{ maxDimension: 720, quality: 50 },
|
|
{ maxDimension: 720, quality: 30 },
|
|
{ maxDimension: 480, quality: 30 },
|
|
{ maxDimension: 320, quality: 20 },
|
|
{ maxDimension: 160, quality: 20 },
|
|
];
|
|
|
|
// The server accepts a new thumbnail only from the file's owner, so files other
|
|
// people own in albums shared with this account are never checked or repaired.
|
|
const NOT_OWNED_REASON =
|
|
"owned by another account (only the owner can replace its thumbnail)";
|
|
|
|
export interface MissingThumbnailInfo {
|
|
fileID: number;
|
|
title: string;
|
|
collection: string;
|
|
reason: string;
|
|
}
|
|
|
|
// Three outcomes, not two. "fixed": a thumbnail was generated and uploaded.
|
|
// "failed": something went wrong (download, encode, upload) and the file still
|
|
// has no thumbnail. "skipped": the server would refuse any thumbnail for the
|
|
// file or this helper cannot regenerate it — a file another account owns, a
|
|
// recorded thumbnail size nothing fits within, a video, or an image that is
|
|
// not a baseline JPEG. Skipped is a deliberate, expected outcome, not an error
|
|
// (issue #17): the repair path is JPEG-only because `jpeg-js` is, and a PNG or
|
|
// HEIC is left for a format-aware tool rather than reported as a failure.
|
|
export type ThumbnailFixStatus = "fixed" | "skipped" | "failed";
|
|
|
|
export interface ThumbnailFixResult {
|
|
fileID: number;
|
|
title: string;
|
|
collection: string;
|
|
status: ThumbnailFixStatus;
|
|
// Why the file was skipped or failed; unset when it was fixed.
|
|
reason?: string;
|
|
}
|
|
|
|
// Reports one line of progress. `level` is "error" when the line reports a
|
|
// file that could not be checked or fixed, and is left out for every other
|
|
// line.
|
|
export type ProgressCallback = (message: string, level?: "error") => void;
|
|
|
|
// Enumerate every file the library knows about, newest album first, each file
|
|
// once, and report those whose server-side thumbnail is missing. "Missing" is
|
|
// only two answers: an empty body, or a 404. Any other error reaching this
|
|
// point has already exhausted its retries — a failing server, a dropped
|
|
// connection, a deadline — and says nothing about whether the thumbnail
|
|
// exists, so it is logged and the file is left unreported. That distinction is
|
|
// what stops `fix-missing-thumbnails` from regenerating and uploading over
|
|
// thumbnails that were fine all along while the CDN was briefly returning 500s.
|
|
// Files another account owns are logged as skipped and not checked.
|
|
export const listMissingThumbnails = async (
|
|
lib: Library,
|
|
client: Client,
|
|
onProgress?: ProgressCallback,
|
|
): Promise<MissingThumbnailInfo[]> => {
|
|
const log = onProgress ?? (() => {});
|
|
const api = client.getApiClient();
|
|
const { userID } = client.whoami();
|
|
const missing: MissingThumbnailInfo[] = [];
|
|
const seen = new Set<number>();
|
|
|
|
for (const album of lib.albums.list()) {
|
|
log(`[${album.name}] Checking thumbnails...`);
|
|
for (const photo of album.photos.list()) {
|
|
if (seen.has(photo.fileID)) continue;
|
|
seen.add(photo.fileID);
|
|
const file = lib.getFile(album.collectionID, photo.fileID);
|
|
if (file && file.ownerID !== userID) {
|
|
log(
|
|
`[${album.name}] Skipping ${photo.title}: ${NOT_OWNED_REASON}`,
|
|
);
|
|
continue;
|
|
}
|
|
try {
|
|
const stream = await api.getThumbnailStream(photo.fileID);
|
|
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: photo.fileID,
|
|
title: photo.title,
|
|
collection: album.name,
|
|
reason: "empty thumbnail (0 bytes)",
|
|
});
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.status === 404) {
|
|
missing.push({
|
|
fileID: photo.fileID,
|
|
title: photo.title,
|
|
collection: album.name,
|
|
reason: "thumbnail not found (HTTP 404)",
|
|
});
|
|
} else {
|
|
log(
|
|
`[${album.name}] Could not check ${photo.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`,
|
|
"error",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return missing;
|
|
};
|
|
|
|
// Bilinear resize of an 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 = (
|
|
decoded: { data: Uint8Array; width: number; height: number },
|
|
maxDimension: number,
|
|
quality: number,
|
|
): Uint8Array => {
|
|
const { width: srcW, height: srcH } = decoded;
|
|
const scale = Math.min(maxDimension / srcW, maxDimension / 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 },
|
|
quality,
|
|
);
|
|
return new Uint8Array(encoded.data);
|
|
};
|
|
|
|
// A baseline/JFIF JPEG starts with the SOI marker 0xFFD8. `jpeg-js` decodes
|
|
// only JPEG, so this signature check is what separates a file the helper can
|
|
// regenerate from one it must skip: a PNG, HEIC, or the odd non-image byte
|
|
// stream all fail this and are reported as skipped rather than crashing the
|
|
// decoder into an opaque failure (issue #17).
|
|
const isJpeg = (bytes: Uint8Array): boolean =>
|
|
bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8;
|
|
|
|
// The reason a file cannot have a JPEG thumbnail regenerated for it, known from
|
|
// its record alone before any bytes are fetched, or undefined when it might. A
|
|
// still image still has to be checked against its actual bytes once
|
|
// downloaded.
|
|
const reasonToSkip = (file: EnteFile, userID: number): string | undefined => {
|
|
if (file.ownerID !== userID) {
|
|
return NOT_OWNED_REASON;
|
|
}
|
|
if (file.metadata.fileType !== "image") {
|
|
return `unsupported file type: ${file.metadata.fileType} (only JPEG images can be regenerated)`;
|
|
}
|
|
if (!file.thumbnail.size) {
|
|
return `recorded thumbnail size is ${file.thumbnail.size ?? "unknown"} (the server refuses a thumbnail larger than the one it records)`;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// Encrypt the largest encoding of the decoded image whose ciphertext is no
|
|
// larger than `maxSize`, or return undefined when even the smallest is larger.
|
|
const encryptThumbnailWithin = (
|
|
decoded: { data: Uint8Array; width: number; height: number },
|
|
key: Uint8Array,
|
|
maxSize: number,
|
|
): { header: Uint8Array; ciphertext: Uint8Array } | undefined => {
|
|
for (const { maxDimension, quality } of THUMB_ENCODINGS) {
|
|
const thumbJpeg = generateThumbnail(decoded, maxDimension, quality);
|
|
const encrypted = encryptBlob(thumbJpeg, key);
|
|
if (encrypted.ciphertext.length <= maxSize) return encrypted;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// Regenerate and upload a thumbnail for each requested file. Originals are read
|
|
// through the library's content cache (`photo.original()`); the generated
|
|
// thumbnail is JPEG-encoded, encrypted under the file's own key, and registered
|
|
// with the server — the encrypt-and-upload path is unchanged. Each file is
|
|
// resolved to one outcome (fixed / skipped / failed) and a failure on one file
|
|
// never stops the others.
|
|
export const fixMissingThumbnails = async (
|
|
lib: Library,
|
|
client: Client,
|
|
fileIDs: number[],
|
|
onProgress?: ProgressCallback,
|
|
): Promise<ThumbnailFixResult[]> => {
|
|
const log = onProgress ?? (() => {});
|
|
const results: ThumbnailFixResult[] = [];
|
|
const api = client.getApiClient();
|
|
const { userID } = client.whoami();
|
|
|
|
// Resolve each requested fileID to its file record and owning album by
|
|
// enumerating the library, each file taken from the first album that holds
|
|
// it. The raw `EnteFile` carries the per-file key the thumbnail is
|
|
// encrypted under, which the projected records deliberately do not.
|
|
const wanted = new Set(fileIDs);
|
|
const fileMap = new Map<
|
|
number,
|
|
{ file: EnteFile; collectionName: string }
|
|
>();
|
|
for (const album of lib.albums.list()) {
|
|
for (const photo of album.photos.list()) {
|
|
if (!wanted.has(photo.fileID) || fileMap.has(photo.fileID))
|
|
continue;
|
|
const file = lib.getFile(album.collectionID, photo.fileID);
|
|
if (file) {
|
|
fileMap.set(photo.fileID, {
|
|
file,
|
|
collectionName: album.name,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const fileID of fileIDs) {
|
|
const entry = fileMap.get(fileID);
|
|
if (!entry) {
|
|
results.push({
|
|
fileID,
|
|
title: "unknown",
|
|
collection: "unknown",
|
|
status: "failed",
|
|
reason: "file not found in any collection",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const { file, collectionName } = entry;
|
|
const title = file.metadata.title;
|
|
|
|
const skipReason = reasonToSkip(file, userID);
|
|
if (skipReason) {
|
|
log(`[${collectionName}] Skipping ${title}: ${skipReason}`);
|
|
results.push({
|
|
fileID,
|
|
title,
|
|
collection: collectionName,
|
|
status: "skipped",
|
|
reason: skipReason,
|
|
});
|
|
continue;
|
|
}
|
|
const maxSize = file.thumbnail.size!;
|
|
|
|
try {
|
|
const photo = lib.photos.byID({ fileID });
|
|
if (!photo) {
|
|
throw new Error("file not present in the library cache");
|
|
}
|
|
|
|
log(
|
|
`[${collectionName}] Downloading ${title} for thumbnail generation...`,
|
|
);
|
|
const { path } = await photo.original();
|
|
const fileBytes = new Uint8Array(readFileSync(path));
|
|
|
|
if (!isJpeg(fileBytes)) {
|
|
const reason =
|
|
"unsupported image format (only baseline JPEG can be regenerated)";
|
|
log(`[${collectionName}] Skipping ${title}: ${reason}`);
|
|
results.push({
|
|
fileID,
|
|
title,
|
|
collection: collectionName,
|
|
status: "skipped",
|
|
reason,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
log(`[${collectionName}] Generating thumbnail for ${title}...`);
|
|
const decoded = jpeg.decode(fileBytes, {
|
|
useTArray: true,
|
|
formatAsRGBA: true,
|
|
});
|
|
const fitting = encryptThumbnailWithin(decoded, file.key, maxSize);
|
|
if (!fitting) {
|
|
const reason = `no thumbnail encoding fits the recorded thumbnail size of ${maxSize} bytes`;
|
|
log(`[${collectionName}] Skipping ${title}: ${reason}`);
|
|
results.push({
|
|
fileID,
|
|
title,
|
|
collection: collectionName,
|
|
status: "skipped",
|
|
reason,
|
|
});
|
|
continue;
|
|
}
|
|
const { header, ciphertext } = fitting;
|
|
|
|
log(
|
|
`[${collectionName}] Uploading thumbnail (${ciphertext.length} bytes)...`,
|
|
);
|
|
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 ${title}`);
|
|
results.push({
|
|
fileID,
|
|
title,
|
|
collection: collectionName,
|
|
status: "fixed",
|
|
});
|
|
} catch (err) {
|
|
log(
|
|
`[${collectionName}] FAILED ${title}: ${err instanceof Error ? err.message : err}`,
|
|
"error",
|
|
);
|
|
results.push({
|
|
fileID,
|
|
title,
|
|
collection: collectionName,
|
|
status: "failed",
|
|
reason: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
};
|