1 Commits
Author SHA1 Message Date
sneak 085f445b80 quak get and get-thumb stop fetching collections once they find the file (closes #222)
check / check (push) Canceled after 0s
`quak get` and `quak get-thumb` used to wait for a full refresh, which on
an empty cache fetched the files of every collection. `freshFile` now
reads the collection list, then one collection's files at a time, and
stops at the first that holds the file. It tries first the collections
the cache says hold the file, and stores nothing. The commands fetch the
content through the new `Library.originalOf()` and
`Library.thumbnailOf()`, which take the file record itself, since the
store may not hold it.

They no longer print a refresh's progress lines. The `-q` test that
relied on `get` refreshing now runs `helper fix-missing-thumbnails`.

Model: opus-5-5
2026-10-26 08:20:51 +00:00
12 changed files with 1367 additions and 2108 deletions
+1236 -1719
View File
File diff suppressed because it is too large Load Diff
+7 -17
View File
@@ -26,23 +26,13 @@ declares one.
# Completed Steps # Completed Steps
- 2026-10-26: On an empty cache, `quak helper fix-missing-thumbnails --file` - 2026-10-26: `quak get` and `quak get-thumb` stop fetching collections' files
stops fetching collections' files once it finds every file it names once they find the file (https://git.eeqj.de/sneak/quak/issues/222). They used
(https://git.eeqj.de/sneak/quak/issues/223). It used to wait for a refresh, to wait for a refresh, which on an empty cache fetched the files of every
which on an empty cache fetched the files of every collection. Now it reads collection. They now read the collection list, then one collection's files at
the collection list, then one collection's files at a time, the most recently a time, first those the cache says hold the file. They store nothing they
updated first. It stores none of the collections or file records it reads. It read, and fetch the file through the new `lib.originalOf()` and
fetches each original through `lib.originalOf()`, which keeps it in the cache. `lib.thumbnailOf()`.
Once the cache holds the account's collections, it still waits for a refresh.
- 2026-10-26: On an empty cache, `quak get` and `quak get-thumb` stop fetching
collections' files once they find the file
(https://git.eeqj.de/sneak/quak/issues/222). They used to wait for a refresh,
which on an empty cache fetched the files of every collection. Now they read
the collection list, then one collection's files at a time. They store none of
the collections or file records they read, and fetch the original or thumbnail
through the new `lib.originalOf()` and `lib.thumbnailOf()`. Once the cache
holds the account's collections, they still wait for a refresh.
- 2026-10-26: `quak files` with a collection ID fetches that collection's files - 2026-10-26: `quak files` with a collection ID fetches that collection's files
alone (https://git.eeqj.de/sneak/quak/issues/217). It used to wait for a alone (https://git.eeqj.de/sneak/quak/issues/217). It used to wait for a
+13 -26
View File
@@ -37,16 +37,9 @@ import {
thumbnailName, thumbnailName,
} from "./cli-output.js"; } from "./cli-output.js";
import { type Log, logRefresh } from "./cli-log.js"; import { type Log, logRefresh } from "./cli-log.js";
import { import { freshCollections, freshFiles, freshFile } from "./cli-read.js";
cachedFilesByID,
freshCollections,
freshFiles,
freshFile,
freshFilesByID,
} from "./cli-read.js";
import { withExtension } from "./filename.js"; import { withExtension } from "./filename.js";
import { runMetadataBackup } from "./metadata-backup.js"; import { runMetadataBackup } from "./metadata-backup.js";
import type { Collection, EnteFile } from "./model/types.js";
import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js"; import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js";
export interface CliContext { export interface CliContext {
@@ -139,11 +132,10 @@ const readLibraryClient = (client: Client): LibraryClient => ({
// precache (issue #48) is off, because a one-shot `collections` or `get` must // precache (issue #48) is off, because a one-shot `collections` or `get` must
// not start downloading the whole account. No refresh starts as the library // not start downloading the whole account. No refresh starts as the library
// opens or on a timer. Each command asks for the refresh it needs. // opens or on a timer. Each command asks for the refresh it needs.
// `collections` and `files` with a collection ID need none (issues #210 and // `collections` needs none (issue #210). Nor do `get`, `get-thumb` and `files`
// #217). Nor do `get` and `get-thumb` on an empty cache (issue #222). Nor does // with a collection ID (issues #217 and #222). While a refresh runs, each
// `helper fix-missing-thumbnails --file` on an empty cache (issue #223). While // collection whose files it has fetched is logged, so a large account shows
// a refresh runs, each collection whose files it has fetched is logged, so a // progress rather than nothing.
// large account shows progress rather than nothing.
const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> => const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
Library.open({ Library.open({
client: readLibraryClient(client), client: readLibraryClient(client),
@@ -331,8 +323,8 @@ export const getCommand = async (
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Force a server round-trip so the file resolves against current state // Ask the server for the file as it is now (issue #36 amendment,
// (issue #36 amendment, issue #52). // issues #52 and #222).
const file = await freshFile(lib, fileID); const file = await freshFile(lib, fileID);
if (!file) { if (!file) {
ctx.log("fatal", `File ${fileID} not found`); ctx.log("fatal", `File ${fileID} not found`);
@@ -388,8 +380,8 @@ export const getThumbCommand = async (
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Force a server round-trip so the file resolves against current state // Ask the server for the file as it is now (issue #36 amendment,
// (issue #36 amendment, issue #52). // issues #52 and #222).
const file = await freshFile(lib, fileID); const file = await freshFile(lib, fileID);
if (!file) { if (!file) {
ctx.log("fatal", `File ${fileID} not found`); ctx.log("fatal", `File ${fileID} not found`);
@@ -573,13 +565,10 @@ export const fixMissingThumbnailsCommand = async (
} }
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
let found: Map<number, { file: EnteFile; collection: Collection }>; // Refresh first so files added since the cache was written are found;
if (fileIDs.length > 0) { // a failed refresh throws.
found = await freshFilesByID(lib, fileIDs); await lib.fresh();
} else { if (fileIDs.length === 0) {
// Refresh first so files added since the cache was written are
// checked; a failed refresh throws.
await lib.fresh();
ctx.log("info", "Scanning for missing thumbnails..."); ctx.log("info", "Scanning for missing thumbnails...");
const missing = await listMissingThumbnails( const missing = await listMissingThumbnails(
lib, lib,
@@ -592,14 +581,12 @@ export const fixMissingThumbnailsCommand = async (
return 0; return 0;
} }
ctx.log("info", `Found ${fileIDs.length} file(s) to fix.`); ctx.log("info", `Found ${fileIDs.length} file(s) to fix.`);
found = cachedFilesByID(lib, fileIDs);
} }
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(
lib, lib,
client, client,
fileIDs, fileIDs,
found,
(msg, level) => ctx.log(level ?? "info", msg), (msg, level) => ctx.log(level ?? "info", msg),
); );
+7 -10
View File
@@ -1,18 +1,15 @@
// The CLI's log level, and the one function the commands write stderr lines // The CLI's log level, and the one function the commands write stderr lines
// through. // through.
// //
// Every line quak logs goes to stderr, at one of six levels, from the // Every line quak prints on stderr is a log line at one of six levels, from
// quietest: fatal, error, warning, info, debug, trace. A run prints the lines // the quietest: fatal, error, warning, info, debug, trace. A run prints the
// at its own level and at every quieter one. Its level is info unless `-v` or // lines at its own level and at every quieter one. Its level is info unless
// `-q` asks for another (see "What quak prints" in the README). A command that // `-v` or `-q` asks for another (see "CLI surface" in the README). Every
// runs and exits non-zero logs a fatal line saying why, so even `-q`, which // command that exits non-zero logs a fatal line saying why, so even `-q`, which
// prints only fatal lines, never leaves such an exit unexplained. An error // 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 // 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 // warning something that did not fail but needs the user's attention. stdout
// carries a command's output and the prompts of `login`, and is not a log. // carries only a command's output and is not a log.
// Two things on stderr are not log lines: the `error:` message for a
// command line that quak cannot parse, and the usage printed when no command
// is given.
// //
// The library itself writes no lines: its layers report through callbacks, and // The library itself writes no lines: its layers report through callbacks, and
// the CLI logs what they report. `logRequests` gives the API client options // the CLI logs what they report. `logRequests` gives the API client options
+24 -79
View File
@@ -2,18 +2,16 @@
// //
// `collections`, `files`, `get`, and `get-thumb` must answer for // `collections`, `files`, `get`, and `get-thumb` must answer for
// the account's state at the moment the command runs, not for whatever the // the account's state at the moment the command runs, not for whatever the
// local cache last happened to hold (owner amendment, issue #36). `get`, // local cache last happened to hold (owner amendment, issue #36). `files`
// `get-thumb` and `files` without a collection ID therefore force a server // without a collection ID therefore forces a server round-trip through
// round-trip through `Library.fresh()` and only then read, so a change made // `Library.fresh()` and only then reads, so a change made elsewhere is visible.
// elsewhere is visible. `collections` asks the library for the collection list // `collections` asks the library for the collection list alone through
// alone through `Library.freshCollections()` (issue #210): a refresh would also // `Library.freshCollections()` (issue #210): a refresh would also fetch the
// fetch the files of every collection, which on a large account takes long and // files of every collection, which on a large account takes long and which
// which `collections` does not print. `files` with a collection ID reads that // `collections` does not print. `files` with a collection ID reads that list
// list too, then that one collection's files through `Library.freshFiles()` // too, then that one collection's files through `Library.freshFiles()` (issue
// (issue #217). On an empty cache, `get` and `get-thumb` read that list, then // #217). `get` and `get-thumb` read that list, then one collection's files at a
// one collection's files at a time, until they find the file (issue #222). // time, until they find the file (issue #222).
// `helper fix-missing-thumbnails --file` does the same until it finds every
// file it names (issue #223).
// //
// `collections` and `files` with a collection ID list in the server's order. // `collections` and `files` with a collection ID list in the server's order.
// `files` without one lists in the library's own enumeration order // `files` without one lists in the library's own enumeration order
@@ -35,7 +33,7 @@ export interface FreshReadLibrary {
freshFiles(collection: Collection): Promise<EnteFile[]>; freshFiles(collection: Collection): Promise<EnteFile[]>;
listCollections(): Collection[]; listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[]; listFiles(collectionID: number): EnteFile[];
getFileByID(fileID: number): EnteFile | undefined; getFile(collectionID: number, fileID: number): EnteFile | undefined;
} }
// Every live collection, current as of one collections request, in the // Every live collection, current as of one collections request, in the
@@ -72,79 +70,26 @@ export const freshFiles = async (
}; };
// One file as the server holds it now, or `undefined` when no collection holds // One file as the server holds it now, or `undefined` when no collection holds
// it. When the cache holds the account's collections, it refreshes and looks // it. It reads the collection list, then one collection's files at a time, and
// the file up in the cache. On an empty cache, it reads the collection list, // stops at the first collection that holds the file. It tries first the
// then one collection's files at a time, and stops at the first collection // collections the cache says hold the file, then the rest in the server's
// that holds the file. That search stores nothing. // order. It stores nothing.
export const freshFile = async ( export const freshFile = async (
lib: FreshReadLibrary, lib: FreshReadLibrary,
fileID: number, fileID: number,
): Promise<EnteFile | undefined> => { ): Promise<EnteFile | undefined> => {
// Only a refresh stores collections, so a cache holding any holds them all. const collections = await lib.freshCollections();
if (lib.listCollections().length > 0) { // Whether the cache holds the file in collection `c`.
await lib.fresh(); const inCache = (c: Collection): boolean =>
return lib.getFileByID(fileID); lib.getFile(c.id, fileID) !== undefined;
} const ordered = [
for (const collection of await lib.freshCollections()) { ...collections.filter(inCache),
...collections.filter((c) => !inCache(c)),
];
for (const collection of ordered) {
const files = await lib.freshFiles(collection); const files = await lib.freshFiles(collection);
const file = files.find((f) => f.id === fileID); const file = files.find((f) => f.id === fileID);
if (file) return file; if (file) return file;
} }
return undefined; return undefined;
}; };
// The most recently updated collection first, and the higher ID first on a
// tie. `Library.albums.list()` lists albums in this order.
const byNewestCollection = (a: Collection, b: Collection): number =>
b.updationTime - a.updationTime || b.id - a.id;
// Each file named by `fileIDs` that the cache holds, mapped to its record and
// the collection it was found in. Collections are searched in
// `byNewestCollection` order, and a file is found in the first that holds it.
// So `helper fix-missing-thumbnails` names a file after the same album as
// `helper list-missing-thumbnails`. It reads the cache alone, so the caller
// refreshes first.
export const cachedFilesByID = (
lib: FreshReadLibrary,
fileIDs: number[],
): Map<number, { file: EnteFile; collection: Collection }> => {
const wanted = new Set(fileIDs);
const found = new Map<number, { file: EnteFile; collection: Collection }>();
const collections = [...lib.listCollections()].sort(byNewestCollection);
for (const collection of collections) {
for (const file of lib.listFiles(collection.id)) {
if (wanted.has(file.id) && !found.has(file.id)) {
found.set(file.id, { file, collection });
}
}
}
return found;
};
// Each file named by `fileIDs` as the server holds it now, mapped to its record
// and collection as `cachedFilesByID` maps it. When the cache holds the
// account's collections, it refreshes and looks the files up in the cache. On
// an empty cache, it reads the collection list, then one collection's files at
// a time, and stops once it has found every file. That search stores nothing.
export const freshFilesByID = async (
lib: FreshReadLibrary,
fileIDs: number[],
): Promise<Map<number, { file: EnteFile; collection: Collection }>> => {
// Only a refresh stores collections, so a cache holding any holds them all.
if (lib.listCollections().length > 0) {
await lib.fresh();
return cachedFilesByID(lib, fileIDs);
}
const wanted = new Set(fileIDs);
const found = new Map<number, { file: EnteFile; collection: Collection }>();
const collections = await lib.freshCollections();
for (const collection of [...collections].sort(byNewestCollection)) {
if (found.size === wanted.size) break;
for (const file of await lib.freshFiles(collection)) {
if (wanted.has(file.id) && !found.has(file.id)) {
found.set(file.id, { file, collection });
}
}
}
return found;
};
+4 -4
View File
@@ -647,8 +647,8 @@ export class Library {
} }
// The original of `file`, fetched and cached as `Photo.original()` does. // The original of `file`, fetched and cached as `Photo.original()` does.
// `file` is used as given, so the store need not hold it. On an empty // `file` is used as given, so the store need not hold it. `quak get`
// cache, `quak get` passes a file that `freshFiles()` returned. // passes a file that `freshFiles()` returned.
async originalOf( async originalOf(
file: EnteFile, file: EnteFile,
opts?: ContentOptions, opts?: ContentOptions,
@@ -662,8 +662,8 @@ export class Library {
} }
// The thumbnail of `file`, fetched and cached as `Photo.thumbnail()` does. // The thumbnail of `file`, fetched and cached as `Photo.thumbnail()` does.
// `file` is used as given, so the store need not hold it. On an empty // `file` is used as given, so the store need not hold it. `quak get-thumb`
// cache, `quak get-thumb` passes a file that `freshFiles()` returned. // passes a file that `freshFiles()` returned.
async thumbnailOf( async thumbnailOf(
file: EnteFile, file: EnteFile,
opts?: ContentOptions, opts?: ContentOptions,
+36 -11
View File
@@ -5,7 +5,7 @@ import type { Client } from "./client.js";
import type { Library } from "./library/index.js"; import type { Library } from "./library/index.js";
import { ApiError } from "./api/client.js"; import { ApiError } from "./api/client.js";
import { encryptBlob, toBase64 } from "./crypto/index.js"; import { encryptBlob, toBase64 } from "./crypto/index.js";
import type { Collection, EnteFile } from "./model/types.js"; import type { EnteFile } from "./model/types.js";
// The server refuses a thumbnail larger than the one it already records for the // The server refuses a thumbnail larger than the one it already records for the
// file (`thumbnail.size`, the encrypted size), so these encodings are tried // file (`thumbnail.size`, the encrypted size), so these encodings are tried
@@ -226,18 +226,16 @@ const encryptThumbnailWithin = (
return undefined; return undefined;
}; };
// Regenerate and upload a thumbnail for each requested file. `found` holds the // Regenerate and upload a thumbnail for each requested file. Originals are read
// record of each requested file that a collection holds, and the collection it // through the library's content cache (`photo.original()`); the generated
// was found in. Each original is read through the library's content cache, by // thumbnail is JPEG-encoded, encrypted under the file's own key, and registered
// `lib.originalOf(file)`. The new thumbnail is a JPEG, encrypted under the // with the server — the encrypt-and-upload path is unchanged. Each file is
// file's own key. It is uploaded, then registered with the server. Each file is
// resolved to one outcome (fixed / skipped / failed) and a failure on one file // resolved to one outcome (fixed / skipped / failed) and a failure on one file
// never stops the others. // never stops the others.
export const fixMissingThumbnails = async ( export const fixMissingThumbnails = async (
lib: Library, lib: Library,
client: Client, client: Client,
fileIDs: number[], fileIDs: number[],
found: Map<number, { file: EnteFile; collection: Collection }>,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
): Promise<ThumbnailFixResult[]> => { ): Promise<ThumbnailFixResult[]> => {
const log = onProgress ?? (() => {}); const log = onProgress ?? (() => {});
@@ -245,8 +243,31 @@ export const fixMissingThumbnails = async (
const api = client.getApiClient(); const api = client.getApiClient();
const { userID } = client.whoami(); const { userID } = client.whoami();
// Resolve each requested fileID to its file record and owning album by
// enumerating the library, each file taken from the first album that holds
// it. The raw `EnteFile` carries the per-file key the thumbnail is
// encrypted under, which the projected records deliberately do not.
const wanted = new Set(fileIDs);
const fileMap = new Map<
number,
{ file: EnteFile; collectionName: string }
>();
for (const album of lib.albums.list()) {
for (const photo of album.photos.list()) {
if (!wanted.has(photo.fileID) || fileMap.has(photo.fileID))
continue;
const file = lib.getFile(album.collectionID, photo.fileID);
if (file) {
fileMap.set(photo.fileID, {
file,
collectionName: album.name,
});
}
}
}
for (const fileID of fileIDs) { for (const fileID of fileIDs) {
const entry = found.get(fileID); const entry = fileMap.get(fileID);
if (!entry) { if (!entry) {
results.push({ results.push({
fileID, fileID,
@@ -258,8 +279,7 @@ export const fixMissingThumbnails = async (
continue; continue;
} }
const { file } = entry; const { file, collectionName } = entry;
const collectionName = entry.collection.name;
const title = file.metadata.title; const title = file.metadata.title;
const skipReason = reasonToSkip(file, userID); const skipReason = reasonToSkip(file, userID);
@@ -277,10 +297,15 @@ export const fixMissingThumbnails = async (
const maxSize = file.thumbnail.size!; const maxSize = file.thumbnail.size!;
try { try {
const photo = lib.photos.byID({ fileID });
if (!photo) {
throw new Error("file not present in the library cache");
}
log( log(
`[${collectionName}] Downloading ${title} for thumbnail generation...`, `[${collectionName}] Downloading ${title} for thumbnail generation...`,
); );
const { path } = await lib.originalOf(file); const { path } = await photo.original();
const fileBytes = new Uint8Array(readFileSync(path)); const fileBytes = new Uint8Array(readFileSync(path));
if (!isJpeg(fileBytes)) { if (!isJpeg(fileBytes)) {
+1 -1
View File
@@ -418,7 +418,7 @@ describe("the first backup of the account", () => {
expect(downloads.sort()).toEqual([100, 200, 300]); expect(downloads.sort()).toEqual([100, 200, 300]);
}); });
// The tree under "The backup directory" in the README. Each original is at // The tree "Backup layout" in the README describes. Each original is at
// its save path, `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, with // its save path, `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, with
// the extension of its title; the live photo is its image and its video, // the extension of its title; the live photo is its image and its video,
// each with the extension it had inside the ZIP, and a `.livephoto.json` // each with the extension it had inside the ZIP, and a `.livephoto.json`
+12 -111
View File
@@ -1353,23 +1353,6 @@ describe("helper fix-missing-thumbnails", () => {
).toBe(1); ).toBe(1);
expect(stderr.text).toBe(last); 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", () => { describe("backup-metadata --exif", () => {
@@ -1589,11 +1572,11 @@ describe("an ID given on the command line", () => {
// An account with many albums (issues #210, #217 and #222). On an empty cache // 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 // `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 // refresh that fetched and decrypted the files of every album, one album after
// another. So did `helper fix-missing-thumbnails --file` (issue #223). On such // another. On such an account they seemed to hang. The account here has 40
// an account they seemed to hang. The account here has 40 albums of one photo // albums of one photo each, encrypted as Ente's clients encrypt them, on the
// each, encrypted as Ente's clients encrypt them, on the fake Ente server of // fake Ente server of `test/ente-server.ts`. The tests record the path and query
// `test/ente-server.ts`. The tests record the path and query of each request // of each request quak sends. The session is the one logging in to that server
// quak sends. The session is the one logging in to that server saves. // saves.
describe("an account with many albums", () => { describe("an account with many albums", () => {
const ALBUMS = 40; const ALBUMS = 40;
const PASSWORD = "many-albums"; const PASSWORD = "many-albums";
@@ -1616,9 +1599,7 @@ describe("an account with many albums", () => {
// A photo in album `collectionID` as the server sends it, and its original // 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. // encrypted as the server stores it. The original's bytes are the title.
// These tests fetch no thumbnail. The server records a thumbnail size, so // These tests fetch no thumbnail.
// `helper fix-missing-thumbnails` downloads the original before it skips
// the photo as not a JPEG.
const photoRow = (photo: { const photoRow = (photo: {
id: number; id: number;
collectionID: number; collectionID: number;
@@ -1657,7 +1638,6 @@ describe("an account with many albums", () => {
}, },
file: { decryptionHeader: toBase64(original.header) }, file: { decryptionHeader: toBase64(original.header) },
thumbnail: { decryptionHeader: "" }, thumbnail: { decryptionHeader: "" },
info: { thumbSize: 1000 },
updationTime: photo.updationTime, updationTime: photo.updationTime,
}; };
return { row, original: original.ciphertext }; return { row, original: original.ciphertext };
@@ -1836,7 +1816,7 @@ describe("an account with many albums", () => {
// Photo 200 is in album 2, so `get` asks for no file of album 3 or later. // Photo 200 is in album 2, so `get` asks for no file of album 3 or later.
// The last request downloads the photo's original. // The last request downloads the photo's original.
it("get on an empty cache asks for the albums' files only until one holds the photo", async () => { it("get asks for the albums' files only until one holds the photo", async () => {
const out = join(root, "photo.jpg"); const out = join(root, "photo.jpg");
expect(await getCommand(accountContext(), "200", { out })).toBe(0); expect(await getCommand(accountContext(), "200", { out })).toBe(0);
@@ -1851,9 +1831,9 @@ describe("an account with many albums", () => {
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false); expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
}); });
// `files` first fills the cache, so `get` waits for a refresh. No album has // `files` first fills the cache. The cache then says album 40, the last,
// changed since, so the refresh asks for no album's files. // holds photo 4000, so `get` asks for that album's files alone.
it("get on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => { it("get asks first for the files of the album the cache says holds the photo", async () => {
const ctx = accountContext(); const ctx = accountContext();
expect(await filesCommand(ctx, undefined, {})).toBe(0); expect(await filesCommand(ctx, undefined, {})).toBe(0);
requests.length = 0; requests.length = 0;
@@ -1861,93 +1841,14 @@ describe("an account with many albums", () => {
const out = join(root, "photo.jpg"); const out = join(root, "photo.jpg");
expect(await getCommand(ctx, "4000", { out })).toBe(0); expect(await getCommand(ctx, "4000", { out })).toBe(0);
expect(readFileSync(out, "utf-8")).toBe("photo-40.jpg"); expect(readFileSync(out, "utf-8")).toBe("photo-40.jpg");
expect(stderr.text).toBe(`12 bytes -> ${out}\n`); expect(stderr.text).toBe(`12 bytes -> ${out}\n`);
expect(requests).toEqual([
"/collections/v2?sinceTime=1",
"/?fileID=4000",
]);
requests.length = 0;
stderr.text = "";
expect(await getCommand(ctx, "999", { out })).toBe(1);
expect(stderr.text).toBe("File 999 not found\n");
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([ expect(requests).toEqual([
"/collections/v2?sinceTime=0", "/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=40&sinceTime=0", "/collections/v2/diff?collectionID=40&sinceTime=0",
"/collections/v2/diff?collectionID=39&sinceTime=0", "/?fileID=4000",
"/?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 () => { it("files counts on stderr each album whose files its refresh fetched", async () => {
+4 -4
View File
@@ -234,9 +234,9 @@ describe("-q", () => {
}); });
}); });
// No album holds file 999, so fixing it fails. The summary from // No album holds file 999, so fixing it fails. The refresh's progress and
// `--- Done ---` to the end of the `Failed files:` list is left out. The // the list of failed files are left out. The line saying why the command
// line saying why the command failed is the fatal one, and it is printed. // failed is the fatal one, and it is printed.
it("prints only the line a failing command exits with", async () => { it("prints only the line a failing command exits with", async () => {
const fix = ["helper", "fix-missing-thumbnails", "--file", "999"]; const fix = ["helper", "fix-missing-thumbnails", "--file", "999"];
expect(await quak("-q", ...fix)).toEqual({ expect(await quak("-q", ...fix)).toEqual({
@@ -328,7 +328,7 @@ describe("what the library reports", () => {
]); ]);
}); });
// Each operation is named in plain words. // Each operation is named in plain words, as the README names it.
it("logs a refresh's progress at info, and its start and end at debug", () => { it("logs a refresh's progress at info, and its start and end at debug", () => {
const { log, lines } = collect(); const { log, lines } = collect();
const onProgress = logRefresh(log); const onProgress = logRefresh(log);
+14 -87
View File
@@ -3,19 +3,16 @@
* issue #36, issue #52). * issue #36, issue #52).
* *
* The `collections`, `files`, `get`, and `get-thumb` commands must answer for * The `collections`, `files`, `get`, and `get-thumb` commands must answer for
* current server state, not the local cache. `get`, `get-thumb` and `files` * current server state, not the local cache. `files` without a collection ID
* without a collection ID force a `Library.fresh()` round-trip before they * forces a `Library.fresh()` round-trip before it reads. The stand-in library
* read. The stand-in library below serves no cached record until `fresh()` has * below serves no cached record until `fresh()` has been awaited, so a helper
* been awaited, so a helper that read without refreshing would come back empty * that read without refreshing would come back empty and fail here.
* and fail here. `collections` asks the library for the collection list alone * `collections` asks the library for the collection list alone
* (`freshCollections()`, issue #210). `files` with a collection ID asks for * (`freshCollections()`, issue #210). `files` with a collection ID asks for
* that list, then for that one collection's files (`freshFiles()`, issue * that list, then for that one collection's files (`freshFiles()`, issue
* #217). Neither may wait for a refresh, which would fetch the files of every * #217). `get` and `get-thumb` ask for that list, then for one collection's
* collection. On an empty cache, `get` and `get-thumb` ask for that list, then * files at a time, until they find the file (issue #222). None of these may
* for one collection's files at a time, until they find the file (issue #222). * wait for a refresh, which would fetch the files of every collection.
* `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 * `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 * the albums/photos projection's newest-first order. The fixtures are seeded in
@@ -30,7 +27,6 @@ import {
freshCollections, freshCollections,
freshFiles, freshFiles,
freshFile, freshFile,
freshFilesByID,
type FreshReadLibrary, type FreshReadLibrary,
} from "../../src/cli-read.js"; } from "../../src/cli-read.js";
import { fileListRow } from "../../src/cli-output.js"; import { fileListRow } from "../../src/cli-output.js";
@@ -109,9 +105,8 @@ class FakeLibrary implements FreshReadLibrary {
: []; : [];
} }
getFileByID(fileID: number): EnteFile | undefined { getFile(collectionID: number, fileID: number): EnteFile | undefined {
if (!this.refreshed) return undefined; return this.listFiles(collectionID).find((f) => f.id === fileID);
return this.files.find((f) => f.id === fileID);
} }
} }
@@ -226,7 +221,7 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
], ],
); );
it("freshFile on an empty cache reads each collection's files in the server's order until one holds the file", async () => { it("freshFile reads each collection's files in the server's order until one holds the file", async () => {
const lib = threeCollections(); const lib = threeCollections();
const found = await freshFile(lib, 20); const found = await freshFile(lib, 20);
@@ -238,18 +233,16 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
}); });
// The stand-in's cache holds what the server holds once `fresh()` has run. // The stand-in's cache holds what the server holds once `fresh()` has run.
it("freshFile on a cache that holds the collections refreshes, then looks the file up in the cache", async () => { it("freshFile reads first the files of the collection the cache says holds the file", async () => {
const lib = threeCollections(); const lib = threeCollections();
await lib.fresh(); await lib.fresh();
const found = await freshFile(lib, 30); const found = await freshFile(lib, 30);
expect(lib.freshCalls).toBe(2); expect(lib.freshFilesCalls).toEqual([3]);
expect(lib.freshCollectionsCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(found?.collectionID).toBe(3); expect(found?.collectionID).toBe(3);
}); });
it("freshFile on an empty cache returns undefined for an unknown file, after reading every collection's files", async () => { it("freshFile returns undefined for an unknown file, after reading every collection's files", async () => {
const lib = threeCollections(); const lib = threeCollections();
const found = await freshFile(lib, 404); const found = await freshFile(lib, 404);
@@ -257,70 +250,4 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
expect(lib.freshFilesCalls).toEqual([1, 2, 3]); expect(lib.freshFilesCalls).toEqual([1, 2, 3]);
expect(found).toBeUndefined(); 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);
});
}); });
+9 -39
View File
@@ -6,12 +6,10 @@
* working thumbnails, others return 404 or empty bodies. The tests * working thumbnails, others return 404 or empty bodies. The tests
* verify that the detection and repair logic handles each case correctly. * verify that the detection and repair logic handles each case correctly.
* *
* `listMissingThumbnails` takes an open `Library` to enumerate the files. Both * As of issue #52 both helpers take an open `Library` for enumeration and the
* helpers take the `Client` for the API operations: the thumbnail existence * `Client` for the API operations that stay unchanged (the thumbnail existence
* check, and the encrypt-and-upload path. `fixMissingThumbnails` takes the * check, and the encrypt-and-upload path). `fixMissingThumbnails` reads each
* record of each file, which these tests look up with `cachedFilesByID`. It * original through the library's content cache (`photo.original()`).
* reads each original through the library's content cache, by
* `lib.originalOf(file)`.
* *
* `fixMissingThumbnails` is the most complex function in quak: it * `fixMissingThumbnails` is the most complex function in quak: it
* downloads the original file, generates a JPEG thumbnail with jpeg-js, * downloads the original file, generates a JPEG thumbnail with jpeg-js,
@@ -43,7 +41,6 @@ import {
import { SRP, SrpServer } from "fast-srp-hap"; import { SRP, SrpServer } from "fast-srp-hap";
import { Client } from "../../src/client.js"; import { Client } from "../../src/client.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import { cachedFilesByID } from "../../src/cli-read.js";
import { import {
listMissingThumbnails, listMissingThumbnails,
fixMissingThumbnails, fixMissingThumbnails,
@@ -659,12 +656,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock)); const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [101]);
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close(); lib.close();
expect(results.length).toBe(1); expect(results.length).toBe(1);
@@ -701,12 +693,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock)); const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [999]);
lib,
client,
[999],
cachedFilesByID(lib, [999]),
);
lib.close(); lib.close();
expect(results.length).toBe(1); expect(results.length).toBe(1);
@@ -728,7 +715,6 @@ describe("fixMissingThumbnails", () => {
lib, lib,
client, client,
[101, 102], [101, 102],
cachedFilesByID(lib, [101, 102]),
(msg, level) => { (msg, level) => {
if (level === "error") errors.push(msg); if (level === "error") errors.push(msg);
}, },
@@ -759,7 +745,6 @@ describe("fixMissingThumbnails", () => {
lib, lib,
client, client,
[101, 103, 104], [101, 103, 104],
cachedFilesByID(lib, [101, 103, 104]),
); );
lib.close(); lib.close();
@@ -794,12 +779,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(counted.fetch); const client = await login(counted.fetch);
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [101]);
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close(); lib.close();
expect(results[0]!.status).toBe("skipped"); expect(results[0]!.status).toBe("skipped");
@@ -820,12 +800,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(counted.fetch); const client = await login(counted.fetch);
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [101]);
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close(); lib.close();
expect(results[0]!.status).toBe("skipped"); expect(results[0]!.status).toBe("skipped");
@@ -866,12 +841,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock)); const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [101]);
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close(); lib.close();
expect(results[0]!.status).toBe("fixed"); expect(results[0]!.status).toBe("fixed");