Port the quak CLI to the library API (closes #52)
check / check (push) Successful in 27s

Route every command through Library.open instead of scanning the client. collections/files read lib.albums; get/get-thumb resolve lib.photos.byID and copy the cached original/thumbnail to --out, so --collection is now accepted but ignored. backup-metadata and the thumbnail helpers enumerate through the library and read originals via photo.original(); ML fetch, EXIF, and thumbnail upload are unchanged. A new global --cache-dir sets the cache location; point commands open with the background precache off so a one-shot command never starts downloading the whole account.

files, get, and get-thumb present each file from its own decrypted metadata (raw title, creationTime in microseconds) rather than the PhotoRecord projection, which prefers editedName/editedTime and milliseconds. This keeps the CLI output byte-identical to the pre-library version.

Also addresses #17: fix-missing-thumbnails now reports a non-JPEG image or a video as skipped (unsupported), distinct from failed, and only a genuine failure exits non-zero.

Model: opus-4-8
This commit is contained in:
2026-09-22 20:38:26 +00:00
parent 2e00139d3c
commit 2269b413a4
9 changed files with 723 additions and 376 deletions
+191 -135
View File
@@ -2,13 +2,25 @@
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 {
fileListRow,
fileListLine,
originalName,
thumbnailName,
} from "../src/cli-output.js";
import { runMetadataBackup } from "../src/metadata-backup.js";
import {
listMissingThumbnails,
@@ -55,7 +67,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 <path>",
"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> =>
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 +182,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 +227,32 @@ 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);
// Present each file from its own decrypted metadata, not the
// PhotoRecord projection, so the raw title and the microsecond
// creationTime print as the pre-library CLI did (issue #52). The
// album's photo order is kept; only the field source changes.
const files = album.photos.list().flatMap((p) => {
const file = lib.getFile(collectionID, p.fileID);
return file ? [file] : [];
});
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,
})),
null,
2,
) + "\n",
JSON.stringify(files.map(fileListRow), null, 2) + "\n",
);
} else {
for (const f of files) {
stdout.write(
`${f.id}\t${f.metadata.fileType}\t${f.metadata.title}\n`,
);
for (const file of files) {
stdout.write(fileListLine(file) + "\n");
}
}
finish(lib, 0);
});
program
@@ -196,98 +260,66 @@ program
.description("Download and decrypt a single file")
.argument("<fileID>", "File ID (from `quak files`)")
.option("--out <path>", "Output file path")
.option(
"--collection <id>",
"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 <id>", "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 });
const file = lib.getFileByID(fileID);
if (!photo || !file) {
stderr.write(`File ${fileID} not found\n`);
finish(lib, 1);
return;
}
const result = await photo.original();
// Default name is the file's own title, as the pre-library CLI used
// (not the editedName-preferring projection title) (issue #52).
const outPath = opts.out ?? originalName(file);
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("<fileID>", "File ID (from `quak files`)")
.option("--out <path>", "Output file path")
.option(
"--collection <id>",
"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 <id>", "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 });
const file = lib.getFileByID(fileID);
if (!photo || !file) {
stderr.write(`File ${fileID} not found\n`);
finish(lib, 1);
return;
}
const result = await photo.thumbnail();
// Default name is thumb_<file's own title>, as the pre-library CLI
// used (not the projection title) (issue #52).
const outPath = opts.out ?? thumbnailName(file);
copyFileSync(result.path, outPath);
stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
finish(lib, 0);
});
program
.command("backup-metadata")
@@ -303,10 +335,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 +355,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 +385,7 @@ program
}
}
process.exit(result.failed > 0 ? 1 : 0);
finish(lib, result.failed > 0 ? 1 : 0);
});
const helper = program
@@ -362,7 +399,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 +420,7 @@ helper
}
}
}
finish(lib, 0);
});
helper
@@ -397,44 +436,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();