Compare commits
1
Commits
next
...
2e885d54e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e885d54e5 |
@@ -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 —
|
Every command runs on the same cache-backed library. The read commands —
|
||||||
`collections`, `files`, `get`, and `get-thumb` — force a fresh server round-trip
|
`collections`, `files`, `get`, `get-thumb`, `backup-metadata`,
|
||||||
before they answer, so they report current account state rather than whatever
|
`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` — force a
|
||||||
the cache last held. `--cache-dir` overrides where the cache lives; without it
|
fresh server round-trip before they answer, so they report current account state
|
||||||
each account gets its own directory under the per-user cache path.
|
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
|
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is
|
||||||
accepted for backward compatibility but ignored. `backup-metadata --exif` (alias
|
accepted for backward compatibility but ignored. `backup-metadata --exif` (alias
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ Tag v1.0.0.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
(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
|
`fresh()` does, and rejects before touching any file when it fails, leaving
|
||||||
|
|||||||
@@ -357,6 +357,9 @@ export const backupMetadataCommand = async (
|
|||||||
if (!client) return 1;
|
if (!client) return 1;
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client);
|
||||||
try {
|
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, {
|
const { failedMLBatches } = await runMetadataBackup(lib, client, dir, {
|
||||||
exif: opts.exif || opts.all,
|
exif: opts.exif || opts.all,
|
||||||
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
|
onProgress: (msg) => ctx.stderr.write(msg + "\n"),
|
||||||
@@ -423,6 +426,9 @@ export const listMissingThumbnailsCommand = async (
|
|||||||
if (!client) return 1;
|
if (!client) return 1;
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client);
|
||||||
try {
|
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) => {
|
const missing = await listMissingThumbnails(lib, client, (msg) => {
|
||||||
if (!opts.json) ctx.stderr.write(msg + "\n");
|
if (!opts.json) ctx.stderr.write(msg + "\n");
|
||||||
});
|
});
|
||||||
@@ -458,6 +464,9 @@ export const fixMissingThumbnailsCommand = async (
|
|||||||
if (!client) return 1;
|
if (!client) return 1;
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client);
|
||||||
try {
|
try {
|
||||||
|
// Refresh first so files added since the cache was written are found;
|
||||||
|
// a failed refresh throws.
|
||||||
|
await lib.fresh();
|
||||||
let fileIDs: number[];
|
let fileIDs: number[];
|
||||||
if (opts.file && opts.file.length > 0) {
|
if (opts.file && opts.file.length > 0) {
|
||||||
fileIDs = opts.file.map(Number).filter(Number.isFinite);
|
fileIDs = opts.file.map(Number).filter(Number.isFinite);
|
||||||
|
|||||||
@@ -140,8 +140,8 @@ const extractExif = async (
|
|||||||
// Dump every decrypted metadata layer the account holds into a directory tree
|
// Dump every decrypted metadata layer the account holds into a directory tree
|
||||||
// of plain JSON: account, per-collection, and per-file records including the
|
// of plain JSON: account, per-collection, and per-file records including the
|
||||||
// private and public magic metadata and (by default) the ML data. Collections
|
// 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
|
// and files are enumerated from the library's cache, which the caller refreshes
|
||||||
// scan. Returns how many ML data requests failed; their files are still
|
// first. Returns how many ML data requests failed; their files are still
|
||||||
// written, with `mlDataError` in place of `mlData`.
|
// written, with `mlDataError` in place of `mlData`.
|
||||||
export const runMetadataBackup = async (
|
export const runMetadataBackup = async (
|
||||||
lib: Library,
|
lib: Library,
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ import {
|
|||||||
getCommand,
|
getCommand,
|
||||||
getThumbCommand,
|
getThumbCommand,
|
||||||
backupCommand,
|
backupCommand,
|
||||||
|
backupMetadataCommand,
|
||||||
listMissingThumbnailsCommand,
|
listMissingThumbnailsCommand,
|
||||||
|
fixMissingThumbnailsCommand,
|
||||||
} from "../../src/cli-commands.js";
|
} from "../../src/cli-commands.js";
|
||||||
import { run } from "../../src/cli-run.js";
|
import { run } from "../../src/cli-run.js";
|
||||||
import { loadSession } from "../../src/cli-session.js";
|
import { loadSession } from "../../src/cli-session.js";
|
||||||
@@ -84,8 +86,26 @@ const FILES: Record<number, EnteFile[]> = {
|
|||||||
|
|
||||||
// An original is 7 bytes and a thumbnail 3. `failID` makes that file's
|
// An original is 7 bytes and a thumbnail 3. `failID` makes that file's
|
||||||
// original fail; `emptyThumbID` makes the server report that file's
|
// original fail; `emptyThumbID` makes the server report that file's
|
||||||
// thumbnail as empty.
|
// thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing
|
||||||
const fakeClient = (opts: { failID?: number; emptyThumbID?: number } = {}) => {
|
// 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 = {
|
const source: ContentSource = {
|
||||||
original: async ({ file: f, destination }) => {
|
original: async ({ file: f, destination }) => {
|
||||||
if (f.id === opts.failID) throw new Error("HTTP 500 from server");
|
if (f.id === opts.failID) throw new Error("HTTP 500 from server");
|
||||||
@@ -99,18 +119,19 @@ const fakeClient = (opts: { failID?: number; emptyThumbID?: number } = {}) => {
|
|||||||
};
|
};
|
||||||
const fake = {
|
const fake = {
|
||||||
whoami: () => ({ email: "cli@example.com", userID: USER_ID }),
|
whoami: () => ({ email: "cli@example.com", userID: USER_ID }),
|
||||||
collectionsSince: async () => ({
|
collectionsSince: async () => {
|
||||||
collections: COLLECTIONS,
|
if (opts.refreshError) throw new Error(opts.refreshError);
|
||||||
deleted: [],
|
return { collections, deleted: [], cursor: 1 };
|
||||||
cursor: 1,
|
},
|
||||||
}),
|
|
||||||
filesSince: async (args: { collectionID: number }) => ({
|
filesSince: async (args: { collectionID: number }) => ({
|
||||||
files: FILES[args.collectionID] ?? [],
|
files: files[args.collectionID] ?? [],
|
||||||
deleted: [],
|
deleted: [],
|
||||||
cursor: 1,
|
cursor: 1,
|
||||||
}),
|
}),
|
||||||
contentSource: () => source,
|
contentSource: () => source,
|
||||||
getApiClient: () => ({
|
getApiClient: () => ({
|
||||||
|
// The ML data request of `backup-metadata`: no file has any.
|
||||||
|
postJSON: async () => ({ data: [] }),
|
||||||
getThumbnailStream: async (fileID: number) =>
|
getThumbnailStream: async (fileID: number) =>
|
||||||
new ReadableStream<Uint8Array>({
|
new ReadableStream<Uint8Array>({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
@@ -492,3 +513,66 @@ describe("helper list-missing-thumbnails", () => {
|
|||||||
expect(stderr.text).toBe("");
|
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