diff --git a/README.md b/README.md index 0d1d7d9..8453581 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,7 @@ you would treat the password itself. ### CLI surface ``` +quak [--cache-dir ] global: local metadata/content cache location quak login interactive or QUAK_EMAIL/QUAK_PASSWORD quak whoami print logged-in account as JSON quak logout delete saved session @@ -432,9 +433,20 @@ quak helper list-missing-thumbnails [--json] find files with missing thumbnai quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbnails ``` -`get` and `get-thumb` search all collections for the file ID when `--collection` -is not specified. All listing and backup commands support `--json` for -machine-readable output. +Every command reads through the local library cache: the first run fetches the +account's metadata from the server, and later runs serve from the cache and +refresh in the background. `--cache-dir` overrides where that cache lives; +without it each account gets its own directory under the per-user cache path. + +`get` and `get-thumb` resolve the file by ID directly, so `--collection` is +accepted for backward compatibility but ignored. All listing and backup commands +support `--json` for machine-readable output. + +`helper fix-missing-thumbnails` regenerates thumbnails for baseline JPEG images +only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG +image (PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept +distinct from a `failed` repair, and does not affect the exit code; a genuine +failure still exits non-zero. ### Backup layout diff --git a/bin/quak.ts b/bin/quak.ts index 6789287..6438be0 100644 --- a/bin/quak.ts +++ b/bin/quak.ts @@ -2,13 +2,19 @@ import { input, password as passwordPrompt } from "@inquirer/prompts"; import { stdout, stderr } from "node:process"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; import { Command } from "commander"; import envPaths from "env-paths"; import { Client, type ClientSnapshot } from "../src/client.js"; import { init } from "../src/crypto/index.js"; -import { Library } from "../src/library/index.js"; +import { Library, type LibraryClient } from "../src/library/index.js"; import { runMetadataBackup } from "../src/metadata-backup.js"; import { listMissingThumbnails, @@ -55,7 +61,61 @@ const program = new Command(); program .name("quak") .description("CLI for the Ente end-to-end encrypted photo service") - .version("0.0.0"); + .version("0.0.0") + .option( + "--cache-dir ", + "Directory for the local metadata/content cache " + + "(default: the per-user cache directory)", + ); + +// The `--cache-dir` global, or undefined to let the library pick its per-user +// default keyed by the account id. +const cacheDirOption = (): string | undefined => + program.opts<{ cacheDir?: string }>().cacheDir; + +// A library client that omits `fetchMLData`, so the point commands below do not +// kick the library's background ML backfill: they read metadata, or fetch one +// file's content, and exit. `backup` and `backup-metadata` handle ML on their +// own terms. The content source is kept so `get`/`get-thumb`/`--exif` can fetch +// originals through the on-disk cache. +const readLibraryClient = (client: Client): LibraryClient => ({ + whoami: () => client.whoami(), + collectionsSince: (args) => client.collectionsSince(args), + filesSince: (args) => client.filesSince(args), + contentSource: () => client.contentSource(), +}); + +// Open a library for a single point command: the aggressive background precache +// (issue #48) is off — a one-shot `collections` or `get` must not start +// downloading the whole account — and the refresh interval is long so no second +// refresh fires mid-command. +const openReadLibrary = (client: Client): Promise => + Library.open({ + client: readLibraryClient(client), + cacheDirectory: cacheDirOption(), + refreshIntervalSeconds: 3600, + precacheThumbnails: false, + precacheOriginals: false, + }); + +// Close the library and exit once stdout/stderr have drained. `process.exit` +// alone can truncate buffered piped output, and the library keeps the event +// loop alive with a background refresh, so a plain return could hang; this does +// neither. +const finish = (lib: Library | undefined, code: number): void => { + lib?.close(); + const pending = [stdout, stderr].filter((s) => s.writableLength > 0); + if (pending.length === 0) { + process.exit(code); + return; + } + let remaining = pending.length; + for (const s of pending) { + s.once("drain", () => { + if (--remaining === 0) process.exit(code); + }); + } +}; program .command("login") @@ -116,30 +176,32 @@ program .action(async (opts: { json?: boolean }) => { await init(); const client = requireSession(); - const collections = await client.listCollections(); + const lib = await openReadLibrary(client); + const albums = lib.albums.list(); if (opts.json) { stdout.write( JSON.stringify( - collections.map((c) => ({ - id: c.id, - name: c.name, - type: c.type, - ownerID: c.ownerID, - isShared: c.isShared, - updationTime: c.updationTime, + albums.map((a) => ({ + id: a.collectionID, + name: a.name, + type: a.type, + ownerID: lib.getCollection(a.collectionID)?.ownerID, + isShared: a.isShared, + updationTime: a.updationTime, })), null, 2, ) + "\n", ); } else { - for (const c of collections) { + for (const a of albums) { stdout.write( - `${c.id}\t${c.type}\t${c.name}${c.isShared ? " (shared)" : ""}\n`, + `${a.collectionID}\t${a.type}\t${a.name}${a.isShared ? " (shared)" : ""}\n`, ); } } + finish(lib, 0); }); program @@ -159,36 +221,35 @@ program process.exit(1); } - const collections = await client.listCollections(); - const col = collections.find((c) => c.id === collectionID); - if (!col) { + const lib = await openReadLibrary(client); + const album = lib.albums.byID({ collectionID }); + if (!album) { stderr.write(`Collection ${collectionID} not found\n`); - process.exit(1); + finish(lib, 1); + return; } - const files = await client.listFiles(col.id, col.key); - + const photos = album.photos.list(); if (opts.json) { stdout.write( JSON.stringify( - files.map((f) => ({ - id: f.id, - title: f.metadata.title, - fileType: f.metadata.fileType, - creationTime: f.metadata.creationTime, - collectionID: f.collectionID, + photos.map((p) => ({ + id: p.fileID, + title: p.title, + fileType: p.fileType, + creationTime: p.takenAt, + collectionID, })), null, 2, ) + "\n", ); } else { - for (const f of files) { - stdout.write( - `${f.id}\t${f.metadata.fileType}\t${f.metadata.title}\n`, - ); + for (const p of photos) { + stdout.write(`${p.fileID}\t${p.fileType}\t${p.title}\n`); } } + finish(lib, 0); }); program @@ -196,98 +257,60 @@ program .description("Download and decrypt a single file") .argument("", "File ID (from `quak files`)") .option("--out ", "Output file path") - .option( - "--collection ", - "Collection ID (required to look up the file key)", - ) - .action( - async ( - fileIDStr: string, - opts: { out?: string; collection?: string }, - ) => { - await init(); - const client = requireSession(); - const fileID = Number(fileIDStr); - if (!Number.isFinite(fileID)) { - stderr.write("Invalid file ID\n"); - process.exit(1); - } - - const collections = await client.listCollections(); - let targetCol; - if (opts.collection) { - targetCol = collections.find( - (c) => c.id === Number(opts.collection), - ); - } - - // Search all collections (or the specified one) for the file - const searchCols = targetCol ? [targetCol] : collections; - for (const col of searchCols) { - const files = await client.listFiles(col.id, col.key); - const file = files.find((f) => f.id === fileID); - if (file) { - const result = await client.downloadFile(file, opts.out); - stderr.write( - `${result.bytesWritten} bytes -> ${result.path}\n`, - ); - return; - } - } - stderr.write(`File ${fileID} not found\n`); + .option("--collection ", "Accepted for compatibility; ignored") + .action(async (fileIDStr: string, opts: { out?: string }) => { + await init(); + const client = requireSession(); + const fileID = Number(fileIDStr); + if (!Number.isFinite(fileID)) { + stderr.write("Invalid file ID\n"); process.exit(1); - }, - ); + } + + const lib = await openReadLibrary(client); + const photo = lib.photos.byID({ fileID }); + if (!photo) { + stderr.write(`File ${fileID} not found\n`); + finish(lib, 1); + return; + } + + const result = await photo.original(); + const outPath = opts.out ?? photo.title; + copyFileSync(result.path, outPath); + stderr.write(`${result.bytes} bytes -> ${outPath}\n`); + finish(lib, 0); + }); program .command("get-thumb") .description("Download and decrypt a thumbnail") .argument("", "File ID (from `quak files`)") .option("--out ", "Output file path") - .option( - "--collection ", - "Collection ID (required to look up the file key)", - ) - .action( - async ( - fileIDStr: string, - opts: { out?: string; collection?: string }, - ) => { - await init(); - const client = requireSession(); - const fileID = Number(fileIDStr); - if (!Number.isFinite(fileID)) { - stderr.write("Invalid file ID\n"); - process.exit(1); - } - - const collections = await client.listCollections(); - let targetCol; - if (opts.collection) { - targetCol = collections.find( - (c) => c.id === Number(opts.collection), - ); - } - - const searchCols = targetCol ? [targetCol] : collections; - for (const col of searchCols) { - const files = await client.listFiles(col.id, col.key); - const file = files.find((f) => f.id === fileID); - if (file) { - const result = await client.downloadThumbnail( - file, - opts.out, - ); - stderr.write( - `${result.bytesWritten} bytes -> ${result.path}\n`, - ); - return; - } - } - stderr.write(`File ${fileID} not found\n`); + .option("--collection ", "Accepted for compatibility; ignored") + .action(async (fileIDStr: string, opts: { out?: string }) => { + await init(); + const client = requireSession(); + const fileID = Number(fileIDStr); + if (!Number.isFinite(fileID)) { + stderr.write("Invalid file ID\n"); process.exit(1); - }, - ); + } + + const lib = await openReadLibrary(client); + const photo = lib.photos.byID({ fileID }); + if (!photo) { + stderr.write(`File ${fileID} not found\n`); + finish(lib, 1); + return; + } + + const result = await photo.thumbnail(); + const outPath = opts.out ?? `thumb_${photo.title}`; + copyFileSync(result.path, outPath); + stderr.write(`${result.bytes} bytes -> ${outPath}\n`); + finish(lib, 0); + }); program .command("backup-metadata") @@ -303,10 +326,12 @@ program .action(async (dir: string, opts: { exif?: boolean; all?: boolean }) => { await init(); const client = requireSession(); - await runMetadataBackup(client, dir, { + const lib = await openReadLibrary(client); + await runMetadataBackup(lib, client, dir, { exif: opts.exif || opts.all, onProgress: (msg) => stderr.write(msg + "\n"), }); + finish(lib, 0); }); program @@ -321,14 +346,17 @@ program const client = requireSession(); stderr.write("Starting backup...\n"); - const lib = await Library.open({ client, downloadDirectory: dir }); + const lib = await Library.open({ + client, + downloadDirectory: dir, + cacheDirectory: cacheDirOption(), + }); const result = await lib.backup({ downloadDirectory: dir, onProgress: (msg) => { if (!opts.json) stderr.write(msg + "\n"); }, }); - lib.close(); if (opts.json) { stdout.write(JSON.stringify(result, null, 2) + "\n"); @@ -348,7 +376,7 @@ program } } - process.exit(result.failed > 0 ? 1 : 0); + finish(lib, result.failed > 0 ? 1 : 0); }); const helper = program @@ -362,7 +390,8 @@ helper .action(async (opts: { json?: boolean }) => { await init(); const client = requireSession(); - const missing = await listMissingThumbnails(client, (msg) => { + const lib = await openReadLibrary(client); + const missing = await listMissingThumbnails(lib, client, (msg) => { if (!opts.json) stderr.write(msg + "\n"); }); @@ -382,6 +411,7 @@ helper } } } + finish(lib, 0); }); helper @@ -397,44 +427,61 @@ helper .action(async (opts: { file?: string[]; json?: boolean }) => { await init(); const client = requireSession(); + const lib = await openReadLibrary(client); let fileIDs: number[]; if (opts.file && opts.file.length > 0) { fileIDs = opts.file.map(Number).filter(Number.isFinite); } else { stderr.write("Scanning for missing thumbnails...\n"); - const missing = await listMissingThumbnails(client, (msg) => { + const missing = await listMissingThumbnails(lib, client, (msg) => { if (!opts.json) stderr.write(msg + "\n"); }); fileIDs = missing.map((m) => m.fileID); if (fileIDs.length === 0) { stderr.write("No missing thumbnails found.\n"); + finish(lib, 0); return; } stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`); } - const results = await fixMissingThumbnails(client, fileIDs, (msg) => { - if (!opts.json) stderr.write(msg + "\n"); - }); + const results = await fixMissingThumbnails( + lib, + client, + fileIDs, + (msg) => { + if (!opts.json) stderr.write(msg + "\n"); + }, + ); if (opts.json) { stdout.write(JSON.stringify(results, null, 2) + "\n"); } else { - const ok = results.filter((r) => r.success).length; - const fail = results.filter((r) => !r.success).length; + const fixed = results.filter((r) => r.status === "fixed").length; + const skipped = results.filter( + (r) => r.status === "skipped", + ).length; + const failed = results.filter((r) => r.status === "failed").length; stderr.write(`\n--- Done ---\n`); - stderr.write(` Fixed: ${ok}\n`); - stderr.write(` Failed: ${fail}\n`); - if (fail > 0) { + stderr.write(` Fixed: ${fixed}\n`); + stderr.write(` Skipped: ${skipped}\n`); + stderr.write(` Failed: ${failed}\n`); + if (skipped > 0) { + stderr.write("\nSkipped (unsupported format):\n"); + for (const r of results.filter((r) => r.status === "skipped")) { + stderr.write(` ${r.fileID}\t${r.title}\t${r.reason}\n`); + } + } + if (failed > 0) { stderr.write("\nFailed files:\n"); - for (const r of results.filter((r) => !r.success)) { - stderr.write(` ${r.fileID}\t${r.title}\t${r.error}\n`); + for (const r of results.filter((r) => r.status === "failed")) { + stderr.write(` ${r.fileID}\t${r.title}\t${r.reason}\n`); } } } - process.exit(results.some((r) => !r.success) ? 1 : 0); + finish(lib, results.some((r) => r.status === "failed") ? 1 : 0); }); await init(); diff --git a/src/metadata-backup.ts b/src/metadata-backup.ts index 89bc1a9..d5877bd 100644 --- a/src/metadata-backup.ts +++ b/src/metadata-backup.ts @@ -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 | 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(); const seenFileIDs = new Set(); - 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(); - for (const { file, colDirName } of allFiles) { + for (const { file, photo, colDirName } of allFiles) { const colDir = join(outDir, "collections", colDirName); const fileMeta: Record = { @@ -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); diff --git a/src/thumbnails.ts b/src/thumbnails.ts index 254f53d..4183b1e 100644 --- a/src/thumbnails.ts +++ b/src/thumbnails.ts @@ -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 => { const log = onProgress ?? (() => {}); + const api = client.getApiClient(); const missing: MissingThumbnailInfo[] = []; const seen = new Set(); - 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 }); } } diff --git a/test/cli/metadata-backup.test.ts b/test/cli/metadata-backup.test.ts index 4a0fb49..c13c612 100644 --- a/test/cli/metadata-backup.test.ts +++ b/test/cli/metadata-backup.test.ts @@ -2,13 +2,17 @@ * Tests for `quak backup-metadata `. * * This command dumps all decrypted account metadata into a directory - * tree of plain JSON files, without downloading any file content. It - * is fast (no multi-megabyte downloads) and produces a complete - * plaintext record of every collection name, file title, creation - * date, GPS coordinate, camera model, caption, face label, and any - * other metadata the Ente clients have attached. + * tree of plain JSON files, without downloading any file content (unless + * `--exif` is given). It is fast and produces a complete plaintext record of + * every collection name, file title, creation date, GPS coordinate, camera + * model, caption, face label, and any other metadata the Ente clients have + * attached. * - * Layout: + * As of issue #52 it runs on the library API: `runMetadataBackup(lib, client, + * dir)` enumerates collections and files from the library's cache rather than + * scanning the client directly, and `--exif` reads each original through the + * library's content cache (`photo.original()`). The ML fetch is unchanged. The + * output tree is identical: * * / * account.json { email, userID } @@ -44,7 +48,11 @@ import { } from "../../src/crypto/index.js"; import * as jpegJs from "jpeg-js"; import { Client } from "../../src/client.js"; -import { runMetadataBackup } from "../../src/metadata-backup.js"; +import { Library } from "../../src/library/index.js"; +import { + runMetadataBackup, + type MetadataBackupOptions, +} from "../../src/metadata-backup.js"; import type { KeyAttributes } from "../../src/auth/types.js"; const TEST_EMAIL = "metabackup@example.com"; @@ -431,16 +439,50 @@ afterAll(() => { rmSync(testDir, { recursive: true, force: true }); }); +// Log in against the mock and open a library over its cache. The point commands +// open the library with the background precache off and a long refresh interval; +// the same here keeps the test deterministic (no thumbnail/original prefetch it +// did not ask for, no second refresh mid-test). +const openLib = async (client: Client): Promise => + Library.open({ + // The library client omits `fetchMLData`, matching how the CLI opens + // point commands: `runMetadataBackup` fetches ML data itself through + // the client, so the library's background backfill would only be a + // redundant second pass over the same endpoint. + client: { + whoami: () => client.whoami(), + collectionsSince: (args) => client.collectionsSince(args), + filesSince: (args) => client.filesSince(args), + contentSource: () => client.contentSource(), + }, + cacheDirectory: mkdtempSync(join(testDir, "cache-")), + refreshIntervalSeconds: 3600, + precacheThumbnails: false, + precacheOriginals: false, + }); + +// Run one metadata backup end to end: fresh client, fresh library, then close. +const runBackup = async ( + outDir: string, + opts?: MetadataBackupOptions, +): Promise => { + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildMetaFetch(mock) }, + }); + const lib = await openLib(client); + try { + await runMetadataBackup(lib, client, outDir, opts); + } finally { + lib.close(); + } +}; + describe("quak backup-metadata", () => { it("writes account.json with email and userID", async () => { const outDir = join(testDir, "full"); - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildMetaFetch(mock) }, - }); - - await runMetadataBackup(client, outDir); + await runBackup(outDir); const account = JSON.parse( readFileSync(join(outDir, "account.json"), "utf-8"), @@ -451,13 +493,7 @@ describe("quak backup-metadata", () => { it("creates per-collection directories with _collection.json", async () => { const outDir = join(testDir, "collections"); - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildMetaFetch(mock) }, - }); - - await runMetadataBackup(client, outDir); + await runBackup(outDir); const collDirs = readdirSync(join(outDir, "collections")); expect(collDirs.length).toBe(2); @@ -478,13 +514,7 @@ describe("quak backup-metadata", () => { it("decrypts collection-level pubMagicMetadata", async () => { const outDir = join(testDir, "coll-magic"); - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildMetaFetch(mock) }, - }); - - await runMetadataBackup(client, outDir); + await runBackup(outDir); const collDirs = readdirSync(join(outDir, "collections")); const vacDir = collDirs.find((d) => d.includes("Vacation"))!; @@ -501,13 +531,7 @@ describe("quak backup-metadata", () => { it("writes per-file JSON with all three metadata layers", async () => { const outDir = join(testDir, "file-meta"); - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildMetaFetch(mock) }, - }); - - await runMetadataBackup(client, outDir); + await runBackup(outDir); const collDirs = readdirSync(join(outDir, "collections")); const vacDir = collDirs.find((d) => d.includes("Vacation"))!; @@ -526,13 +550,7 @@ describe("quak backup-metadata", () => { it("handles files with no magic metadata gracefully", async () => { const outDir = join(testDir, "no-magic"); - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildMetaFetch(mock) }, - }); - - await runMetadataBackup(client, outDir); + await runBackup(outDir); const collDirs = readdirSync(join(outDir, "collections")); const workDir = collDirs.find((d) => d.includes("Work"))!; @@ -550,14 +568,8 @@ describe("quak backup-metadata", () => { it("is incremental: second run does not fail", async () => { const outDir = join(testDir, "incremental"); - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildMetaFetch(mock) }, - }); - - await runMetadataBackup(client, outDir); - await runMetadataBackup(client, outDir); + await runBackup(outDir); + await runBackup(outDir); const account = JSON.parse( readFileSync(join(outDir, "account.json"), "utf-8"), @@ -567,13 +579,7 @@ describe("quak backup-metadata", () => { it("fetches and decrypts ML data by default", async () => { const outDir = join(testDir, "ml-data"); - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildMetaFetch(mock) }, - }); - - await runMetadataBackup(client, outDir); + await runBackup(outDir); const collDirs = readdirSync(join(outDir, "collections")); const vacDir = collDirs.find((d) => d.includes("Vacation"))!; @@ -597,13 +603,7 @@ describe("quak backup-metadata", () => { it("extracts EXIF from downloaded files when --exif is set", async () => { const outDir = join(testDir, "exif-data"); - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildMetaFetch(mock) }, - }); - - await runMetadataBackup(client, outDir, { exif: true }); + await runBackup(outDir, { exif: true }); const collDirs = readdirSync(join(outDir, "collections")); const vacDir = collDirs.find((d) => d.includes("Vacation"))!; diff --git a/test/thumbnails/thumbnails.test.ts b/test/thumbnails/thumbnails.test.ts index 2743d17..940eaaa 100644 --- a/test/thumbnails/thumbnails.test.ts +++ b/test/thumbnails/thumbnails.test.ts @@ -6,17 +6,30 @@ * working thumbnails, others return 404 or empty bodies. The tests * verify that the detection and repair logic handles each case correctly. * + * As of issue #52 both helpers take an open `Library` for enumeration and the + * `Client` for the API operations that stay unchanged (the thumbnail existence + * check, and the encrypt-and-upload path). `fixMissingThumbnails` reads each + * original through the library's content cache (`photo.original()`). + * * `fixMissingThumbnails` is the most complex function in quak: it * downloads the original file, generates a JPEG thumbnail with jpeg-js, * encrypts it with secretstream push, gets a presigned upload URL, * uploads to S3, and registers the new thumbnail with the API. The * test verifies each step actually happened and the uploaded data is * a valid encrypted blob that decrypts to a JPEG. + * + * It regenerates thumbnails for baseline JPEGs only, because `jpeg-js` decodes + * only JPEG. A non-JPEG image (PNG, HEIC) or a video is reported as "skipped + * (unsupported)" rather than crashing the decoder into an opaque failure + * (issue #17); the mixed test below locks that distinction down. */ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import sodium from "libsodium-wrappers-sumo"; import * as jpegJs from "jpeg-js"; -import { beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { init, toBase64, @@ -27,6 +40,7 @@ import { } from "../../src/crypto/index.js"; import { SRP, SrpServer } from "fast-srp-hap"; import { Client } from "../../src/client.js"; +import { Library } from "../../src/library/index.js"; import { listMissingThumbnails, fixMissingThumbnails, @@ -42,6 +56,7 @@ const TEST_EMAIL = "thumb@example.com"; const TEST_PASSWORD = "thumbpass"; const TEST_OPS = 2; const TEST_MEM = 64 * 1024 * 1024; +const TEST_TIME = 1700000000000000; interface ThumbMockState { verifier: Buffer; @@ -63,8 +78,17 @@ interface ThumbMockState { } let mock: ThumbMockState; +let tmpRoot: string; -const buildThumbMock = async (): Promise => { +// PNG signature bytes — enough for `fixMissingThumbnails` to recognise a +// non-JPEG image and skip it. It need not be a decodable PNG. +const PNG_BYTES = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, +]); + +const buildThumbMock = async (opts?: { + extraFormats?: boolean; +}): Promise => { const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM); const loginSubKeyBytes = deriveLoginSubkey(kek); @@ -102,7 +126,6 @@ const buildThumbMock = async (): Promise => { opsLimit: TEST_OPS, }; - // One collection with 3 files: ok thumbnail, empty thumbnail, 404 thumbnail const collKey = sodium.crypto_secretbox_keygen(); const ckN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); const encCK = sodium.crypto_secretbox_easy(collKey, ckN, masterKey); @@ -118,10 +141,11 @@ const buildThumbMock = async (): Promise => { encryptedName: toBase64(encCN), nameDecryptionNonce: toBase64(cnN), type: "album", - updationTime: 1700000000000000, + updationTime: TEST_TIME, }; - // Generate a real tiny JPEG via jpeg-js + // Generate a real tiny JPEG via jpeg-js, used as the encrypted body of the + // JPEG files so a repair actually decodes and re-encodes real pixels. const w = 100; const h = 80; const pixels = new Uint8Array(w * h * 4); @@ -131,26 +155,32 @@ const buildThumbMock = async (): Promise => { pixels[i + 2] = 0; // B pixels[i + 3] = 255; // A } - const tinyJpeg = jpegJs.encode( - { data: pixels, width: w, height: h }, - 80, - ).data; + const tinyJpeg = new Uint8Array( + jpegJs.encode({ data: pixels, width: w, height: h }, 80).data, + ); const fileKeys: Record = {}; const fileCiphertexts: Record = {}; - const rawFiles: Record[] = []; - for (const fileID of [100, 101, 102]) { + // Build one raw file record: encrypt its metadata and its body under a + // fresh per-file key, and record the key and ciphertext for the mock to + // serve and for the test to verify against. + const makeRawFile = ( + fileID: number, + fileType: number, + title: string, + body: Uint8Array, + ): Record => { const fk = sodium.crypto_secretstream_xchacha20poly1305_keygen(); fileKeys[fileID] = fk; const fkN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); const encFK = sodium.crypto_secretbox_easy(fk, fkN, collKey); const meta = JSON.stringify({ - title: `file-${fileID}.jpg`, - fileType: 0, - creationTime: 1700000000000000, - modificationTime: 1700000000000000, + title, + fileType, + creationTime: TEST_TIME, + modificationTime: TEST_TIME, }); const metaPush = sodium.crypto_secretstream_xchacha20poly1305_init_push(fk); @@ -161,18 +191,17 @@ const buildThumbMock = async (): Promise => { sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, ); - // Encrypt the tiny JPEG as the file body const filePush = sodium.crypto_secretstream_xchacha20poly1305_init_push(fk); const encFile = sodium.crypto_secretstream_xchacha20poly1305_push( filePush.state, - new Uint8Array(tinyJpeg), + body, null, sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, ); fileCiphertexts[fileID] = encFile; - rawFiles.push({ + return { id: fileID, collectionID: 1, ownerID: 42, @@ -186,8 +215,30 @@ const buildThumbMock = async (): Promise => { thumbnail: { decryptionHeader: toBase64(sodium.randombytes_buf(24)), }, - updationTime: 1700000000000000, - }); + updationTime: TEST_TIME, + }; + }; + + // Three JPEG files: ok thumbnail, empty thumbnail, 404 thumbnail. + const rawFiles: Record[] = []; + for (const fileID of [100, 101, 102]) { + rawFiles.push(makeRawFile(fileID, 0, `file-${fileID}.jpg`, tinyJpeg)); + } + const thumbnailBehavior: Record = { + 100: "ok", + 101: "empty", + 102: "404", + }; + + // For the issue #17 mixed test: a non-JPEG image and a video, both with a + // missing (404) thumbnail so they surface in the missing list too. + if (opts?.extraFormats) { + rawFiles.push(makeRawFile(103, 0, "file-103.png", PNG_BYTES)); + rawFiles.push( + makeRawFile(104, 1, "file-104.mp4", new Uint8Array([0, 0, 0, 1])), + ); + thumbnailBehavior[103] = "404"; + thumbnailBehavior[104] = "404"; } return { @@ -206,11 +257,7 @@ const buildThumbMock = async (): Promise => { filesByCollection: { 1: rawFiles }, fileCiphertexts, fileKeys, - thumbnailBehavior: { - 100: "ok", - 101: "empty", - 102: "404", - }, + thumbnailBehavior, uploadedThumbnails: [], }; }; @@ -381,6 +428,31 @@ const countingFetch = ( return { fetch: fake as typeof globalThis.fetch, matched: () => matched }; }; +// Open a library over a mock-backed client. As the CLI does for point commands, +// the background precache is off and the refresh interval is long, and the +// library client omits `fetchMLData` so no background ML fetch runs. The real +// `Client` is still used for the API operations the helpers perform directly. +const openLib = (client: Client): Promise => + Library.open({ + client: { + whoami: () => client.whoami(), + collectionsSince: (args) => client.collectionsSince(args), + filesSince: (args) => client.filesSince(args), + contentSource: () => client.contentSource(), + }, + cacheDirectory: mkdtempSync(join(tmpRoot, "cache-")), + refreshIntervalSeconds: 3600, + precacheThumbnails: false, + precacheOriginals: false, + }); + +const login = (fetch: typeof globalThis.fetch, retry?: RetryOptions) => + Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: retry ? { fetch, retry } : { fetch }, + }); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -389,17 +461,21 @@ beforeAll(async () => { await init(); await sodium.ready; mock = await buildThumbMock(); + tmpRoot = mkdtempSync(join(tmpdir(), "quak-thumb-test-")); +}); + +afterAll(() => { + if (tmpRoot && existsSync(tmpRoot)) + rmSync(tmpRoot, { recursive: true, force: true }); }); describe("listMissingThumbnails", () => { it("identifies files with empty and 404 thumbnails, ignores working ones", async () => { - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildThumbFetch(mock) }, - }); + const client = await login(buildThumbFetch(mock)); + const lib = await openLib(client); - const missing = await listMissingThumbnails(client); + const missing = await listMissingThumbnails(lib, client); + lib.close(); // File 100 has a working thumbnail → not reported // File 101 has an empty thumbnail → reported @@ -436,13 +512,11 @@ describe("listMissingThumbnails", () => { buildThumbFetch(failingMock), (url) => url.includes("thumbnails.ente.io") && url.includes("102"), ); - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: counted.fetch, retry: { ...noWait } }, - }); + const client = await login(counted.fetch, { ...noWait }); + const lib = await openLib(client); - const missing = await listMissingThumbnails(client); + const missing = await listMissingThumbnails(lib, client); + lib.close(); // Only the genuinely empty thumbnail is reported. expect(missing.map((m) => m.fileID)).toEqual([101]); @@ -475,13 +549,11 @@ describe("listMissingThumbnails", () => { return inner(input, init); }) as typeof globalThis.fetch; - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch, retry: { ...noWait } }, - }); + const client = await login(fetch, { ...noWait }); + const lib = await openLib(client); - const missing = await listMissingThumbnails(client); + const missing = await listMissingThumbnails(lib, client); + lib.close(); expect(missing.map((m) => m.fileID)).toEqual([101]); expect(thumbRequests).toBe(4); @@ -500,13 +572,11 @@ describe("listMissingThumbnails", () => { mockWithDupes.filesByCollection[2] = mockWithDupes.filesByCollection[1]!; - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildThumbFetch(mockWithDupes) }, - }); + const client = await login(buildThumbFetch(mockWithDupes)); + const lib = await openLib(client); - const missing = await listMissingThumbnails(client); + const missing = await listMissingThumbnails(lib, client); + lib.close(); // Should still be 2, not 4 (each file checked only once) expect(missing.length).toBe(2); @@ -516,16 +586,14 @@ describe("listMissingThumbnails", () => { describe("fixMissingThumbnails", () => { it("downloads original, generates thumbnail, encrypts, uploads, and registers", async () => { const fixMock = await buildThumbMock(); - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildThumbFetch(fixMock) }, - }); + const client = await login(buildThumbFetch(fixMock)); + const lib = await openLib(client); - const results = await fixMissingThumbnails(client, [101]); + const results = await fixMissingThumbnails(lib, client, [101]); + lib.close(); expect(results.length).toBe(1); - expect(results[0]!.success).toBe(true); + expect(results[0]!.status).toBe("fixed"); expect(results[0]!.fileID).toBe(101); expect(results[0]!.title).toBe("file-101.jpg"); expect(results[0]!.collection).toBe("Photos"); @@ -555,48 +623,76 @@ describe("fixMissingThumbnails", () => { it("reports failure for nonexistent file IDs without crashing", async () => { const fixMock = await buildThumbMock(); - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildThumbFetch(fixMock) }, - }); + const client = await login(buildThumbFetch(fixMock)); + const lib = await openLib(client); - const results = await fixMissingThumbnails(client, [999]); + const results = await fixMissingThumbnails(lib, client, [999]); + lib.close(); expect(results.length).toBe(1); - expect(results[0]!.success).toBe(false); + expect(results[0]!.status).toBe("failed"); expect(results[0]!.fileID).toBe(999); - expect(results[0]!.error).toContain("not found"); + expect(results[0]!.reason).toContain("not found"); }); it("continues after one file fails and reports mixed results", async () => { const fixMock = await buildThumbMock(); - // Make file 102 fail by removing its ciphertext so download fails + // Make file 102 fail by removing its ciphertext so the download 404s. delete fixMock.fileCiphertexts[102]; - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildThumbFetch(fixMock) }, - }); + const client = await login(buildThumbFetch(fixMock)); + const lib = await openLib(client); - const results = await fixMissingThumbnails(client, [101, 102]); + const results = await fixMissingThumbnails(lib, client, [101, 102]); + lib.close(); expect(results.length).toBe(2); const success = results.find((r) => r.fileID === 101)!; const failure = results.find((r) => r.fileID === 102)!; - expect(success.success).toBe(true); - expect(failure.success).toBe(false); + expect(success.status).toBe("fixed"); + expect(failure.status).toBe("failed"); + }); + + it("skips a non-JPEG image and a video as unsupported, not failed (issue #17)", async () => { + // A PNG and a video both throw inside the JPEG decoder. The helper must + // recognise them up front and report "skipped", distinct from a genuine + // "failed", and must not upload anything for them. The JPEG in the same + // batch is still repaired. + const fixMock = await buildThumbMock({ extraFormats: true }); + const client = await login(buildThumbFetch(fixMock)); + const lib = await openLib(client); + + const results = await fixMissingThumbnails( + lib, + client, + [101, 103, 104], + ); + lib.close(); + + const jpeg = results.find((r) => r.fileID === 101)!; + const png = results.find((r) => r.fileID === 103)!; + const video = results.find((r) => r.fileID === 104)!; + + expect(jpeg.status).toBe("fixed"); + + // The PNG is a still image but not a JPEG: skipped only after its bytes + // are inspected. + expect(png.status).toBe("skipped"); + expect(png.reason).toContain("JPEG"); + + // The video is skipped from its type alone, before any download. + expect(video.status).toBe("skipped"); + expect(video.reason).toContain("video"); + + // Only the JPEG was uploaded; the two skipped files touched no upload. + expect(fixMock.uploadedThumbnails.length).toBe(1); + expect(fixMock.uploadedThumbnails[0]!.fileID).toBe(101); }); }); describe("Client.getApiClient", () => { it("returns the ApiClient when logged in", async () => { - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildThumbFetch(mock) }, - }); + const client = await login(buildThumbFetch(mock)); const api = client.getApiClient(); expect(api).toBeDefined(); @@ -604,11 +700,7 @@ describe("Client.getApiClient", () => { }); it("throws after logout", async () => { - const client = await Client.login({ - email: TEST_EMAIL, - password: TEST_PASSWORD, - apiOptions: { fetch: buildThumbFetch(mock) }, - }); + const client = await login(buildThumbFetch(mock)); client.logout(); expect(() => client.getApiClient()).toThrow(/logged out/);