quak helper fix-missing-thumbnails --file on an empty cache stops fetching collections once it finds every file (closes #223)
check / check (push) Waiting to run

`helper fix-missing-thumbnails --file` used to wait for a refresh, which
on an empty cache fetched the files of every collection. It now finds the
named files through the new `freshFilesByID` in `src/cli-read.ts`, by the
rule `freshFile` follows. On an empty cache it reads the collection list,
then one collection's files at a time, and stores none of the collections
or file records it reads. It searches the most recently updated
collection first. So a file in several albums is still named after the
first of them that `Library.albums.list()` lists. `fixMissingThumbnails`
now takes the records the command found, and fetches each original
through `lib.originalOf()`.

Model: opus-5-5
This commit is contained in:
2026-10-26 10:05:17 +00:00
parent f65c237e2c
commit 631bd5d4bf
9 changed files with 322 additions and 70 deletions
+101 -6
View File
@@ -1353,6 +1353,23 @@ describe("helper fix-missing-thumbnails", () => {
).toBe(1);
expect(stderr.text).toBe(last);
});
// beach.jpg (100) is in Vacation (1) and Work (2), both updated at the same
// time. The fixer names a file after the first album holding it in the
// order of `Library.albums.list()`. On a tie, the album with the higher ID
// comes first, so beach.jpg is named after Work.
it("names a photo in two albums after the same album on an empty cache as on a cache that holds the account", async () => {
const ctx = context(fakeClient({ beachInWork: true }));
const opts = { file: ["100"], json: true };
const inWork = [{ fileID: 100, collection: "Work", status: "skipped" }];
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(0);
expect(JSON.parse(stdout.text)).toMatchObject(inWork);
expect(await filesCommand(ctx, undefined, {})).toBe(0);
stdout.text = "";
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(0);
expect(JSON.parse(stdout.text)).toMatchObject(inWork);
});
});
describe("backup-metadata --exif", () => {
@@ -1572,11 +1589,11 @@ describe("an ID given on the command line", () => {
// An account with many albums (issues #210, #217 and #222). On an empty cache
// `collections`, `files` with a collection ID and `get` used to wait for a
// refresh that fetched and decrypted the files of every album, one album after
// another. On such an account they seemed to hang. The account here has 40
// albums of one photo each, encrypted as Ente's clients encrypt them, on the
// fake Ente server of `test/ente-server.ts`. The tests record the path and query
// of each request quak sends. The session is the one logging in to that server
// saves.
// another. So did `helper fix-missing-thumbnails --file` (issue #223). On such
// an account they seemed to hang. The account here has 40 albums of one photo
// each, encrypted as Ente's clients encrypt them, on the fake Ente server of
// `test/ente-server.ts`. The tests record the path and query of each request
// quak sends. The session is the one logging in to that server saves.
describe("an account with many albums", () => {
const ALBUMS = 40;
const PASSWORD = "many-albums";
@@ -1599,7 +1616,9 @@ describe("an account with many albums", () => {
// A photo in album `collectionID` as the server sends it, and its original
// encrypted as the server stores it. The original's bytes are the title.
// These tests fetch no thumbnail.
// These tests fetch no thumbnail. The server records a thumbnail size, so
// `helper fix-missing-thumbnails` downloads the original before it skips
// the photo as not a JPEG.
const photoRow = (photo: {
id: number;
collectionID: number;
@@ -1638,6 +1657,7 @@ describe("an account with many albums", () => {
},
file: { decryptionHeader: toBase64(original.header) },
thumbnail: { decryptionHeader: "" },
info: { thumbSize: 1000 },
updationTime: photo.updationTime,
};
return { row, original: original.ciphertext };
@@ -1855,6 +1875,81 @@ describe("an account with many albums", () => {
expect(requests).toEqual(["/collections/v2?sinceTime=1"]);
});
// The helper searches the most recently updated album first. All 40 were
// updated at the same time, and on a tie the higher ID comes first. Photo
// 3900 is in album 39, so the helper asks for no file of album 38 or
// earlier. The last request downloads the photo's original.
it("fix-missing-thumbnails --file on an empty cache asks for the albums' files only until one holds the photo", async () => {
const opts = { file: ["3900"], json: true };
expect(await fixMissingThumbnailsCommand(accountContext(), opts)).toBe(
0,
);
const reason =
"unsupported image format (only baseline JPEG can be regenerated)";
expect(JSON.parse(stdout.text)).toEqual([
{
fileID: 3900,
title: "photo-39.jpg",
collection: "Album 39",
status: "skipped",
reason,
},
]);
expect(stderr.text).toBe(
"[Album 39] Downloading photo-39.jpg for thumbnail generation...\n" +
`[Album 39] Skipping photo-39.jpg: ${reason}\n`,
);
expect(requests).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=40&sinceTime=0",
"/collections/v2/diff?collectionID=39&sinceTime=0",
"/?fileID=3900",
]);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
});
// `files` first fills the cache, so the helper waits for a refresh. No
// album has changed since, so the refresh asks for no album's files.
it("fix-missing-thumbnails --file on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
const ctx = accountContext();
expect(await filesCommand(ctx, undefined, {})).toBe(0);
requests.length = 0;
stdout.text = "";
stderr.text = "";
const opts = { file: ["3900"], json: true };
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(0);
const reason =
"unsupported image format (only baseline JPEG can be regenerated)";
expect(JSON.parse(stdout.text)).toEqual([
{
fileID: 3900,
title: "photo-39.jpg",
collection: "Album 39",
status: "skipped",
reason,
},
]);
expect(stderr.text).toBe(
"[Album 39] Downloading photo-39.jpg for thumbnail generation...\n" +
`[Album 39] Skipping photo-39.jpg: ${reason}\n`,
);
expect(requests).toEqual([
"/collections/v2?sinceTime=1",
"/?fileID=3900",
]);
requests.length = 0;
stdout.text = "";
const notFound = { file: ["999"], json: true };
expect(await fixMissingThumbnailsCommand(ctx, notFound)).toBe(1);
expect(JSON.parse(stdout.text)).toMatchObject([
{ fileID: 999, reason: "file not found in any collection" },
]);
expect(requests).toEqual(["/collections/v2?sinceTime=1"]);
});
it("files counts on stderr each album whose files its refresh fetched", async () => {
expect(await filesCommand(accountContext(), undefined, {})).toBe(0);
+3 -3
View File
@@ -234,9 +234,9 @@ describe("-q", () => {
});
});
// No album holds file 999, so fixing it fails. The refresh's progress and
// the list of failed files are left out. The line saying why the command
// failed is the fatal one, and it is printed.
// No album holds file 999, so fixing it fails. The summary from
// `--- Done ---` to the end of the `Failed files:` list is left out. The
// line saying why the command failed is the fatal one, and it is printed.
it("prints only the line a failing command exits with", async () => {
const fix = ["helper", "fix-missing-thumbnails", "--file", "999"];
expect(await quak("-q", ...fix)).toEqual({
+70
View File
@@ -13,6 +13,9 @@
* #217). Neither may wait for a refresh, which would fetch the files of every
* collection. On an empty cache, `get` and `get-thumb` ask for that list, then
* for one collection's files at a time, until they find the file (issue #222).
* `helper fix-missing-thumbnails --file` does the same until it finds every
* file it names, reading the most recently updated collection first (issue
* #223).
*
* `collections` and `files` also list in the order the library gives them, not
* the albums/photos projection's newest-first order. The fixtures are seeded in
@@ -27,6 +30,7 @@ import {
freshCollections,
freshFiles,
freshFile,
freshFilesByID,
type FreshReadLibrary,
} from "../../src/cli-read.js";
import { fileListRow } from "../../src/cli-output.js";
@@ -253,4 +257,70 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
expect(lib.freshFilesCalls).toEqual([1, 2, 3]);
expect(found).toBeUndefined();
});
// The server lists collections 1, 2 and 3, and collection 3 was updated
// most recently. File 11 is in collections 1 and 2.
const fileInTwoCollections = (): FakeLibrary =>
new FakeLibrary(
[collection(1, 100), collection(2, 200), collection(3, 300)],
[
file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001),
file(11, 2, 1_700_000_000_000_001),
file(20, 2, 1_700_000_000_000_002),
file(30, 3, 1_700_000_000_000_003),
],
);
it("freshFilesByID on an empty cache reads the most recently updated collection's files first, until it has found every file", async () => {
const lib = fileInTwoCollections();
const found = await freshFilesByID(lib, [11, 20]);
expect(lib.freshCalls).toBe(0);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([3, 2]);
expect(found.get(11)?.collection.id).toBe(2);
expect(found.get(11)?.file.collectionID).toBe(2);
expect(found.get(20)?.collection.id).toBe(2);
});
// Collection 1 was updated last, though collections 2 and 3 have higher
// IDs. File 11 is in collections 1 and 2.
it("freshFilesByID on an empty cache reads the most recently updated collection first, though its ID is the lowest", async () => {
const lib = new FakeLibrary(
[collection(1, 400), collection(2, 200), collection(3, 300)],
[
file(11, 1, 1_700_000_000_000_001),
file(11, 2, 1_700_000_000_000_001),
],
);
const found = await freshFilesByID(lib, [11]);
expect(lib.freshFilesCalls).toEqual([1]);
expect(found.get(11)?.collection.id).toBe(1);
expect(found.get(11)?.file.collectionID).toBe(1);
});
// The stand-in's cache holds what the server holds once `fresh()` has run.
it("freshFilesByID on a cache that holds the collections refreshes, then finds each file in the most recently updated collection that holds it", async () => {
const lib = fileInTwoCollections();
await lib.fresh();
const found = await freshFilesByID(lib, [11, 404]);
expect(lib.freshCalls).toBe(2);
expect(lib.freshCollectionsCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(found.get(11)?.collection.id).toBe(2);
expect(found.get(11)?.file.collectionID).toBe(2);
expect(found.has(404)).toBe(false);
});
it("freshFilesByID on an empty cache reads every collection's files when a file is in none of them", async () => {
const lib = fileInTwoCollections();
const found = await freshFilesByID(lib, [10, 404]);
expect(lib.freshFilesCalls).toEqual([3, 2, 1]);
expect(found.get(10)?.collection.id).toBe(1);
expect(found.has(404)).toBe(false);
});
});
+39 -9
View File
@@ -6,10 +6,12 @@
* working thumbnails, others return 404 or empty bodies. The tests
* verify that the detection and repair logic handles each case correctly.
*
* As of issue #52 both helpers take an open `Library` for enumeration and the
* `Client` for the API operations that stay unchanged (the thumbnail existence
* check, and the encrypt-and-upload path). `fixMissingThumbnails` reads each
* original through the library's content cache (`photo.original()`).
* `listMissingThumbnails` takes an open `Library` to enumerate the files. Both
* helpers take the `Client` for the API operations: the thumbnail existence
* check, and the encrypt-and-upload path. `fixMissingThumbnails` takes the
* record of each file, which these tests look up with `cachedFilesByID`. It
* reads each original through the library's content cache, by
* `lib.originalOf(file)`.
*
* `fixMissingThumbnails` is the most complex function in quak: it
* downloads the original file, generates a JPEG thumbnail with jpeg-js,
@@ -41,6 +43,7 @@ import {
import { SRP, SrpServer } from "fast-srp-hap";
import { Client } from "../../src/client.js";
import { Library } from "../../src/library/index.js";
import { cachedFilesByID } from "../../src/cli-read.js";
import {
listMissingThumbnails,
fixMissingThumbnails,
@@ -656,7 +659,12 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client);
const results = await fixMissingThumbnails(lib, client, [101]);
const results = await fixMissingThumbnails(
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close();
expect(results.length).toBe(1);
@@ -693,7 +701,12 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client);
const results = await fixMissingThumbnails(lib, client, [999]);
const results = await fixMissingThumbnails(
lib,
client,
[999],
cachedFilesByID(lib, [999]),
);
lib.close();
expect(results.length).toBe(1);
@@ -715,6 +728,7 @@ describe("fixMissingThumbnails", () => {
lib,
client,
[101, 102],
cachedFilesByID(lib, [101, 102]),
(msg, level) => {
if (level === "error") errors.push(msg);
},
@@ -745,6 +759,7 @@ describe("fixMissingThumbnails", () => {
lib,
client,
[101, 103, 104],
cachedFilesByID(lib, [101, 103, 104]),
);
lib.close();
@@ -779,7 +794,12 @@ describe("fixMissingThumbnails", () => {
const client = await login(counted.fetch);
const lib = await openLib(client);
const results = await fixMissingThumbnails(lib, client, [101]);
const results = await fixMissingThumbnails(
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close();
expect(results[0]!.status).toBe("skipped");
@@ -800,7 +820,12 @@ describe("fixMissingThumbnails", () => {
const client = await login(counted.fetch);
const lib = await openLib(client);
const results = await fixMissingThumbnails(lib, client, [101]);
const results = await fixMissingThumbnails(
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close();
expect(results[0]!.status).toBe("skipped");
@@ -841,7 +866,12 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client);
const results = await fixMissingThumbnails(lib, client, [101]);
const results = await fixMissingThumbnails(
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close();
expect(results[0]!.status).toBe("fixed");