Ports the CLI to the library API. collections/files/get/get-thumb use the fresh read variants (Library.fresh(), current server state); backup runs lib.backup; backup-metadata and the missing-thumbnail helpers enumerate via the library. files/get output is byte-identical to the pre-port CLI — raw metadata.title, microsecond creationTime, pre-port row order — exit codes unchanged. Adds --cache-dir; fixes the helper JPEG-only assumption (closes #17). Model: opus-4-8
This commit was merged in pull request #74.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
// How the CLI presents a file's identity in `files`, `get`, and `get-thumb`.
|
||||
//
|
||||
// These read the file's own decrypted metadata — the raw title and the
|
||||
// creationTime in microseconds — rather than the `PhotoRecord` projection the
|
||||
// rest of the library exposes. The projection prefers `editedName`/`editedTime`
|
||||
// and reports time in milliseconds, which is right for a photo browser but
|
||||
// would change the CLI's externally-visible output. The pre-library CLI printed
|
||||
// `metadata.title` and `metadata.creationTime` and named downloads after
|
||||
// `metadata.title`, and issue #52 requires that output stay byte-identical, so
|
||||
// the commands shape their output from the raw `EnteFile` through here.
|
||||
|
||||
import type { EnteFile, FileType, Microseconds } from "./model/types.js";
|
||||
|
||||
// One row of `quak files --json`.
|
||||
export interface FileListRow {
|
||||
id: number;
|
||||
title: string;
|
||||
fileType: FileType;
|
||||
creationTime: Microseconds;
|
||||
collectionID: number;
|
||||
}
|
||||
|
||||
export const fileListRow = (file: EnteFile): FileListRow => ({
|
||||
id: file.id,
|
||||
title: file.metadata.title,
|
||||
fileType: file.metadata.fileType,
|
||||
creationTime: file.metadata.creationTime,
|
||||
collectionID: file.collectionID,
|
||||
});
|
||||
|
||||
// One line of `quak files` in its human, tab-separated form.
|
||||
export const fileListLine = (file: EnteFile): string =>
|
||||
`${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`;
|
||||
|
||||
// Default output path for `quak get` when `--out` is not given.
|
||||
export const originalName = (file: EnteFile): string => file.metadata.title;
|
||||
|
||||
// Default output path for `quak get-thumb` when `--out` is not given.
|
||||
export const thumbnailName = (file: EnteFile): string =>
|
||||
`thumb_${file.metadata.title}`;
|
||||
@@ -0,0 +1,62 @@
|
||||
// How the CLI's read commands obtain current data.
|
||||
//
|
||||
// `collections`, `files --collection`, `get`, and `get-thumb` must answer for
|
||||
// the account's state at the moment the command runs, not for whatever the
|
||||
// local cache last happened to hold (owner amendment, issue #36). Each helper
|
||||
// therefore forces a server round-trip through `Library.fresh()` and only then
|
||||
// reads — so a collection, file, or metadata change made elsewhere is visible.
|
||||
//
|
||||
// `collections` and `files` also list in the library's own enumeration order —
|
||||
// `listCollections()`/`listFiles()`, the order the pre-library CLI printed —
|
||||
// rather than the `albums`/`photos` projection's newest-first order, which
|
||||
// re-sorts the rows. The field values still come from each record's raw
|
||||
// metadata via `cli-output.ts`.
|
||||
|
||||
import type { Collection, EnteFile } from "./model/types.js";
|
||||
import type { Photo, PhotosAPI } from "./library/index.js";
|
||||
|
||||
// The slice of `Library` these helpers read. `Library` satisfies it
|
||||
// structurally; a test can drive them with a stand-in that records the
|
||||
// `fresh()` call and serves records in a known enumeration order.
|
||||
export interface FreshReadLibrary {
|
||||
fresh(): Promise<unknown>;
|
||||
listCollections(): Collection[];
|
||||
getCollection(id: number): Collection | undefined;
|
||||
listFiles(collectionID: number): EnteFile[];
|
||||
getFileByID(fileID: number): EnteFile | undefined;
|
||||
photos: Pick<PhotosAPI, "byID">;
|
||||
}
|
||||
|
||||
// Every live collection, current as of a forced refresh, in enumeration order.
|
||||
export const freshCollections = async (
|
||||
lib: FreshReadLibrary,
|
||||
): Promise<Collection[]> => {
|
||||
await lib.fresh();
|
||||
return lib.listCollections();
|
||||
};
|
||||
|
||||
// The files of one collection, current as of a forced refresh, in enumeration
|
||||
// order. `undefined` (not an empty list) when the collection does not exist, so
|
||||
// the caller can tell "no such collection" from "an empty collection".
|
||||
export const freshFiles = async (
|
||||
lib: FreshReadLibrary,
|
||||
collectionID: number,
|
||||
): Promise<EnteFile[] | undefined> => {
|
||||
await lib.fresh();
|
||||
if (!lib.getCollection(collectionID)) return undefined;
|
||||
return lib.listFiles(collectionID);
|
||||
};
|
||||
|
||||
// One file, current as of a forced refresh, resolved to both its content
|
||||
// handle (`Photo`, for fetching bytes) and its raw record (`EnteFile`, for the
|
||||
// default output name and field values). `undefined` when the file is unknown.
|
||||
export const freshFile = async (
|
||||
lib: FreshReadLibrary,
|
||||
fileID: number,
|
||||
): Promise<{ photo: Photo; file: EnteFile } | undefined> => {
|
||||
await lib.fresh();
|
||||
const photo = lib.photos.byID({ fileID });
|
||||
const file = lib.getFileByID(fileID);
|
||||
if (!photo || !file) return undefined;
|
||||
return { photo, file };
|
||||
};
|
||||
@@ -447,6 +447,13 @@ export class Library {
|
||||
return this.store.getFile(collectionID, fileID);
|
||||
}
|
||||
|
||||
// Any membership of a file, addressed by file id alone. A file's own
|
||||
// metadata (title, creationTime) is identical across the collections it
|
||||
// belongs to, so this serves the point commands that hold only a fileID.
|
||||
getFileByID(fileID: number): EnteFile | undefined {
|
||||
return this.store.getFileByID(fileID);
|
||||
}
|
||||
|
||||
// A synchronous, RAM-only projection of the whole library into plain
|
||||
// records (no keys), the surface the GUI reads across IPC. Photos are
|
||||
// deduplicated to one record per file and ordered newest first.
|
||||
|
||||
+32
-25
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as jpeg from "jpeg-js";
|
||||
import exifReader from "exif-reader";
|
||||
import type { Client } from "./client.js";
|
||||
import type { Library, Photo } from "./library/index.js";
|
||||
import { fetchMLData } from "./mldata-fetch.js";
|
||||
import type { EnteFile } from "./model/types.js";
|
||||
|
||||
@@ -104,24 +98,29 @@ const extractImageMetadata = (
|
||||
}
|
||||
};
|
||||
|
||||
// Read a file's original bytes through the library's content cache and extract
|
||||
// its embedded image metadata. The bytes come from `photo.original()` — the
|
||||
// same on-disk cache the rest of the library fills — rather than a fresh
|
||||
// per-call download to a throwaway temp file.
|
||||
const extractExif = async (
|
||||
client: Client,
|
||||
file: EnteFile,
|
||||
photo: Photo,
|
||||
): Promise<Record<string, unknown> | undefined> => {
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "quak-exif-"));
|
||||
try {
|
||||
const origPath = join(tmpDir, "original");
|
||||
await client.downloadFile(file, origPath);
|
||||
const fileBytes = new Uint8Array(readFileSync(origPath));
|
||||
const { path } = await photo.original();
|
||||
const fileBytes = new Uint8Array(readFileSync(path));
|
||||
return extractImageMetadata(fileBytes);
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Dump every decrypted metadata layer the account holds into a directory tree
|
||||
// of plain JSON: account, per-collection, and per-file records including the
|
||||
// private and public magic metadata and (by default) the ML data. Collections
|
||||
// and files are enumerated from the library's cache rather than a fresh server
|
||||
// scan; the ML fetch and EXIF extraction are unchanged.
|
||||
export const runMetadataBackup = async (
|
||||
lib: Library,
|
||||
client: Client,
|
||||
outDir: string,
|
||||
opts?: MetadataBackupOptions,
|
||||
@@ -139,13 +138,19 @@ export const runMetadataBackup = async (
|
||||
);
|
||||
|
||||
log("Fetching collections...");
|
||||
const collections = await client.listCollections();
|
||||
|
||||
const allFiles: { file: EnteFile; colDirName: string }[] = [];
|
||||
// Enumerate through the library's read surface. Each album carries its
|
||||
// photos, but the full decrypted `Collection`/`EnteFile` records (with the
|
||||
// magic-metadata layers this dump exists to preserve) come from the
|
||||
// library's by-id accessors.
|
||||
const allFiles: { file: EnteFile; photo: Photo; colDirName: string }[] = [];
|
||||
const fileKeys = new Map<number, Uint8Array>();
|
||||
const seenFileIDs = new Set<number>();
|
||||
|
||||
for (const col of collections) {
|
||||
for (const album of lib.albums.list()) {
|
||||
const col = lib.getCollection(album.collectionID);
|
||||
if (!col) continue;
|
||||
|
||||
const dirName = `${col.id}-${sanitizePath(col.name || "unnamed")}`;
|
||||
const colDir = join(outDir, "collections", dirName);
|
||||
mkdirSync(colDir, { recursive: true });
|
||||
@@ -170,11 +175,13 @@ export const runMetadataBackup = async (
|
||||
);
|
||||
|
||||
log(`[${col.name}] Fetching files...`);
|
||||
const files = await client.listFiles(col.id, col.key);
|
||||
log(`[${col.name}] ${files.length} file(s)`);
|
||||
const photos = album.photos.list();
|
||||
log(`[${col.name}] ${photos.length} file(s)`);
|
||||
|
||||
for (const file of files) {
|
||||
allFiles.push({ file, colDirName: dirName });
|
||||
for (const photo of photos) {
|
||||
const file = lib.getFile(col.id, photo.fileID);
|
||||
if (!file) continue;
|
||||
allFiles.push({ file, photo, colDirName: dirName });
|
||||
if (!seenFileIDs.has(file.id)) {
|
||||
fileKeys.set(file.id, file.key);
|
||||
seenFileIDs.add(file.id);
|
||||
@@ -191,7 +198,7 @@ export const runMetadataBackup = async (
|
||||
log(`Got ML data for ${mlDataMap.size} file(s)`);
|
||||
|
||||
const writtenFileIDs = new Set<number>();
|
||||
for (const { file, colDirName } of allFiles) {
|
||||
for (const { file, photo, colDirName } of allFiles) {
|
||||
const colDir = join(outDir, "collections", colDirName);
|
||||
|
||||
const fileMeta: Record<string, unknown> = {
|
||||
@@ -210,7 +217,7 @@ export const runMetadataBackup = async (
|
||||
|
||||
if (wantExif && !writtenFileIDs.has(file.id)) {
|
||||
log(`[${file.metadata.title}] Extracting EXIF...`);
|
||||
const exifData = await extractExif(client, file);
|
||||
const exifData = await extractExif(photo);
|
||||
if (exifData) fileMeta.imageMetadata = exifData;
|
||||
}
|
||||
writtenFileIDs.add(file.id);
|
||||
|
||||
+123
-66
@@ -2,13 +2,10 @@ 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 { 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;
|
||||
@@ -20,34 +17,51 @@ export interface MissingThumbnailInfo {
|
||||
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 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.
|
||||
export type ThumbnailFixStatus = "fixed" | "skipped" | "failed";
|
||||
|
||||
export interface ThumbnailFixResult {
|
||||
fileID: number;
|
||||
title: string;
|
||||
collection: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
status: ThumbnailFixStatus;
|
||||
// Why the file was skipped or failed; unset when it was fixed.
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type ProgressCallback = (message: string) => 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.
|
||||
export const listMissingThumbnails = async (
|
||||
lib: Library,
|
||||
client: Client,
|
||||
onProgress?: ProgressCallback,
|
||||
): Promise<MissingThumbnailInfo[]> => {
|
||||
const log = onProgress ?? (() => {});
|
||||
const api = client.getApiClient();
|
||||
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);
|
||||
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);
|
||||
try {
|
||||
const api = client.getApiClient();
|
||||
const stream = await api.getThumbnailStream(file.id);
|
||||
const stream = await api.getThumbnailStream(photo.fileID);
|
||||
const reader = stream.getReader();
|
||||
let totalBytes = 0;
|
||||
for (;;) {
|
||||
@@ -57,35 +71,23 @@ export const listMissingThumbnails = async (
|
||||
}
|
||||
if (totalBytes === 0) {
|
||||
missing.push({
|
||||
fileID: file.id,
|
||||
title: file.metadata.title,
|
||||
collection: col.name,
|
||||
fileID: photo.fileID,
|
||||
title: photo.title,
|
||||
collection: album.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,
|
||||
fileID: photo.fileID,
|
||||
title: photo.title,
|
||||
collection: album.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)`,
|
||||
`[${album.name}] Could not check ${photo.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -94,7 +96,7 @@ export const listMissingThumbnails = async (
|
||||
return missing;
|
||||
};
|
||||
|
||||
// Bilinear resize of RGBA pixel buffer
|
||||
// Bilinear resize of an RGBA pixel buffer.
|
||||
const resizeRGBA = (
|
||||
src: Uint8Array,
|
||||
srcW: number,
|
||||
@@ -161,7 +163,33 @@ const generateThumbnail = (fileBytes: Uint8Array): Uint8Array => {
|
||||
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 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 => {
|
||||
if (file.metadata.fileType !== "image") {
|
||||
return `unsupported file type: ${file.metadata.fileType} (only JPEG images can be regenerated)`;
|
||||
}
|
||||
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,
|
||||
@@ -170,19 +198,24 @@ export const fixMissingThumbnails = async (
|
||||
const results: ThumbnailFixResult[] = [];
|
||||
const api = client.getApiClient();
|
||||
|
||||
const collections = await client.listCollections();
|
||||
// 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 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, {
|
||||
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: col.name,
|
||||
collectionName: album.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -195,33 +228,61 @@ export const fixMissingThumbnails = async (
|
||||
fileID,
|
||||
title: "unknown",
|
||||
collection: "unknown",
|
||||
success: false,
|
||||
error: "file not found in any collection",
|
||||
status: "failed",
|
||||
reason: "file not found in any collection",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { file, collectionName } = entry;
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "quak-thumb-"));
|
||||
const title = file.metadata.title;
|
||||
|
||||
const typeReason = unsupportedByType(file);
|
||||
if (typeReason) {
|
||||
log(`[${collectionName}] Skipping ${title}: ${typeReason}`);
|
||||
results.push({
|
||||
fileID,
|
||||
title,
|
||||
collection: collectionName,
|
||||
status: "skipped",
|
||||
reason: typeReason,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
log(
|
||||
`[${collectionName}] Downloading ${file.metadata.title} for thumbnail generation...`,
|
||||
);
|
||||
const origPath = join(tmpDir, "original");
|
||||
await downloadFile(api, file, origPath);
|
||||
const photo = lib.photos.byID({ fileID });
|
||||
if (!photo) {
|
||||
throw new Error("file not present in the library cache");
|
||||
}
|
||||
|
||||
log(
|
||||
`[${collectionName}] Generating thumbnail for ${file.metadata.title}...`,
|
||||
`[${collectionName}] Downloading ${title} for thumbnail generation...`,
|
||||
);
|
||||
const fileBytes = readFileSync(origPath);
|
||||
const thumbJpeg = generateThumbnail(new Uint8Array(fileBytes));
|
||||
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 thumbJpeg = generateThumbnail(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,
|
||||
@@ -230,28 +291,24 @@ export const fixMissingThumbnails = async (
|
||||
await api.putFile(url, ciphertext);
|
||||
await api.updateThumbnail(file.id, objectKey, toBase64(header));
|
||||
|
||||
log(
|
||||
`[${collectionName}] Thumbnail uploaded for ${file.metadata.title}`,
|
||||
);
|
||||
log(`[${collectionName}] Thumbnail uploaded for ${title}`);
|
||||
results.push({
|
||||
fileID,
|
||||
title: file.metadata.title,
|
||||
title,
|
||||
collection: collectionName,
|
||||
success: true,
|
||||
status: "fixed",
|
||||
});
|
||||
} catch (err) {
|
||||
log(
|
||||
`[${collectionName}] FAILED ${file.metadata.title}: ${err instanceof Error ? err.message : err}`,
|
||||
`[${collectionName}] FAILED ${title}: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
results.push({
|
||||
fileID,
|
||||
title: file.metadata.title,
|
||||
title,
|
||||
collection: collectionName,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
status: "failed",
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user