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
clawbot af1491966c quak files COLLECTION fetches that collection's files alone (closes #217)
check / check (push) Canceled after 0s
`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
11 changed files with 528 additions and 232 deletions
+28 -11
View File
@@ -685,17 +685,23 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`,
`helper fix-missing-thumbnails` — force a fresh server round-trip before they `helper fix-missing-thumbnails` — force a fresh server round-trip before they
answer, so they report current account state rather than whatever the cache last answer, so they report current account state rather than whatever the cache last
held. `collections` asks the server for the collection list alone, in one held. `collections` asks the server for the collection list alone, in one
request, and neither fetches any collection's files nor changes the cache. The request, and neither fetches any collection's files nor changes the cache.
others wait for a refresh. With an empty cache, as on a first run, that refresh `files` with a collection ID asks for the same list, then for that one
fetches the files of every collection, one collection after another, and stores collection's files, and does not change the cache either. `get` and `get-thumb`
nothing until it has them all, which on a large account takes a while. Meanwhile ask for the same list, then for one collection's files at a time, until they
the command logs a line on stderr for each collection whose files it has find the file. They ask first for the collections the cache says hold the file.
fetched, such as `Fetched the files of 3 of 120 collections`, with `--json` too; They store nothing they read. The original or thumbnail they fetch is kept in
`backup` does the same while the refresh it starts with runs. stdout holds only the cache. The others wait for a refresh, and so does `files` without a
the command's output. If that round-trip fails, the command prints the error on collection ID. With an empty cache, as on a first run, that refresh fetches the
one line and exits 1, or 3 when the server no longer accepts the saved session files of every collection, one collection after another, and stores nothing
(see "Session handling"). An album or file quak cannot decrypt does not fail it; until it has them all, which on a large account takes a while. Meanwhile the
the command answers from what did decrypt (see "Default reads vs. fresh reads"). command logs a line on stderr for each collection whose files it has fetched,
such as `Fetched the files of 3 of 120 collections`, with `--json` too; `backup`
does the same while the refresh it starts with runs. stdout holds only the
command's output. If that round-trip fails, the command prints the error on one
line and exits 1, or 3 when the server no longer accepts the saved session (see
"Session handling"). An album or file quak cannot decrypt does not fail it; the
command answers from what did decrypt (see "Default reads vs. fresh reads").
`--cache-dir` overrides where the cache lives; without it each account gets its `--cache-dir` overrides where the cache lives; without it each account gets its
own directory under the per-user cache path. own directory under the per-user cache path.
@@ -1180,6 +1186,12 @@ since the stored copy, so a collection stored without its files would never get
them. `quak collections` uses it (issue them. `quak collections` uses it (issue
https://git.eeqj.de/sneak/quak/issues/210). https://git.eeqj.de/sneak/quak/issues/210).
`await lib.freshFiles(collection)` is the fresh read of one collection's files.
It answers with every live file in that collection that decrypts, in the
server's order. `collection` is a record that `freshCollections()` returned. The
method fetches no other collection's files and stores nothing. `quak files` with
a collection ID uses it, and so do `quak get` and `quak get-thumb`.
An album or file in the server's answer that quak cannot decrypt, or whose An album or file in the server's answer that quak cannot decrypt, or whose
metadata is not JSON, does not fail the refresh. The refresh leaves it out, an metadata is not JSON, does not fail the refresh. The refresh leaves it out, an
album with all its files, and drops any older copy the cache held, so reads album with all its files, and drops any older copy the cache held, so reads
@@ -1261,6 +1273,11 @@ source. An original that `original()`, `content()` or `exif()` downloads lands
in the cache, which does not make `isLocal` true; only `download()` and in the cache, which does not make `isLocal` true; only `download()` and
`lib.backup()` do. `lib.backup()` do.
`await lib.originalOf(file, opts?)` and `await lib.thumbnailOf(file, opts?)`
fetch and cache as `photo.original()` and `photo.thumbnail()` do. `file` is an
`EnteFile` the caller holds, and the library need not hold it. `quak get` and
`quak get-thumb` pass one that `lib.freshFiles()` returned.
Lower-level accessors that return decrypted model objects (which hold key Lower-level accessors that return decrypted model objects (which hold key
material) are also available: `listCollections()`, `getCollection(id)`, material) are also available: `listCollections()`, `getCollection(id)`,
`listFiles(collectionID)`, `getFile(collectionID, fileID)`, and `listFiles(collectionID)`, `getFile(collectionID, fileID)`, and
+15
View File
@@ -26,6 +26,21 @@ declares one.
# Completed Steps # Completed Steps
- 2026-10-26: `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. They now read the collection list, then one collection's files at
a time, first those the cache says hold the file. They store nothing they
read, and fetch the file through the new `lib.originalOf()` and
`lib.thumbnailOf()`.
- 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 - 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 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 (https://git.eeqj.de/sneak/quak/issues/218). The keywords are the titles of
+23 -23
View File
@@ -128,12 +128,14 @@ const readLibraryClient = (client: Client): LibraryClient => ({
contentSource: () => client.contentSource(), contentSource: () => client.contentSource(),
}); });
// Open a library for a single point command: the aggressive background precache // Open a library for a single point command. The aggressive background
// (issue #48) is off — a one-shot `collections` or `get` must not start // precache (issue #48) is off, because a one-shot `collections` or `get` must
// downloading the whole account — and no refresh starts as it opens or on a // not start downloading the whole account. No refresh starts as the library
// timer: each command asks for the one it needs, and `collections` needs none // opens or on a timer. Each command asks for the refresh it needs.
// (issue #210). While a refresh runs, each collection whose files it has // `collections` needs none (issue #210). Nor do `get`, `get-thumb` and `files`
// fetched is logged, so a large account shows progress rather than nothing. // with a collection ID (issues #217 and #222). 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> => const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
Library.open({ Library.open({
client: readLibraryClient(client), client: readLibraryClient(client),
@@ -282,11 +284,11 @@ export const filesCommand = async (
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Force a server round-trip and list in enumeration order (issue #36 // Ask the server for current state (issue #36 amendment, issue #52).
// amendment, issue #52). Each file prints from its own decrypted // Each file prints from its own decrypted metadata (raw title,
// metadata (raw title, microsecond creationTime) via cli-output, and in // microsecond creationTime) via cli-output. The files print in the
// the pre-library CLI's enumeration order, not the projection's // order `freshFiles` returns them, not the projection's newest-first
// newest-first order. // order.
const files = await freshFiles(lib, collectionID); const files = await freshFiles(lib, collectionID);
if (!files) { if (!files) {
ctx.log("fatal", `Collection ${collectionID} not found`); ctx.log("fatal", `Collection ${collectionID} not found`);
@@ -321,16 +323,15 @@ 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 resolved = await freshFile(lib, fileID); const file = await freshFile(lib, fileID);
if (!resolved) { if (!file) {
ctx.log("fatal", `File ${fileID} not found`); ctx.log("fatal", `File ${fileID} not found`);
return 1; 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 // Default name is the file's own title, as the pre-library CLI used
// (not the editedName-preferring projection title) (issue #52). // (not the editedName-preferring projection title) (issue #52).
if (result.videoPath === undefined) { if (result.videoPath === undefined) {
@@ -379,16 +380,15 @@ 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 resolved = await freshFile(lib, fileID); const file = await freshFile(lib, fileID);
if (!resolved) { if (!file) {
ctx.log("fatal", `File ${fileID} not found`); ctx.log("fatal", `File ${fileID} not found`);
return 1; 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 // Default name is thumb_<file's own title>, as the pre-library CLI
// used (not the projection title) (issue #52). // used (not the projection title) (issue #52).
const outPath = opts.out ?? thumbnailName(file); const outPath = opts.out ?? thumbnailName(file);
+49 -38
View File
@@ -2,38 +2,38 @@
// //
// `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). `files`, // local cache last happened to hold (owner amendment, issue #36). `files`
// `get` and `get-thumb` therefore force a server round-trip through // without a collection ID therefore forces a server round-trip through
// `Library.fresh()` and only then read — so a collection, file, or metadata // `Library.fresh()` and only then reads, so a change made elsewhere is visible.
// change made elsewhere is visible. `collections` asks the library for the // `collections` asks the library for the collection list alone through
// collection list alone through `Library.freshCollections()` (issue #210): a // `Library.freshCollections()` (issue #210): a refresh would also fetch the
// refresh would also fetch the files of every collection, which on a large // files of every collection, which on a large account takes long and which
// account takes long and which `collections` does not print. // `collections` does not print. `files` with a collection ID reads that list
// too, then that one collection's files through `Library.freshFiles()` (issue
// #217). `get` and `get-thumb` read that list, then one collection's files at a
// time, until they find the file (issue #222).
// //
// `collections` lists in the server's order and `files` in the library's own // `collections` and `files` with a collection ID list in the server's order.
// enumeration order (`listFiles()`), the orders the pre-library CLI printed, // `files` without one lists in the library's own enumeration order
// rather than the `albums`/`photos` projection's newest-first order, which // (`listFiles()`). Neither command uses the `albums`/`photos` projection's
// re-sorts the rows. The field values still come from each record's raw // newest-first order, which re-sorts the rows. The field values still come
// metadata via `cli-output.ts`. `files` without a collection ID lists every // from each record's raw metadata via `cli-output.ts`. `files` without a
// file in the account once (issue #211): the library keeps a record of a file // collection ID lists every file in the account once (issue #211): the library
// for each collection that holds it, and only the first collection's record is // keeps a record of a file for each collection that holds it, and only the
// listed. // first collection's record is listed.
import type { Collection, EnteFile } from "./model/types.js"; 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 // The slice of `Library` these helpers read. `Library` satisfies it
// structurally; a test can drive them with a stand-in that records the // structurally; a test can drive them with a stand-in that records each fresh
// `fresh()` and `freshCollections()` calls and serves records in a known // read and serves records in a known enumeration order.
// enumeration order.
export interface FreshReadLibrary { export interface FreshReadLibrary {
fresh(): Promise<unknown>; fresh(): Promise<unknown>;
freshCollections(): Promise<Collection[]>; freshCollections(): Promise<Collection[]>;
getCollection(id: number): Collection | undefined; 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;
photos: Pick<PhotosAPI, "byID">;
} }
// Every live collection, current as of one collections request, in the // Every live collection, current as of one collections request, in the
@@ -42,21 +42,21 @@ export const freshCollections = (
lib: FreshReadLibrary, lib: FreshReadLibrary,
): Promise<Collection[]> => lib.freshCollections(); ): 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 // 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 // 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 // a collection ID, every file in the account as of a forced refresh, each once,
// collection that holds it. // as held by the first collection that holds it.
export const freshFiles = async ( export const freshFiles = async (
lib: FreshReadLibrary, lib: FreshReadLibrary,
collectionID?: number, collectionID?: number,
): Promise<EnteFile[] | undefined> => { ): Promise<EnteFile[] | undefined> => {
await lib.fresh();
if (collectionID !== undefined) { if (collectionID !== undefined) {
return lib.getCollection(collectionID) const collections = await lib.freshCollections();
? lib.listFiles(collectionID) const collection = collections.find((c) => c.id === collectionID);
: undefined; return collection ? lib.freshFiles(collection) : undefined;
} }
await lib.fresh();
const listed = new Set<number>(); const listed = new Set<number>();
const files: EnteFile[] = []; const files: EnteFile[] = [];
for (const collection of lib.listCollections()) { for (const collection of lib.listCollections()) {
@@ -69,16 +69,27 @@ export const freshFiles = async (
return files; return files;
}; };
// One file, current as of a forced refresh, resolved to both its content // One file as the server holds it now, or `undefined` when no collection holds
// handle (`Photo`, for fetching bytes) and its raw record (`EnteFile`, for the // it. It reads the collection list, then one collection's files at a time, and
// default output name and field values). `undefined` when the file is unknown. // stops at the first collection that holds the file. It tries first the
// collections the cache says hold the file, then the rest in the server's
// order. It stores nothing.
export const freshFile = async ( export const freshFile = async (
lib: FreshReadLibrary, lib: FreshReadLibrary,
fileID: number, fileID: number,
): Promise<{ photo: Photo; file: EnteFile } | undefined> => { ): Promise<EnteFile | undefined> => {
await lib.fresh(); const collections = await lib.freshCollections();
const photo = lib.photos.byID({ fileID }); // Whether the cache holds the file in collection `c`.
const file = lib.getFileByID(fileID); const inCache = (c: Collection): boolean =>
if (!photo || !file) return undefined; lib.getFile(c.id, fileID) !== undefined;
return { photo, file }; const ordered = [
...collections.filter(inCache),
...collections.filter((c) => !inCache(c)),
];
for (const collection of ordered) {
const files = await lib.freshFiles(collection);
const file = files.find((f) => f.id === fileID);
if (file) return file;
}
return undefined;
}; };
+38 -11
View File
@@ -581,14 +581,34 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
fileID: number, fileID: number,
opts?: ContentOptions, opts?: ContentOptions,
): Promise<ContentResult> { ): 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( async thumbnail(
fileID: number, fileID: number,
opts?: ContentOptions, opts?: ContentOptions,
): Promise<ContentResult> { ): 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 // 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, destination: string,
): Promise<ContentResult> { ): Promise<ContentResult> {
const result = await this.acquire( const result = await this.acquire(
fileID, this.fileOrThrow(fileID),
"original", "original",
"on-demand", "on-demand",
undefined, undefined,
@@ -638,8 +658,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
fileID: number, fileID: number,
destination: string, destination: string,
): Promise<{ path: string; videoPath?: string }> { ): Promise<{ path: string; videoPath?: string }> {
const file = this.getFile(fileID); const file = this.fileOrThrow(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
return this.pools.content.run( return this.pools.content.run(
async () => { async () => {
const fetched = await this.fetchInto( const fetched = await this.fetchInto(
@@ -721,7 +740,8 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
onProgress: ((event: EnsureEvent) => void) | undefined, onProgress: ((event: EnsureEvent) => void) | undefined,
): Promise<EnsureResult> { ): Promise<EnsureResult> {
try { 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"; const status = result.cached ? "skipped" : "done";
onProgress?.({ fileID, status, path: result.path }); onProgress?.({ fileID, status, path: result.path });
return { fileID, 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( private async get(
fileID: number, file: EnteFile,
kind: Kind, kind: Kind,
priority: Priority, priority: Priority,
onProgress: ((event: ContentEvent) => void) | undefined, onProgress: ((event: ContentEvent) => void) | undefined,
@@ -745,7 +773,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
const onByte: ProgressCallback | undefined = onProgress const onByte: ProgressCallback | undefined = onProgress
? (bytesDone) => onProgress({ status: "downloading", bytesDone }) ? (bytesDone) => onProgress({ status: "downloading", bytesDone })
: undefined; : undefined;
const result = await this.acquire(fileID, kind, priority, undefined, { const result = await this.acquire(file, kind, priority, undefined, {
onByte, onByte,
}); });
onProgress?.( onProgress?.(
@@ -767,7 +795,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
// photo's original is present only with its video, and is returned with // photo's original is present only with its video, and is returned with
// it. // it.
private async acquire( private async acquire(
fileID: number, file: EnteFile,
kind: Kind, kind: Kind,
priority: Priority, priority: Priority,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
@@ -778,8 +806,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
videoPath?: string; videoPath?: string;
cached: boolean; cached: boolean;
}> { }> {
const file = this.getFile(fileID); const fileID = file.id;
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
const isLivePhoto = const isLivePhoto =
kind === "original" && file.metadata.fileType === "livePhoto"; 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 // `freshCollections()` is the fresh read of the collection list alone (issue
// #210): one collections request, no collection's files, and nothing stored, // #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 // for a caller such as `quak collections` that needs no more. `freshFiles()`
// asks for each refresh it needs can also open the library with // is the fresh read of one collection's files, and stores nothing either
// `refreshOnOpen: false`, so that `open()` starts none. // (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 // A refresh reports through `onProgress` each collection whose files it has
// fetched, so a large account, whose first refresh fetches the files of every // fetched, so a large account, whose first refresh fetches the files of every
@@ -65,6 +66,8 @@ import {
ContentCache, ContentCache,
savePath, savePath,
storedAtSavePath, storedAtSavePath,
type ContentOptions,
type ContentResult,
type ContentSource, type ContentSource,
type ThumbnailsAPI, type ThumbnailsAPI,
type EnsureOptions, type EnsureOptions,
@@ -625,6 +628,54 @@ export class Library {
return collections; 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. `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. `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 // Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure // library's, each original at its save path, with a durable failure
// ledger (issue #51). Takes the lock in that directory first, unless the // ledger (issue #51). Takes the lock in that directory first, unless the
+218 -91
View File
@@ -194,9 +194,6 @@ const FETCHED_BOTH_ALBUMS =
"Fetched the files of 1 of 2 collections\n" + "Fetched the files of 1 of 2 collections\n" +
"Fetched the files of 2 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. // Collects everything written to it.
class Output { class Output {
text = ""; text = "";
@@ -535,9 +532,7 @@ describe("files", () => {
it("exits 1 for an unknown collection", async () => { it("exits 1 for an unknown collection", async () => {
expect(await filesCommand(context(), "9", {})).toBe(1); expect(await filesCommand(context(), "9", {})).toBe(1);
expect(stderr.text).toBe( expect(stderr.text).toBe("Collection 9 not found\n");
FETCHED_BOTH_ALBUMS + "Collection 9 not found\n",
);
}); });
// Without a collection ID it lists every file in the account once (issue // 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"); const out = join(root, "diagram.png");
expect(await getCommand(context(), "200", { out })).toBe(0); expect(await getCommand(context(), "200", { out })).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200)); 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 () => { it("get-thumb finds a file in any album without --collection", async () => {
const out = join(root, "thumb.jpg"); const out = join(root, "thumb.jpg");
expect(await getThumbCommand(context(), "200", { out })).toBe(0); expect(await getThumbCommand(context(), "200", { out })).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200)); 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 () => { it("get exits 1 when no album has the file", async () => {
const out = join(root, "x"); const out = join(root, "x");
expect(await getCommand(context(), "999", { out })).toBe(1); 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); expect(existsSync(out)).toBe(false);
}); });
it("get-thumb exits 1 when no album has the file", async () => { it("get-thumb exits 1 when no album has the file", async () => {
const out = join(root, "x"); const out = join(root, "x");
expect(await getThumbCommand(context(), "999", { out })).toBe(1); 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); 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(Buffer.byteLength(name)).toBe(255);
expect(code).toBe(0); expect(code).toBe(0);
expect(readdirSync(dir)).toEqual([name]); 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 () => { 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(Buffer.byteLength(name)).toBe(255);
expect(code).toBe(0); expect(code).toBe(0);
expect(readdirSync(dir)).toEqual([name]); 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 () => { 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), Buffer.from(VIDEO),
); );
expect(stderr.text).toBe( 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`, `${VIDEO.length} bytes -> IMG_0300.mov\n`,
); );
}); });
@@ -802,8 +796,7 @@ describe("a live photo", () => {
expect(readFileSync(out)).toEqual(Buffer.from(IMAGE)); expect(readFileSync(out)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO)); expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
expect(stderr.text).toBe( 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(out)).toBe(false);
expect(existsSync(join(root, "photo.mov"))).toBe(false); expect(existsSync(join(root, "photo.mov"))).toBe(false);
expect(stderr.text).toBe( 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`,
); );
}); });
@@ -1390,11 +1382,11 @@ describe("backup-metadata --exif", () => {
}); });
// Each test first runs `files` so the cache holds the account as it was, then // 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 // changes the server under it. (`collections` and `files` with a collection ID
// collection list alone and stores nothing.) // would not do: they store nothing.)
describe("backup-metadata and the thumbnail helpers refresh first", () => { describe("backup-metadata and the thumbnail helpers refresh first", () => {
beforeEach(async () => { beforeEach(async () => {
expect(await filesCommand(context(), "1", {})).toBe(0); expect(await filesCommand(context(), undefined, {})).toBe(0);
stdout.text = ""; stdout.text = "";
stderr.text = ""; stderr.text = "";
}); });
@@ -1577,20 +1569,80 @@ describe("an ID given on the command line", () => {
); );
}); });
// An account with many albums (issue #210). On an empty cache `collections` // An account with many albums (issues #210, #217 and #222). On an empty cache
// used to wait for a refresh that fetched and decrypted the files of every // `collections`, `files` with a collection ID and `get` used to wait for a
// album, one album after another, before it printed anything, so on such an // refresh that fetched and decrypted the files of every album, one album after
// account it seemed to hang. The account here has 40 albums of one photo each, // another. On such an account they seemed to hang. The account here has 40
// encrypted as Ente's clients encrypt them, on the fake Ente server of // albums of one photo each, encrypted as Ente's clients encrypt them, on the
// `test/ente-server.ts`, which also records the path of each request quak // fake Ente server of `test/ente-server.ts`. The tests record the path and query
// sends. The session is the one logging in to that server saves. // of each request quak sends. The session is the one logging in to that server
// 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";
const paths: string[] = []; const requests: string[] = [];
let server: typeof globalThis.fetch; let server: typeof globalThis.fetch;
let snapshot: ClientSnapshot; 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.
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: "" },
updationTime: photo.updationTime,
};
return { row, original: original.ciphertext };
};
// Album n is named `Album n` and holds one photo, n * 100, titled // Album n is named `Album n` and holds one photo, n * 100, titled
// `photo-n.jpg`. // `photo-n.jpg`.
beforeAll(async () => { beforeAll(async () => {
@@ -1598,8 +1650,10 @@ describe("an account with many albums", () => {
const account = await buildAccount(PASSWORD); const account = await buildAccount(PASSWORD);
const collections: Record<string, unknown>[] = []; const collections: Record<string, unknown>[] = [];
const filesByCollection: Record<number, Record<string, unknown>[]> = {}; const filesByCollection: Record<number, Record<string, unknown>[]> = {};
const fileCiphertexts: Record<number, Uint8Array> = {};
for (let id = 1; id <= ALBUMS; id++) { for (let id = 1; id <= ALBUMS; id++) {
const albumKey = sodium.crypto_secretbox_keygen(); const albumKey = sodium.crypto_secretbox_keygen();
albumKeys.set(id, albumKey);
const albumKeyBox = encryptSecretbox(albumKey, account.masterKey); const albumKeyBox = encryptSecretbox(albumKey, account.masterKey);
const name = encryptSecretbox( const name = encryptSecretbox(
new TextEncoder().encode(`Album ${id}`), new TextEncoder().encode(`Album ${id}`),
@@ -1615,37 +1669,14 @@ describe("an account with many albums", () => {
type: "album", type: "album",
updationTime: 1, updationTime: 1,
}); });
const photoKey = const { row, original } = photoRow({
sodium.crypto_secretstream_xchacha20poly1305_keygen(); id: id * 100,
const photoKeyBox = encryptSecretbox(photoKey, albumKey); collectionID: id,
const metadata = encryptStreamBlob( title: `photo-${id}.jpg`,
new TextEncoder().encode( updationTime: 1,
JSON.stringify({ });
title: `photo-${id}.jpg`, filesByCollection[id] = [row];
fileType: 0, fileCiphertexts[id * 100] = original;
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 ente = buildMetaFetch({ const ente = buildMetaFetch({
verifier: account.verifier, verifier: account.verifier,
@@ -1655,7 +1686,7 @@ describe("an account with many albums", () => {
collections, collections,
filesByCollection, filesByCollection,
encryptedMLData: {}, encryptedMLData: {},
fileCiphertexts: {}, fileCiphertexts,
}); });
server = (async (input: RequestInfo | URL, init?: RequestInit) => { server = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = const url =
@@ -1664,7 +1695,8 @@ describe("an account with many albums", () => {
: input instanceof URL : input instanceof URL
? input.href ? input.href
: input.url; : input.url;
paths.push(new URL(url).pathname); const { pathname, search } = new URL(url);
requests.push(pathname + search);
return ente(input, init); return ente(input, init);
}) as typeof globalThis.fetch; }) as typeof globalThis.fetch;
const client = await Client.login({ const client = await Client.login({
@@ -1676,16 +1708,17 @@ describe("an account with many albums", () => {
}); });
beforeEach(() => { beforeEach(() => {
paths.length = 0; requests.length = 0;
}); });
// A context whose saved session sends its requests to the fake server. // A context whose saved session sends its requests to `fetch`, by default
const accountContext = (): CliContext => { // the fake server.
const accountContext = (fetch = server): CliContext => {
const ctx = context(); const ctx = context();
saveSession(ctx.sessionDir, snapshot); saveSession(ctx.sessionDir, snapshot);
return { return {
...ctx, ...ctx,
loadSession: (path) => loadSession(path, { fetch: server }), loadSession: (path) => loadSession(path, { fetch }),
}; };
}; };
@@ -1698,46 +1731,140 @@ describe("an account with many albums", () => {
albums += `${id}\talbum\tAlbum ${id}\n`; albums += `${id}\talbum\tAlbum ${id}\n`;
expect(stdout.text).toBe(albums); expect(stdout.text).toBe(albums);
expect(stderr.text).toBe(""); 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: // 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 = ""; stdout.text = "";
expect(await filesCommand(ctx, "7", {})).toBe(0); expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n"); 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(await filesCommand(accountContext(), "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n"); expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
let progress = ""; expect(stderr.text).toBe("");
for (let n = 1; n <= ALBUMS; n++) { expect(requests).toEqual([
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`; "/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 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. The cache then says album 40, the last,
// holds photo 4000, so `get` asks for that album's files alone.
it("get asks first for the files of the album the cache says holds the photo", 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=0",
"/collections/v2/diff?collectionID=40&sinceTime=0",
"/?fileID=4000",
]);
});
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); expect(stderr.text).toBe(progress);
}); });
// `--json` shapes stdout only, so the refresh's progress is still counted // `--json` shapes stdout only, so the refresh's progress is still counted
// on stderr. // on stderr.
it("files --json counts on stderr each album whose files its refresh fetched", async () => { it("files --json counts on stderr each album whose files its refresh fetched", async () => {
expect(await filesCommand(accountContext(), "7", { json: true })).toBe( const opts = { json: true };
0, expect(await filesCommand(accountContext(), undefined, opts)).toBe(0);
);
expect(JSON.parse(stdout.text)).toEqual([ expect(JSON.parse(stdout.text)).toHaveLength(ALBUMS);
{
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(stderr.text).toBe(progress); expect(stderr.text).toBe(progress);
}); });
}); });
+5 -3
View File
@@ -234,13 +234,15 @@ describe("-q", () => {
}); });
}); });
// The refresh's progress is left out; the line saying why the command // No album holds file 999, so fixing it fails. The refresh's progress and
// the list of failed files are left out. The line saying why the command
// failed is the fatal one, and it is printed. // 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 () => {
expect(await quak("-q", "files", "9")).toEqual({ const fix = ["helper", "fix-missing-thumbnails", "--file", "999"];
expect(await quak("-q", ...fix)).toEqual({
code: 1, code: 1,
stdout: "", stdout: "",
stderr: "Collection 9 not found\n", stderr: `quak: 1 file(s) failed; see the "Failed files:" list, or the JSON with --json\n`,
}); });
}); });
+73 -52
View File
@@ -3,20 +3,22 @@
* 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. `files`, `get` and `get-thumb` * current server state, not the local cache. `files` without a collection ID
* force a `Library.fresh()` round-trip before they read; the stand-in library * forces a `Library.fresh()` round-trip before it reads. The stand-in library
* below serves nothing until `fresh()` has been awaited, so a helper that read * below serves no cached record until `fresh()` has been awaited, so a helper
* without refreshing would come back empty and fail here. `collections` asks * that read without refreshing would come back empty and fail here.
* the library for the collection list alone (`freshCollections()`, issue * `collections` asks the library for the collection list alone
* #210), and must not wait for a refresh, which would fetch the files of every * (`freshCollections()`, issue #210). `files` with a collection ID asks for
* collection. * that list, then for that one collection's files (`freshFiles()`, issue
* #217). `get` and `get-thumb` ask for that list, then for one collection's
* files at a time, until they find the file (issue #222). None of these may
* wait for a refresh, which would fetch the files of every collection.
* *
* `collections` and `files` also list in the library's enumeration order * `collections` and `files` also list in the order the library gives them, not
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — 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
* an enumeration order that a newest-first sort would rearrange, so a * an order that a newest-first sort would rearrange, so a regression to the
* regression to the projection order would fail here too. Field values still * projection order would fail here too. Field values still come from the raw
* come from the raw metadata via `cli-output.ts`. * metadata via `cli-output.ts`.
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
@@ -28,7 +30,6 @@ import {
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";
import type { Photo } from "../../src/library/index.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
const collection = (id: number, updationTime: number): Collection => ({ const collection = (id: number, updationTime: number): Collection => ({
@@ -62,14 +63,15 @@ const file = (
updationTime: creationTime, updationTime: creationTime,
}); });
// A library that reveals its records only after `fresh()` has been awaited, and // A library that reveals its cached records only after `fresh()` has been
// serves them in the enumeration order it was given. `freshCollections()` // awaited, and serves them in the enumeration order it was given.
// answers the collection list in that order without a refresh. `photos.byID` // `freshCollections()` and `freshFiles()` answer in that order without a
// returns a stand-in `Photo` carrying just the fileID the helper passes // refresh.
// through.
class FakeLibrary implements FreshReadLibrary { class FakeLibrary implements FreshReadLibrary {
freshCalls = 0; freshCalls = 0;
freshCollectionsCalls = 0; freshCollectionsCalls = 0;
// The ID of each collection `freshFiles()` was asked for.
freshFilesCalls: number[] = [];
private refreshed = false; private refreshed = false;
constructor( constructor(
@@ -88,9 +90,9 @@ class FakeLibrary implements FreshReadLibrary {
return this.collections; return this.collections;
} }
getCollection(id: number): Collection | undefined { async freshFiles(collection: Collection): Promise<EnteFile[]> {
if (!this.refreshed) return undefined; this.freshFilesCalls.push(collection.id);
return this.collections.find((c) => c.id === id); return this.files.filter((f) => f.collectionID === collection.id);
} }
listCollections(): Collection[] { listCollections(): Collection[] {
@@ -103,18 +105,9 @@ 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);
} }
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)", () => { describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
@@ -138,18 +131,24 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
expect(rows.map((c) => c.id)).not.toEqual(newestFirst); expect(rows.map((c) => c.id)).not.toEqual(newestFirst);
}); });
it("freshFiles refreshes first, lists in enumeration order, keeps raw fields", async () => { it("freshFiles with a collection ID reads that collection's files alone and keeps their order and raw fields", async () => {
// Enumeration order by id 10, 11, 12; creationTimes ascending, so a // Given in the order 10, 11, 12; creationTimes ascending, so a
// newest-first sort would reverse them. // newest-first sort would reverse them.
const files = [ const files = [
file(10, 1, 1_700_000_000_000_000), file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001), file(11, 1, 1_700_000_000_000_001),
file(12, 1, 1_700_000_000_000_002), 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); 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]); expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]);
// Field values come from raw metadata: microsecond creationTime and the // Field values come from raw metadata: microsecond creationTime and the
@@ -206,27 +205,49 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
it("freshFiles returns undefined for an unknown collection", async () => { it("freshFiles returns undefined for an unknown collection", async () => {
const lib = new FakeLibrary([collection(1, 100)], []); const lib = new FakeLibrary([collection(1, 100)], []);
const rows = await freshFiles(lib, 999); const rows = await freshFiles(lib, 999);
expect(lib.freshCalls).toBe(1); expect(lib.freshCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(rows).toBeUndefined(); expect(rows).toBeUndefined();
}); });
it("freshFile refreshes first, then resolves the photo and its raw record", async () => { // Three collections, each holding one file.
const f = file(10, 1, 1_700_000_000_000_000); const threeCollections = (): FakeLibrary =>
const lib = new FakeLibrary([collection(1, 100)], [f]); new FakeLibrary(
[collection(1, 100), collection(2, 200), collection(3, 300)],
const resolved = await freshFile(lib, 10); [
expect(lib.freshCalls).toBe(1); file(10, 1, 1_700_000_000_000_000),
expect(resolved?.photo.fileID).toBe(10); file(20, 2, 1_700_000_000_000_001),
expect(resolved?.file.metadata.title).toBe("file-10.jpg"); file(30, 3, 1_700_000_000_000_002),
expect(resolved?.file.metadata.creationTime).toBe( ],
1_700_000_000_000_000,
); );
it("freshFile 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 () => { // The stand-in's cache holds what the server holds once `fresh()` has run.
const lib = new FakeLibrary([collection(1, 100)], []); it("freshFile reads first the files of the collection the cache says holds the file", async () => {
const resolved = await freshFile(lib, 404); const lib = threeCollections();
expect(lib.freshCalls).toBe(1); await lib.fresh();
expect(resolved).toBeUndefined();
const found = await freshFile(lib, 30);
expect(lib.freshFilesCalls).toEqual([3]);
expect(found?.collectionID).toBe(3);
});
it("freshFile 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();
}); });
}); });
+6
View File
@@ -253,6 +253,12 @@ describe("Library content wiring", () => {
await expect( await expect(
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }), lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
).rejects.toThrow(/content cache/i); ).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(); await lib.close();
}); });
}); });
+19
View File
@@ -375,6 +375,25 @@ describe("ContentCache.original / thumbnail", () => {
await cache.open(); await cache.open();
await expect(cache.original(999)).rejects.toThrow(/unknown file/i); 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", () => { describe("ContentCache.ensureThumbnails", () => {