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:
+187
-126
@@ -2,13 +2,26 @@
|
||||
|
||||
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 { freshCollections, freshFiles, freshFile } from "../src/cli-read.js";
|
||||
import { runMetadataBackup } from "../src/metadata-backup.js";
|
||||
import {
|
||||
listMissingThumbnails,
|
||||
@@ -55,7 +68,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,7 +183,11 @@ program
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
await init();
|
||||
const client = requireSession();
|
||||
const collections = await client.listCollections();
|
||||
const lib = await openReadLibrary(client);
|
||||
// Force a server round-trip and list in enumeration order (issue #36
|
||||
// amendment, issue #52): the pre-library CLI printed current state in
|
||||
// this order, not the albums projection's newest-first order.
|
||||
const collections = await freshCollections(lib);
|
||||
|
||||
if (opts.json) {
|
||||
stdout.write(
|
||||
@@ -140,6 +211,7 @@ program
|
||||
);
|
||||
}
|
||||
}
|
||||
finish(lib, 0);
|
||||
});
|
||||
|
||||
program
|
||||
@@ -159,36 +231,29 @@ program
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const collections = await client.listCollections();
|
||||
const col = collections.find((c) => c.id === collectionID);
|
||||
if (!col) {
|
||||
const lib = await openReadLibrary(client);
|
||||
// Force a server round-trip and list in enumeration order (issue #36
|
||||
// amendment, issue #52). Each file prints from its own decrypted
|
||||
// metadata (raw title, microsecond creationTime) via cli-output, and in
|
||||
// the pre-library CLI's enumeration order, not the projection's
|
||||
// newest-first order.
|
||||
const files = await freshFiles(lib, collectionID);
|
||||
if (!files) {
|
||||
stderr.write(`Collection ${collectionID} not found\n`);
|
||||
process.exit(1);
|
||||
finish(lib, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await client.listFiles(col.id, col.key);
|
||||
|
||||
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 +261,70 @@ 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);
|
||||
// Force a server round-trip so the file resolves against current state
|
||||
// (issue #36 amendment, issue #52).
|
||||
const resolved = await freshFile(lib, fileID);
|
||||
if (!resolved) {
|
||||
stderr.write(`File ${fileID} not found\n`);
|
||||
finish(lib, 1);
|
||||
return;
|
||||
}
|
||||
const { photo, file } = resolved;
|
||||
|
||||
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);
|
||||
// Force a server round-trip so the file resolves against current state
|
||||
// (issue #36 amendment, issue #52).
|
||||
const resolved = await freshFile(lib, fileID);
|
||||
if (!resolved) {
|
||||
stderr.write(`File ${fileID} not found\n`);
|
||||
finish(lib, 1);
|
||||
return;
|
||||
}
|
||||
const { photo, file } = resolved;
|
||||
|
||||
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 +340,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 +360,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 +390,7 @@ program
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(result.failed > 0 ? 1 : 0);
|
||||
finish(lib, result.failed > 0 ? 1 : 0);
|
||||
});
|
||||
|
||||
const helper = program
|
||||
@@ -362,7 +404,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 +425,7 @@ helper
|
||||
}
|
||||
}
|
||||
}
|
||||
finish(lib, 0);
|
||||
});
|
||||
|
||||
helper
|
||||
@@ -397,44 +441,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();
|
||||
|
||||
Reference in New Issue
Block a user