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
498 lines
17 KiB
JavaScript
498 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { input, password as passwordPrompt } from "@inquirer/prompts";
|
|
import { stdout, stderr } from "node:process";
|
|
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, 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,
|
|
fixMissingThumbnails,
|
|
} from "../src/thumbnails.js";
|
|
|
|
const paths = envPaths("quak", { suffix: "" });
|
|
const sessionPath = join(paths.data, "session.json");
|
|
|
|
const loadSession = (): ClientSnapshot | null => {
|
|
if (!existsSync(sessionPath)) return null;
|
|
try {
|
|
return JSON.parse(readFileSync(sessionPath, "utf-8")) as ClientSnapshot;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const saveSession = (snapshot: ClientSnapshot): void => {
|
|
mkdirSync(paths.data, { recursive: true, mode: 0o700 });
|
|
writeFileSync(sessionPath, JSON.stringify(snapshot, null, 2), {
|
|
mode: 0o600,
|
|
});
|
|
};
|
|
|
|
const requireSession = (): Client => {
|
|
const snapshot = loadSession();
|
|
if (!snapshot) {
|
|
stderr.write(
|
|
`Not logged in. Run "quak login" first.\nSession file: ${sessionPath}\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
return Client.fromJSON(snapshot);
|
|
};
|
|
|
|
const prompt = async (message: string): Promise<string> => input({ message });
|
|
|
|
const promptSecret = async (message: string): Promise<string> =>
|
|
passwordPrompt({ message, mask: true });
|
|
|
|
const program = new Command();
|
|
|
|
program
|
|
.name("quak")
|
|
.description("CLI for the Ente end-to-end encrypted photo service")
|
|
.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")
|
|
.description("Log in to an Ente account and save the session")
|
|
.action(async () => {
|
|
await init();
|
|
const email = process.env.QUAK_EMAIL ?? (await prompt("Email"));
|
|
const password =
|
|
process.env.QUAK_PASSWORD ?? (await promptSecret("Password"));
|
|
|
|
stderr.write("Authenticating...\n");
|
|
try {
|
|
const client = await Client.login({
|
|
email,
|
|
password,
|
|
totp: async () => prompt("TOTP code: "),
|
|
emailOTP: async () => prompt("Email verification code: "),
|
|
});
|
|
|
|
saveSession(client.toJSON());
|
|
const info = client.whoami();
|
|
stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`);
|
|
stderr.write(`Session saved to ${sessionPath}\n`);
|
|
} catch (err) {
|
|
stderr.write(
|
|
`Login failed: ${err instanceof Error ? err.message : err}\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("whoami")
|
|
.description("Print the logged-in account")
|
|
.action(() => {
|
|
const client = requireSession();
|
|
const info = client.whoami();
|
|
stdout.write(JSON.stringify(info) + "\n");
|
|
});
|
|
|
|
program
|
|
.command("logout")
|
|
.description("Delete the saved session")
|
|
.action(async () => {
|
|
if (existsSync(sessionPath)) {
|
|
const { unlinkSync } = await import("node:fs");
|
|
unlinkSync(sessionPath);
|
|
stderr.write("Session deleted.\n");
|
|
} else {
|
|
stderr.write("No session found.\n");
|
|
}
|
|
});
|
|
|
|
program
|
|
.command("collections")
|
|
.description("List all collections (albums)")
|
|
.option("--json", "Output as JSON array")
|
|
.action(async (opts: { json?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
const lib = await openReadLibrary(client);
|
|
const albums = lib.albums.list();
|
|
|
|
if (opts.json) {
|
|
stdout.write(
|
|
JSON.stringify(
|
|
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 a of albums) {
|
|
stdout.write(
|
|
`${a.collectionID}\t${a.type}\t${a.name}${a.isShared ? " (shared)" : ""}\n`,
|
|
);
|
|
}
|
|
}
|
|
finish(lib, 0);
|
|
});
|
|
|
|
program
|
|
.command("files")
|
|
.description("List files in a collection")
|
|
.requiredOption(
|
|
"--collection <id>",
|
|
"Collection ID (from `quak collections`)",
|
|
)
|
|
.option("--json", "Output as JSON array")
|
|
.action(async (opts: { collection: string; json?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
const collectionID = Number(opts.collection);
|
|
if (!Number.isFinite(collectionID)) {
|
|
stderr.write("Invalid collection ID\n");
|
|
process.exit(1);
|
|
}
|
|
|
|
const lib = await openReadLibrary(client);
|
|
const album = lib.albums.byID({ collectionID });
|
|
if (!album) {
|
|
stderr.write(`Collection ${collectionID} not found\n`);
|
|
finish(lib, 1);
|
|
return;
|
|
}
|
|
|
|
// 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(fileListRow), null, 2) + "\n",
|
|
);
|
|
} else {
|
|
for (const file of files) {
|
|
stdout.write(fileListLine(file) + "\n");
|
|
}
|
|
}
|
|
finish(lib, 0);
|
|
});
|
|
|
|
program
|
|
.command("get")
|
|
.description("Download and decrypt a single file")
|
|
.argument("<fileID>", "File ID (from `quak files`)")
|
|
.option("--out <path>", "Output file path")
|
|
.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>", "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")
|
|
.description(
|
|
"Dump all decrypted account metadata to a directory of JSON files",
|
|
)
|
|
.argument("<dir>", "Output directory")
|
|
.option(
|
|
"--exif",
|
|
"Download each file and extract full EXIF/IPTC/XMP metadata (slow)",
|
|
)
|
|
.option("--all", "Alias for --exif")
|
|
.action(async (dir: string, opts: { exif?: boolean; all?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
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
|
|
.command("backup")
|
|
.description(
|
|
"Download all photos to a local directory, organized by collection",
|
|
)
|
|
.argument("<dir>", "Output directory")
|
|
.option("--json", "Print result as JSON instead of human-readable summary")
|
|
.action(async (dir: string, opts: { json?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
|
|
stderr.write("Starting backup...\n");
|
|
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");
|
|
},
|
|
});
|
|
|
|
if (opts.json) {
|
|
stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
} else {
|
|
stderr.write("\n--- Backup complete ---\n");
|
|
stderr.write(` Total files: ${result.totalFiles}\n`);
|
|
stderr.write(` Downloaded: ${result.downloaded}\n`);
|
|
stderr.write(` Skipped: ${result.skipped}\n`);
|
|
stderr.write(` Failed: ${result.failed}\n`);
|
|
if (result.errors.length > 0) {
|
|
stderr.write("\nFailed files:\n");
|
|
for (const e of result.errors) {
|
|
stderr.write(
|
|
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
finish(lib, result.failed > 0 ? 1 : 0);
|
|
});
|
|
|
|
const helper = program
|
|
.command("helper")
|
|
.description("Maintenance and repair utilities");
|
|
|
|
helper
|
|
.command("list-missing-thumbnails")
|
|
.description("List files whose thumbnails are missing or empty")
|
|
.option("--json", "Output as JSON array")
|
|
.action(async (opts: { json?: boolean }) => {
|
|
await init();
|
|
const client = requireSession();
|
|
const lib = await openReadLibrary(client);
|
|
const missing = await listMissingThumbnails(lib, client, (msg) => {
|
|
if (!opts.json) stderr.write(msg + "\n");
|
|
});
|
|
|
|
if (opts.json) {
|
|
stdout.write(JSON.stringify(missing, null, 2) + "\n");
|
|
} else {
|
|
if (missing.length === 0) {
|
|
stderr.write("No missing thumbnails found.\n");
|
|
} else {
|
|
stderr.write(
|
|
`\n${missing.length} file(s) with missing thumbnails:\n`,
|
|
);
|
|
for (const m of missing) {
|
|
stdout.write(
|
|
`${m.fileID}\t${m.title}\t${m.collection}\t${m.reason}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
finish(lib, 0);
|
|
});
|
|
|
|
helper
|
|
.command("fix-missing-thumbnails")
|
|
.description(
|
|
"Generate and upload thumbnails for files that are missing them",
|
|
)
|
|
.option(
|
|
"--file <ids...>",
|
|
"Specific file IDs to fix (default: fix all missing)",
|
|
)
|
|
.option("--json", "Output as JSON")
|
|
.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(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(
|
|
lib,
|
|
client,
|
|
fileIDs,
|
|
(msg) => {
|
|
if (!opts.json) stderr.write(msg + "\n");
|
|
},
|
|
);
|
|
|
|
if (opts.json) {
|
|
stdout.write(JSON.stringify(results, null, 2) + "\n");
|
|
} else {
|
|
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: ${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.status === "failed")) {
|
|
stderr.write(` ${r.fileID}\t${r.title}\t${r.reason}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
finish(lib, results.some((r) => r.status === "failed") ? 1 : 0);
|
|
});
|
|
|
|
await init();
|
|
program.parse();
|