Skip thumbnail repairs the server always refuses (closes #109)
check / check (push) Successful in 49s
check / check (push) Successful in 49s
The server accepts a new thumbnail only from the file's owner and only when it is no larger than the thumbnail size it records. Both thumbnail helpers now skip files another account owns without fetching them. The fixer skips a file whose recorded thumbnail size is 0 or unknown before downloading it, and otherwise tries smaller encodings until the encrypted thumbnail fits, skipping the file if none does. Model: opus-5-5
This commit was merged in pull request #119.
This commit is contained in:
+1
-1
@@ -495,7 +495,7 @@ export const fixMissingThumbnailsCommand = async (
|
||||
ctx.stderr.write(` Skipped: ${skipped}\n`);
|
||||
ctx.stderr.write(` Failed: ${failed}\n`);
|
||||
if (skipped > 0) {
|
||||
ctx.stderr.write("\nSkipped (unsupported format):\n");
|
||||
ctx.stderr.write("\nSkipped:\n");
|
||||
for (const r of results.filter((r) => r.status === "skipped")) {
|
||||
ctx.stderr.write(
|
||||
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
|
||||
|
||||
+88
-30
@@ -7,8 +7,21 @@ import { ApiError } from "./api/client.js";
|
||||
import { encryptBlob, toBase64 } from "./crypto/index.js";
|
||||
import type { EnteFile } from "./model/types.js";
|
||||
|
||||
const THUMB_MAX_DIMENSION = 720;
|
||||
const THUMB_JPEG_QUALITY = 50;
|
||||
// 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;
|
||||
@@ -19,11 +32,12 @@ export interface MissingThumbnailInfo {
|
||||
|
||||
// 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 file is a format this helper cannot
|
||||
// regenerate — 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.
|
||||
// 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 {
|
||||
@@ -45,6 +59,7 @@ export type ProgressCallback = (message: string) => void;
|
||||
// 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,
|
||||
@@ -52,6 +67,7 @@ export const listMissingThumbnails = async (
|
||||
): Promise<MissingThumbnailInfo[]> => {
|
||||
const log = onProgress ?? (() => {});
|
||||
const api = client.getApiClient();
|
||||
const { userID } = client.whoami();
|
||||
const missing: MissingThumbnailInfo[] = [];
|
||||
const seen = new Set<number>();
|
||||
|
||||
@@ -60,6 +76,13 @@ export const listMissingThumbnails = async (
|
||||
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();
|
||||
@@ -135,17 +158,13 @@ const resizeRGBA = (
|
||||
return dst;
|
||||
};
|
||||
|
||||
const generateThumbnail = (fileBytes: Uint8Array): Uint8Array => {
|
||||
const decoded = jpeg.decode(fileBytes, {
|
||||
useTArray: true,
|
||||
formatAsRGBA: true,
|
||||
});
|
||||
const generateThumbnail = (
|
||||
decoded: { data: Uint8Array; width: number; height: number },
|
||||
maxDimension: number,
|
||||
quality: number,
|
||||
): Uint8Array => {
|
||||
const { width: srcW, height: srcH } = decoded;
|
||||
const scale = Math.min(
|
||||
THUMB_MAX_DIMENSION / srcW,
|
||||
THUMB_MAX_DIMENSION / srcH,
|
||||
1,
|
||||
);
|
||||
const scale = Math.min(maxDimension / srcW, maxDimension / srcH, 1);
|
||||
const dstW = Math.round(srcW * scale);
|
||||
const dstH = Math.round(srcH * scale);
|
||||
|
||||
@@ -158,7 +177,7 @@ const generateThumbnail = (fileBytes: Uint8Array): Uint8Array => {
|
||||
|
||||
const encoded = jpeg.encode(
|
||||
{ data: pixels, width: dstW, height: dstH },
|
||||
THUMB_JPEG_QUALITY,
|
||||
quality,
|
||||
);
|
||||
return new Uint8Array(encoded.data);
|
||||
};
|
||||
@@ -171,14 +190,35 @@ const generateThumbnail = (fileBytes: Uint8Array): Uint8Array => {
|
||||
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 from its
|
||||
// metadata alone, before any bytes are fetched, or undefined when it might. A
|
||||
// non-image (video, live photo) is unsupported outright; a still image still
|
||||
// has to be checked against its actual bytes once downloaded.
|
||||
const unsupportedByType = (file: EnteFile): string | undefined => {
|
||||
// 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;
|
||||
};
|
||||
|
||||
@@ -197,6 +237,7 @@ export const fixMissingThumbnails = async (
|
||||
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
|
||||
@@ -237,18 +278,19 @@ export const fixMissingThumbnails = async (
|
||||
const { file, collectionName } = entry;
|
||||
const title = file.metadata.title;
|
||||
|
||||
const typeReason = unsupportedByType(file);
|
||||
if (typeReason) {
|
||||
log(`[${collectionName}] Skipping ${title}: ${typeReason}`);
|
||||
const skipReason = reasonToSkip(file, userID);
|
||||
if (skipReason) {
|
||||
log(`[${collectionName}] Skipping ${title}: ${skipReason}`);
|
||||
results.push({
|
||||
fileID,
|
||||
title,
|
||||
collection: collectionName,
|
||||
status: "skipped",
|
||||
reason: typeReason,
|
||||
reason: skipReason,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const maxSize = file.thumbnail.size!;
|
||||
|
||||
try {
|
||||
const photo = lib.photos.byID({ fileID });
|
||||
@@ -277,12 +319,28 @@ export const fixMissingThumbnails = async (
|
||||
}
|
||||
|
||||
log(`[${collectionName}] Generating thumbnail for ${title}...`);
|
||||
const thumbJpeg = generateThumbnail(fileBytes);
|
||||
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}] Encrypting and uploading thumbnail (${thumbJpeg.length} bytes)...`,
|
||||
`[${collectionName}] Uploading thumbnail (${ciphertext.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,
|
||||
|
||||
Reference in New Issue
Block a user