Author SHA1 Message Date
sneak 38e661a8de README.md rewritten sentence by sentence (closes #219)
check / check (push) Waiting to run
The old text served as the list of what to cover. The new text leads
with what quak is, what it is for and how to use it. Every command,
option, exit code and file layout follows in reference sections, then the
library reference, then design, entrypoints and development. Every
statement was checked against the code on next; the pull request lists
each disagreement. Sentences stay under about 30 words, tables are lists,
and no issue links or project history remain in text for users. Two code
comments now name the new README sections, and the one in
`src/cli-log.ts` matches "What quak prints" on stderr, stdout and exit
status; a third no longer cites the README.

Model: fable-5-1
2026-10-26 16:30:20 +00:00
clawbot a1ea47f9a8 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
2026-10-26 11:25:10 +01:00
clawbot f65c237e2c quak get and get-thumb on an empty cache stop fetching collections once they find the file (closes #222)
check / check (push) Waiting to run
`quak get` and `quak get-thumb` used to wait for a refresh, which on an
empty cache fetched the files of every collection. Now, on an empty
cache, `freshFile` reads the collection list, then one collection's
files at a time, and stops at the first that holds the file. That search
stores nothing. Once the cache holds the account's collections,
`freshFile` refreshes as before and looks the file up in the cache. 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.

On an empty cache 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 10:18:28 +01:00
clawbot af1491966c quak files COLLECTION fetches that collection's files alone (closes #217)
check / check (push) Waiting to run
`quak files` with a collection ID used to wait for a full refresh, which
on an empty cache fetched the files of every collection in the account.
It now reads the collection list, then that one collection's files
through the new `Library.freshFiles()`, and stores nothing. The files
print in the server's order, and no refresh progress lines appear. Each
file prints once, as the latest copy the server sent. `quak files`
without a collection ID still waits for a refresh.

Some tests ran `quak files` with a collection ID to fill the cache, to
show a refresh's progress or to show what `-q` hides. They now run a
command that still refreshes.

Model: opus-5-5
2026-10-26 09:04:39 +01:00
clawbot 657266a60e A file's JSON holds keywords in place of its CLIP embedding (closes #218)
check / check (push) Canceled after 0s
`quak backup` and `quak backup-metadata` write each file's ML data without its
CLIP embedding, and `keywords` in its place: the titles of the Discover
categories of Ente's photos app that the file falls in, highest score first. A
file falls in a category when the cosine similarity of its CLIP embedding and
the text embedding of the category's query reaches the category's minimum
score; as in Ente's app, five categories leave out videos.

The 16 text embeddings ship in `src/clip-keyword-vectors.ts`, written by
`bin/clip-keyword-vectors` from Ente's list and CLIP text model. quak runs no
model and gains no dependency.

Disclosure: the text embeddings are committed model output, against
REPO_POLICIES' rule on generated files.

Model: opus-5-5
2026-10-22 23:32:25 +02:00
15 changed files with 2523 additions and 1487 deletions
+1713 -1214
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -26,6 +26,31 @@ declares one.
# Completed Steps
- 2026-10-26: On an empty cache, `quak helper fix-missing-thumbnails --file`
stops fetching collections' files once it finds every file it names
(https://git.eeqj.de/sneak/quak/issues/223). It used to wait for a refresh,
which on an empty cache fetched the files of every collection. Now it reads
the collection list, then one collection's files at a time, the most recently
updated first. It stores none of the collections or file records it reads. It
fetches each original through `lib.originalOf()`, which keeps it in the cache.
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
alone (https://git.eeqj.de/sneak/quak/issues/217). It used to wait for a
refresh, which on an empty cache fetched the files of every collection. It now
reads the collection list through `lib.freshCollections()`, then the one
collection's files through the new `lib.freshFiles()`. It stores nothing, and
the files print in the server's order.
- 2026-10-22: The JSON `quak backup` and `quak backup-metadata` write for a file
holds `keywords` in place of the CLIP embedding in its ML data
(https://git.eeqj.de/sneak/quak/issues/218). The keywords are the titles of
+37 -24
View File
@@ -37,9 +37,16 @@ import {
thumbnailName,
} from "./cli-output.js";
import { type Log, logRefresh } from "./cli-log.js";
import { freshCollections, freshFiles, freshFile } from "./cli-read.js";
import {
cachedFilesByID,
freshCollections,
freshFiles,
freshFile,
freshFilesByID,
} from "./cli-read.js";
import { withExtension } from "./filename.js";
import { runMetadataBackup } from "./metadata-backup.js";
import type { Collection, EnteFile } from "./model/types.js";
import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js";
export interface CliContext {
@@ -128,12 +135,15 @@ const readLibraryClient = (client: Client): LibraryClient => ({
contentSource: () => client.contentSource(),
});
// Open a library for a single point command: the aggressive background precache
// (issue #48) is off — a one-shot `collections` or `get` must not start
// 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 logged, so a large account shows progress rather than nothing.
// Open a library for a single point command. The aggressive background
// 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
// 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
// #217). Nor do `get` and `get-thumb` on an empty cache (issue #222). Nor does
// `helper fix-missing-thumbnails --file` on an empty cache (issue #223). While
// a refresh runs, each collection whose files it has 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),
@@ -282,11 +292,11 @@ export const filesCommand = async (
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
// metadata (raw title, microsecond creationTime) via cli-output, and in
// the pre-library CLI's enumeration order, not the projection's
// newest-first order.
// Ask the server for current state (issue #36 amendment, issue #52).
// Each file prints from its own decrypted metadata (raw title,
// microsecond creationTime) via cli-output. The files print in the
// order `freshFiles` returns them, not the projection's newest-first
// order.
const files = await freshFiles(lib, collectionID);
if (!files) {
ctx.log("fatal", `Collection ${collectionID} not found`);
@@ -323,14 +333,13 @@ export const getCommand = async (
try {
// Force a server round-trip so the file resolves against current state
// (issue #36 amendment, issue #52).
const resolved = await freshFile(lib, fileID);
if (!resolved) {
const file = await freshFile(lib, fileID);
if (!file) {
ctx.log("fatal", `File ${fileID} not found`);
return 1;
}
const { photo, file } = resolved;
const result = await photo.original();
const result = await lib.originalOf(file);
// Default name is the file's own title, as the pre-library CLI used
// (not the editedName-preferring projection title) (issue #52).
if (result.videoPath === undefined) {
@@ -381,14 +390,13 @@ export const getThumbCommand = async (
try {
// Force a server round-trip so the file resolves against current state
// (issue #36 amendment, issue #52).
const resolved = await freshFile(lib, fileID);
if (!resolved) {
const file = await freshFile(lib, fileID);
if (!file) {
ctx.log("fatal", `File ${fileID} not found`);
return 1;
}
const { photo, file } = resolved;
const result = await photo.thumbnail();
const result = await lib.thumbnailOf(file);
// Default name is thumb_<file's own title>, as the pre-library CLI
// used (not the projection title) (issue #52).
const outPath = opts.out ?? thumbnailName(file);
@@ -565,10 +573,13 @@ export const fixMissingThumbnailsCommand = async (
}
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) {
let found: Map<number, { file: EnteFile; collection: Collection }>;
if (fileIDs.length > 0) {
found = await freshFilesByID(lib, fileIDs);
} else {
// 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...");
const missing = await listMissingThumbnails(
lib,
@@ -581,12 +592,14 @@ export const fixMissingThumbnailsCommand = async (
return 0;
}
ctx.log("info", `Found ${fileIDs.length} file(s) to fix.`);
found = cachedFilesByID(lib, fileIDs);
}
const results = await fixMissingThumbnails(
lib,
client,
fileIDs,
found,
(msg, level) => ctx.log(level ?? "info", msg),
);
+10 -7
View File
@@ -1,15 +1,18 @@
// 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
// Every line quak logs goes to stderr, 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 "What quak prints" in the README). A command that
// runs and 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
// 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.
// carries a command's output and the prompts of `login`, 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 CLI logs what they report. `logRequests` gives the API client options
+103 -37
View File
@@ -2,38 +2,40 @@
//
// `collections`, `files`, `get`, and `get-thumb` must answer for
// the account's state at the moment the command runs, not for whatever the
// local cache last happened to hold (owner amendment, issue #36). `files`,
// `get` and `get-thumb` therefore force a server round-trip through
// `Library.fresh()` and only then read — so a collection, file, or metadata
// change made elsewhere is visible. `collections` asks the library for the
// collection list alone through `Library.freshCollections()` (issue #210): a
// refresh would also fetch the files of every collection, which on a large
// account takes long and which `collections` does not print.
// local cache last happened to hold (owner amendment, issue #36). `get`,
// `get-thumb` and `files` without a collection ID therefore force a server
// round-trip through `Library.fresh()` and only then read, so a change made
// elsewhere is visible. `collections` asks the library for the collection list
// alone through `Library.freshCollections()` (issue #210): a refresh would also
// fetch the files of every collection, which on a large account takes long and
// which `collections` does not print. `files` with a collection ID reads that
// list too, then that one collection's files through `Library.freshFiles()`
// (issue #217). On an empty cache, `get` and `get-thumb` read that list, then
// 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 (issue #223).
//
// `collections` lists in the server's order and `files` in the library's own
// enumeration order (`listFiles()`), the orders the pre-library CLI printed,
// rather than the `albums`/`photos` projection's newest-first order, which
// re-sorts the rows. The field values still come from each record's raw
// metadata via `cli-output.ts`. `files` without a collection ID lists every
// file in the account once (issue #211): the library keeps a record of a file
// for each collection that holds it, and only the first collection's record is
// listed.
// `collections` and `files` with a collection ID list in the server's order.
// `files` without one lists in the library's own enumeration order
// (`listFiles()`). Neither command uses the `albums`/`photos` projection's
// newest-first order, which re-sorts the rows. The field values still come
// from each record's raw metadata via `cli-output.ts`. `files` without a
// collection ID lists every file in the account once (issue #211): the library
// keeps a record of a file for each collection that holds it, and only the
// first collection's record is listed.
import type { Collection, EnteFile } from "./model/types.js";
import type { Photo, PhotosAPI } from "./library/index.js";
// The slice of `Library` these helpers read. `Library` satisfies it
// structurally; a test can drive them with a stand-in that records the
// `fresh()` and `freshCollections()` calls and serves records in a known
// enumeration order.
// structurally; a test can drive them with a stand-in that records each fresh
// read and serves records in a known enumeration order.
export interface FreshReadLibrary {
fresh(): Promise<unknown>;
freshCollections(): Promise<Collection[]>;
getCollection(id: number): Collection | undefined;
freshFiles(collection: Collection): Promise<EnteFile[]>;
listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[];
getFileByID(fileID: number): EnteFile | undefined;
photos: Pick<PhotosAPI, "byID">;
}
// Every live collection, current as of one collections request, in the
@@ -42,21 +44,21 @@ export const freshCollections = (
lib: FreshReadLibrary,
): Promise<Collection[]> => lib.freshCollections();
// The files of one collection, current as of a forced refresh, in enumeration
// The files of one collection, as the server holds them now, in the server's
// order. `undefined` (not an empty list) when the collection does not exist, so
// the caller can tell "no such collection" from "an empty collection". Without
// a collection ID, every file in the account, each once, as held by the first
// collection that holds it.
// a collection ID, every file in the account as of a forced refresh, each once,
// as held by the first collection that holds it.
export const freshFiles = async (
lib: FreshReadLibrary,
collectionID?: number,
): Promise<EnteFile[] | undefined> => {
await lib.fresh();
if (collectionID !== undefined) {
return lib.getCollection(collectionID)
? lib.listFiles(collectionID)
: undefined;
const collections = await lib.freshCollections();
const collection = collections.find((c) => c.id === collectionID);
return collection ? lib.freshFiles(collection) : undefined;
}
await lib.fresh();
const listed = new Set<number>();
const files: EnteFile[] = [];
for (const collection of lib.listCollections()) {
@@ -69,16 +71,80 @@ export const freshFiles = async (
return files;
};
// One file, current as of a forced refresh, resolved to both its content
// handle (`Photo`, for fetching bytes) and its raw record (`EnteFile`, for the
// default output name and field values). `undefined` when the file is unknown.
// 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
// the file up in the cache. On an empty cache, it reads the collection list,
// then one collection's files at a time, and stops at the first collection
// that holds the file. That search stores nothing.
export const freshFile = async (
lib: FreshReadLibrary,
fileID: number,
): Promise<{ photo: Photo; file: EnteFile } | undefined> => {
await lib.fresh();
const photo = lib.photos.byID({ fileID });
const file = lib.getFileByID(fileID);
if (!photo || !file) return undefined;
return { photo, file };
): Promise<EnteFile | undefined> => {
// Only a refresh stores collections, so a cache holding any holds them all.
if (lib.listCollections().length > 0) {
await lib.fresh();
return lib.getFileByID(fileID);
}
for (const collection of await lib.freshCollections()) {
const files = await lib.freshFiles(collection);
const file = files.find((f) => f.id === fileID);
if (file) return file;
}
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;
};
+38 -11
View File
@@ -581,14 +581,34 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
fileID: number,
opts?: ContentOptions,
): Promise<ContentResult> {
return this.get(fileID, "original", "on-demand", opts?.onProgress);
const file = this.fileOrThrow(fileID);
return this.get(file, "original", "on-demand", opts?.onProgress);
}
async thumbnail(
fileID: number,
opts?: ContentOptions,
): Promise<ContentResult> {
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
const file = this.fileOrThrow(fileID);
return this.get(file, "thumbnail", "on-demand", opts?.onProgress);
}
// The original of `file`, fetched and cached as `original` does. `file` is
// used as given, so `getFile` need not know it.
async originalOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
return this.get(file, "original", "on-demand", opts?.onProgress);
}
// The thumbnail of `file`, fetched and cached as `thumbnail` does. `file`
// is used as given, so `getFile` need not know it.
async thumbnailOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
return this.get(file, "thumbnail", "on-demand", opts?.onProgress);
}
// Put the original at the save path of `file` under the download directory
@@ -615,7 +635,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
destination: string,
): Promise<ContentResult> {
const result = await this.acquire(
fileID,
this.fileOrThrow(fileID),
"original",
"on-demand",
undefined,
@@ -638,8 +658,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
fileID: number,
destination: string,
): Promise<{ path: string; videoPath?: string }> {
const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
const file = this.fileOrThrow(fileID);
return this.pools.content.run(
async () => {
const fetched = await this.fetchInto(
@@ -721,7 +740,8 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
onProgress: ((event: EnsureEvent) => void) | undefined,
): Promise<EnsureResult> {
try {
const result = await this.acquire(fileID, kind, priority, signal);
const file = this.fileOrThrow(fileID);
const result = await this.acquire(file, kind, priority, signal);
const status = result.cached ? "skipped" : "done";
onProgress?.({ fileID, status, path: result.path });
return { fileID, path: result.path };
@@ -736,8 +756,16 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
}
}
// The record `getFile` gives for `fileID`. It throws for a file `getFile`
// does not know.
private fileOrThrow(fileID: number): EnteFile {
const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
return file;
}
private async get(
fileID: number,
file: EnteFile,
kind: Kind,
priority: Priority,
onProgress: ((event: ContentEvent) => void) | undefined,
@@ -745,7 +773,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
const onByte: ProgressCallback | undefined = onProgress
? (bytesDone) => onProgress({ status: "downloading", bytesDone })
: undefined;
const result = await this.acquire(fileID, kind, priority, undefined, {
const result = await this.acquire(file, kind, priority, undefined, {
onByte,
});
onProgress?.(
@@ -767,7 +795,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
// photo's original is present only with its video, and is returned with
// it.
private async acquire(
fileID: number,
file: EnteFile,
kind: Kind,
priority: Priority,
signal: AbortSignal | undefined,
@@ -778,8 +806,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
videoPath?: string;
cached: boolean;
}> {
const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
const fileID = file.id;
const isLivePhoto =
kind === "original" && file.metadata.fileType === "livePhoto";
+54 -3
View File
@@ -20,9 +20,10 @@
//
// `freshCollections()` is the fresh read of the collection list alone (issue
// #210): one collections request, no collection's files, and nothing stored,
// for a caller such as `quak collections` that needs no more. A caller that
// asks for each refresh it needs can also open the library with
// `refreshOnOpen: false`, so that `open()` starts none.
// for a caller such as `quak collections` that needs no more. `freshFiles()`
// is the fresh read of one collection's files, and stores nothing either
// (issue #217). A caller that asks for each refresh it needs can also open the
// library with `refreshOnOpen: false`, so that `open()` starts none.
//
// A refresh reports through `onProgress` each collection whose files it has
// fetched, so a large account, whose first refresh fetches the files of every
@@ -65,6 +66,8 @@ import {
ContentCache,
savePath,
storedAtSavePath,
type ContentOptions,
type ContentResult,
type ContentSource,
type ThumbnailsAPI,
type EnsureOptions,
@@ -625,6 +628,54 @@ export class Library {
return collections;
}
// The fresh read of one collection's files (issue #217): every live file
// in it that decrypts, in the server's order. `collection` is a record
// `freshCollections()` returned, whose key decrypts the files. It fetches
// no other collection's files. Like `freshCollections()`, it stores
// nothing. It returns each file once, as the latest copy the server sent,
// where the first copy was.
async freshFiles(collection: Collection): Promise<EnteFile[]> {
if (this.closed) throw new Error("the library is closed");
const { files } = await this.client.filesSince({
collectionID: collection.id,
collectionKey: collection.key,
sinceTime: 0,
});
const byID = new Map<number, EnteFile>();
for (const file of files) byID.set(file.id, file);
return [...byID.values()];
}
// 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
// cache, `quak get` passes a file that `freshFiles()` returned.
async originalOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
if (!this.cache) {
throw new Error(
"originalOf requires a library opened with a content cache",
);
}
return this.cache.originalOf(file, opts);
}
// 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
// cache, `quak get-thumb` passes a file that `freshFiles()` returned.
async thumbnailOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
if (!this.cache) {
throw new Error(
"thumbnailOf requires a library opened with a content cache",
);
}
return this.cache.thumbnailOf(file, opts);
}
// Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure
// ledger (issue #51). Takes the lock in that directory first, unless the
+11 -36
View File
@@ -5,7 +5,7 @@ import type { Client } from "./client.js";
import type { Library } from "./library/index.js";
import { ApiError } from "./api/client.js";
import { encryptBlob, toBase64 } from "./crypto/index.js";
import type { EnteFile } from "./model/types.js";
import type { Collection, EnteFile } from "./model/types.js";
// 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
@@ -226,16 +226,18 @@ const encryptThumbnailWithin = (
return undefined;
};
// Regenerate and upload a thumbnail for each requested file. Originals are read
// through the library's content cache (`photo.original()`); the generated
// thumbnail is JPEG-encoded, encrypted under the file's own key, and registered
// with the server — the encrypt-and-upload path is unchanged. Each file is
// Regenerate and upload a thumbnail for each requested file. `found` holds the
// record of each requested file that a collection holds, and the collection it
// was found in. Each original is read through the library's content cache, by
// `lib.originalOf(file)`. The new thumbnail is a JPEG, encrypted under the
// 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
// never stops the others.
export const fixMissingThumbnails = async (
lib: Library,
client: Client,
fileIDs: number[],
found: Map<number, { file: EnteFile; collection: Collection }>,
onProgress?: ProgressCallback,
): Promise<ThumbnailFixResult[]> => {
const log = onProgress ?? (() => {});
@@ -243,31 +245,8 @@ export const fixMissingThumbnails = async (
const api = client.getApiClient();
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) {
const entry = fileMap.get(fileID);
const entry = found.get(fileID);
if (!entry) {
results.push({
fileID,
@@ -279,7 +258,8 @@ export const fixMissingThumbnails = async (
continue;
}
const { file, collectionName } = entry;
const { file } = entry;
const collectionName = entry.collection.name;
const title = file.metadata.title;
const skipReason = reasonToSkip(file, userID);
@@ -297,15 +277,10 @@ export const fixMissingThumbnails = async (
const maxSize = file.thumbnail.size!;
try {
const photo = lib.photos.byID({ fileID });
if (!photo) {
throw new Error("file not present in the library cache");
}
log(
`[${collectionName}] Downloading ${title} for thumbnail generation...`,
);
const { path } = await photo.original();
const { path } = await lib.originalOf(file);
const fileBytes = new Uint8Array(readFileSync(path));
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]);
});
// The tree "Backup layout" in the README describes. Each original is at
// The tree under "The backup directory" in the README. Each original is at
// 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,
// each with the extension it had inside the ZIP, and a `.livephoto.json`
+317 -91
View File
@@ -194,9 +194,6 @@ const FETCHED_BOTH_ALBUMS =
"Fetched the files of 1 of 2 collections\n" +
"Fetched the files of 2 of 2 collections\n";
// The same for an account with one album.
const FETCHED_ONE_ALBUM = "Fetched the files of 1 of 1 collections\n";
// Collects everything written to it.
class Output {
text = "";
@@ -535,9 +532,7 @@ describe("files", () => {
it("exits 1 for an unknown collection", async () => {
expect(await filesCommand(context(), "9", {})).toBe(1);
expect(stderr.text).toBe(
FETCHED_BOTH_ALBUMS + "Collection 9 not found\n",
);
expect(stderr.text).toBe("Collection 9 not found\n");
});
// Without a collection ID it lists every file in the account once (issue
@@ -578,27 +573,27 @@ describe("get and get-thumb", () => {
const out = join(root, "diagram.png");
expect(await getCommand(context(), "200", { out })).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200));
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `7 bytes -> ${out}\n`);
expect(stderr.text).toBe(`7 bytes -> ${out}\n`);
});
it("get-thumb finds a file in any album without --collection", async () => {
const out = join(root, "thumb.jpg");
expect(await getThumbCommand(context(), "200", { out })).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200));
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `3 bytes -> ${out}\n`);
expect(stderr.text).toBe(`3 bytes -> ${out}\n`);
});
it("get exits 1 when no album has the file", async () => {
const out = join(root, "x");
expect(await getCommand(context(), "999", { out })).toBe(1);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n");
expect(stderr.text).toBe("File 999 not found\n");
expect(existsSync(out)).toBe(false);
});
it("get-thumb exits 1 when no album has the file", async () => {
const out = join(root, "x");
expect(await getThumbCommand(context(), "999", { out })).toBe(1);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n");
expect(stderr.text).toBe("File 999 not found\n");
expect(existsSync(out)).toBe(false);
});
});
@@ -646,7 +641,7 @@ describe("names from an album name or a title over 255 bytes", () => {
expect(Buffer.byteLength(name)).toBe(255);
expect(code).toBe(0);
expect(readdirSync(dir)).toEqual([name]);
expect(stderr.text).toBe(FETCHED_ONE_ALBUM + `7 bytes -> ${name}\n`);
expect(stderr.text).toBe(`7 bytes -> ${name}\n`);
});
it("get-thumb cuts the title short so that thumb_ and it fit in 255 bytes, and exits 0", async () => {
@@ -658,7 +653,7 @@ describe("names from an album name or a title over 255 bytes", () => {
expect(Buffer.byteLength(name)).toBe(255);
expect(code).toBe(0);
expect(readdirSync(dir)).toEqual([name]);
expect(stderr.text).toBe(FETCHED_ONE_ALBUM + `3 bytes -> ${name}\n`);
expect(stderr.text).toBe(`3 bytes -> ${name}\n`);
});
it("backup-metadata cuts the album name short so that its folder's name fits in 255 bytes, and exits 0", async () => {
@@ -747,8 +742,7 @@ describe("a live photo", () => {
Buffer.from(VIDEO),
);
expect(stderr.text).toBe(
FETCHED_ONE_ALBUM +
`${IMAGE.length} bytes -> IMG_0300.heic\n` +
`${IMAGE.length} bytes -> IMG_0300.heic\n` +
`${VIDEO.length} bytes -> IMG_0300.mov\n`,
);
});
@@ -802,8 +796,7 @@ describe("a live photo", () => {
expect(readFileSync(out)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
expect(stderr.text).toBe(
FETCHED_ONE_ALBUM +
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
);
});
@@ -817,8 +810,7 @@ describe("a live photo", () => {
expect(existsSync(out)).toBe(false);
expect(existsSync(join(root, "photo.mov"))).toBe(false);
expect(stderr.text).toBe(
FETCHED_ONE_ALBUM +
`File 300 is a live photo, and its video would also be written to ${out}\n`,
`File 300 is a live photo, and its video would also be written to ${out}\n`,
);
});
@@ -1361,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", () => {
@@ -1390,11 +1399,11 @@ describe("backup-metadata --exif", () => {
});
// Each test first runs `files` so the cache holds the account as it was, then
// changes the server under it. (`collections` would not do: it reads the
// collection list alone and stores nothing.)
// changes the server under it. (`collections` and `files` with a collection ID
// would not do: they store nothing.)
describe("backup-metadata and the thumbnail helpers refresh first", () => {
beforeEach(async () => {
expect(await filesCommand(context(), "1", {})).toBe(0);
expect(await filesCommand(context(), undefined, {})).toBe(0);
stdout.text = "";
stderr.text = "";
});
@@ -1577,20 +1586,83 @@ describe("an ID given on the command line", () => {
);
});
// An account with many albums (issue #210). On an empty cache `collections`
// used to wait for a refresh that fetched and decrypted the files of every
// album, one album after another, before it printed anything, so on such an
// account it 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`, which also records the path of each request quak
// sends. The session is the one logging in to that server saves.
// 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. 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";
const paths: string[] = [];
const requests: string[] = [];
let server: typeof globalThis.fetch;
let snapshot: ClientSnapshot;
// What `files` without a collection ID prints on an empty cache: every
// album's photo on stdout, and on stderr each album whose files its
// refresh fetched.
let everyPhoto = "";
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
everyPhoto += `${n * 100}\timage\tphoto-${n}.jpg\n`;
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
// The key of each album, by album ID.
const albumKeys = new Map<number, Uint8Array>();
// 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. 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;
title: string;
updationTime: number;
}): { row: Record<string, unknown>; original: Uint8Array } => {
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const photoKeyBox = encryptSecretbox(
photoKey,
albumKeys.get(photo.collectionID)!,
);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title: photo.title,
fileType: 0,
creationTime: 1000,
modificationTime: 1000,
}),
),
photoKey,
);
const original = encryptStreamBlob(
new TextEncoder().encode(photo.title),
photoKey,
);
const row = {
id: photo.id,
collectionID: photo.collectionID,
ownerID: USER_ID,
encryptedKey: toBase64(photoKeyBox.ciphertext),
keyDecryptionNonce: toBase64(photoKeyBox.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
file: { decryptionHeader: toBase64(original.header) },
thumbnail: { decryptionHeader: "" },
info: { thumbSize: 1000 },
updationTime: photo.updationTime,
};
return { row, original: original.ciphertext };
};
// Album n is named `Album n` and holds one photo, n * 100, titled
// `photo-n.jpg`.
beforeAll(async () => {
@@ -1598,8 +1670,10 @@ describe("an account with many albums", () => {
const account = await buildAccount(PASSWORD);
const collections: Record<string, unknown>[] = [];
const filesByCollection: Record<number, Record<string, unknown>[]> = {};
const fileCiphertexts: Record<number, Uint8Array> = {};
for (let id = 1; id <= ALBUMS; id++) {
const albumKey = sodium.crypto_secretbox_keygen();
albumKeys.set(id, albumKey);
const albumKeyBox = encryptSecretbox(albumKey, account.masterKey);
const name = encryptSecretbox(
new TextEncoder().encode(`Album ${id}`),
@@ -1615,37 +1689,14 @@ describe("an account with many albums", () => {
type: "album",
updationTime: 1,
});
const photoKey =
sodium.crypto_secretstream_xchacha20poly1305_keygen();
const photoKeyBox = encryptSecretbox(photoKey, albumKey);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title: `photo-${id}.jpg`,
fileType: 0,
creationTime: 1000,
modificationTime: 1000,
}),
),
photoKey,
);
filesByCollection[id] = [
{
id: id * 100,
collectionID: id,
ownerID: USER_ID,
encryptedKey: toBase64(photoKeyBox.ciphertext),
keyDecryptionNonce: toBase64(photoKeyBox.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
// These tests fetch no original and no thumbnail.
file: { decryptionHeader: "" },
thumbnail: { decryptionHeader: "" },
updationTime: 1,
},
];
const { row, original } = photoRow({
id: id * 100,
collectionID: id,
title: `photo-${id}.jpg`,
updationTime: 1,
});
filesByCollection[id] = [row];
fileCiphertexts[id * 100] = original;
}
const ente = buildMetaFetch({
verifier: account.verifier,
@@ -1655,7 +1706,7 @@ describe("an account with many albums", () => {
collections,
filesByCollection,
encryptedMLData: {},
fileCiphertexts: {},
fileCiphertexts,
});
server = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
@@ -1664,7 +1715,8 @@ describe("an account with many albums", () => {
: input instanceof URL
? input.href
: input.url;
paths.push(new URL(url).pathname);
const { pathname, search } = new URL(url);
requests.push(pathname + search);
return ente(input, init);
}) as typeof globalThis.fetch;
const client = await Client.login({
@@ -1676,16 +1728,17 @@ describe("an account with many albums", () => {
});
beforeEach(() => {
paths.length = 0;
requests.length = 0;
});
// A context whose saved session sends its requests to the fake server.
const accountContext = (): CliContext => {
// A context whose saved session sends its requests to `fetch`, by default
// the fake server.
const accountContext = (fetch = server): CliContext => {
const ctx = context();
saveSession(ctx.sessionDir, snapshot);
return {
...ctx,
loadSession: (path) => loadSession(path, { fetch: server }),
loadSession: (path) => loadSession(path, { fetch }),
};
};
@@ -1698,46 +1751,219 @@ describe("an account with many albums", () => {
albums += `${id}\talbum\tAlbum ${id}\n`;
expect(stdout.text).toBe(albums);
expect(stderr.text).toBe("");
expect(paths).toEqual(["/collections/v2"]);
expect(requests).toEqual(["/collections/v2?sinceTime=0"]);
// It stored nothing that would make a refresh skip an album's files:
// `files` after it still finds the album's photo.
// `files` after it still lists every album's photo.
stdout.text = "";
expect(await filesCommand(ctx, "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(stdout.text).toBe(everyPhoto);
});
it("files counts on stderr each album whose files its refresh fetched", async () => {
it("files with a collection ID asks for the album list and that album's files alone", async () => {
expect(await filesCommand(accountContext(), "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
expect(stderr.text).toBe("");
expect(requests).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=7&sinceTime=0",
]);
});
// Here the server sends album 7's files in two pages. Photo 700 changed
// between them, so both pages hold a copy of it.
it("files with a collection ID lists only the later copy of a photo sent in two pages", async () => {
const firstPage = [
photoRow({
id: 700,
collectionID: 7,
title: "photo-7.jpg",
updationTime: 1,
}).row,
photoRow({
id: 701,
collectionID: 7,
title: "photo-701.jpg",
updationTime: 1,
}).row,
];
const secondPage = [
photoRow({
id: 700,
collectionID: 7,
title: "photo-7-edited.jpg",
updationTime: 2,
}).row,
];
const paged = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : input);
if (
url.pathname !== "/collections/v2/diff" ||
url.searchParams.get("collectionID") !== "7"
) {
return server(input, init);
}
const first = url.searchParams.get("sinceTime") === "0";
return new Response(
JSON.stringify({
diff: first ? firstPage : secondPage,
hasMore: first,
}),
);
}) as typeof globalThis.fetch;
expect(await filesCommand(accountContext(paged), "7", {})).toBe(0);
// Photo 700 is listed once, with the later title, before photo 701.
expect(stdout.text).toBe(
"700\timage\tphoto-7-edited.jpg\n701\timage\tphoto-701.jpg\n",
);
});
// The cache stays empty, so a later `files` without a collection ID still
// fetches the files of all 40 albums.
it("files with a collection ID stores nothing in the cache", async () => {
const ctx = accountContext();
expect(await filesCommand(ctx, "7", {})).toBe(0);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
stdout.text = "";
expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(stdout.text).toBe(everyPhoto);
expect(stderr.text).toBe(progress);
});
// 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.
it("get on an empty cache asks for the albums' files only until one holds the photo", async () => {
const out = join(root, "photo.jpg");
expect(await getCommand(accountContext(), "200", { out })).toBe(0);
expect(readFileSync(out, "utf-8")).toBe("photo-2.jpg");
expect(stderr.text).toBe(`11 bytes -> ${out}\n`);
expect(requests).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=1&sinceTime=0",
"/collections/v2/diff?collectionID=2&sinceTime=0",
"/?fileID=200",
]);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
});
// `files` first fills the cache, so `get` waits for a refresh. No album has
// changed since, so the refresh asks for no album's files.
it("get 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;
stderr.text = "";
const out = join(root, "photo.jpg");
expect(await getCommand(ctx, "4000", { out })).toBe(0);
expect(readFileSync(out, "utf-8")).toBe("photo-40.jpg");
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([
"/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);
expect(stdout.text).toBe(everyPhoto);
expect(stderr.text).toBe(progress);
});
// `--json` shapes stdout only, so the refresh's progress is still counted
// on stderr.
it("files --json counts on stderr each album whose files its refresh fetched", async () => {
expect(await filesCommand(accountContext(), "7", { json: true })).toBe(
0,
);
const opts = { json: true };
expect(await filesCommand(accountContext(), undefined, opts)).toBe(0);
expect(JSON.parse(stdout.text)).toEqual([
{
id: 700,
title: "photo-7.jpg",
fileType: "image",
creationTime: 1000,
collectionID: 7,
},
]);
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
expect(JSON.parse(stdout.text)).toHaveLength(ALBUMS);
expect(stderr.text).toBe(progress);
});
});
+7 -5
View File
@@ -234,13 +234,15 @@ describe("-q", () => {
});
});
// The refresh's progress is 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 () => {
expect(await quak("-q", "files", "9")).toEqual({
const fix = ["helper", "fix-missing-thumbnails", "--file", "999"];
expect(await quak("-q", ...fix)).toEqual({
code: 1,
stdout: "",
stderr: "Collection 9 not found\n",
stderr: `quak: 1 file(s) failed; see the "Failed files:" list, or the JSON with --json\n`,
});
});
@@ -326,7 +328,7 @@ describe("what the library reports", () => {
]);
});
// Each operation is named in plain words, as the README names it.
// Each operation is named in plain words.
it("logs a refresh's progress at info, and its start and end at debug", () => {
const { log, lines } = collect();
const onProgress = logRefresh(log);
+143 -49
View File
@@ -3,20 +3,25 @@
* issue #36, issue #52).
*
* The `collections`, `files`, `get`, and `get-thumb` commands must answer for
* current server state, not the local cache. `files`, `get` and `get-thumb`
* force a `Library.fresh()` round-trip before they read; the stand-in library
* below serves nothing until `fresh()` has been awaited, so a helper that read
* without refreshing would come back empty and fail here. `collections` asks
* the library for the collection list alone (`freshCollections()`, issue
* #210), and must not wait for a refresh, which would fetch the files of every
* collection.
* current server state, not the local cache. `get`, `get-thumb` and `files`
* without a collection ID force a `Library.fresh()` round-trip before they
* read. The stand-in library below serves no cached record until `fresh()` has
* been awaited, so a helper that read without refreshing would come back empty
* and fail here. `collections` asks the library for the collection list alone
* (`freshCollections()`, issue #210). `files` with a collection ID asks for
* 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
* 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 library's enumeration order
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — 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
* an enumeration order that a newest-first sort would rearrange, so a
* regression to the projection order would fail here too. Field values still
* come from the raw metadata via `cli-output.ts`.
* an order that a newest-first sort would rearrange, so a regression to the
* projection order would fail here too. Field values still come from the raw
* metadata via `cli-output.ts`.
*/
import { describe, it, expect } from "vitest";
@@ -25,10 +30,10 @@ import {
freshCollections,
freshFiles,
freshFile,
freshFilesByID,
type FreshReadLibrary,
} from "../../src/cli-read.js";
import { fileListRow } from "../../src/cli-output.js";
import type { Photo } from "../../src/library/index.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
const collection = (id: number, updationTime: number): Collection => ({
@@ -62,14 +67,15 @@ const file = (
updationTime: creationTime,
});
// A library that reveals its records only after `fresh()` has been awaited, and
// serves them in the enumeration order it was given. `freshCollections()`
// answers the collection list in that order without a refresh. `photos.byID`
// returns a stand-in `Photo` carrying just the fileID the helper passes
// through.
// A library that reveals its cached records only after `fresh()` has been
// awaited, and serves them in the enumeration order it was given.
// `freshCollections()` and `freshFiles()` answer in that order without a
// refresh.
class FakeLibrary implements FreshReadLibrary {
freshCalls = 0;
freshCollectionsCalls = 0;
// The ID of each collection `freshFiles()` was asked for.
freshFilesCalls: number[] = [];
private refreshed = false;
constructor(
@@ -88,9 +94,9 @@ class FakeLibrary implements FreshReadLibrary {
return this.collections;
}
getCollection(id: number): Collection | undefined {
if (!this.refreshed) return undefined;
return this.collections.find((c) => c.id === id);
async freshFiles(collection: Collection): Promise<EnteFile[]> {
this.freshFilesCalls.push(collection.id);
return this.files.filter((f) => f.collectionID === collection.id);
}
listCollections(): Collection[] {
@@ -107,14 +113,6 @@ class FakeLibrary implements FreshReadLibrary {
if (!this.refreshed) return undefined;
return this.files.find((f) => f.id === fileID);
}
photos = {
byID: ({ fileID }: { fileID: number }): Photo | undefined => {
if (!this.refreshed) return undefined;
if (!this.files.some((f) => f.id === fileID)) return undefined;
return { fileID } as unknown as Photo;
},
};
}
describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
@@ -138,18 +136,24 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
expect(rows.map((c) => c.id)).not.toEqual(newestFirst);
});
it("freshFiles refreshes first, lists in enumeration order, keeps raw fields", async () => {
// Enumeration order by id 10, 11, 12; creationTimes ascending, so a
it("freshFiles with a collection ID reads that collection's files alone and keeps their order and raw fields", async () => {
// Given in the order 10, 11, 12; creationTimes ascending, so a
// newest-first sort would reverse them.
const files = [
file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001),
file(12, 1, 1_700_000_000_000_002),
file(20, 2, 1_700_000_000_000_003),
];
const lib = new FakeLibrary([collection(1, 100)], files);
const lib = new FakeLibrary(
[collection(1, 100), collection(2, 200)],
files,
);
const rows = await freshFiles(lib, 1);
expect(lib.freshCalls).toBe(1);
expect(lib.freshCalls).toBe(0);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([1]);
expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]);
// Field values come from raw metadata: microsecond creationTime and the
@@ -206,27 +210,117 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
it("freshFiles returns undefined for an unknown collection", async () => {
const lib = new FakeLibrary([collection(1, 100)], []);
const rows = await freshFiles(lib, 999);
expect(lib.freshCalls).toBe(1);
expect(lib.freshCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(rows).toBeUndefined();
});
it("freshFile refreshes first, then resolves the photo and its raw record", async () => {
const f = file(10, 1, 1_700_000_000_000_000);
const lib = new FakeLibrary([collection(1, 100)], [f]);
const resolved = await freshFile(lib, 10);
expect(lib.freshCalls).toBe(1);
expect(resolved?.photo.fileID).toBe(10);
expect(resolved?.file.metadata.title).toBe("file-10.jpg");
expect(resolved?.file.metadata.creationTime).toBe(
1_700_000_000_000_000,
// Three collections, each holding one file.
const threeCollections = (): FakeLibrary =>
new FakeLibrary(
[collection(1, 100), collection(2, 200), collection(3, 300)],
[
file(10, 1, 1_700_000_000_000_000),
file(20, 2, 1_700_000_000_000_001),
file(30, 3, 1_700_000_000_000_002),
],
);
it("freshFile on an empty cache reads each collection's files in the server's order until one holds the file", async () => {
const lib = threeCollections();
const found = await freshFile(lib, 20);
expect(lib.freshCalls).toBe(0);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([1, 2]);
expect(found?.metadata.title).toBe("file-20.jpg");
expect(found?.metadata.creationTime).toBe(1_700_000_000_000_001);
});
it("freshFile returns undefined for an unknown file", async () => {
const lib = new FakeLibrary([collection(1, 100)], []);
const resolved = await freshFile(lib, 404);
expect(lib.freshCalls).toBe(1);
expect(resolved).toBeUndefined();
// 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 () => {
const lib = threeCollections();
await lib.fresh();
const found = await freshFile(lib, 30);
expect(lib.freshCalls).toBe(2);
expect(lib.freshCollectionsCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(found?.collectionID).toBe(3);
});
it("freshFile on an empty cache returns undefined for an unknown file, after reading every collection's files", async () => {
const lib = threeCollections();
const found = await freshFile(lib, 404);
expect(lib.freshCalls).toBe(0);
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);
});
});
+6
View File
@@ -253,6 +253,12 @@ describe("Library content wiring", () => {
await expect(
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
).rejects.toThrow(/content cache/i);
await expect(lib.originalOf(file(1, 1))).rejects.toThrow(
/content cache/i,
);
await expect(lib.thumbnailOf(file(1, 1))).rejects.toThrow(
/content cache/i,
);
await lib.close();
});
});
+19
View File
@@ -375,6 +375,25 @@ describe("ContentCache.original / thumbnail", () => {
await cache.open();
await expect(cache.original(999)).rejects.toThrow(/unknown file/i);
});
// `getFile` knows no file here, so the cache can fetch only the file it is
// given.
it("originalOf and thumbnailOf fetch and cache the file they are given", async () => {
const { cache, source } = buildCache({ files: [] });
await cache.open();
const original = await cache.originalOf(file(5));
const thumbnail = await cache.thumbnailOf(file(5));
expect(source.originalCalls).toEqual([5]);
expect(source.thumbnailCalls).toEqual([5]);
expect(original.path).toBe(join(cacheDir, "originals", "5.jpg"));
expect(thumbnail.path).toBe(join(cacheDir, "thumbnails", "5.jpg"));
expect(cache.pathsFor(5)).toEqual({
originalPath: original.path,
thumbnailPath: thumbnail.path,
});
});
});
describe("ContentCache.ensureThumbnails", () => {
+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");