-v raises the CLI's log level per repeat; -q prints only fatal errors (closes #212)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Every line quak prints on stderr now goes through one log, in src/cli-log.ts, at one of six levels. The default, info, prints what quak printed before. -v adds each retry and the start and end of each refresh and ML data fetch; -vv adds each request with its result. -q prints only fatal lines, and is refused with -v; both are program options. Every non-zero exit logs one fatal line, so a run that exits 1 because files failed says how many and where to look. Every line reporting a failure is at error. --json changes stdout only, so backup and --json runs log their progress on stderr. withRetry and ApiClient report retries and requests through new callbacks. Model: opus-5-5
This commit was merged in pull request #216.
This commit is contained in:
+57
-12
@@ -36,6 +36,15 @@ export interface ApiClientOptions {
|
||||
retry?: RetryOptions;
|
||||
requestTimeoutMs?: number;
|
||||
downloadTimeoutMs?: number;
|
||||
// Called once for each request sent, with the status of its response or
|
||||
// the error it failed with, for a caller that logs requests. A request
|
||||
// that is retried is reported once for each attempt.
|
||||
onRequest?: (request: {
|
||||
method: string;
|
||||
url: string;
|
||||
status?: number;
|
||||
error?: unknown;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
@@ -159,6 +168,7 @@ export class ApiClient {
|
||||
private readonly retry: ResolvedRetryOptions;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private readonly downloadTimeoutMs: number;
|
||||
private readonly onRequest: NonNullable<ApiClientOptions["onRequest"]>;
|
||||
private token: string | undefined;
|
||||
|
||||
constructor(opts?: ApiClientOptions) {
|
||||
@@ -180,6 +190,7 @@ export class ApiClient {
|
||||
opts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
this.downloadTimeoutMs =
|
||||
opts?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
|
||||
this.onRequest = opts?.onRequest ?? (() => {});
|
||||
this.token = opts?.authToken;
|
||||
}
|
||||
|
||||
@@ -214,6 +225,33 @@ export class ApiClient {
|
||||
return h;
|
||||
}
|
||||
|
||||
// Send one request and report it to `onRequest`, with the status of its
|
||||
// response or the error it failed with. `reportedURL` is the URL given to
|
||||
// `onRequest`, when that must not be the one sent.
|
||||
private async send(
|
||||
url: string,
|
||||
init: RequestInit & { method: string },
|
||||
reportedURL = url,
|
||||
): Promise<Response> {
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await this._fetch(url, init);
|
||||
} catch (err) {
|
||||
this.onRequest({
|
||||
method: init.method,
|
||||
url: reportedURL,
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
this.onRequest({
|
||||
method: init.method,
|
||||
url: reportedURL,
|
||||
status: resp.status,
|
||||
});
|
||||
return resp;
|
||||
}
|
||||
|
||||
private async throwIfError(resp: Response): Promise<void> {
|
||||
if (resp.ok) return;
|
||||
const requestID = resp.headers.get("x-request-id") ?? undefined;
|
||||
@@ -257,7 +295,7 @@ export class ApiClient {
|
||||
const url = buildURL(this.apiOrigin, path, query);
|
||||
// A GET changes nothing, so it is retried under the full policy.
|
||||
return withRetry(async () => {
|
||||
const resp = await this._fetch(url, {
|
||||
const resp = await this.send(url, {
|
||||
method: "GET",
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
@@ -280,7 +318,7 @@ export class ApiClient {
|
||||
// 3xx status, which is not retried.
|
||||
return withRetry(
|
||||
async () => {
|
||||
const resp = await this._fetch(url, {
|
||||
const resp = await this.send(url, {
|
||||
method: "POST",
|
||||
headers: this.headers({
|
||||
"Content-Type": "application/json",
|
||||
@@ -321,16 +359,23 @@ export class ApiClient {
|
||||
// object at one key in one request, so replaying it either overwrites
|
||||
// the same bytes or lands them for the first time. There is no partial
|
||||
// state to protect, hence the full policy rather than the POST rule.
|
||||
// It is reported without its query, which holds the upload's
|
||||
// signature: a log keeps what it is given.
|
||||
const { origin, pathname } = new URL(presignedURL);
|
||||
await withRetry(async () => {
|
||||
const resp = await this._fetch(presignedURL, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(data.length),
|
||||
const resp = await this.send(
|
||||
presignedURL,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(data.length),
|
||||
},
|
||||
body: data,
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
},
|
||||
body: data,
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
});
|
||||
origin + pathname,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
// An ApiError, not a bare Error: without the status on the
|
||||
// error the upload path cannot be classified at all, and a
|
||||
@@ -348,7 +393,7 @@ export class ApiClient {
|
||||
// Same replay and redirect rules as `postJSON`, for the same reasons.
|
||||
return withRetry(
|
||||
async () => {
|
||||
const resp = await this._fetch(url, {
|
||||
const resp = await this.send(url, {
|
||||
method: "PUT",
|
||||
headers: this.headers({
|
||||
"Content-Type": "application/json",
|
||||
@@ -394,7 +439,7 @@ export class ApiClient {
|
||||
// headers, when no bytes have arrived either.
|
||||
const deadline = idleDeadline(this.downloadTimeoutMs);
|
||||
try {
|
||||
const resp = await this._fetch(url, {
|
||||
const resp = await this.send(url, {
|
||||
method: "GET",
|
||||
headers: this.headers(),
|
||||
signal: deadline.signal,
|
||||
|
||||
+15
-5
@@ -101,7 +101,10 @@ import type { MLData } from "./mldata-fetch.js";
|
||||
import type { Collection, EnteFile, FileMetadata } from "./model/types.js";
|
||||
import { hasTransportCode } from "./retry.js";
|
||||
|
||||
export type ProgressCallback = (message: string) => void;
|
||||
// Reports one line of the backup's progress. `level` is "error" when the line
|
||||
// reports something that failed, such as a file or an album, and is left out
|
||||
// for every other line.
|
||||
export type ProgressCallback = (message: string, level?: "error") => void;
|
||||
|
||||
// The lock in a backup directory, as `lockBackupDirectory` returns it.
|
||||
export interface BackupLock {
|
||||
@@ -654,7 +657,7 @@ const runLockedBackup = async (
|
||||
for (const record of lib.undecryptable()) {
|
||||
const error = `could not be decrypted: ${record.error}`;
|
||||
if (record.fileID === undefined) {
|
||||
log(`FAILED album ${record.collectionID}: ${error}`);
|
||||
log(`FAILED album ${record.collectionID}: ${error}`, "error");
|
||||
undecryptableAlbums.push(record.collectionID);
|
||||
} else if (scope.has(record.collectionID)) {
|
||||
const copies = memberships.get(record.fileID);
|
||||
@@ -699,6 +702,7 @@ const runLockedBackup = async (
|
||||
} catch (err) {
|
||||
log(
|
||||
`FAILED verifying original ${file.metadata.title}: ${describeError(err)}`,
|
||||
"error",
|
||||
);
|
||||
recordFailure(
|
||||
file.id,
|
||||
@@ -732,6 +736,7 @@ const runLockedBackup = async (
|
||||
} catch (err) {
|
||||
log(
|
||||
`FAILED original ${file.metadata.title}: ${describeError(err)}`,
|
||||
"error",
|
||||
);
|
||||
recordFailure(
|
||||
file.id,
|
||||
@@ -807,7 +812,7 @@ const runLockedBackup = async (
|
||||
await lib.fetchMLData();
|
||||
} catch (err) {
|
||||
mlDataFailure = err;
|
||||
log(`FAILED ML data: ${describeError(err)}`);
|
||||
log(`FAILED ML data: ${describeError(err)}`, "error");
|
||||
}
|
||||
for (const file of distinct.values()) {
|
||||
if (lock.lost) throw lock.lost;
|
||||
@@ -868,7 +873,10 @@ const runLockedBackup = async (
|
||||
downloadDirectory,
|
||||
);
|
||||
} catch (err) {
|
||||
log(`FAILED removing old album directories: ${describeError(err)}`);
|
||||
log(
|
||||
`FAILED removing old album directories: ${describeError(err)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
|
||||
// An album whose folder or JSON cannot be written, such as one whose
|
||||
@@ -897,6 +905,7 @@ const runLockedBackup = async (
|
||||
} catch (err) {
|
||||
log(
|
||||
`FAILED removing old links in ${c.name}: ${describeError(err)}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -915,6 +924,7 @@ const runLockedBackup = async (
|
||||
} catch (err) {
|
||||
log(
|
||||
`FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`,
|
||||
"error",
|
||||
);
|
||||
recordFailure(
|
||||
link.file.id,
|
||||
@@ -931,7 +941,7 @@ const runLockedBackup = async (
|
||||
metaFiles,
|
||||
);
|
||||
} catch (err) {
|
||||
log(`FAILED album ${c.name}: ${describeError(err)}`);
|
||||
log(`FAILED album ${c.name}: ${describeError(err)}`, "error");
|
||||
failedAlbums.push(c.id);
|
||||
}
|
||||
}
|
||||
|
||||
+105
-101
@@ -4,7 +4,10 @@
|
||||
// code; a thrown error is left to the caller. Nothing here calls
|
||||
// `process.exit`: `bin/quak.ts` wires these to the command line, and `run` in
|
||||
// `cli-run.ts` prints a thrown error as one line and exits once output has
|
||||
// drained. Output must stay byte-identical (see `cli-output.ts`).
|
||||
// drained. Output must stay byte-identical (see `cli-output.ts`). Each line on
|
||||
// stderr goes through `ctx.log` at its level (see `cli-log.ts`). Every command
|
||||
// that exits non-zero logs a fatal line saying why; one that exits 1 because
|
||||
// files or albums failed logs it last, saying how many and where to look.
|
||||
|
||||
import {
|
||||
copyFileSync,
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
originalName,
|
||||
thumbnailName,
|
||||
} from "./cli-output.js";
|
||||
import { type Log, logRefresh } from "./cli-log.js";
|
||||
import { freshCollections, freshFiles, freshFile } from "./cli-read.js";
|
||||
import { withExtension } from "./filename.js";
|
||||
import { runMetadataBackup } from "./metadata-backup.js";
|
||||
@@ -40,7 +44,8 @@ import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js";
|
||||
|
||||
export interface CliContext {
|
||||
stdout: { write(text: string): unknown };
|
||||
stderr: { write(text: string): unknown };
|
||||
// Writes a line on stderr at a log level, if the run's level prints it.
|
||||
log: Log;
|
||||
// Directory holding `session.json`.
|
||||
sessionDir: string;
|
||||
// The `--cache-dir` global, or undefined to let the library pick its
|
||||
@@ -82,16 +87,16 @@ const requireSession = (ctx: CliContext): Client | undefined => {
|
||||
try {
|
||||
client = ctx.loadSession(sessionPath(ctx));
|
||||
} catch (err) {
|
||||
ctx.stderr.write(
|
||||
`${err instanceof Error ? err.message : err}\n` +
|
||||
`Run "quak logout" and then "quak login" to replace it.\n`,
|
||||
ctx.log("fatal", err instanceof Error ? err.message : String(err));
|
||||
ctx.log(
|
||||
"fatal",
|
||||
`Run "quak logout" and then "quak login" to replace it.`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (!client) {
|
||||
ctx.stderr.write(
|
||||
`Not logged in. Run "quak login" first.\nSession file: ${sessionPath(ctx)}\n`,
|
||||
);
|
||||
ctx.log("fatal", `Not logged in. Run "quak login" first.`);
|
||||
ctx.log("fatal", `Session file: ${sessionPath(ctx)}`);
|
||||
return undefined;
|
||||
}
|
||||
return client;
|
||||
@@ -107,7 +112,7 @@ const parseID = (
|
||||
): number | undefined => {
|
||||
const id = Number(text);
|
||||
if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id;
|
||||
ctx.stderr.write(`Invalid ${kind} ID: ${text}\n`);
|
||||
ctx.log("fatal", `Invalid ${kind} ID: ${text}`);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -128,13 +133,8 @@ const readLibraryClient = (client: Client): LibraryClient => ({
|
||||
// downloading the whole account — and no refresh starts as it opens or on a
|
||||
// timer: each command asks for the one it needs, and `collections` needs none
|
||||
// (issue #210). While a refresh runs, each collection whose files it has
|
||||
// fetched is counted on stderr, unless `json` is set, so a large account shows
|
||||
// progress rather than nothing.
|
||||
const openReadLibrary = (
|
||||
ctx: CliContext,
|
||||
client: Client,
|
||||
opts: { json?: boolean } = {},
|
||||
): Promise<Library> =>
|
||||
// fetched is logged, so a large account shows progress rather than nothing.
|
||||
const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
|
||||
Library.open({
|
||||
client: readLibraryClient(client),
|
||||
cacheDirectory: ctx.cacheDir,
|
||||
@@ -142,12 +142,7 @@ const openReadLibrary = (
|
||||
refreshIntervalSeconds: Infinity,
|
||||
precacheThumbnails: false,
|
||||
precacheOriginals: false,
|
||||
onProgress: (event) => {
|
||||
if (opts.json || event.status !== "progress") return;
|
||||
ctx.stderr.write(
|
||||
`Fetched the files of ${event.fetched} of ${event.total} collections\n`,
|
||||
);
|
||||
},
|
||||
onProgress: logRefresh(ctx.log),
|
||||
});
|
||||
|
||||
export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
||||
@@ -156,7 +151,7 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
||||
const password =
|
||||
process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
|
||||
|
||||
ctx.stderr.write("Authenticating...\n");
|
||||
ctx.log("info", "Authenticating...");
|
||||
try {
|
||||
const client = await ctx.login({
|
||||
email,
|
||||
@@ -167,11 +162,12 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
||||
|
||||
saveSession(ctx.sessionDir, client.toJSON());
|
||||
const info = client.whoami();
|
||||
ctx.stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`);
|
||||
ctx.stderr.write(`Session saved to ${sessionPath(ctx)}\n`);
|
||||
ctx.log("info", `Logged in as ${info.email} (user ${info.userID})`);
|
||||
ctx.log("info", `Session saved to ${sessionPath(ctx)}`);
|
||||
} catch (err) {
|
||||
ctx.stderr.write(
|
||||
`Login failed: ${err instanceof Error ? err.message : err}\n`,
|
||||
ctx.log(
|
||||
"fatal",
|
||||
`Login failed: ${err instanceof Error ? err.message : err}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -193,7 +189,7 @@ export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
|
||||
export const logoutCommand = async (ctx: CliContext): Promise<number> => {
|
||||
const path = sessionPath(ctx);
|
||||
if (!existsSync(path)) {
|
||||
ctx.stderr.write("No session found.\n");
|
||||
ctx.log("info", "No session found.");
|
||||
return 0;
|
||||
}
|
||||
await init();
|
||||
@@ -211,16 +207,15 @@ export const logoutCommand = async (ctx: CliContext): Promise<number> => {
|
||||
}
|
||||
unlinkSync(path);
|
||||
if (failure === undefined) {
|
||||
ctx.stderr.write("Session ended on the server.\n");
|
||||
ctx.log("info", "Session ended on the server.");
|
||||
} else {
|
||||
ctx.stderr.write(
|
||||
`Could not end the session on the server: ${failure}\n`,
|
||||
);
|
||||
ctx.log("fatal", `Could not end the session on the server: ${failure}`);
|
||||
}
|
||||
ctx.stderr.write("Session deleted.\n");
|
||||
ctx.log("info", "Session deleted.");
|
||||
if (cacheDir !== undefined) {
|
||||
ctx.stderr.write(
|
||||
`Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.\n`,
|
||||
ctx.log(
|
||||
"warning",
|
||||
`Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.`,
|
||||
);
|
||||
}
|
||||
return failure === undefined ? 0 : 1;
|
||||
@@ -285,7 +280,7 @@ export const filesCommand = async (
|
||||
: parseID(ctx, "collection", collectionIDStr);
|
||||
if (collectionIDStr !== undefined && collectionID === undefined) return 1;
|
||||
|
||||
const lib = await openReadLibrary(ctx, client, opts);
|
||||
const lib = await openReadLibrary(ctx, client);
|
||||
try {
|
||||
// Force a server round-trip and list in enumeration order (issue #36
|
||||
// amendment, issue #52). Each file prints from its own decrypted
|
||||
@@ -294,7 +289,7 @@ export const filesCommand = async (
|
||||
// newest-first order.
|
||||
const files = await freshFiles(lib, collectionID);
|
||||
if (!files) {
|
||||
ctx.stderr.write(`Collection ${collectionID} not found\n`);
|
||||
ctx.log("fatal", `Collection ${collectionID} not found`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -330,7 +325,7 @@ export const getCommand = async (
|
||||
// (issue #36 amendment, issue #52).
|
||||
const resolved = await freshFile(lib, fileID);
|
||||
if (!resolved) {
|
||||
ctx.stderr.write(`File ${fileID} not found\n`);
|
||||
ctx.log("fatal", `File ${fileID} not found`);
|
||||
return 1;
|
||||
}
|
||||
const { photo, file } = resolved;
|
||||
@@ -341,7 +336,7 @@ export const getCommand = async (
|
||||
if (result.videoPath === undefined) {
|
||||
const outPath = opts.out ?? originalName(file);
|
||||
copyFileSync(result.path, outPath);
|
||||
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
|
||||
ctx.log("info", `${result.bytes} bytes -> ${outPath}`);
|
||||
return 0;
|
||||
}
|
||||
// A live photo is written as its image and its video, each named after
|
||||
@@ -355,17 +350,16 @@ export const getCommand = async (
|
||||
? originalName(file, videoExt)
|
||||
: withExtension(opts.out, videoExt);
|
||||
if (imageOut.toLowerCase() === videoOut.toLowerCase()) {
|
||||
ctx.stderr.write(
|
||||
`File ${fileID} is a live photo, and its video would also be written to ${imageOut}\n`,
|
||||
ctx.log(
|
||||
"fatal",
|
||||
`File ${fileID} is a live photo, and its video would also be written to ${imageOut}`,
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
copyFileSync(result.path, imageOut);
|
||||
copyFileSync(result.videoPath, videoOut);
|
||||
ctx.stderr.write(
|
||||
`${result.bytes} bytes -> ${imageOut}\n` +
|
||||
`${statSync(videoOut).size} bytes -> ${videoOut}\n`,
|
||||
);
|
||||
ctx.log("info", `${result.bytes} bytes -> ${imageOut}`);
|
||||
ctx.log("info", `${statSync(videoOut).size} bytes -> ${videoOut}`);
|
||||
return 0;
|
||||
} finally {
|
||||
await lib.close();
|
||||
@@ -389,7 +383,7 @@ export const getThumbCommand = async (
|
||||
// (issue #36 amendment, issue #52).
|
||||
const resolved = await freshFile(lib, fileID);
|
||||
if (!resolved) {
|
||||
ctx.stderr.write(`File ${fileID} not found\n`);
|
||||
ctx.log("fatal", `File ${fileID} not found`);
|
||||
return 1;
|
||||
}
|
||||
const { photo, file } = resolved;
|
||||
@@ -399,7 +393,7 @@ export const getThumbCommand = async (
|
||||
// used (not the projection title) (issue #52).
|
||||
const outPath = opts.out ?? thumbnailName(file);
|
||||
copyFileSync(result.path, outPath);
|
||||
ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
|
||||
ctx.log("info", `${result.bytes} bytes -> ${outPath}`);
|
||||
return 0;
|
||||
} finally {
|
||||
await lib.close();
|
||||
@@ -421,9 +415,14 @@ export const backupMetadataCommand = async (
|
||||
await lib.fresh();
|
||||
const result = await runMetadataBackup(lib, client, dir, {
|
||||
exif: opts.exif || opts.all,
|
||||
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
|
||||
onProgress: (msg, level) => ctx.log(level ?? "info", msg),
|
||||
});
|
||||
return result.filesWithMLDataError > 0 ? 1 : 0;
|
||||
if (result.filesWithMLDataError === 0) return 0;
|
||||
ctx.log(
|
||||
"fatal",
|
||||
`quak: the ML data of ${result.filesWithMLDataError} file(s) could not be read; see mlDataError in their JSON in ${dir}`,
|
||||
);
|
||||
return 1;
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
@@ -438,7 +437,7 @@ export const backupCommand = async (
|
||||
const client = requireSession(ctx);
|
||||
if (!client) return 3;
|
||||
|
||||
ctx.stderr.write("Starting backup...\n");
|
||||
ctx.log("info", "Starting backup...");
|
||||
// The lock is taken before the library opens and starts its refresh, so a
|
||||
// run refused while another backup of `dir` runs sends no request.
|
||||
let lock: BackupLock;
|
||||
@@ -446,7 +445,7 @@ export const backupCommand = async (
|
||||
lock = await lockBackupDirectory(dir);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
|
||||
ctx.stderr.write(`quak: ${(err as Error).message}\n`);
|
||||
ctx.log("fatal", `quak: ${(err as Error).message}`);
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
@@ -461,45 +460,48 @@ export const backupCommand = async (
|
||||
refreshIntervalSeconds: Infinity,
|
||||
precacheThumbnails: false,
|
||||
precacheOriginals: false,
|
||||
onProgress: logRefresh(ctx.log),
|
||||
});
|
||||
try {
|
||||
const result = await lib.backup({
|
||||
downloadDirectory: dir,
|
||||
lock,
|
||||
verify: opts.verify,
|
||||
onProgress: (msg) => {
|
||||
if (!opts.json) ctx.stderr.write(msg + "\n");
|
||||
},
|
||||
onProgress: (msg, level) => ctx.log(level ?? "info", msg),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
ctx.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
||||
} else {
|
||||
ctx.stderr.write("\n--- Backup complete ---\n");
|
||||
ctx.stderr.write(` Total files: ${result.totalFiles}\n`);
|
||||
ctx.stderr.write(` Downloaded: ${result.downloaded}\n`);
|
||||
ctx.stderr.write(` Skipped: ${result.skipped}\n`);
|
||||
ctx.log("info", "\n--- Backup complete ---");
|
||||
ctx.log("info", ` Total files: ${result.totalFiles}`);
|
||||
ctx.log("info", ` Downloaded: ${result.downloaded}`);
|
||||
ctx.log("info", ` Skipped: ${result.skipped}`);
|
||||
if (opts.verify) {
|
||||
ctx.stderr.write(` Verified: ${result.verified}\n`);
|
||||
ctx.stderr.write(` Mismatched: ${result.mismatched}\n`);
|
||||
ctx.stderr.write(` Unchecked: ${result.unchecked}\n`);
|
||||
ctx.log("info", ` Verified: ${result.verified}`);
|
||||
ctx.log("info", ` Mismatched: ${result.mismatched}`);
|
||||
ctx.log("info", ` Unchecked: ${result.unchecked}`);
|
||||
}
|
||||
ctx.stderr.write(` Failed: ${result.failed}\n`);
|
||||
ctx.log("info", ` Failed: ${result.failed}`);
|
||||
if (result.errors.length > 0) {
|
||||
ctx.stderr.write("\nFailed files:\n");
|
||||
ctx.log("error", "\nFailed files:");
|
||||
for (const e of result.errors) {
|
||||
ctx.stderr.write(
|
||||
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`,
|
||||
ctx.log(
|
||||
"error",
|
||||
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.failed > 0 ||
|
||||
result.undecryptableAlbums.length > 0 ||
|
||||
result.failedAlbums.length > 0
|
||||
? 1
|
||||
: 0;
|
||||
const albums =
|
||||
result.undecryptableAlbums.length + result.failedAlbums.length;
|
||||
if (result.failed === 0 && albums === 0) return 0;
|
||||
ctx.log(
|
||||
"fatal",
|
||||
`quak: ${result.failed} file(s) and ${albums} album(s) failed; see ${join(dir, "failures.json")} and the FAILED lines`,
|
||||
);
|
||||
return 1;
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
@@ -515,23 +517,24 @@ export const listMissingThumbnailsCommand = async (
|
||||
await init();
|
||||
const client = requireSession(ctx);
|
||||
if (!client) return 3;
|
||||
const lib = await openReadLibrary(ctx, client, opts);
|
||||
const lib = await openReadLibrary(ctx, client);
|
||||
try {
|
||||
// Refresh first so files added since the cache was written are
|
||||
// checked; a failed refresh throws.
|
||||
await lib.fresh();
|
||||
const missing = await listMissingThumbnails(lib, client, (msg) => {
|
||||
if (!opts.json) ctx.stderr.write(msg + "\n");
|
||||
});
|
||||
const missing = await listMissingThumbnails(lib, client, (msg, level) =>
|
||||
ctx.log(level ?? "info", msg),
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
ctx.stdout.write(JSON.stringify(missing, null, 2) + "\n");
|
||||
} else {
|
||||
if (missing.length === 0) {
|
||||
ctx.stderr.write("No missing thumbnails found.\n");
|
||||
ctx.log("info", "No missing thumbnails found.");
|
||||
} else {
|
||||
ctx.stderr.write(
|
||||
`\n${missing.length} file(s) with missing thumbnails:\n`,
|
||||
ctx.log(
|
||||
"info",
|
||||
`\n${missing.length} file(s) with missing thumbnails:`,
|
||||
);
|
||||
for (const m of missing) {
|
||||
ctx.stdout.write(
|
||||
@@ -560,33 +563,34 @@ export const fixMissingThumbnailsCommand = async (
|
||||
if (fileID === undefined) return 1;
|
||||
fileIDs.push(fileID);
|
||||
}
|
||||
const lib = await openReadLibrary(ctx, client, opts);
|
||||
const lib = await openReadLibrary(ctx, client);
|
||||
try {
|
||||
// Refresh first so files added since the cache was written are found;
|
||||
// a failed refresh throws.
|
||||
await lib.fresh();
|
||||
if (fileIDs.length === 0) {
|
||||
ctx.stderr.write("Scanning for missing thumbnails...\n");
|
||||
const missing = await listMissingThumbnails(lib, client, (msg) => {
|
||||
if (!opts.json) ctx.stderr.write(msg + "\n");
|
||||
});
|
||||
ctx.log("info", "Scanning for missing thumbnails...");
|
||||
const missing = await listMissingThumbnails(
|
||||
lib,
|
||||
client,
|
||||
(msg, level) => ctx.log(level ?? "info", msg),
|
||||
);
|
||||
fileIDs = missing.map((m) => m.fileID);
|
||||
if (fileIDs.length === 0) {
|
||||
ctx.stderr.write("No missing thumbnails found.\n");
|
||||
ctx.log("info", "No missing thumbnails found.");
|
||||
return 0;
|
||||
}
|
||||
ctx.stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`);
|
||||
ctx.log("info", `Found ${fileIDs.length} file(s) to fix.`);
|
||||
}
|
||||
|
||||
const results = await fixMissingThumbnails(
|
||||
lib,
|
||||
client,
|
||||
fileIDs,
|
||||
(msg) => {
|
||||
if (!opts.json) ctx.stderr.write(msg + "\n");
|
||||
},
|
||||
(msg, level) => ctx.log(level ?? "info", msg),
|
||||
);
|
||||
|
||||
const failed = results.filter((r) => r.status === "failed").length;
|
||||
if (opts.json) {
|
||||
ctx.stdout.write(JSON.stringify(results, null, 2) + "\n");
|
||||
} else {
|
||||
@@ -594,30 +598,30 @@ export const fixMissingThumbnailsCommand = async (
|
||||
const skipped = results.filter(
|
||||
(r) => r.status === "skipped",
|
||||
).length;
|
||||
const failed = results.filter((r) => r.status === "failed").length;
|
||||
ctx.stderr.write(`\n--- Done ---\n`);
|
||||
ctx.stderr.write(` Fixed: ${fixed}\n`);
|
||||
ctx.stderr.write(` Skipped: ${skipped}\n`);
|
||||
ctx.stderr.write(` Failed: ${failed}\n`);
|
||||
ctx.log("info", `\n--- Done ---`);
|
||||
ctx.log("info", ` Fixed: ${fixed}`);
|
||||
ctx.log("info", ` Skipped: ${skipped}`);
|
||||
ctx.log("info", ` Failed: ${failed}`);
|
||||
if (skipped > 0) {
|
||||
ctx.stderr.write("\nSkipped:\n");
|
||||
ctx.log("info", "\nSkipped:");
|
||||
for (const r of results.filter((r) => r.status === "skipped")) {
|
||||
ctx.stderr.write(
|
||||
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
|
||||
);
|
||||
ctx.log("info", ` ${r.fileID}\t${r.title}\t${r.reason}`);
|
||||
}
|
||||
}
|
||||
if (failed > 0) {
|
||||
ctx.stderr.write("\nFailed files:\n");
|
||||
ctx.log("error", "\nFailed files:");
|
||||
for (const r of results.filter((r) => r.status === "failed")) {
|
||||
ctx.stderr.write(
|
||||
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
|
||||
);
|
||||
ctx.log("error", ` ${r.fileID}\t${r.title}\t${r.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results.some((r) => r.status === "failed") ? 1 : 0;
|
||||
if (failed === 0) return 0;
|
||||
ctx.log(
|
||||
"fatal",
|
||||
`quak: ${failed} file(s) failed; see the "Failed files:" list, or the JSON with --json`,
|
||||
);
|
||||
return 1;
|
||||
} finally {
|
||||
await lib.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// The CLI's log level, and the one function the commands write stderr lines
|
||||
// through.
|
||||
//
|
||||
// Every line quak prints on stderr is a log line at one of six levels, from
|
||||
// the quietest: fatal, error, warning, info, debug, trace. A run prints the
|
||||
// lines at its own level and at every quieter one. Its level is info unless
|
||||
// `-v` or `-q` asks for another (see "CLI surface" in the README). Every
|
||||
// command that exits non-zero logs a fatal line saying why, so even `-q`, which
|
||||
// prints only fatal lines, never leaves a non-zero exit unexplained. An error
|
||||
// line reports something that failed, such as a file or an album, and a
|
||||
// warning something that did not fail but needs the user's attention. stdout
|
||||
// carries only a command's output and is not a log.
|
||||
//
|
||||
// The library itself writes no lines: its layers report through callbacks, and
|
||||
// the CLI logs what they report. `logRequests` gives the API client options
|
||||
// that log each request and each retry, and `logRefresh` the library's
|
||||
// `onProgress` that logs what a refresh and an ML data fetch report.
|
||||
|
||||
import type { ApiClientOptions } from "./api/client.js";
|
||||
import { describeError } from "./errors.js";
|
||||
import type { RefreshEvent } from "./library/index.js";
|
||||
import type { RetryOptions } from "./retry.js";
|
||||
|
||||
const LEVELS = ["fatal", "error", "warning", "info", "debug", "trace"] as const;
|
||||
|
||||
export type LogLevel = (typeof LEVELS)[number];
|
||||
|
||||
// Writes `line` on stderr when the run's level prints lines at `level`.
|
||||
export type Log = (level: LogLevel, line: string) => void;
|
||||
|
||||
// The level asked for by `-v` given `verbose` times, or by `-q`: info without
|
||||
// either, debug for `-v`, trace for `-vv` and more, fatal for `-q`.
|
||||
// `bin/quak.ts` refuses `-q` and `-v` together.
|
||||
export const logLevel = (verbose: number, quiet: boolean): LogLevel => {
|
||||
if (quiet) return "fatal";
|
||||
if (verbose === 0) return "info";
|
||||
return verbose === 1 ? "debug" : "trace";
|
||||
};
|
||||
|
||||
// The log that writes each line at `level` or quieter to `stderr`, ending it
|
||||
// with a newline.
|
||||
export const stderrLog =
|
||||
(stderr: { write(text: string): unknown }, level: LogLevel): Log =>
|
||||
(lineLevel, line) => {
|
||||
if (LEVELS.indexOf(lineLevel) <= LEVELS.indexOf(level)) {
|
||||
stderr.write(line + "\n");
|
||||
}
|
||||
};
|
||||
|
||||
// API client options that log each request at trace, with the status of its
|
||||
// response or the error it failed with, and each retry at debug, with why and
|
||||
// how long it waits. `retry` is the retry policy, the default when not given.
|
||||
export const logRequests = (
|
||||
log: Log,
|
||||
retry?: RetryOptions,
|
||||
): ApiClientOptions => ({
|
||||
retry: {
|
||||
...retry,
|
||||
onRetry: (err, attempt, delayMs) =>
|
||||
log(
|
||||
"debug",
|
||||
`Attempt ${attempt} failed, retrying in ${Math.round(delayMs)} ms: ${describeError(err)}`,
|
||||
),
|
||||
},
|
||||
onRequest: ({ method, url, status, error }) =>
|
||||
log(
|
||||
"trace",
|
||||
`${method} ${url}: ${error === undefined ? `HTTP ${status}` : describeError(error)}`,
|
||||
),
|
||||
});
|
||||
|
||||
// How a log line names each operation the library reports the start and end
|
||||
// of.
|
||||
const OPERATION_NAMES: Record<RefreshEvent["operation"], string> = {
|
||||
refresh: "Refresh",
|
||||
fetchMLData: "ML data fetch",
|
||||
precacheThumbnails: "Thumbnail precache",
|
||||
precacheOriginals: "Original precache",
|
||||
};
|
||||
|
||||
// A library `onProgress` that logs, at info, each collection whose files a
|
||||
// refresh has fetched, and at debug the start and end of each refresh and ML
|
||||
// data fetch, with the error of one that failed.
|
||||
export const logRefresh =
|
||||
(log: Log) =>
|
||||
(event: RefreshEvent): void => {
|
||||
if (event.status === "progress") {
|
||||
log(
|
||||
"info",
|
||||
`Fetched the files of ${event.fetched} of ${event.total} collections`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const error = event.error === undefined ? "" : `: ${event.error}`;
|
||||
log(
|
||||
"debug",
|
||||
`${OPERATION_NAMES[event.operation]} ${event.status}${error}`,
|
||||
);
|
||||
};
|
||||
+12
-9
@@ -2,21 +2,23 @@
|
||||
|
||||
import type { Writable } from "node:stream";
|
||||
import { ApiError } from "./api/client.js";
|
||||
import type { Log } from "./cli-log.js";
|
||||
import { describeError } from "./errors.js";
|
||||
|
||||
// Run a command and exit with its code once stdout/stderr have drained.
|
||||
// Exiting before the drain can truncate piped output, and the library can keep
|
||||
// the event loop alive after a command returns, so a plain return could hang.
|
||||
// An error the command throws is printed as one `quak: MESSAGE` line, without
|
||||
// the stack trace, and exits 1. MESSAGE is the error as `describeError` gives
|
||||
// it, so it carries the reason Node's `fetch` puts on `cause`. A 401 from the
|
||||
// server means it no longer accepts the saved session: that prints one line
|
||||
// saying to log in again and exits 3, as a missing or corrupt session file
|
||||
// does.
|
||||
// An error the command throws is logged as one fatal `quak: MESSAGE` line,
|
||||
// without the stack trace, and exits 1. MESSAGE is the error as
|
||||
// `describeError` gives it, so it carries the reason Node's `fetch` puts on
|
||||
// `cause`. A 401 from the server means it no longer accepts the saved session:
|
||||
// that logs one fatal line saying to log in again and exits 3, as a missing or
|
||||
// corrupt session file does.
|
||||
export const run = async (
|
||||
command: Promise<number>,
|
||||
stdout: Writable,
|
||||
stderr: Writable,
|
||||
log: Log,
|
||||
exit: (code: number) => void,
|
||||
): Promise<void> => {
|
||||
let code: number;
|
||||
@@ -24,12 +26,13 @@ export const run = async (
|
||||
code = await command;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
stderr.write(
|
||||
`quak: the saved session is no longer valid; run "quak login"\n`,
|
||||
log(
|
||||
"fatal",
|
||||
`quak: the saved session is no longer valid; run "quak login"`,
|
||||
);
|
||||
code = 3;
|
||||
} else {
|
||||
stderr.write(`quak: ${describeError(err)}\n`);
|
||||
log("fatal", `quak: ${describeError(err)}`);
|
||||
code = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
} from "./mldata-fetch.js";
|
||||
import type { EnteFile } from "./model/types.js";
|
||||
|
||||
export type ProgressCallback = (message: string) => void;
|
||||
// Reports one line of the dump's progress. `level` is "error" when the line
|
||||
// reports ML data that could not be read, and is left out for every other
|
||||
// line.
|
||||
export type ProgressCallback = (message: string, level?: "error") => void;
|
||||
|
||||
export interface MetadataBackupOptions {
|
||||
exif?: boolean;
|
||||
@@ -195,13 +198,17 @@ export const runMetadataBackup = async (
|
||||
for (const [id, payload] of payloads) mlDataMap.set(id, payload);
|
||||
for (const [id, err] of errors) {
|
||||
const reason = describeError(err);
|
||||
log(`ML data of file ${id} could not be read: ${reason}`);
|
||||
log(
|
||||
`ML data of file ${id} could not be read: ${reason}`,
|
||||
"error",
|
||||
);
|
||||
mlDataErrors.set(id, reason);
|
||||
}
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
log(
|
||||
`ML data request for ${batch.length} file(s) failed: ${reason}`,
|
||||
"error",
|
||||
);
|
||||
for (const id of batch) mlDataErrors.set(id, reason);
|
||||
}
|
||||
|
||||
+8
-1
@@ -24,6 +24,9 @@ export interface RetryOptions {
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
// Injected so the jitter is reproducible under test.
|
||||
random?: () => number;
|
||||
// Called before each wait with the error that failed attempt number
|
||||
// `attempt` and how long the wait is, for a caller that logs retries.
|
||||
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
|
||||
}
|
||||
|
||||
export type ResolvedRetryOptions = Required<RetryOptions>;
|
||||
@@ -35,6 +38,7 @@ export const DEFAULT_RETRY_OPTIONS: ResolvedRetryOptions = {
|
||||
sleep: (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
random: Math.random,
|
||||
onRetry: () => {},
|
||||
};
|
||||
|
||||
// For a run nobody is watching, such as `quak backup` from cron. A request that
|
||||
@@ -55,6 +59,7 @@ export const resolveRetryOptions = (
|
||||
maxDelayMs: opts?.maxDelayMs ?? DEFAULT_RETRY_OPTIONS.maxDelayMs,
|
||||
sleep: opts?.sleep ?? DEFAULT_RETRY_OPTIONS.sleep,
|
||||
random: opts?.random ?? DEFAULT_RETRY_OPTIONS.random,
|
||||
onRetry: opts?.onRetry ?? DEFAULT_RETRY_OPTIONS.onRetry,
|
||||
});
|
||||
|
||||
// Transport failures: the request did not complete, for reasons below HTTP.
|
||||
@@ -234,7 +239,9 @@ export const withRetry = async <T>(
|
||||
// and its status intact.
|
||||
throw err;
|
||||
}
|
||||
await policy.sleep(backoffMs(attempt, policy));
|
||||
const delayMs = backoffMs(attempt, policy);
|
||||
policy.onRetry(err, attempt, delayMs);
|
||||
await policy.sleep(delayMs);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+6
-1
@@ -49,7 +49,10 @@ export interface ThumbnailFixResult {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type ProgressCallback = (message: string) => void;
|
||||
// Reports one line of progress. `level` is "error" when the line reports a
|
||||
// file that could not be checked or fixed, and is left out for every other
|
||||
// line.
|
||||
export type ProgressCallback = (message: string, level?: "error") => 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
|
||||
@@ -111,6 +114,7 @@ export const listMissingThumbnails = async (
|
||||
} else {
|
||||
log(
|
||||
`[${album.name}] Could not check ${photo.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -359,6 +363,7 @@ export const fixMissingThumbnails = async (
|
||||
} catch (err) {
|
||||
log(
|
||||
`[${collectionName}] FAILED ${title}: ${err instanceof Error ? err.message : err}`,
|
||||
"error",
|
||||
);
|
||||
results.push({
|
||||
fileID,
|
||||
|
||||
Reference in New Issue
Block a user