Compare commits
2
Commits
4c325edc9b
...
2e885d54e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e885d54e5 | ||
|
|
6757ddea94 |
@@ -449,10 +449,13 @@ quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbn
|
||||
```
|
||||
|
||||
Every command runs on the same cache-backed library. The read commands —
|
||||
`collections`, `files`, `get`, and `get-thumb` — force a fresh server round-trip
|
||||
before they answer, so they report current account state rather than whatever
|
||||
the cache last held. `--cache-dir` overrides where the cache lives; without it
|
||||
each account gets its own directory under the per-user cache path.
|
||||
`collections`, `files`, `get`, `get-thumb`, `backup-metadata`,
|
||||
`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` — force a
|
||||
fresh server round-trip before they answer, so they report current account state
|
||||
rather than whatever the cache last held. If that round-trip fails, the command
|
||||
prints the error on one line and exits 1. `--cache-dir` overrides where the
|
||||
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. `backup-metadata --exif` (alias
|
||||
|
||||
@@ -18,6 +18,20 @@ Tag v1.0.0.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-23: `backup-metadata`, `helper list-missing-thumbnails` and
|
||||
`helper fix-missing-thumbnails` refresh before they answer (issue 100). Each
|
||||
awaits `lib.fresh()` before reading, so a file added since the cache was
|
||||
written is included, and a failed refresh prints one line and exits 1 instead
|
||||
of answering from a stale or empty cache. The README lists them among the
|
||||
commands that refresh first.
|
||||
|
||||
- 2026-09-23: `quak backup` waits for the server refresh and fails when it fails
|
||||
(issue 99). `lib.backup()` joins a refresh already running or starts one, as
|
||||
`fresh()` does, and rejects before touching any file when it fails, leaving
|
||||
`failures.json` as it was, so `quak backup` prints the error as one line and
|
||||
exits 1 instead of backing up the previous run's file list, or nothing, and
|
||||
exiting 0.
|
||||
|
||||
- 2026-09-23: CLI errors print a message instead of a stack trace (issue 102).
|
||||
An error a command throws is printed as one `quak: MESSAGE` line on stderr and
|
||||
the CLI exits 1 once output has drained. The wrapper that does this moved from
|
||||
|
||||
+5
-4
@@ -1,9 +1,10 @@
|
||||
// The backup command, rebuilt on the library API (issue #51).
|
||||
//
|
||||
// `lib.backup()` refreshes the library, then, for every file in scope, gets its
|
||||
// original bytes onto disk under `downloadDirectory` and rebuilds the derived
|
||||
// views (per-file sidecars, per-collection symlink trees, per-collection JSON)
|
||||
// from the model. The on-disk layout is the historical one, unchanged:
|
||||
// `lib.backup()` waits for a completed refresh of the library (a failed one
|
||||
// fails the backup before any file is touched), then, for every file in scope,
|
||||
// gets its original bytes onto disk under `downloadDirectory` and rebuilds the
|
||||
// derived views (per-file sidecars, per-collection symlink trees,
|
||||
// per-collection JSON) from the model. The on-disk layout is the historical one, unchanged:
|
||||
//
|
||||
// <downloadDirectory>/
|
||||
// originals/<fileID>.<ext> the decrypted bytes
|
||||
|
||||
@@ -357,6 +357,9 @@ export const backupMetadataCommand = async (
|
||||
if (!client) return 1;
|
||||
const lib = await openReadLibrary(ctx, client);
|
||||
try {
|
||||
// Refresh first so the dump holds current account state, not what the
|
||||
// cache last held; a failed refresh throws.
|
||||
await lib.fresh();
|
||||
const { failedMLBatches } = await runMetadataBackup(lib, client, dir, {
|
||||
exif: opts.exif || opts.all,
|
||||
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
|
||||
@@ -423,6 +426,9 @@ export const listMissingThumbnailsCommand = async (
|
||||
if (!client) return 1;
|
||||
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");
|
||||
});
|
||||
@@ -458,6 +464,9 @@ export const fixMissingThumbnailsCommand = async (
|
||||
if (!client) return 1;
|
||||
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();
|
||||
let fileIDs: number[];
|
||||
if (opts.file && opts.file.length > 0) {
|
||||
fileIDs = opts.file.map(Number).filter(Number.isFinite);
|
||||
|
||||
@@ -538,11 +538,13 @@ export class Library {
|
||||
}
|
||||
|
||||
// Back up every in-scope file to `downloadDirectory` in the historical
|
||||
// on-disk layout, with a durable failure ledger (issue #51). Refreshes
|
||||
// first, fetches pending originals (and optional thumbnails) through the
|
||||
// content cache and pools, then rebuilds the derived symlink/JSON views
|
||||
// from the model. Throws before any network work when no download directory
|
||||
// is available or no content cache backs the originals it must fetch.
|
||||
// on-disk layout, with a durable failure ledger (issue #51). Waits for a
|
||||
// completed refresh first, as `fresh()` does, joining one already running,
|
||||
// and rejects before touching any file when it fails. Then fetches pending
|
||||
// originals (and optional thumbnails) through the content cache and pools,
|
||||
// and rebuilds the derived symlink/JSON views from the model. Throws before
|
||||
// any network work when no download directory is available or no content
|
||||
// cache backs the originals it must fetch.
|
||||
backup(opts?: BackupOptions): Promise<BackupResult> {
|
||||
const downloadDirectory =
|
||||
opts?.downloadDirectory ?? this.downloadDirectory;
|
||||
@@ -566,7 +568,7 @@ export class Library {
|
||||
const cache = this.cache;
|
||||
return runBackup(
|
||||
{
|
||||
refresh: () => this.runRefresh(),
|
||||
refresh: () => this.refreshNow(),
|
||||
listCollections: () => this.store.listCollections(),
|
||||
listFiles: (id) => this.store.listFiles(id),
|
||||
original: (fileID) => cache!.original(fileID),
|
||||
|
||||
@@ -140,8 +140,8 @@ const extractExif = async (
|
||||
// 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. Returns how many ML data requests failed; their files are still
|
||||
// and files are enumerated from the library's cache, which the caller refreshes
|
||||
// first. Returns how many ML data requests failed; their files are still
|
||||
// written, with `mlDataError` in place of `mlData`.
|
||||
export const runMetadataBackup = async (
|
||||
lib: Library,
|
||||
|
||||
@@ -625,6 +625,93 @@ describe("lib.backup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Every refresh fails, as with an expired session or no network.
|
||||
class FailingClient extends MockClient {
|
||||
override async collectionsSince(): Promise<CollectionsPage> {
|
||||
throw new Error("HTTP 401 from server");
|
||||
}
|
||||
}
|
||||
|
||||
// Holds its refresh open until `release()` is called, then reports a third
|
||||
// album, so a backup can be started while that refresh is still running.
|
||||
class HeldClient extends MockClient {
|
||||
release!: () => void;
|
||||
private held = new Promise<void>((resolve) => {
|
||||
this.release = resolve;
|
||||
});
|
||||
override async collectionsSince(): Promise<CollectionsPage> {
|
||||
await this.held;
|
||||
return {
|
||||
collections: [collection(3, "Later")],
|
||||
deleted: [],
|
||||
cursor: 2,
|
||||
};
|
||||
}
|
||||
override async filesSince(args: {
|
||||
collectionID: number;
|
||||
}): Promise<FilesPage> {
|
||||
if (args.collectionID !== 3) return super.filesSince(args);
|
||||
return { files: [file(300, 3, "late.jpg")], deleted: [], cursor: 2 };
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the library cache on disk, so the next open starts its refresh in the
|
||||
// background instead of waiting for it.
|
||||
const fillCache = async (): Promise<void> => {
|
||||
const lib = await openLibrary(stubSource());
|
||||
await lib.close();
|
||||
};
|
||||
|
||||
describe("the refresh before a backup", () => {
|
||||
it("waits for a refresh already running and backs up what it found", async () => {
|
||||
await fillCache();
|
||||
const client = new HeldClient();
|
||||
const lib = await openLibrary(stubSource(), client);
|
||||
const outDir = join(root, "backup");
|
||||
|
||||
const backup = lib.backup({ downloadDirectory: outDir });
|
||||
client.release();
|
||||
const result = await backup;
|
||||
|
||||
expect(result.totalFiles).toBe(4);
|
||||
expect(existsSync(join(outDir, "originals", "300.jpg"))).toBe(true);
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
it("fails before any download when the refresh fails, leaving failures.json as it was", async () => {
|
||||
await fillCache();
|
||||
const outDir = join(root, "backup");
|
||||
seedLedger(outDir, 100, "beach.jpg");
|
||||
const ledgerPath = join(outDir, "failures.json");
|
||||
const ledgerBefore = readFileSync(ledgerPath, "utf-8");
|
||||
const source = stubSource();
|
||||
const lib = await openLibrary(source, new FailingClient());
|
||||
|
||||
await expect(lib.backup({ downloadDirectory: outDir })).rejects.toThrow(
|
||||
"HTTP 401 from server",
|
||||
);
|
||||
|
||||
expect(source.originalCalls).toBe(0);
|
||||
expect(readFileSync(ledgerPath, "utf-8")).toBe(ledgerBefore);
|
||||
expect(existsSync(join(outDir, "originals"))).toBe(false);
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
it("fails when the refresh fails on an empty cache, instead of backing up nothing", async () => {
|
||||
const source = stubSource();
|
||||
const lib = await openLibrary(source, new FailingClient());
|
||||
const outDir = join(root, "backup");
|
||||
|
||||
await expect(lib.backup({ downloadDirectory: outDir })).rejects.toThrow(
|
||||
"HTTP 401 from server",
|
||||
);
|
||||
|
||||
expect(source.originalCalls).toBe(0);
|
||||
expect(existsSync(outDir)).toBe(false);
|
||||
await lib.close();
|
||||
});
|
||||
});
|
||||
|
||||
// The album folders under collections/, driven through `runBackup` with a
|
||||
// stand-in library whose albums a test changes between runs.
|
||||
describe("backup album folders", () => {
|
||||
|
||||
+122
-8
@@ -20,6 +20,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -32,8 +33,11 @@ import {
|
||||
getCommand,
|
||||
getThumbCommand,
|
||||
backupCommand,
|
||||
backupMetadataCommand,
|
||||
listMissingThumbnailsCommand,
|
||||
fixMissingThumbnailsCommand,
|
||||
} from "../../src/cli-commands.js";
|
||||
import { run } from "../../src/cli-run.js";
|
||||
import { loadSession } from "../../src/cli-session.js";
|
||||
import type { Client, ClientSnapshot } from "../../src/client.js";
|
||||
import type { ContentSource } from "../../src/library/content.js";
|
||||
@@ -82,8 +86,26 @@ const FILES: Record<number, EnteFile[]> = {
|
||||
|
||||
// An original is 7 bytes and a thumbnail 3. `failID` makes that file's
|
||||
// original fail; `emptyThumbID` makes the server report that file's
|
||||
// thumbnail as empty.
|
||||
const fakeClient = (opts: { failID?: number; emptyThumbID?: number } = {}) => {
|
||||
// thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing
|
||||
// the collection's updationTime as the server does, and `refreshError` makes
|
||||
// listing collections fail with that message.
|
||||
const fakeClient = (
|
||||
opts: {
|
||||
failID?: number;
|
||||
emptyThumbID?: number;
|
||||
withNewFile?: boolean;
|
||||
refreshError?: string;
|
||||
} = {},
|
||||
) => {
|
||||
const collections = opts.withNewFile
|
||||
? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]]
|
||||
: COLLECTIONS;
|
||||
const files = opts.withNewFile
|
||||
? {
|
||||
...FILES,
|
||||
1: [...FILES[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }],
|
||||
}
|
||||
: FILES;
|
||||
const source: ContentSource = {
|
||||
original: async ({ file: f, destination }) => {
|
||||
if (f.id === opts.failID) throw new Error("HTTP 500 from server");
|
||||
@@ -97,18 +119,19 @@ const fakeClient = (opts: { failID?: number; emptyThumbID?: number } = {}) => {
|
||||
};
|
||||
const fake = {
|
||||
whoami: () => ({ email: "cli@example.com", userID: USER_ID }),
|
||||
collectionsSince: async () => ({
|
||||
collections: COLLECTIONS,
|
||||
deleted: [],
|
||||
cursor: 1,
|
||||
}),
|
||||
collectionsSince: async () => {
|
||||
if (opts.refreshError) throw new Error(opts.refreshError);
|
||||
return { collections, deleted: [], cursor: 1 };
|
||||
},
|
||||
filesSince: async (args: { collectionID: number }) => ({
|
||||
files: FILES[args.collectionID] ?? [],
|
||||
files: files[args.collectionID] ?? [],
|
||||
deleted: [],
|
||||
cursor: 1,
|
||||
}),
|
||||
contentSource: () => source,
|
||||
getApiClient: () => ({
|
||||
// The ML data request of `backup-metadata`: no file has any.
|
||||
postJSON: async () => ({ data: [] }),
|
||||
getThumbnailStream: async (fileID: number) =>
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
@@ -430,6 +453,34 @@ describe("backup", () => {
|
||||
expect(result.errors[0].fileID).toBe(101);
|
||||
expect(stderr.text).toBe("Starting backup...\n");
|
||||
});
|
||||
|
||||
it("exits 1 with the error on one line when the refresh fails", async () => {
|
||||
const client = {
|
||||
...fakeClient(),
|
||||
collectionsSince: async () => {
|
||||
throw new Error("HTTP 401 from server");
|
||||
},
|
||||
} as unknown as Client;
|
||||
const dir = join(root, "backup");
|
||||
// Through `run`, as `bin/quak.ts` does, which prints a thrown error.
|
||||
const runStderr = new PassThrough();
|
||||
let runText = "";
|
||||
runStderr.on("data", (chunk: Buffer) => {
|
||||
runText += chunk.toString();
|
||||
});
|
||||
const code = await new Promise<number>((resolve) => {
|
||||
void run(
|
||||
backupCommand(context(client), dir, {}),
|
||||
new PassThrough(),
|
||||
runStderr,
|
||||
resolve,
|
||||
);
|
||||
});
|
||||
expect(code).toBe(1);
|
||||
expect(runText).toBe("quak: HTTP 401 from server\n");
|
||||
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
|
||||
expect(existsSync(join(dir, "originals"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("helper list-missing-thumbnails", () => {
|
||||
@@ -462,3 +513,66 @@ describe("helper list-missing-thumbnails", () => {
|
||||
expect(stderr.text).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// Each test first runs `collections` so the cache holds the account as it was,
|
||||
// then changes the server under it.
|
||||
describe("backup-metadata and the thumbnail helpers refresh first", () => {
|
||||
beforeEach(async () => {
|
||||
expect(await collectionsCommand(context(), {})).toBe(0);
|
||||
stdout.text = "";
|
||||
stderr.text = "";
|
||||
});
|
||||
|
||||
it("backup-metadata writes a file added since the cache was written", async () => {
|
||||
const ctx = context(fakeClient({ withNewFile: true }));
|
||||
const dir = join(root, "dump");
|
||||
expect(await backupMetadataCommand(ctx, dir, {})).toBe(0);
|
||||
expect(
|
||||
existsSync(join(dir, "collections", "1-Vacation", "102.json")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("list-missing-thumbnails checks a file added since the cache was written", async () => {
|
||||
const ctx = context(
|
||||
fakeClient({ withNewFile: true, emptyThumbID: 102 }),
|
||||
);
|
||||
expect(await listMissingThumbnailsCommand(ctx, {})).toBe(0);
|
||||
expect(stdout.text).toBe(
|
||||
"102\tnew.jpg\tVacation\tempty thumbnail (0 bytes)\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("fix-missing-thumbnails finds a file added since the cache was written", async () => {
|
||||
const ctx = context(fakeClient({ withNewFile: true }));
|
||||
expect(
|
||||
await fixMissingThumbnailsCommand(ctx, {
|
||||
file: ["102"],
|
||||
json: true,
|
||||
}),
|
||||
).toBe(0);
|
||||
// Found, then skipped because the server records no thumbnail size
|
||||
// for it; a file missing from the cache would fail as not found.
|
||||
expect(JSON.parse(stdout.text)).toMatchObject([
|
||||
{ fileID: 102, title: "new.jpg", status: "skipped" },
|
||||
]);
|
||||
});
|
||||
|
||||
// `run` in `cli-run.ts` prints a thrown error as one line and exits 1.
|
||||
it("all three throw when the refresh fails", async () => {
|
||||
const ctx = context(
|
||||
fakeClient({ refreshError: "HTTP 503 from server" }),
|
||||
);
|
||||
const dir = join(root, "dump");
|
||||
await expect(backupMetadataCommand(ctx, dir, {})).rejects.toThrow(
|
||||
"HTTP 503 from server",
|
||||
);
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
await expect(listMissingThumbnailsCommand(ctx, {})).rejects.toThrow(
|
||||
"HTTP 503 from server",
|
||||
);
|
||||
await expect(
|
||||
fixMissingThumbnailsCommand(ctx, { file: ["100"] }),
|
||||
).rejects.toThrow("HTTP 503 from server");
|
||||
expect(stdout.text).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user