quak collections reads the collection list alone (closes #210)
check / check (push) Waiting to run
check / check (push) Waiting to run
On an empty cache the read commands opened their library with a refresh that fetched and decrypted the files of every collection in turn, printing nothing and storing nothing until it had them all, so `quak collections` seemed to hang on a large account. The new `lib.freshCollections()` is the fresh read of the collection list alone: one request, and nothing stored, so a later refresh still fetches every collection's files. The read commands open their library with the new `refreshOnOpen: false` and make only the refresh they ask for. A refresh sends a "progress" event after each collection whose files it fetched; the commands that wait on one print it on stderr, except with `--json`. Model: opus-5-5
This commit was merged in pull request #214.
This commit is contained in:
@@ -661,7 +661,15 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`,
|
|||||||
`backup-metadata`, `helper list-missing-thumbnails` and
|
`backup-metadata`, `helper list-missing-thumbnails` and
|
||||||
`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. If that round-trip fails, the command prints the error on one line and
|
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
|
||||||
|
others wait for a refresh. With an empty cache, as on a first run, that refresh
|
||||||
|
fetches the files of every collection, one collection after another, and stores
|
||||||
|
nothing until it has them all, which on a large account takes a while. Meanwhile
|
||||||
|
the command prints a line on stderr for each collection whose files it has
|
||||||
|
fetched, such as `Fetched the files of 3 of 120 collections`; with `--json`,
|
||||||
|
`files` and the two helpers print no such line. 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
|
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
|
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").
|
answers from what did decrypt (see "Default reads vs. fresh reads").
|
||||||
@@ -961,7 +969,11 @@ loop, and resolves to a `Library`. On an empty cache it awaits the first
|
|||||||
refresh, so it opens onto the account's data whenever the server is reachable;
|
refresh, so it opens onto the account's data whenever the server is reachable;
|
||||||
if that refresh fails, it opens with no data and records the error in
|
if that refresh fails, it opens with no data and records the error in
|
||||||
`lib.status()`. On an existing cache it returns immediately and refreshes in the
|
`lib.status()`. On an existing cache it returns immediately and refreshes in the
|
||||||
background, so an unreachable server does not block opening.
|
background, so an unreachable server does not block opening. With
|
||||||
|
`refreshOnOpen: false` it starts no refresh and returns at once onto what the
|
||||||
|
cache holds, empty or not; a refresh then starts only when `fresh()` or
|
||||||
|
`lib.backup()` asks, or the background timer fires. The CLI's read commands open
|
||||||
|
it so (see "CLI surface").
|
||||||
|
|
||||||
`LibraryOptions`:
|
`LibraryOptions`:
|
||||||
|
|
||||||
@@ -971,6 +983,7 @@ background, so an unreachable server does not block opening.
|
|||||||
| `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live |
|
| `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live |
|
||||||
| `downloadDirectory` | `photos` in the working directory | root of the save paths; an original stored there counts as cached |
|
| `downloadDirectory` | `photos` in the working directory | root of the save paths; an original stored there counts as cached |
|
||||||
| `refreshIntervalSeconds` | `3` | background refresh cadence; `Infinity` for no background timer |
|
| `refreshIntervalSeconds` | `3` | background refresh cadence; `Infinity` for no background timer |
|
||||||
|
| `refreshOnOpen` | `true` | start a refresh as the library opens |
|
||||||
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
|
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
|
||||||
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
|
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
|
||||||
| `precacheOriginalsDays` | `7` | length in days of that latest window |
|
| `precacheOriginalsDays` | `7` | length in days of that latest window |
|
||||||
@@ -984,6 +997,14 @@ background, so an unreachable server does not block opening.
|
|||||||
The default `downloadDirectory` is resolved against the working directory once,
|
The default `downloadDirectory` is resolved against the working directory once,
|
||||||
when the library opens; `lib.downloadDirectory` holds the result.
|
when the library opens; `lib.downloadDirectory` holds the result.
|
||||||
|
|
||||||
|
`onProgress` receives a `RefreshEvent` with status `"started"` as a refresh, an
|
||||||
|
ML data fetch or a precache sweep starts, and `"done"` or `"failed"` as it ends.
|
||||||
|
In between, a refresh sends `"progress"` after each collection whose files it
|
||||||
|
fetched, with `fetched`, how many collections it has fetched the files of so
|
||||||
|
far, and `total`, how many it will. A refresh fetches the files of each
|
||||||
|
collection that is new or changed since the cache's copy, so on an empty cache
|
||||||
|
it fetches those of every collection.
|
||||||
|
|
||||||
Concurrency is set through `pools`: construct
|
Concurrency is set through `pools`: construct
|
||||||
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
|
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
|
||||||
and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
|
and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
|
||||||
@@ -1023,6 +1044,15 @@ rejects the caller (default reads stay silent and keep serving the last good
|
|||||||
copy). The CLI's read commands use fresh reads (issue
|
copy). The CLI's read commands use fresh reads (issue
|
||||||
https://git.eeqj.de/sneak/quak/issues/75).
|
https://git.eeqj.de/sneak/quak/issues/75).
|
||||||
|
|
||||||
|
`await lib.freshCollections()` is the fresh read of the collection list alone:
|
||||||
|
one collections request, answered with every live collection that decrypts, as
|
||||||
|
the `Collection` objects `listCollections()` returns, in the server's order. It
|
||||||
|
fetches no collection's files and stores nothing, not even the list: a refresh
|
||||||
|
fetches a collection's files only when the collection is new or has changed
|
||||||
|
since the stored copy, so a collection stored without its files would never get
|
||||||
|
them. `quak collections` uses it (issue
|
||||||
|
https://git.eeqj.de/sneak/quak/issues/210).
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ declares one.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-18: `quak collections` no longer waits for the files of every
|
||||||
|
collection (issue 210). On an empty cache it opened its library with a refresh
|
||||||
|
that fetched and decrypted the files of each collection in turn, printing
|
||||||
|
nothing and storing nothing until all were done, so on a large account it
|
||||||
|
seemed to hang. It now reads the collection list alone through the new
|
||||||
|
`lib.freshCollections()`, one request that stores nothing. The read commands
|
||||||
|
open their library with the new `refreshOnOpen: false`, so each makes only the
|
||||||
|
refresh it asks for. A refresh sends a `"progress"` event after each
|
||||||
|
collection whose files it fetched, and the commands that wait on one print it
|
||||||
|
on stderr, except with `--json`.
|
||||||
|
|
||||||
- 2026-10-16: ML data the server sends that does not decrypt, gunzip or parse is
|
- 2026-10-16: ML data the server sends that does not decrypt, gunzip or parse is
|
||||||
no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for
|
no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for
|
||||||
each such file beside the payloads; ML data that parses to something other
|
each such file beside the payloads; ML data that parses to something other
|
||||||
|
|||||||
+25
-10
@@ -125,15 +125,29 @@ const readLibraryClient = (client: Client): LibraryClient => ({
|
|||||||
|
|
||||||
// Open a library for a single point command: the aggressive background precache
|
// 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
|
// (issue #48) is off — a one-shot `collections` or `get` must not start
|
||||||
// downloading the whole account — and the refresh interval is long so no second
|
// downloading the whole account — and no refresh starts as it opens or on a
|
||||||
// refresh fires mid-command.
|
// timer: each command asks for the one it needs, and `collections` needs none
|
||||||
const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
|
// (issue #210). While a refresh runs, each collection whose files it has
|
||||||
|
// fetched is counted on stderr, unless `json` is set, so a large account shows
|
||||||
|
// progress rather than nothing.
|
||||||
|
const openReadLibrary = (
|
||||||
|
ctx: CliContext,
|
||||||
|
client: Client,
|
||||||
|
opts: { json?: boolean } = {},
|
||||||
|
): Promise<Library> =>
|
||||||
Library.open({
|
Library.open({
|
||||||
client: readLibraryClient(client),
|
client: readLibraryClient(client),
|
||||||
cacheDirectory: ctx.cacheDir,
|
cacheDirectory: ctx.cacheDir,
|
||||||
refreshIntervalSeconds: 3600,
|
refreshOnOpen: false,
|
||||||
|
refreshIntervalSeconds: Infinity,
|
||||||
precacheThumbnails: false,
|
precacheThumbnails: false,
|
||||||
precacheOriginals: false,
|
precacheOriginals: false,
|
||||||
|
onProgress: (event) => {
|
||||||
|
if (opts.json || event.status !== "progress") return;
|
||||||
|
ctx.stderr.write(
|
||||||
|
`Fetched the files of ${event.fetched} of ${event.total} collections\n`,
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
||||||
@@ -221,9 +235,10 @@ export const collectionsCommand = async (
|
|||||||
if (!client) return 3;
|
if (!client) return 3;
|
||||||
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 the collection list alone and list it in its
|
||||||
// amendment, issue #52): the pre-library CLI printed current state in
|
// order (issue #36 amendment, issues #52 and #210): the pre-library
|
||||||
// this order, not the albums projection's newest-first order.
|
// CLI printed current state in this order, not the albums
|
||||||
|
// projection's newest-first order.
|
||||||
const collections = await freshCollections(lib);
|
const collections = await freshCollections(lib);
|
||||||
|
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
@@ -264,7 +279,7 @@ export const filesCommand = async (
|
|||||||
const collectionID = parseID(ctx, "collection", opts.collection);
|
const collectionID = parseID(ctx, "collection", opts.collection);
|
||||||
if (collectionID === undefined) return 1;
|
if (collectionID === undefined) return 1;
|
||||||
|
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client, opts);
|
||||||
try {
|
try {
|
||||||
// Force a server round-trip and list in enumeration order (issue #36
|
// Force a server round-trip and list in enumeration order (issue #36
|
||||||
// amendment, issue #52). Each file prints from its own decrypted
|
// amendment, issue #52). Each file prints from its own decrypted
|
||||||
@@ -490,7 +505,7 @@ export const listMissingThumbnailsCommand = async (
|
|||||||
await init();
|
await init();
|
||||||
const client = requireSession(ctx);
|
const client = requireSession(ctx);
|
||||||
if (!client) return 3;
|
if (!client) return 3;
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client, opts);
|
||||||
try {
|
try {
|
||||||
// Refresh first so files added since the cache was written are
|
// Refresh first so files added since the cache was written are
|
||||||
// checked; a failed refresh throws.
|
// checked; a failed refresh throws.
|
||||||
@@ -535,7 +550,7 @@ export const fixMissingThumbnailsCommand = async (
|
|||||||
if (fileID === undefined) return 1;
|
if (fileID === undefined) return 1;
|
||||||
fileIDs.push(fileID);
|
fileIDs.push(fileID);
|
||||||
}
|
}
|
||||||
const lib = await openReadLibrary(ctx, client);
|
const lib = await openReadLibrary(ctx, client, opts);
|
||||||
try {
|
try {
|
||||||
// Refresh first so files added since the cache was written are found;
|
// Refresh first so files added since the cache was written are found;
|
||||||
// a failed refresh throws.
|
// a failed refresh throws.
|
||||||
|
|||||||
+16
-13
@@ -2,12 +2,16 @@
|
|||||||
//
|
//
|
||||||
// `collections`, `files --collection`, `get`, and `get-thumb` must answer for
|
// `collections`, `files --collection`, `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). Each helper
|
// local cache last happened to hold (owner amendment, issue #36). `files`,
|
||||||
// therefore forces a server round-trip through `Library.fresh()` and only then
|
// `get` and `get-thumb` therefore force a server round-trip through
|
||||||
// reads — so a collection, file, or metadata change made elsewhere is visible.
|
// `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.
|
||||||
//
|
//
|
||||||
// `collections` and `files` also list in the library's own enumeration order —
|
// `collections` lists in the server's order and `files` in the library's own
|
||||||
// `listCollections()`/`listFiles()`, the order the pre-library CLI printed —
|
// enumeration order (`listFiles()`), the orders the pre-library CLI printed,
|
||||||
// rather than the `albums`/`photos` projection's newest-first order, which
|
// 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
|
// re-sorts the rows. The field values still come from each record's raw
|
||||||
// metadata via `cli-output.ts`.
|
// metadata via `cli-output.ts`.
|
||||||
@@ -17,23 +21,22 @@ 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 the
|
||||||
// `fresh()` call and serves records in a known enumeration order.
|
// `fresh()` and `freshCollections()` calls and serves records in a known
|
||||||
|
// enumeration order.
|
||||||
export interface FreshReadLibrary {
|
export interface FreshReadLibrary {
|
||||||
fresh(): Promise<unknown>;
|
fresh(): Promise<unknown>;
|
||||||
listCollections(): Collection[];
|
freshCollections(): Promise<Collection[]>;
|
||||||
getCollection(id: number): Collection | undefined;
|
getCollection(id: number): Collection | undefined;
|
||||||
listFiles(collectionID: number): EnteFile[];
|
listFiles(collectionID: number): EnteFile[];
|
||||||
getFileByID(fileID: number): EnteFile | undefined;
|
getFileByID(fileID: number): EnteFile | undefined;
|
||||||
photos: Pick<PhotosAPI, "byID">;
|
photos: Pick<PhotosAPI, "byID">;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every live collection, current as of a forced refresh, in enumeration order.
|
// Every live collection, current as of one collections request, in the
|
||||||
export const freshCollections = async (
|
// server's order.
|
||||||
|
export const freshCollections = (
|
||||||
lib: FreshReadLibrary,
|
lib: FreshReadLibrary,
|
||||||
): Promise<Collection[]> => {
|
): Promise<Collection[]> => lib.freshCollections();
|
||||||
await lib.fresh();
|
|
||||||
return lib.listCollections();
|
|
||||||
};
|
|
||||||
|
|
||||||
// The files of one collection, current as of a forced refresh, in enumeration
|
// The files of one collection, current as of a forced refresh, in enumeration
|
||||||
// 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
|
||||||
|
|||||||
+56
-10
@@ -18,6 +18,16 @@
|
|||||||
// the caller (the default reads stay silent and serve the last good copy). The
|
// the caller (the default reads stay silent and serve the last good copy). The
|
||||||
// default methods and the background loop are unchanged.
|
// default methods and the background loop are unchanged.
|
||||||
//
|
//
|
||||||
|
// `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.
|
||||||
|
//
|
||||||
|
// A refresh reports through `onProgress` each collection whose files it has
|
||||||
|
// fetched, so a large account, whose first refresh fetches the files of every
|
||||||
|
// collection, shows progress.
|
||||||
|
//
|
||||||
// A refresh stages all of its network work first and only mutates the store
|
// A refresh stages all of its network work first and only mutates the store
|
||||||
// once every fetch has succeeded. A refresh that fails partway therefore never
|
// once every fetch has succeeded. A refresh that fails partway therefore never
|
||||||
// becomes visible to reads: the last good snapshot stays in place, and the
|
// becomes visible to reads: the last good snapshot stays in place, and the
|
||||||
@@ -161,18 +171,21 @@ export interface LibraryClient {
|
|||||||
// exactly one of "done" or "failed"; "failed" carries the error message and
|
// exactly one of "done" or "failed"; "failed" carries the error message and
|
||||||
// an ML "done" reports how many payloads it stored and, in `mlDataErrors`,
|
// an ML "done" reports how many payloads it stored and, in `mlDataErrors`,
|
||||||
// each file whose ML data did not decrypt, gunzip or parse, with the reason.
|
// each file whose ML data did not decrypt, gunzip or parse, with the reason.
|
||||||
// The precache fills ("precacheThumbnails"/"precacheOriginals", #48) fire
|
// In between, a refresh fires "progress" after each collection whose files it
|
||||||
// "started"/"done" around each sweep that has work, "done" reporting the count
|
// fetched: `fetched` is how many collections it has fetched the files of so
|
||||||
// newly cached.
|
// far, `total` how many it will. The precache fills
|
||||||
|
// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
|
||||||
|
// each sweep that has work, "done" reporting the count newly cached.
|
||||||
export interface RefreshEvent {
|
export interface RefreshEvent {
|
||||||
operation:
|
operation:
|
||||||
| "refresh"
|
| "refresh"
|
||||||
| "fetchMLData"
|
| "fetchMLData"
|
||||||
| "precacheThumbnails"
|
| "precacheThumbnails"
|
||||||
| "precacheOriginals";
|
| "precacheOriginals";
|
||||||
status: "started" | "done" | "failed";
|
status: "started" | "progress" | "done" | "failed";
|
||||||
error?: string;
|
error?: string;
|
||||||
fetched?: number;
|
fetched?: number;
|
||||||
|
total?: number;
|
||||||
mlDataErrors?: { fileID: number; error: string }[];
|
mlDataErrors?: { fileID: number; error: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +205,10 @@ export interface LibraryOptions {
|
|||||||
// `Infinity` starts none: the library then refreshes only as it opens and
|
// `Infinity` starts none: the library then refreshes only as it opens and
|
||||||
// when `fresh()` or `backup()` asks.
|
// when `fresh()` or `backup()` asks.
|
||||||
refreshIntervalSeconds?: number;
|
refreshIntervalSeconds?: number;
|
||||||
|
// Whether `open()` starts a refresh. Defaults to true. With false, `open()`
|
||||||
|
// returns at once onto what the cache holds, empty or not, and a refresh
|
||||||
|
// starts only when `fresh()` or `backup()` asks or the timer fires.
|
||||||
|
refreshOnOpen?: boolean;
|
||||||
onProgress?: RefreshProgressCallback;
|
onProgress?: RefreshProgressCallback;
|
||||||
// The bounded request pools (issue #45), shared by the ML-data fetch (the
|
// The bounded request pools (issue #45), shared by the ML-data fetch (the
|
||||||
// metadata pool) and the content cache. Defaults to a fresh set at the
|
// metadata pool) and the content cache. Defaults to a fresh set at the
|
||||||
@@ -382,7 +399,8 @@ export class Library {
|
|||||||
// then opens empty with the failure recorded in `status()`. With an
|
// then opens empty with the failure recorded in `status()`. With an
|
||||||
// existing cache the first refresh runs in the background and `open()`
|
// existing cache the first refresh runs in the background and `open()`
|
||||||
// returns as soon as the cached data is ready — an unreachable server does
|
// returns as soon as the cached data is ready — an unreachable server does
|
||||||
// not block opening.
|
// not block opening. With `refreshOnOpen: false` there is no first
|
||||||
|
// refresh: `open()` returns onto the cache as it is.
|
||||||
static async open(opts: LibraryOptions): Promise<Library> {
|
static async open(opts: LibraryOptions): Promise<Library> {
|
||||||
const { userID } = opts.client.whoami();
|
const { userID } = opts.client.whoami();
|
||||||
const cacheDirectory =
|
const cacheDirectory =
|
||||||
@@ -485,7 +503,10 @@ export class Library {
|
|||||||
// refresh.
|
// refresh.
|
||||||
precache?.start();
|
precache?.start();
|
||||||
|
|
||||||
if (store.loadedFromDisk) {
|
if (opts.refreshOnOpen === false) {
|
||||||
|
// The caller asks for each refresh it needs.
|
||||||
|
lib.scheduleNext();
|
||||||
|
} else if (store.loadedFromDisk) {
|
||||||
// An existing copy already answers reads; refresh in the background
|
// An existing copy already answers reads; refresh in the background
|
||||||
// and start the interval once that first cycle settles.
|
// and start the interval once that first cycle settles.
|
||||||
void lib.runRefresh().then(() => lib.scheduleNext());
|
void lib.runRefresh().then(() => lib.scheduleNext());
|
||||||
@@ -589,6 +610,21 @@ export class Library {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The fresh read of the collection list alone (issue #210): every live
|
||||||
|
// collection that decrypts, in the server's order, from one collections
|
||||||
|
// request. Unlike a refresh, it fetches no collection's files, which on a
|
||||||
|
// large account take long. Nothing is stored: a refresh fetches a
|
||||||
|
// collection's files only when the collection is new or has changed since
|
||||||
|
// the stored copy, so a collection stored here without its files would
|
||||||
|
// never have them fetched.
|
||||||
|
async freshCollections(): Promise<Collection[]> {
|
||||||
|
if (this.closed) throw new Error("the library is closed");
|
||||||
|
const { collections } = await this.client.collectionsSince({
|
||||||
|
sinceTime: 0,
|
||||||
|
});
|
||||||
|
return collections;
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -742,18 +778,28 @@ export class Library {
|
|||||||
// Stage per-collection file diffs. A collection's files are
|
// Stage per-collection file diffs. A collection's files are
|
||||||
// re-enumerated only when its updationTime has advanced past the cached
|
// re-enumerated only when its updationTime has advanced past the cached
|
||||||
// copy; an unchanged album's file list cannot have changed. New
|
// copy; an unchanged album's file list cannot have changed. New
|
||||||
// collections enumerate from the beginning of time.
|
// collections enumerate from the beginning of time. Each collection
|
||||||
|
// whose files are fetched is reported, with how many there are to
|
||||||
|
// fetch.
|
||||||
|
const changedCollections = page.collections.filter((collection) => {
|
||||||
|
const known = this.store.getCollection(collection.id);
|
||||||
|
return !known || collection.updationTime > known.updationTime;
|
||||||
|
});
|
||||||
const filePages: { collectionID: number; page: FilesPage }[] = [];
|
const filePages: { collectionID: number; page: FilesPage }[] = [];
|
||||||
for (const collection of page.collections) {
|
for (const collection of changedCollections) {
|
||||||
const known = this.store.getCollection(collection.id);
|
const known = this.store.getCollection(collection.id);
|
||||||
if (known && collection.updationTime <= known.updationTime)
|
|
||||||
continue;
|
|
||||||
const filePage = await this.client.filesSince({
|
const filePage = await this.client.filesSince({
|
||||||
collectionID: collection.id,
|
collectionID: collection.id,
|
||||||
collectionKey: collection.key,
|
collectionKey: collection.key,
|
||||||
sinceTime: known ? known.updationTime : 0,
|
sinceTime: known ? known.updationTime : 0,
|
||||||
});
|
});
|
||||||
filePages.push({ collectionID: collection.id, page: filePage });
|
filePages.push({ collectionID: collection.id, page: filePage });
|
||||||
|
this.emit({
|
||||||
|
operation: "refresh",
|
||||||
|
status: "progress",
|
||||||
|
fetched: filePages.length,
|
||||||
|
total: changedCollections.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network work done; commit to the store and persist only if something
|
// Network work done; commit to the store and persist only if something
|
||||||
|
|||||||
+203
-12
@@ -53,11 +53,21 @@ import {
|
|||||||
} from "../../src/cli-commands.js";
|
} from "../../src/cli-commands.js";
|
||||||
import { run } from "../../src/cli-run.js";
|
import { run } from "../../src/cli-run.js";
|
||||||
import { loadSession } from "../../src/cli-session.js";
|
import { loadSession } from "../../src/cli-session.js";
|
||||||
import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js";
|
import {
|
||||||
|
Client,
|
||||||
|
type ClientSnapshot,
|
||||||
|
type LoginOptions,
|
||||||
|
} from "../../src/client.js";
|
||||||
import { savePath, type ContentSource } from "../../src/library/content.js";
|
import { savePath, type ContentSource } from "../../src/library/content.js";
|
||||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||||
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
|
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
|
||||||
import { defaultCacheDirectory } from "../../src/library/index.js";
|
import { defaultCacheDirectory } from "../../src/library/index.js";
|
||||||
|
import {
|
||||||
|
buildAccount,
|
||||||
|
buildMetaFetch,
|
||||||
|
encryptSecretbox,
|
||||||
|
encryptStreamBlob,
|
||||||
|
} from "../ente-server.js";
|
||||||
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
||||||
import {
|
import {
|
||||||
asLivePhoto,
|
asLivePhoto,
|
||||||
@@ -171,6 +181,12 @@ const fakeClient = (
|
|||||||
return fake as unknown as Client;
|
return fake as unknown as Client;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// What a read command prints on stderr while the refresh it waits for fetches
|
||||||
|
// the files of each of the fake's two albums, on an empty cache.
|
||||||
|
const FETCHED_BOTH_ALBUMS =
|
||||||
|
"Fetched the files of 1 of 2 collections\n" +
|
||||||
|
"Fetched the files of 2 of 2 collections\n";
|
||||||
|
|
||||||
// Collects everything written to it.
|
// Collects everything written to it.
|
||||||
class Output {
|
class Output {
|
||||||
text = "";
|
text = "";
|
||||||
@@ -510,7 +526,9 @@ describe("files", () => {
|
|||||||
|
|
||||||
it("exits 1 for an unknown collection", async () => {
|
it("exits 1 for an unknown collection", async () => {
|
||||||
expect(await filesCommand(context(), { collection: "9" })).toBe(1);
|
expect(await filesCommand(context(), { collection: "9" })).toBe(1);
|
||||||
expect(stderr.text).toBe("Collection 9 not found\n");
|
expect(stderr.text).toBe(
|
||||||
|
FETCHED_BOTH_ALBUMS + "Collection 9 not found\n",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -519,27 +537,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(`7 bytes -> ${out}\n`);
|
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `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(`3 bytes -> ${out}\n`);
|
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `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("File 999 not found\n");
|
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "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("File 999 not found\n");
|
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n");
|
||||||
expect(existsSync(out)).toBe(false);
|
expect(existsSync(out)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -549,6 +567,9 @@ describe("get and get-thumb", () => {
|
|||||||
// one live photo, 300, downloaded through the real download layer
|
// one live photo, 300, downloaded through the real download layer
|
||||||
// (test/live-photo.ts).
|
// (test/live-photo.ts).
|
||||||
describe("a live photo", () => {
|
describe("a live photo", () => {
|
||||||
|
// What `get` prints on stderr while its refresh fetches Vacation's files.
|
||||||
|
const FETCHED_ONE_ALBUM = "Fetched the files of 1 of 1 collections\n";
|
||||||
|
|
||||||
const livePhotoClient = async (image = IMAGE): Promise<Client> => {
|
const livePhotoClient = async (image = IMAGE): Promise<Client> => {
|
||||||
const { file: live, body } = await asLivePhoto(
|
const { file: live, body } = await asLivePhoto(
|
||||||
file(300, 1, "IMG_0300.HEIC"),
|
file(300, 1, "IMG_0300.HEIC"),
|
||||||
@@ -593,7 +614,8 @@ describe("a live photo", () => {
|
|||||||
Buffer.from(VIDEO),
|
Buffer.from(VIDEO),
|
||||||
);
|
);
|
||||||
expect(stderr.text).toBe(
|
expect(stderr.text).toBe(
|
||||||
`${IMAGE.length} bytes -> IMG_0300.heic\n` +
|
FETCHED_ONE_ALBUM +
|
||||||
|
`${IMAGE.length} bytes -> IMG_0300.heic\n` +
|
||||||
`${VIDEO.length} bytes -> IMG_0300.mov\n`,
|
`${VIDEO.length} bytes -> IMG_0300.mov\n`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -609,7 +631,8 @@ 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(
|
||||||
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
|
FETCHED_ONE_ALBUM +
|
||||||
|
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -623,7 +646,8 @@ 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(
|
||||||
`File 300 is a live photo, and its video would also be written to ${out}\n`,
|
FETCHED_ONE_ALBUM +
|
||||||
|
`File 300 is a live photo, and its video would also be written to ${out}\n`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1103,11 +1127,12 @@ describe("backup-metadata --exif", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Each test first runs `collections` so the cache holds the account as it was,
|
// Each test first runs `files` so the cache holds the account as it was, then
|
||||||
// then changes the server under it.
|
// changes the server under it. (`collections` would not do: it reads the
|
||||||
|
// collection list alone and stores 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 collectionsCommand(context(), {})).toBe(0);
|
expect(await filesCommand(context(), { collection: "1" })).toBe(0);
|
||||||
stdout.text = "";
|
stdout.text = "";
|
||||||
stderr.text = "";
|
stderr.text = "";
|
||||||
});
|
});
|
||||||
@@ -1292,3 +1317,169 @@ 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.
|
||||||
|
describe("an account with many albums", () => {
|
||||||
|
const ALBUMS = 40;
|
||||||
|
const PASSWORD = "many-albums";
|
||||||
|
const paths: string[] = [];
|
||||||
|
let server: typeof globalThis.fetch;
|
||||||
|
let snapshot: ClientSnapshot;
|
||||||
|
|
||||||
|
// Album n is named `Album n` and holds one photo, n * 100, titled
|
||||||
|
// `photo-n.jpg`.
|
||||||
|
beforeAll(async () => {
|
||||||
|
await sodium.ready;
|
||||||
|
const account = await buildAccount(PASSWORD);
|
||||||
|
const collections: Record<string, unknown>[] = [];
|
||||||
|
const filesByCollection: Record<number, Record<string, unknown>[]> = {};
|
||||||
|
for (let id = 1; id <= ALBUMS; id++) {
|
||||||
|
const albumKey = sodium.crypto_secretbox_keygen();
|
||||||
|
const albumKeyBox = encryptSecretbox(albumKey, account.masterKey);
|
||||||
|
const name = encryptSecretbox(
|
||||||
|
new TextEncoder().encode(`Album ${id}`),
|
||||||
|
albumKey,
|
||||||
|
);
|
||||||
|
collections.push({
|
||||||
|
id,
|
||||||
|
owner: { id: USER_ID },
|
||||||
|
encryptedKey: toBase64(albumKeyBox.ciphertext),
|
||||||
|
keyDecryptionNonce: toBase64(albumKeyBox.nonce),
|
||||||
|
encryptedName: toBase64(name.ciphertext),
|
||||||
|
nameDecryptionNonce: toBase64(name.nonce),
|
||||||
|
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 ente = buildMetaFetch({
|
||||||
|
verifier: account.verifier,
|
||||||
|
srpAttributes: account.srpAttributes,
|
||||||
|
keyAttributes: account.keyAttributes,
|
||||||
|
encryptedToken: account.encryptedToken,
|
||||||
|
collections,
|
||||||
|
filesByCollection,
|
||||||
|
encryptedMLData: {},
|
||||||
|
fileCiphertexts: {},
|
||||||
|
});
|
||||||
|
server = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: input.url;
|
||||||
|
paths.push(new URL(url).pathname);
|
||||||
|
return ente(input, init);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
const client = await Client.login({
|
||||||
|
email: "many@example.com",
|
||||||
|
password: PASSWORD,
|
||||||
|
apiOptions: { fetch: server },
|
||||||
|
});
|
||||||
|
snapshot = client.toJSON();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
paths.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// A context whose saved session sends its requests to the fake server.
|
||||||
|
const accountContext = (): CliContext => {
|
||||||
|
const ctx = context();
|
||||||
|
saveSession(ctx.sessionDir, snapshot);
|
||||||
|
return {
|
||||||
|
...ctx,
|
||||||
|
loadSession: (path) => loadSession(path, { fetch: server }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it("collections lists every album from one request, fetching no album's files", async () => {
|
||||||
|
const ctx = accountContext();
|
||||||
|
expect(await collectionsCommand(ctx, {})).toBe(0);
|
||||||
|
|
||||||
|
let albums = "";
|
||||||
|
for (let id = 1; id <= ALBUMS; id++)
|
||||||
|
albums += `${id}\talbum\tAlbum ${id}\n`;
|
||||||
|
expect(stdout.text).toBe(albums);
|
||||||
|
expect(stderr.text).toBe("");
|
||||||
|
expect(paths).toEqual(["/collections/v2"]);
|
||||||
|
|
||||||
|
// It stored nothing that would make a refresh skip an album's files:
|
||||||
|
// `files` after it still finds the album's photo.
|
||||||
|
stdout.text = "";
|
||||||
|
expect(await filesCommand(ctx, { collection: "7" })).toBe(0);
|
||||||
|
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("files counts on stderr each album whose files its refresh fetched", async () => {
|
||||||
|
expect(await filesCommand(accountContext(), { collection: "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(progress);
|
||||||
|
});
|
||||||
|
|
||||||
|
// With `--json` the refresh's progress is not printed, as `backup` and
|
||||||
|
// the thumbnail helpers print none of theirs.
|
||||||
|
it("files --json prints no progress", async () => {
|
||||||
|
expect(
|
||||||
|
await filesCommand(accountContext(), {
|
||||||
|
collection: "7",
|
||||||
|
json: true,
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
|
||||||
|
expect(JSON.parse(stdout.text)).toEqual([
|
||||||
|
{
|
||||||
|
id: 700,
|
||||||
|
title: "photo-7.jpg",
|
||||||
|
fileType: "image",
|
||||||
|
creationTime: 1000,
|
||||||
|
collectionID: 7,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(stderr.text).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+20
-11
@@ -3,10 +3,13 @@
|
|||||||
* 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, so each helper forces a
|
* current server state, not the local cache. `files`, `get` and `get-thumb`
|
||||||
* `Library.fresh()` round-trip before it reads. The stand-in library below
|
* force a `Library.fresh()` round-trip before they read; the stand-in library
|
||||||
* serves nothing until `fresh()` has been awaited, so a helper that read
|
* below serves nothing until `fresh()` has been awaited, so a helper that read
|
||||||
* without refreshing would come back empty and fail here.
|
* 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.
|
||||||
*
|
*
|
||||||
* `collections` and `files` also list in the library's enumeration order
|
* `collections` and `files` also list in the library's enumeration order
|
||||||
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not
|
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not
|
||||||
@@ -60,10 +63,13 @@ const file = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// A library that reveals its records only after `fresh()` has been awaited, and
|
// A library that reveals its records only after `fresh()` has been awaited, and
|
||||||
// serves them in the enumeration order it was given. `photos.byID` returns a
|
// serves them in the enumeration order it was given. `freshCollections()`
|
||||||
// stand-in `Photo` carrying just the fileID the helper passes through.
|
// 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.
|
||||||
class FakeLibrary implements FreshReadLibrary {
|
class FakeLibrary implements FreshReadLibrary {
|
||||||
freshCalls = 0;
|
freshCalls = 0;
|
||||||
|
freshCollectionsCalls = 0;
|
||||||
private refreshed = false;
|
private refreshed = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -77,12 +83,14 @@ class FakeLibrary implements FreshReadLibrary {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
listCollections(): Collection[] {
|
async freshCollections(): Promise<Collection[]> {
|
||||||
return this.refreshed ? this.collections : [];
|
this.freshCollectionsCalls++;
|
||||||
|
return this.collections;
|
||||||
}
|
}
|
||||||
|
|
||||||
getCollection(id: number): Collection | undefined {
|
getCollection(id: number): Collection | undefined {
|
||||||
return this.listCollections().find((c) => c.id === id);
|
if (!this.refreshed) return undefined;
|
||||||
|
return this.collections.find((c) => c.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
listFiles(collectionID: number): EnteFile[] {
|
listFiles(collectionID: number): EnteFile[] {
|
||||||
@@ -106,7 +114,7 @@ class FakeLibrary implements FreshReadLibrary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
|
describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
|
||||||
it("freshCollections refreshes first, then lists in enumeration order", async () => {
|
it("freshCollections reads the collection list alone, in enumeration order", async () => {
|
||||||
// Enumeration order 2, 1, 3; a newest-first sort would be 3, 2, 1.
|
// Enumeration order 2, 1, 3; a newest-first sort would be 3, 2, 1.
|
||||||
const lib = new FakeLibrary(
|
const lib = new FakeLibrary(
|
||||||
[collection(2, 200), collection(1, 300), collection(3, 100)],
|
[collection(2, 200), collection(1, 300), collection(3, 100)],
|
||||||
@@ -114,7 +122,8 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
|
|||||||
);
|
);
|
||||||
const rows = await freshCollections(lib);
|
const rows = await freshCollections(lib);
|
||||||
|
|
||||||
expect(lib.freshCalls).toBe(1);
|
expect(lib.freshCollectionsCalls).toBe(1);
|
||||||
|
expect(lib.freshCalls).toBe(0);
|
||||||
expect(rows.map((c) => c.id)).toEqual([2, 1, 3]);
|
expect(rows.map((c) => c.id)).toEqual([2, 1, 3]);
|
||||||
// The projection's newest-first order is a different sequence, so this
|
// The projection's newest-first order is a different sequence, so this
|
||||||
// is not accidentally that order.
|
// is not accidentally that order.
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
* three.
|
* three.
|
||||||
* 3. A refresh that fails rejects the fresh read (currency was unavailable),
|
* 3. A refresh that fails rejects the fresh read (currency was unavailable),
|
||||||
* while the default reads stay silent and keep serving the last good copy.
|
* while the default reads stay silent and keep serving the last good copy.
|
||||||
|
* 4. `freshCollections()` reads the collection list alone, with one request
|
||||||
|
* and no collection's files, and stores nothing, so the next refresh still
|
||||||
|
* fetches the files of every collection (issue #210).
|
||||||
*
|
*
|
||||||
* The client is the same metadata-only mock the background-refresh tests use:
|
* The client is the same metadata-only mock the background-refresh tests use:
|
||||||
* no crypto, no network, scripted pages, and a record of each call. A long
|
* no crypto, no network, scripted pages, and a record of each call. A long
|
||||||
@@ -270,3 +273,70 @@ describe("Library.fresh", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Library.freshCollections", () => {
|
||||||
|
let dir: string;
|
||||||
|
let cacheDirectory: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dir = mkdtempSync(join(tmpdir(), "quak-fresh-collections-"));
|
||||||
|
cacheDirectory = join(dir, "cache");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// A refresh fetches a collection's files only when the collection is new
|
||||||
|
// or its updationTime has advanced past the stored copy's. Had the
|
||||||
|
// collection list been stored, the next refresh would take every
|
||||||
|
// collection for one it already holds the files of, and fetch none.
|
||||||
|
it("asks for the collection list alone and stores nothing", async () => {
|
||||||
|
const client = new MockClient();
|
||||||
|
const page = {
|
||||||
|
collections: [collection(2, 100), collection(1, 110)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 110,
|
||||||
|
};
|
||||||
|
client.collectionsQueue.push(page, page);
|
||||||
|
client.filesFor(1, {
|
||||||
|
files: [file(1001, 1, 90)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 90,
|
||||||
|
});
|
||||||
|
client.filesFor(2, {
|
||||||
|
files: [file(2001, 2, 95)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 95,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({
|
||||||
|
client,
|
||||||
|
cacheDirectory,
|
||||||
|
refreshOnOpen: false,
|
||||||
|
refreshIntervalSeconds: SLOW_INTERVAL,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// One request, the whole list in the server's order, and no
|
||||||
|
// collection's files.
|
||||||
|
const collections = await lib.freshCollections();
|
||||||
|
expect(collections.map((c) => c.id)).toEqual([2, 1]);
|
||||||
|
expect(collections.map((c) => c.name)).toEqual([
|
||||||
|
"album-2",
|
||||||
|
"album-1",
|
||||||
|
]);
|
||||||
|
expect(client.collectionsSinceTimes).toEqual([0]);
|
||||||
|
expect(client.filesCalls).toEqual([]);
|
||||||
|
expect(lib.listCollections()).toEqual([]);
|
||||||
|
|
||||||
|
// The next refresh still asks from the beginning and fetches the
|
||||||
|
// files of both collections.
|
||||||
|
const reads = await lib.fresh();
|
||||||
|
expect(client.collectionsSinceTimes).toEqual([0, 0]);
|
||||||
|
expect(reads.photos.byID({ fileID: 1001 })?.fileID).toBe(1001);
|
||||||
|
expect(reads.photos.byID({ fileID: 2001 })?.fileID).toBe(2001);
|
||||||
|
} finally {
|
||||||
|
await lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
* opening.
|
* opening.
|
||||||
* 8. A save failure that leaves RAM ahead of disk keeps `status().lastError`
|
* 8. A save failure that leaves RAM ahead of disk keeps `status().lastError`
|
||||||
* set and keeps retrying the write; a later empty refresh does not clear it.
|
* set and keeps retrying the write; a later empty refresh does not clear it.
|
||||||
|
* 9. With `refreshOnOpen: false`, `open()` starts no refresh at all, and a
|
||||||
|
* refresh reports through `onProgress` each collection whose files it
|
||||||
|
* fetched.
|
||||||
*
|
*
|
||||||
* The client is a mock: no crypto, no network. It serves scripted pages and
|
* The client is a mock: no crypto, no network. It serves scripted pages and
|
||||||
* records the `sinceTime` each call carried so cursor threading is provable.
|
* records the `sinceTime` each call carried so cursor threading is provable.
|
||||||
@@ -581,6 +584,107 @@ describe("Library.open and background refresh", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A caller that asks for each refresh it needs, as the CLI's read commands
|
||||||
|
// do, opens without one: on an empty cache the refresh `open()` would
|
||||||
|
// await fetches the files of every collection, which `quak collections`
|
||||||
|
// never needs (issue #210).
|
||||||
|
it("starts no refresh as it opens when refreshOnOpen is false", async () => {
|
||||||
|
const client = new MockClient();
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(1, 100)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({
|
||||||
|
client,
|
||||||
|
cacheDirectory,
|
||||||
|
refreshOnOpen: false,
|
||||||
|
refreshIntervalSeconds: Infinity,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(client.collectionsSinceTimes).toEqual([]);
|
||||||
|
expect(lib.listCollections()).toEqual([]);
|
||||||
|
expect(lib.status().lastRefreshAt).toBeUndefined();
|
||||||
|
|
||||||
|
// The refresh comes when the caller asks for it.
|
||||||
|
await lib.fresh();
|
||||||
|
expect(client.collectionsSinceTimes).toEqual([0]);
|
||||||
|
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
|
||||||
|
} finally {
|
||||||
|
await lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A refresh fetches the files of each collection that changed, one
|
||||||
|
// collection after another, and stores nothing until it has them all. On
|
||||||
|
// a large account that takes a while, so it reports each collection whose
|
||||||
|
// files it has fetched, and how many it will fetch in all (issue #210).
|
||||||
|
it("reports each collection whose files a refresh fetched", async () => {
|
||||||
|
const events: RefreshEvent[] = [];
|
||||||
|
const client = new MockClient();
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [
|
||||||
|
collection(1, 100),
|
||||||
|
collection(2, 100),
|
||||||
|
collection(3, 100),
|
||||||
|
],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const lib = await Library.open({
|
||||||
|
client,
|
||||||
|
cacheDirectory,
|
||||||
|
refreshIntervalSeconds: Infinity,
|
||||||
|
onProgress: (e) => events.push(e),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
expect(events).toEqual([
|
||||||
|
{ operation: "refresh", status: "started" },
|
||||||
|
{
|
||||||
|
operation: "refresh",
|
||||||
|
status: "progress",
|
||||||
|
fetched: 1,
|
||||||
|
total: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "refresh",
|
||||||
|
status: "progress",
|
||||||
|
fetched: 2,
|
||||||
|
total: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operation: "refresh",
|
||||||
|
status: "progress",
|
||||||
|
fetched: 3,
|
||||||
|
total: 3,
|
||||||
|
},
|
||||||
|
{ operation: "refresh", status: "done" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Only collection 2 changed since, so the next refresh fetches
|
||||||
|
// its files alone and counts one.
|
||||||
|
events.length = 0;
|
||||||
|
client.collectionsQueue.push({
|
||||||
|
collections: [collection(2, 200)],
|
||||||
|
deleted: [],
|
||||||
|
cursor: 200,
|
||||||
|
});
|
||||||
|
await lib.fresh();
|
||||||
|
expect(events.filter((e) => e.status === "progress")).toEqual([
|
||||||
|
{
|
||||||
|
operation: "refresh",
|
||||||
|
status: "progress",
|
||||||
|
fetched: 1,
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await lib.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a save failure visible until a save actually succeeds", async () => {
|
it("keeps a save failure visible until a save actually succeeds", async () => {
|
||||||
const saveSpy = vi
|
const saveSpy = vi
|
||||||
.spyOn(MetadataStore.prototype, "save")
|
.spyOn(MetadataStore.prototype, "save")
|
||||||
|
|||||||
Reference in New Issue
Block a user