Author SHA1 Message Date
sneak ec07fd1dc9 quak files COLLECTION fetches that collection's files alone (closes #217)
check / check (push) Waiting to run
`quak files COLLECTION` used to wait for a full refresh, which on an
empty cache fetched the files of every collection first. It now reads
the collection list, then calls the new `Library.refreshCollection()`.
That brings one collection up to date in the store as a refresh would.
Once the stored cursor is past 0, it only updates a collection the store
holds. For a new one, `quak files COLLECTION` waits for a refresh. A
refresh from a cursor of 0 replaces all the store holds, so collections
then sit in the server's order. `open()` awaits the first refresh while
the cursor is 0. The store lists what did not decrypt in a fixed order.

Model: opus-5-5
2026-10-26 05:46:57 +00:00
22 changed files with 2317 additions and 2662 deletions
+1308 -1706
View File
File diff suppressed because it is too large Load Diff
+14 -24
View File
@@ -26,30 +26,20 @@ declares one.
# Completed Steps
- 2026-10-26: On an empty cache, `quak helper fix-missing-thumbnails --file`
stops fetching collections' files once it finds every file it names
(https://git.eeqj.de/sneak/quak/issues/223). It used to wait for a refresh,
which on an empty cache fetched the files of every collection. Now it reads
the collection list, then one collection's files at a time, the most recently
updated first. It stores none of the collections or file records it reads. It
fetches each original through `lib.originalOf()`, which keeps it in the cache.
Once the cache holds the account's collections, it still waits for a refresh.
- 2026-10-26: On an empty cache, `quak get` and `quak get-thumb` stop fetching
collections' files once they find the file
(https://git.eeqj.de/sneak/quak/issues/222). They used to wait for a refresh,
which on an empty cache fetched the files of every collection. Now they read
the collection list, then one collection's files at a time. They store none of
the collections or file records they read, and fetch the original or thumbnail
through the new `lib.originalOf()` and `lib.thumbnailOf()`. Once the cache
holds the account's collections, they still wait for a refresh.
- 2026-10-26: `quak files` with a collection ID fetches that collection's files
alone (https://git.eeqj.de/sneak/quak/issues/217). It used to wait for a
refresh, which on an empty cache fetched the files of every collection. It now
reads the collection list through `lib.freshCollections()`, then the one
collection's files through the new `lib.freshFiles()`. It stores nothing, and
the files print in the server's order.
- 2026-10-25: `quak files COLLECTION` fetches the files of that collection 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 first. It now
reads the collection list through `lib.freshCollections()`, then brings that
one collection up to date through the new `lib.refreshCollection()`. That
stores and saves its files as a refresh would, and leaves the refresh cursor
where it was. When the stored copy is current, it asks for none of the files.
It never runs at the same time as a refresh. Once a refresh has received the
account's collections, it stores no collection the cache does not hold, and
`quak files COLLECTION` waits for a refresh instead. A refresh from a cursor
of 0 replaces all the cache holds, so collections keep the server's order. A
library opened on a cache that only `lib.refreshCollection()` wrote awaits its
first refresh. `quak files` with no argument still waits for a refresh. The
store lists what did not decrypt by collection ID and file ID.
- 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
+24 -35
View File
@@ -37,16 +37,9 @@ import {
thumbnailName,
} from "./cli-output.js";
import { type Log, logRefresh } from "./cli-log.js";
import {
cachedFilesByID,
freshCollections,
freshFiles,
freshFile,
freshFilesByID,
} from "./cli-read.js";
import { freshCollections, freshFiles, freshFile } from "./cli-read.js";
import { withExtension } from "./filename.js";
import { runMetadataBackup } from "./metadata-backup.js";
import type { Collection, EnteFile } from "./model/types.js";
import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js";
export interface CliContext {
@@ -137,13 +130,12 @@ const readLibraryClient = (client: Client): LibraryClient => ({
// Open a library for a single point command. The aggressive background
// precache (issue #48) is off, because a one-shot `collections` or `get` must
// not start downloading the whole account. No refresh starts as the library
// opens or on a timer. Each command asks for the refresh it needs.
// `collections` and `files` with a collection ID need none (issues #210 and
// #217). Nor do `get` and `get-thumb` on an empty cache (issue #222). Nor does
// `helper fix-missing-thumbnails --file` on an empty cache (issue #223). While
// a refresh runs, each collection whose files it has fetched is logged, so a
// large account shows progress rather than nothing.
// not start downloading the whole account. No refresh starts as it opens or on
// a timer. Each command asks for the refresh it needs. `collections` needs none
// (issue #210). `files COLLECTION` needs one only when `refreshCollection()`
// resolves to `false` (issue #217). While a refresh runs,
// each collection whose files it has fetched is logged, so a large account
// shows progress rather than nothing.
const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
Library.open({
client: readLibraryClient(client),
@@ -292,11 +284,11 @@ export const filesCommand = async (
const lib = await openReadLibrary(ctx, client);
try {
// Ask the server for current state (issue #36 amendment, issue #52).
// Each file prints from its own decrypted metadata (raw title,
// microsecond creationTime) via cli-output. The files print in the
// order `freshFiles` returns them, not the projection's newest-first
// order.
// Force a server round-trip and list in enumeration order (issue #36
// amendment, issue #52). Each file prints from its own decrypted
// metadata (raw title, microsecond creationTime) via cli-output, and in
// the pre-library CLI's enumeration order, not the projection's
// newest-first order.
const files = await freshFiles(lib, collectionID);
if (!files) {
ctx.log("fatal", `Collection ${collectionID} not found`);
@@ -333,13 +325,14 @@ export const getCommand = async (
try {
// Force a server round-trip so the file resolves against current state
// (issue #36 amendment, issue #52).
const file = await freshFile(lib, fileID);
if (!file) {
const resolved = await freshFile(lib, fileID);
if (!resolved) {
ctx.log("fatal", `File ${fileID} not found`);
return 1;
}
const { photo, file } = resolved;
const result = await lib.originalOf(file);
const result = await photo.original();
// Default name is the file's own title, as the pre-library CLI used
// (not the editedName-preferring projection title) (issue #52).
if (result.videoPath === undefined) {
@@ -390,13 +383,14 @@ export const getThumbCommand = async (
try {
// Force a server round-trip so the file resolves against current state
// (issue #36 amendment, issue #52).
const file = await freshFile(lib, fileID);
if (!file) {
const resolved = await freshFile(lib, fileID);
if (!resolved) {
ctx.log("fatal", `File ${fileID} not found`);
return 1;
}
const { photo, file } = resolved;
const result = await lib.thumbnailOf(file);
const result = await photo.thumbnail();
// Default name is thumb_<file's own title>, as the pre-library CLI
// used (not the projection title) (issue #52).
const outPath = opts.out ?? thumbnailName(file);
@@ -573,13 +567,10 @@ export const fixMissingThumbnailsCommand = async (
}
const lib = await openReadLibrary(ctx, client);
try {
let found: Map<number, { file: EnteFile; collection: Collection }>;
if (fileIDs.length > 0) {
found = await freshFilesByID(lib, fileIDs);
} else {
// Refresh first so files added since the cache was written are
// checked; a failed refresh throws.
await lib.fresh();
// Refresh first so files added since the cache was written are found;
// a failed refresh throws.
await lib.fresh();
if (fileIDs.length === 0) {
ctx.log("info", "Scanning for missing thumbnails...");
const missing = await listMissingThumbnails(
lib,
@@ -592,14 +583,12 @@ export const fixMissingThumbnailsCommand = async (
return 0;
}
ctx.log("info", `Found ${fileIDs.length} file(s) to fix.`);
found = cachedFilesByID(lib, fileIDs);
}
const results = await fixMissingThumbnails(
lib,
client,
fileIDs,
found,
(msg, level) => ctx.log(level ?? "info", msg),
);
+1 -1
View File
@@ -4,7 +4,7 @@
// Every line quak prints on stderr is a log line at one of six levels, from
// the quietest: fatal, error, warning, info, debug, trace. A run prints the
// lines at its own level and at every quieter one. Its level is info unless
// `-v` or `-q` asks for another (see "What quak prints" in the README). Every
// `-v` or `-q` asks for another (see "CLI surface" in the README). Every
// command that exits non-zero logs a fatal line saying why, so even `-q`, which
// prints only fatal lines, never leaves a non-zero exit unexplained. An error
// line reports something that failed, such as a file or an album, and a
+53 -102
View File
@@ -2,40 +2,47 @@
//
// `collections`, `files`, `get`, and `get-thumb` must answer for
// the account's state at the moment the command runs, not for whatever the
// local cache last happened to hold (owner amendment, issue #36). `get`,
// `get-thumb` and `files` without a collection ID therefore force a server
// round-trip through `Library.fresh()` and only then read, so a change made
// elsewhere is visible. `collections` asks the library for the collection list
// alone through `Library.freshCollections()` (issue #210): a refresh would also
// fetch the files of every collection, which on a large account takes long and
// which `collections` does not print. `files` with a collection ID reads that
// list too, then that one collection's files through `Library.freshFiles()`
// (issue #217). On an empty cache, `get` and `get-thumb` read that list, then
// one collection's files at a time, until they find the file (issue #222).
// `helper fix-missing-thumbnails --file` does the same until it finds every
// file it names (issue #223).
// local cache last happened to hold (owner amendment, issue #36). `files`
// without a collection ID, `get` and `get-thumb` therefore force a server
// round-trip through `Library.fresh()`, and only then read. A collection, file,
// or metadata change made elsewhere is then 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
// `collections` does not print, and on a large account that takes long.
// `files COLLECTION` reads the collection list the same way, for the same
// reason. It then brings that one collection up to date through
// `Library.refreshCollection()` (issue #217), and fetches no other collection's
// files. Once a refresh has received the account's collections,
// `refreshCollection()` stores no collection the library does not hold.
// `files COLLECTION` then forces a refresh instead.
//
// `collections` and `files` with a collection ID list in the server's order.
// `files` without one lists in the library's own enumeration order
// (`listFiles()`). Neither command uses the `albums`/`photos` projection's
// newest-first order, which re-sorts the rows. The field values still come
// from each record's raw metadata via `cli-output.ts`. `files` without a
// collection ID lists every file in the account once (issue #211): the library
// keeps a record of a file for each collection that holds it, and only the
// first collection's record is listed.
// `collections` lists in the server's order. `files` lists in the library's
// own enumeration order: `listCollections()`, then `listFiles()` for each.
// Those are the orders the pre-library CLI printed. The `albums`/`photos`
// projection would re-sort the rows newest first. After a refresh, the
// library holds collections in the order the server first sent them. The
// field values still come from each record's raw metadata via
// `cli-output.ts`. `files` without a collection ID lists every file in the
// account once (issue #211).
// The library keeps a record of a file for each collection that holds it, and
// only the first collection's record is listed.
import type { Collection, EnteFile } from "./model/types.js";
import type { Photo, PhotosAPI } from "./library/index.js";
// The slice of `Library` these helpers read. `Library` satisfies it
// structurally; a test can drive them with a stand-in that records each fresh
// read and serves records in a known enumeration order.
// structurally. A test can drive them with a stand-in that records the
// `fresh()`, `freshCollections()` and `refreshCollection()` calls. The
// stand-in serves records in a known enumeration order.
export interface FreshReadLibrary {
fresh(): Promise<unknown>;
freshCollections(): Promise<Collection[]>;
freshFiles(collection: Collection): Promise<EnteFile[]>;
refreshCollection(collection: Collection): Promise<boolean>;
getCollection(id: number): Collection | undefined;
listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[];
getFileByID(fileID: number): EnteFile | undefined;
photos: Pick<PhotosAPI, "byID">;
}
// Every live collection, current as of one collections request, in the
@@ -44,11 +51,14 @@ export const freshCollections = (
lib: FreshReadLibrary,
): Promise<Collection[]> => lib.freshCollections();
// The files of one collection, as the server holds them now, in the server's
// order. `undefined` (not an empty list) when the collection does not exist, so
// the caller can tell "no such collection" from "an empty collection". Without
// a collection ID, every file in the account as of a forced refresh, each once,
// as held by the first collection that holds it.
// The files of one collection, in enumeration order. `undefined` (not an empty
// list) when the collection does not exist, so the caller can tell "no such
// collection" from "an empty collection". They are current as of one
// collections request and the requests that bring that collection alone up to
// date. When `refreshCollection()` resolves to `false`, they are current as of
// a forced refresh instead. Without a collection ID, every file in the account,
// current as of a forced refresh. Each file is listed once, as held by the
// first collection that holds it.
export const freshFiles = async (
lib: FreshReadLibrary,
collectionID?: number,
@@ -56,7 +66,12 @@ export const freshFiles = async (
if (collectionID !== undefined) {
const collections = await lib.freshCollections();
const collection = collections.find((c) => c.id === collectionID);
return collection ? lib.freshFiles(collection) : undefined;
if (!collection) return undefined;
if (!(await lib.refreshCollection(collection))) {
await lib.fresh();
if (!lib.getCollection(collectionID)) return undefined;
}
return lib.listFiles(collectionID);
}
await lib.fresh();
const listed = new Set<number>();
@@ -71,80 +86,16 @@ export const freshFiles = async (
return files;
};
// One file as the server holds it now, or `undefined` when no collection holds
// it. When the cache holds the account's collections, it refreshes and looks
// the file up in the cache. On an empty cache, it reads the collection list,
// then one collection's files at a time, and stops at the first collection
// that holds the file. That search stores nothing.
// One file, current as of a forced refresh, resolved to both its content
// handle (`Photo`, for fetching bytes) and its raw record (`EnteFile`, for the
// default output name and field values). `undefined` when the file is unknown.
export const freshFile = async (
lib: FreshReadLibrary,
fileID: number,
): Promise<EnteFile | undefined> => {
// Only a refresh stores collections, so a cache holding any holds them all.
if (lib.listCollections().length > 0) {
await lib.fresh();
return lib.getFileByID(fileID);
}
for (const collection of await lib.freshCollections()) {
const files = await lib.freshFiles(collection);
const file = files.find((f) => f.id === fileID);
if (file) return file;
}
return undefined;
};
// The most recently updated collection first, and the higher ID first on a
// tie. `Library.albums.list()` lists albums in this order.
const byNewestCollection = (a: Collection, b: Collection): number =>
b.updationTime - a.updationTime || b.id - a.id;
// Each file named by `fileIDs` that the cache holds, mapped to its record and
// the collection it was found in. Collections are searched in
// `byNewestCollection` order, and a file is found in the first that holds it.
// So `helper fix-missing-thumbnails` names a file after the same album as
// `helper list-missing-thumbnails`. It reads the cache alone, so the caller
// refreshes first.
export const cachedFilesByID = (
lib: FreshReadLibrary,
fileIDs: number[],
): Map<number, { file: EnteFile; collection: Collection }> => {
const wanted = new Set(fileIDs);
const found = new Map<number, { file: EnteFile; collection: Collection }>();
const collections = [...lib.listCollections()].sort(byNewestCollection);
for (const collection of collections) {
for (const file of lib.listFiles(collection.id)) {
if (wanted.has(file.id) && !found.has(file.id)) {
found.set(file.id, { file, collection });
}
}
}
return found;
};
// Each file named by `fileIDs` as the server holds it now, mapped to its record
// and collection as `cachedFilesByID` maps it. When the cache holds the
// account's collections, it refreshes and looks the files up in the cache. On
// an empty cache, it reads the collection list, then one collection's files at
// a time, and stops once it has found every file. That search stores nothing.
export const freshFilesByID = async (
lib: FreshReadLibrary,
fileIDs: number[],
): Promise<Map<number, { file: EnteFile; collection: Collection }>> => {
// Only a refresh stores collections, so a cache holding any holds them all.
if (lib.listCollections().length > 0) {
await lib.fresh();
return cachedFilesByID(lib, fileIDs);
}
const wanted = new Set(fileIDs);
const found = new Map<number, { file: EnteFile; collection: Collection }>();
const collections = await lib.freshCollections();
for (const collection of [...collections].sort(byNewestCollection)) {
if (found.size === wanted.size) break;
for (const file of await lib.freshFiles(collection)) {
if (wanted.has(file.id) && !found.has(file.id)) {
found.set(file.id, { file, collection });
}
}
}
return found;
): Promise<{ photo: Photo; file: EnteFile } | undefined> => {
await lib.fresh();
const photo = lib.photos.byID({ fileID });
const file = lib.getFileByID(fileID);
if (!photo || !file) return undefined;
return { photo, file };
};
+11 -38
View File
@@ -581,34 +581,14 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
fileID: number,
opts?: ContentOptions,
): Promise<ContentResult> {
const file = this.fileOrThrow(fileID);
return this.get(file, "original", "on-demand", opts?.onProgress);
return this.get(fileID, "original", "on-demand", opts?.onProgress);
}
async thumbnail(
fileID: number,
opts?: ContentOptions,
): Promise<ContentResult> {
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);
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
}
// Put the original at the save path of `file` under the download directory
@@ -635,7 +615,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
destination: string,
): Promise<ContentResult> {
const result = await this.acquire(
this.fileOrThrow(fileID),
fileID,
"original",
"on-demand",
undefined,
@@ -658,7 +638,8 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
fileID: number,
destination: string,
): Promise<{ path: string; videoPath?: string }> {
const file = this.fileOrThrow(fileID);
const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
return this.pools.content.run(
async () => {
const fetched = await this.fetchInto(
@@ -740,8 +721,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
onProgress: ((event: EnsureEvent) => void) | undefined,
): Promise<EnsureResult> {
try {
const file = this.fileOrThrow(fileID);
const result = await this.acquire(file, kind, priority, signal);
const result = await this.acquire(fileID, kind, priority, signal);
const status = result.cached ? "skipped" : "done";
onProgress?.({ fileID, status, path: result.path });
return { fileID, path: result.path };
@@ -756,16 +736,8 @@ 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(
file: EnteFile,
fileID: number,
kind: Kind,
priority: Priority,
onProgress: ((event: ContentEvent) => void) | undefined,
@@ -773,7 +745,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
const onByte: ProgressCallback | undefined = onProgress
? (bytesDone) => onProgress({ status: "downloading", bytesDone })
: undefined;
const result = await this.acquire(file, kind, priority, undefined, {
const result = await this.acquire(fileID, kind, priority, undefined, {
onByte,
});
onProgress?.(
@@ -795,7 +767,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
// photo's original is present only with its video, and is returned with
// it.
private async acquire(
file: EnteFile,
fileID: number,
kind: Kind,
priority: Priority,
signal: AbortSignal | undefined,
@@ -806,7 +778,8 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
videoPath?: string;
cached: boolean;
}> {
const fileID = file.id;
const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
const isLivePhoto =
kind === "original" && file.metadata.fileType === "livePhoto";
+282 -206
View File
@@ -1,29 +1,37 @@
// The library surface over the local cache.
//
// `Library.open()` loads the on-disk metadata store (issue #41), then starts
// the refresh loop. When the cache loaded empty it awaits the first refresh,
// so the library never opens onto an empty store it could have filled; when an
// existing copy loaded, that first refresh runs in the background and `open()`
// returns as soon as the cached data is ready to serve — a slow or unreachable
// server no longer stalls opening. A background timer then refreshes every
// `refreshIntervalSeconds`. Every default read is answered from RAM — no
// default read touches the network. There is deliberately no `sync()`, no
// `refresh()`, no `serverReachable` flag, and no "before each read" mode
// (design #36).
// the refresh loop. When the stored cursor is 0, `open()` awaits the first
// refresh. No refresh has then stored any collection: the cache is empty, or
// holds only what `refreshCollection()` stored. The library must not serve
// that as the whole account. Otherwise that first refresh runs in the
// background. `open()` returns as soon as the cached data is ready to serve,
// so a slow or unreachable server does not stall opening. A background timer
// then takes a turn `refreshIntervalSeconds` after its last turn ends. Each
// turn starts a refresh, unless a refresh or `refreshCollection()` is already
// running. Every default read is answered from RAM — no default read touches
// the network. There is deliberately no `sync()`, no `refresh()`, no
// `serverReachable` flag, and no "before each read" mode (design #36).
//
// `fresh()` is the one exception (issue #75, an owner amendment to #36): it
// forces a refresh, awaits it, and only then hands back the read namespaces, so
// a caller that needs server-current data can ask for it. Concurrent `fresh()`
// calls coalesce onto one in-flight refresh, and a refresh that fails rejects
// the caller (the default reads stay silent and serve the last good copy). The
// default methods and the background loop are unchanged.
// `fresh()` is an exception (issue #75, an owner amendment to #36). It awaits
// a refresh, and only then hands back the read namespaces. A caller that needs
// server-current data uses it. Concurrent `fresh()` calls coalesce onto one
// in-flight refresh. A refresh that fails rejects the caller. The default
// reads stay silent and serve the last good copy. The 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. `freshFiles()`
// is the fresh read of one collection's files, and stores nothing either
// (issue #217). A caller that asks for each refresh it needs can also open the
// library with `refreshOnOpen: false`, so that `open()` starts none.
// #210). It sends one collections request and stores nothing.
// `quak collections` uses it. `refreshCollection()` brings one collection
// from that list up to date in the store (issue #217). It fetches that
// collection's files alone, as a refresh would. `quak files COLLECTION` uses
// it. It never runs at the same time as a refresh. Once the stored cursor is
// past 0, it only updates a collection the store holds, and only a refresh
// adds one. A refresh from a stored cursor of 0 replaces all the store holds.
// So after a refresh, the store holds collections in the order the server
// first sent them. 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
@@ -32,10 +40,12 @@
// 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
// becomes visible to reads: the last good snapshot stays in place, and the
// failure surfaces through `onProgress` and `status()` instead. A commit that
// mutates RAM but then fails to persist keeps `status().lastError` set and the
// store marked unsaved until a later save actually lands, so a stuck disk is
// never masked by a subsequent empty refresh.
// failure surfaces through `onProgress` and `status()` instead. A save can
// still fail after the store has changed in RAM. The store then stays marked
// unsaved until a later save lands. Until then, every `commit()` retries the
// save. A refresh whose save fails sets `status().lastError`, which stays set
// until a later refresh succeeds. `refreshCollection()` never sets or clears
// it. So a stuck disk is never hidden by a later refresh that changes nothing.
import { rm } from "node:fs/promises";
import { join, resolve } from "node:path";
@@ -66,8 +76,6 @@ import {
ContentCache,
savePath,
storedAtSavePath,
type ContentOptions,
type ContentResult,
type ContentSource,
type ThumbnailsAPI,
type EnsureOptions,
@@ -162,21 +170,23 @@ export interface LibraryClient {
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}>;
// The byte source for the on-disk content cache. Optional so a mock client
// that only serves metadata still satisfies the interface; when absent (and
// no explicit `contentSource` is passed to `open`) the content cache is
// disabled and `Photo.original`/`thumbnail` and `thumbnails.ensure` throw.
// The byte source for the on-disk content cache. Optional, so a mock
// client that only serves metadata still satisfies the interface. Without
// it, and with no `contentSource` passed to `open`, the content cache is
// disabled. `Photo.original`/`thumbnail` and `thumbnails.ensure` then
// throw.
contentSource?(): ContentSource;
}
// A progress event for one unit of background work. A metadata "refresh" or an
// ML "fetchMLData" pass each fire "started" before their network work and then
// exactly one of "done" or "failed"; "failed" carries the error message and
// 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.
// In between, a refresh fires "progress" after each collection whose files it
// fetched: `fetched` is how many collections it has fetched the files of so
// far, `total` how many it will. The precache fills
// A progress event for one unit of background work. A metadata "refresh" and
// an ML "fetchMLData" pass each fire "started" before their network work. Each
// then fires exactly one of "done" or "failed". A "failed" event carries the
// error message. An ML "done" reports in `fetched` how many payloads it
// stored. Its `mlDataErrors` lists each file whose ML data did not decrypt,
// gunzip or parse, with the reason. In between, a refresh fires "progress"
// after each collection whose files it fetched. Its `fetched` is how many
// collections it has fetched the files of so far, and `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 {
@@ -204,9 +214,10 @@ export interface LibraryOptions {
// directory at open. The content cache treats an original already stored
// at its save path as present.
downloadDirectory?: string;
// How long after one refresh ends the background timer starts the next.
// `Infinity` starts none: the library then refreshes only as it opens and
// when `fresh()` or `backup()` asks.
// How long after its last turn ends the background timer takes the next.
// A turn starts a refresh unless a refresh or `refreshCollection()` is
// running. With `Infinity` the timer takes no turn: the library then
// refreshes only as it opens and when `fresh()` or `backup()` asks.
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
@@ -245,8 +256,9 @@ export interface LibraryStatus {
// Wall-clock ms of the last refresh that succeeded, or undefined if none
// has yet.
lastRefreshAt?: number;
// The message from the most recent refresh, set only while that refresh
// failed; cleared by the next success.
// The error of the most recent refresh, set only when it failed. The next
// refresh that succeeds clears it. `refreshCollection()` never sets or
// clears it.
lastError?: string;
// Wall-clock ms of the last ML fetch pass that succeeded, or undefined if
// none has yet (or ML fetching is disabled).
@@ -309,12 +321,17 @@ export class Library {
private readonly precache?: Precache;
private timer?: ReturnType<typeof setTimeout>;
// The in-flight refresh cycle, or undefined when none runs. One slot serves
// both paths: the background loop skips when it is set, and a fresh read
// (issue #75) coalesces onto it or starts one. The promise carries the
// cycle's real outcome (it rejects on failure); the background loop ignores
// that, a fresh read propagates it.
// The in-flight refresh cycle or `refreshCollection()`, or undefined when
// neither runs. One slot holds both, so they never run at the same time.
// The background loop skips while it is set. A fresh read (issue #75)
// coalesces onto a running refresh. It waits for a running
// `refreshCollection()` to end, and then joins or starts a refresh. The
// promise carries the real outcome: it rejects on failure. The background
// loop ignores a failure. A fresh read passes on the failure of the
// refresh it joins or starts, and `refreshCollection()` passes on its own.
private cycle?: Promise<void>;
// Whether `cycle` is a refresh cycle rather than a `refreshCollection()`.
private cycleIsRefresh = false;
// Guards the ML fetch pass so a slow backfill never runs twice at once; a
// refresh whose pass is still running kicks nothing new. Holds the running
// pass, so `close()` and `backup()` can wait for it. It rejects when the
@@ -327,19 +344,20 @@ export class Library {
private lastMLError?: string;
// For each file whose ML data the last ML fetch pass that succeeded got
// but could not decrypt, gunzip or parse, why not, by fileID. A pass
// gathers its own and puts them here when it ends without failing, so
// `status()` and `backup()` never see the list of a pass that is still
// running or that failed. Kept in RAM only, never in the cache.
// gathers its own and puts them here only when it ends without failing.
// So `status()` and `backup()` never see the list of a pass still running
// or one that failed. Kept in RAM only, never in the cache.
private mlDataErrors = new Map<number, unknown>();
// The plain-record projection as of the last refresh, and the GUI change
// subscribers. A refresh that alters the projection notifies each with the
// delta; `lastRecords` is kept current every refresh so a subscriber that
// joins later diffs against the state its own `snapshot()` already returned.
// The plain-record projection as of the last store change, and the GUI
// change subscribers. A refresh or `refreshCollection()` that alters the
// projection notifies each subscriber with the delta. `lastRecords`
// advances on each `commit()` that changes the store. A subscriber that
// joins later then diffs against the state its own `snapshot()` returned.
private readonly subscribers = new Set<(change: LibraryChange) => void>();
private lastRecords: DerivedRecords;
// RAM holds changes disk has not yet accepted (an earlier save failed).
// Cleared only when a save actually succeeds; keeps the store trying to
// persist and the failure visible in `status()` until then.
// Cleared only when a save succeeds. Until then, every `commit()` retries
// the save.
private unsaved = false;
private constructor(args: {
@@ -369,7 +387,7 @@ export class Library {
this.lastRecords = this.deriveNow();
// The read namespaces derive fresh from the store on each call, so they
// always reflect the latest refresh.
// always reflect what it holds now.
const derive = (): DerivedRecords => this.deriveNow();
const root = this.downloadDirectory;
const saves: SavePathLookup = {
@@ -396,14 +414,16 @@ export class Library {
this.mldata = makeMLDataAPI(() => this.mlStore);
}
// Load the cache and start the refresh loop. With an empty cache the first
// refresh is awaited, so `open()` resolves onto populated data whenever the
// server is reachable; that awaited refresh may still fail, and the library
// then opens empty with the failure recorded in `status()`. With an
// existing cache the first refresh runs in the background and `open()`
// returns as soon as the cached data is ready — an unreachable server does
// not block opening. With `refreshOnOpen: false` there is no first
// refresh: `open()` returns onto the cache as it is.
// Load the cache and start the refresh loop. When the stored cursor is 0,
// the first refresh is awaited. Such a cache is empty, or holds only what
// `refreshCollection()` stored. `open()` then resolves onto the whole
// account whenever the server is reachable. If that refresh fails, the
// library opens onto what the cache holds, with the failure recorded in
// `status()`. With any other cache the first refresh runs in the
// background, and `open()` returns as soon as the cached data is ready.
// An unreachable server then does 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> {
const { userID } = opts.client.whoami();
const cacheDirectory =
@@ -419,8 +439,8 @@ export class Library {
let store = await MetadataStore.load(metadataPath);
// A cache directory given explicitly can hold another account's cache.
// Its records and cursor are not this account's, so delete it and the
// ML data beside it and start empty. A user ID of 0 means the cache
// was never refreshed and so holds nothing to discard.
// ML data beside it and start empty. A user ID of 0 means nothing was
// ever stored there, so there is nothing to discard.
if (store.userID !== 0 && store.userID !== userID) {
await rm(metadataPath, { force: true });
await rm(join(cacheDirectory, "mldata"), {
@@ -443,10 +463,11 @@ export class Library {
? await MLDataStore.open(join(cacheDirectory, "mldata"))
: undefined;
// Build the content cache from an explicit source or the client's own,
// and take its record of what is already cached (and reap orphan temp
// files) before the first projection, so cached paths are present from
// the start and the first refresh raises no spurious path-change diff.
// Build the content cache from an explicit source or the client's own.
// Take its record of what is already cached, and reap orphan temp
// files, before the first projection. Cached paths are then present
// from the start, and the first `commit()` raises no spurious
// path-change diff.
const source = opts.contentSource ?? opts.client.contentSource?.();
let cache: ContentCache | undefined;
let precache: Precache | undefined;
@@ -500,22 +521,26 @@ export class Library {
precache,
});
// Start filling from whatever the loaded store already holds; each
// refresh below re-kicks with the new files (and retries any that
// failed). An empty store starts empty here and fills after its first
// refresh.
// Start filling from whatever the loaded store already holds. Each
// later `commit()` re-kicks the fills with its new files, and retries
// any that failed. An empty store starts empty here, and fills once a
// `commit()` stores files.
precache?.start();
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
// and start the interval once that first cycle settles.
} else if (store.collectionsSinceTime > 0) {
// The stored cursor has moved, so a refresh has stored the
// account's collections and the cache's copy answers reads.
// Refresh in the background, and start the interval once that
// first cycle settles.
void lib.runRefresh().then(() => lib.scheduleNext());
} else {
// Nothing was cached: wait for the first refresh to fill the store
// (or fail) rather than resolve onto an empty library.
// The stored cursor is still 0, so no refresh has stored any
// collection: the cache is empty, or holds only what
// `refreshCollection()` stored. Wait for the first refresh to fill
// the store (or fail) rather than resolve onto part of the account.
await lib.runRefresh();
lib.scheduleNext();
}
@@ -553,9 +578,9 @@ export class Library {
return snapshotFrom(this.deriveNow(), Date.now());
}
// Deliver a `LibraryChange` whenever a refresh alters the projection. A
// refresh that changes nothing delivers nothing. The returned handle's
// `unsubscribe` stops delivery.
// Deliver a `LibraryChange` whenever a refresh or `refreshCollection()`
// alters the projection. One that changes nothing delivers nothing. The
// returned handle's `unsubscribe` stops delivery.
subscribe(args: { onChange: (change: LibraryChange) => void }): {
unsubscribe: () => void;
} {
@@ -598,12 +623,13 @@ export class Library {
};
}
// Fresh reads (issue #75, owner amendment to design #36). Force a refresh,
// wait for it to complete and persist, then hand back the same
// `albums`/`photos`/`timeline` namespaces — now guaranteed to reflect a
// completed server round-trip. Concurrent calls coalesce onto one refresh;
// a refresh that fails rejects here, where the default namespaces would
// instead stay silent and serve the last good copy.
// Fresh reads (issue #75, owner amendment to design #36). Wait for a
// refresh to complete and persist, then hand back the same
// `albums`/`photos`/`timeline` namespaces. They then reflect a completed
// server round-trip. A running `refreshCollection()` is waited for first.
// Concurrent calls coalesce onto one refresh. A refresh that fails rejects
// here, where the default namespaces would stay silent and serve the last
// good copy.
async fresh(): Promise<FreshReads> {
await this.refreshNow();
return {
@@ -616,10 +642,10 @@ 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.
// large account take long. It stores nothing. A refresh from a stored
// cursor past 0 fetches a collection's files only when the collection is
// new or has changed since the stored copy. So does `refreshCollection()`.
// Neither would fetch the files of a collection stored here without them.
async freshCollections(): Promise<Collection[]> {
if (this.closed) throw new Error("the library is closed");
const { collections } = await this.client.collectionsSince({
@@ -628,67 +654,44 @@ export class Library {
return collections;
}
// The fresh read of one collection's files (issue #217): every live file
// in it that decrypts, in the server's order. `collection` is a record
// `freshCollections()` returned, whose key decrypts the files. It fetches
// no other collection's files. Like `freshCollections()`, it stores
// nothing. It returns each file once, as the latest copy the server sent,
// where the first copy was.
async freshFiles(collection: Collection): Promise<EnteFile[]> {
// Bring one collection up to date in the store (issue #217), given its
// record from `freshCollections()`. It does for that collection what a
// refresh does for a changed one. It fetches no other collection's files.
// It resolves to whether the store then holds the collection.
//
// It adds a collection the store does not hold only while the stored
// cursor is 0, before any refresh has received a collection. After that,
// it stores nothing for such a collection and resolves to false. Only a
// refresh may then add it, after the collections the store holds.
//
// It runs in the same slot as a refresh, so the two never run at the same
// time. It first waits for a running refresh or `refreshCollection()` to
// end. While its own work runs, a fresh read or `backup()` waits for it,
// and the background loop skips its turn.
async refreshCollection(collection: Collection): Promise<boolean> {
while (this.cycle) await this.cycle.catch(() => {});
if (this.closed) throw new Error("the library is closed");
const { files } = await this.client.filesSince({
collectionID: collection.id,
collectionKey: collection.key,
sinceTime: 0,
let held = false;
await this.startCycle(false, async () => {
held = await this.refreshCollectionOnce(collection);
});
const byID = new Map<number, EnteFile>();
for (const file of files) byID.set(file.id, file);
return [...byID.values()];
}
// The original of `file`, fetched and cached as `Photo.original()` does.
// `file` is used as given, so the store need not hold it. On an empty
// cache, `quak get` passes a file that `freshFiles()` returned.
async originalOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
if (!this.cache) {
throw new Error(
"originalOf requires a library opened with a content cache",
);
}
return this.cache.originalOf(file, opts);
}
// The thumbnail of `file`, fetched and cached as `Photo.thumbnail()` does.
// `file` is used as given, so the store need not hold it. On an empty
// cache, `quak get-thumb` passes a file that `freshFiles()` returned.
async thumbnailOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
if (!this.cache) {
throw new Error(
"thumbnailOf requires a library opened with a content cache",
);
}
return this.cache.thumbnailOf(file, opts);
return held;
}
// Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure
// ledger (issue #51). Takes the lock in that directory first, unless the
// caller passes the lock it took as `opts.lock`, failing at once while
// another backup of it runs, and before it starts another file once the
// lock is lost (see `lockBackupDirectory`). Waits for a
// completed refresh, as `fresh()` does, joining one already running, and
// rejects before touching any file but the lock when it fails. Then puts
// pending originals at their save paths as `Photo.download()` does (and
// optional thumbnails) through the content cache and pools, waits for an
// ML data fetch, and rebuilds the derived symlink/JSON views from the
// model, each file's JSON with its ML data, beside an `account.json` with
// the account's email and user ID.
// ledger (issue #51). It first takes the lock in that directory, unless
// the caller passes the lock it took as `opts.lock`. Taking it fails at
// once while another backup of that directory runs. Once the lock is lost,
// the backup fails before it starts another file (see
// `lockBackupDirectory`). It waits for a completed refresh as `fresh()`
// does, joining one already running. When that refresh fails, it rejects
// before touching any file but the lock. It then puts pending originals
// at their save paths as `Photo.download()` does, through the content
// cache and pools. With `includeThumbnails` it also fetches thumbnails. It
// waits for an ML data fetch. It rebuilds the derived symlink/JSON views
// from the model, each file's JSON with its ML data. An `account.json`
// beside them holds the account's email and user ID.
// Throws before any network work when no content cache backs the
// originals it must fetch.
backup(opts?: BackupOptions): Promise<BackupResult> {
@@ -725,12 +728,15 @@ export class Library {
);
}
// Stop the background timer. Idempotent. An in-flight refresh is left to
// finish; it will not schedule another cycle once closed. The returned
// promise resolves once that refresh (including its cache write), the ML
// fetch pass and the precache fetches already running have all finished,
// so a caller can then remove the cache directory. A refresh failure is
// reported through `status()`, not thrown here.
// Stop the background timer. Idempotent. A refresh or `refreshCollection()`
// in flight is left to finish, and nothing starts after it. The returned
// promise resolves once all of these have finished:
// - that refresh or `refreshCollection()`, with its cache write;
// - the ML fetch pass;
// - the precache fetches already running.
// A caller can then remove the cache directory. A refresh failure is
// reported through `status()`, and a `refreshCollection()` failure to its
// own caller. Neither is thrown here.
async close(): Promise<void> {
this.closed = true;
const precacheClosed = this.precache?.close();
@@ -753,33 +759,41 @@ export class Library {
this.timer.unref?.();
}
// The background loop's refresh: run a cycle unless one is already in flight
// (or the library is closed), and never let a failure escape — the
// background path reports errors through `status()`/`onProgress`, it does
// not throw. Resolves once the cycle it started (or skipped past) settles.
// The background loop's refresh: run a cycle unless a refresh or
// `refreshCollection()` is in flight (or the library is closed), and never
// let a failure escape. The background path reports errors through
// `status()`/`onProgress`; it does not throw. It resolves once the cycle
// it started settles, or at once when it starts none.
private runRefresh(): Promise<void> {
if (this.closed || this.cycle) return Promise.resolve();
return this.startCycle().catch(() => {});
return this.startCycle(true, () => this.refreshCycle()).catch(() => {});
}
// A fresh read's refresh (issue #75): force a cycle and await it, rejecting
// if it fails. Concurrent fresh reads coalesce onto the one in-flight cycle
// — the background loop's included — so they never fan out into redundant
// server round-trips.
private refreshNow(): Promise<void> {
if (this.closed) {
return Promise.reject(new Error("the library is closed"));
// A fresh read's refresh (issue #75): join the running refresh or start
// one, and await it, rejecting if it fails. Concurrent fresh reads
// coalesce onto the one in-flight cycle — the background loop's included —
// so they never fan out into redundant server round-trips. A running
// `refreshCollection()` is waited for first.
private async refreshNow(): Promise<void> {
while (this.cycle && !this.cycleIsRefresh) {
await this.cycle.catch(() => {});
}
return this.cycle ?? this.startCycle();
if (this.closed) throw new Error("the library is closed");
return this.cycle ?? this.startCycle(true, () => this.refreshCycle());
}
// Start one refresh cycle and record it as the in-flight cycle so every
// caller coalesces onto it. The returned promise carries the cycle's real
// outcome; each caller attaches the handling its own path needs, and the
// slot is cleared once the cycle settles.
private startCycle(): Promise<void> {
const cycle = this.refreshCycle();
// Start `work` and record it in the in-flight slot, so every other caller
// sees it. `isRefresh` says whether it is a refresh cycle, which a fresh
// read can coalesce onto. The returned promise carries the real outcome.
// Each caller attaches the handling its own path needs, and the slot is
// cleared once the work settles.
private startCycle(
isRefresh: boolean,
work: () => Promise<void>,
): Promise<void> {
const cycle = work();
this.cycle = cycle;
this.cycleIsRefresh = isRefresh;
void cycle.then(
() => {
if (this.cycle === cycle) this.cycle = undefined;
@@ -802,9 +816,9 @@ export class Library {
this.lastError = undefined;
this.emit({ operation: "refresh", status: "done" });
// Backfill ML data for the files this refresh knows about. It runs
// outside the refresh's success/failure so a fetch or disk problem
// there never marks the metadata refresh failed, and it is not
// awaited so it never stalls the refresh interval. Its failure is
// outside the refresh's success or failure, so a fetch or disk
// problem there never marks the metadata refresh failed. It is not
// awaited, so it never stalls the refresh interval. Its failure is
// reported through `status()` and `onProgress`.
void this.fetchMLDataNow().catch(() => {});
} catch (err) {
@@ -817,11 +831,14 @@ export class Library {
// Fetch every change since the stored cursor, then commit. All network
// reads happen before any store mutation, so a fetch that throws leaves the
// store untouched and the previous snapshot intact. A collection or file
// that does not decrypt is left out instead, as if deleted, with the reason
// kept in the store: the cursor moves past it, so no later refresh sees it
// again until the server's copy changes.
// store untouched and the previous snapshot intact.
//
// From a stored cursor of 0, the server lists every live collection. The
// refresh then replaces all the store holds, and counts every collection
// as new. Only `refreshCollection()` can have stored anything before it.
// So the store ends as it would had `refreshCollection()` never run.
private async refreshOnce(): Promise<void> {
const replace = this.store.collectionsSinceTime === 0;
const page = await this.client.collectionsSince({
sinceTime: this.store.collectionsSinceTime,
});
@@ -829,16 +846,19 @@ export class Library {
// Stage per-collection file diffs. A collection's files are
// re-enumerated only when its updationTime has advanced past the cached
// copy; an unchanged album's file list cannot have changed. New
// collections enumerate from the beginning of time. Each collection
// collections enumerate from the beginning of time. When the refresh
// replaces the store, every collection counts as new. Each collection
// whose files are fetched is reported, with how many there are to
// fetch.
const storedCopy = (id: number): Collection | undefined =>
replace ? undefined : this.store.getCollection(id);
const changedCollections = page.collections.filter((collection) => {
const known = this.store.getCollection(collection.id);
const known = storedCopy(collection.id);
return !known || collection.updationTime > known.updationTime;
});
const filePages: { collectionID: number; page: FilesPage }[] = [];
for (const collection of changedCollections) {
const known = this.store.getCollection(collection.id);
const known = storedCopy(collection.id);
const filePage = await this.client.filesSince({
collectionID: collection.id,
collectionKey: collection.key,
@@ -853,8 +873,56 @@ export class Library {
});
}
// Network work done; commit to the store and persist only if something
// actually changed.
await this.commit(page, filePages, replace);
}
// `refreshCollection()`'s work, run in the slot: fetch the files of one
// collection changed since the stored copy, then commit. It resolves to
// whether the store then holds the collection.
private async refreshCollectionOnce(
collection: Collection,
): Promise<boolean> {
const known = this.store.getCollection(collection.id);
// A refresh has received the account's collections, and this one is
// not stored. It is new since then, or a refresh dropped it. Leave it
// to the next refresh, which adds new collections after the stored
// ones, in the server's order.
if (!known && this.store.collectionsSinceTime > 0) return false;
// The stored copy is as new or newer, so there is nothing to fetch.
if (known && collection.updationTime <= known.updationTime) return true;
const filePage = await this.client.filesSince({
collectionID: collection.id,
collectionKey: collection.key,
sinceTime: known ? known.updationTime : 0,
});
// The stored cursor stays where it is, so the next refresh still
// fetches the files of every other collection that changed.
await this.commit(
{
collections: [collection],
deleted: [],
cursor: this.store.collectionsSinceTime,
},
[{ collectionID: collection.id, page: filePage }],
false,
);
return true;
}
// Commit a collections page and the file pages fetched for its collections
// to the store. With `replace`, the page lists every live collection, so
// the commit first deletes every stored collection. A collection or file
// that does not decrypt is left out, as if deleted. The reason is kept in
// the store. Until the server's copy changes, only a refresh from a stored
// cursor of 0 fetches it again. Last, save the store when it holds changes
// not yet saved.
private async commit(
page: CollectionsPage,
filePages: { collectionID: number; page: FilesPage }[],
replace: boolean,
): Promise<void> {
// The network work is done, so change the store now. `changed` says
// whether this commit changed anything.
let changed = false;
if (this.store.userID !== this.userID) {
@@ -862,6 +930,13 @@ export class Library {
changed = true;
}
if (replace) {
for (const { id } of this.store.listCollections()) {
this.store.deleteCollection(id);
changed = true;
}
}
for (const id of page.deleted) {
if (this.store.getCollection(id)) {
this.store.deleteCollection(id);
@@ -915,10 +990,10 @@ export class Library {
if (changed) this.unsaved = true;
// Reproject and notify subscribers of the delta. This tracks RAM (what
// reads see), so it fires whether or not the save below succeeds; a
// save failure surfaces separately through `status().lastError`.
// `lastRecords` advances every changed refresh so the next diff is
// against current state.
// reads see), so it fires whether or not the save below succeeds. A
// failed save rejects the caller, and a refresh also records it in
// `status().lastError`. `lastRecords` advances on every commit that
// changes something, so the next diff is against current state.
if (changed) {
const next = this.deriveNow();
if (this.subscribers.size > 0) {
@@ -929,16 +1004,16 @@ export class Library {
// Recompute the fill orders and pinned set against the new library.
this.precache?.update(next);
}
// Re-kick the fills every cycle: a finished sweep starts afresh to pick
// up new files and retry any that failed, and a running one is left be.
// Re-kick the fills on every commit. A finished sweep starts afresh to
// pick up new files and retry any that failed. A running one is left
// be.
this.precache?.start();
// Persist whenever RAM holds changes disk has not accepted — including
// changes an earlier cycle staged whose save failed. `unsaved` clears
// only once a save lands, so a save failure both stays visible through
// `status().lastError` (the throw below records it) and keeps being
// retried, instead of a later empty refresh silently clearing it while
// the on-disk cache is still behind RAM.
// only once a save lands, so a later commit retries a failed save even
// when it changes nothing. A failed save rejects the caller, and a
// refresh also records it in `status().lastError`.
if (this.unsaved) {
await this.store.save();
this.unsaved = false;
@@ -954,13 +1029,14 @@ export class Library {
return this.mlFetch;
}
// One ML fetch pass: fetch, decrypt and store the ML data for every file
// the store knows about that is not cached (or whose `updationTime` has
// advanced), through the metadata pool, and update the CLIP index. For a
// file whose ML data does not decrypt, gunzip or parse, it gathers the
// reason instead, and puts the reasons it gathered in `mlDataErrors` when
// it ends without failing. A failure leaves `mlDataErrors` as it was and
// is reported through `status()` and `onProgress`, then thrown.
// One ML fetch pass. It fetches, decrypts and stores the ML data of every
// file the store knows about that is not cached, or whose `updationTime`
// has advanced. It fetches through the metadata pool, and updates the CLIP
// index. For a file whose ML data does not decrypt, gunzip or parse, it
// gathers the reason instead. It puts the reasons it gathered in
// `mlDataErrors` when it ends without failing. A failure leaves
// `mlDataErrors` as it was and is reported through `status()` and
// `onProgress`, then thrown.
private async runMLFetch(): Promise<void> {
const mldata = this.mlStore;
// Bind so the call keeps the client as its receiver when invoked
@@ -1028,9 +1104,9 @@ export class Library {
}));
}
// The distinct files the store holds, one entry per fileID (a file in
// several collections shares its ML data), each carrying the key and the
// newest `updationTime` seen across its memberships.
// The distinct files the store holds, one entry per fileID, since a file
// in several collections shares its ML data. Each entry carries the key
// and the newest `updationTime` seen across the file's memberships.
private uniqueFiles(): {
id: number;
key: Uint8Array;
+1 -1
View File
@@ -122,7 +122,7 @@ export class Precache {
}
// Recompute the fill orders and the pinned set from the current projection.
// Called at open and after every refresh that changes the library.
// Called at open and after every change to the store.
update(records: DerivedRecords): void {
const photos = [...records.photos.values()].sort(
(a, b) => b.takenAt - a.takenAt || b.fileID - a.fileID,
+3 -2
View File
@@ -71,13 +71,14 @@ export interface LibrarySnapshot {
takenAt: number;
}
// What one refresh or `refreshCollection()` changed in the records.
export interface LibraryChange {
// Full records for albums/photos added or changed by the refresh.
// Full records for albums/photos added or changed.
albumsChanged: AlbumRecord[];
photosChanged: PhotoRecord[];
fileIDsRemoved: number[];
albumIDsRemoved: number[];
// Wall-clock milliseconds of the refresh that produced this change.
// Wall-clock milliseconds when the store changed.
refreshedAt: number;
}
+13 -12
View File
@@ -3,7 +3,7 @@
// The store keeps one `metadata.json` file holding the account's server
// state: the user id, a schema version, the cursor for the incremental
// collections listing, the decrypted collection and file records, and the
// collections and files the refresh could not decrypt. The whole file is read
// collections and files that did not decrypt. The whole file is read
// into RAM on load and rewritten as a whole on save; there is no partial
// update and no lock file. A separate refresh unit populates the store from
// the server — this module only stores what it is given.
@@ -34,9 +34,11 @@ const FILE_MODE = 0o600;
type StoredCollection = Omit<Collection, "key"> & { key: string };
type StoredFile = Omit<EnteFile, "key"> & { key: string };
// A collection, or with `fileID` a file in it, that the last refresh to fetch
// it could not decrypt or parse, and why. It is kept until a later refresh
// fetches it again, because a refresh fetches only what changed.
// A collection, or with `fileID` a file in it, that did not decrypt or parse,
// and why. A refresh or `refreshCollection()` records it on fetching such a
// copy. A later fetch drops it on getting a copy that decrypts, or on learning
// that the server deleted it. Each fetch asks only for what changed, so the
// store keeps the record between fetches.
export interface Undecryptable {
collectionID: number;
fileID?: number;
@@ -70,12 +72,6 @@ export class MetadataStore {
userID = 0;
collectionsSinceTime: Microseconds = 0;
// True when `load` populated this store from a valid existing file; false
// on a first run or a missing/corrupt/wrong-version file that loaded empty.
// `Library.open` reads it to decide whether the first refresh may run in
// the background (an existing copy already serves reads) or must be awaited.
loadedFromDisk = false;
private readonly collections = new Map<number, Collection>();
private readonly files = new Map<string, EnteFile>();
private undecryptable: Undecryptable[] = [];
@@ -102,7 +98,6 @@ export class MetadataStore {
if (parsed.schemaVersion !== METADATA_SCHEMA_VERSION) {
return store;
}
store.loadedFromDisk = true;
store.userID = parsed.userID ?? 0;
store.collectionsSinceTime = parsed.collectionsSinceTime ?? 0;
for (const stored of parsed.collections ?? []) {
@@ -229,8 +224,14 @@ export class MetadataStore {
this.files.delete(fileKey(collectionID, fileID));
}
// Sorted by collection ID, then file ID, so the order does not depend on
// when each record was fetched.
listUndecryptable(): Undecryptable[] {
return [...this.undecryptable];
return [...this.undecryptable].sort(
(a, b) =>
a.collectionID - b.collectionID ||
(a.fileID ?? 0) - (b.fileID ?? 0),
);
}
// Replaces any earlier record of the same collection or file.
+36 -11
View File
@@ -5,7 +5,7 @@ import type { Client } from "./client.js";
import type { Library } from "./library/index.js";
import { ApiError } from "./api/client.js";
import { encryptBlob, toBase64 } from "./crypto/index.js";
import type { Collection, EnteFile } from "./model/types.js";
import type { EnteFile } from "./model/types.js";
// The server refuses a thumbnail larger than the one it already records for the
// file (`thumbnail.size`, the encrypted size), so these encodings are tried
@@ -226,18 +226,16 @@ const encryptThumbnailWithin = (
return undefined;
};
// Regenerate and upload a thumbnail for each requested file. `found` holds the
// record of each requested file that a collection holds, and the collection it
// was found in. Each original is read through the library's content cache, by
// `lib.originalOf(file)`. The new thumbnail is a JPEG, encrypted under the
// file's own key. It is uploaded, then registered with the server. Each file is
// Regenerate and upload a thumbnail for each requested file. Originals are read
// through the library's content cache (`photo.original()`); the generated
// thumbnail is JPEG-encoded, encrypted under the file's own key, and registered
// with the server — the encrypt-and-upload path is unchanged. Each file is
// resolved to one outcome (fixed / skipped / failed) and a failure on one file
// never stops the others.
export const fixMissingThumbnails = async (
lib: Library,
client: Client,
fileIDs: number[],
found: Map<number, { file: EnteFile; collection: Collection }>,
onProgress?: ProgressCallback,
): Promise<ThumbnailFixResult[]> => {
const log = onProgress ?? (() => {});
@@ -245,8 +243,31 @@ export const fixMissingThumbnails = async (
const api = client.getApiClient();
const { userID } = client.whoami();
// Resolve each requested fileID to its file record and owning album by
// enumerating the library, each file taken from the first album that holds
// it. The raw `EnteFile` carries the per-file key the thumbnail is
// encrypted under, which the projected records deliberately do not.
const wanted = new Set(fileIDs);
const fileMap = new Map<
number,
{ file: EnteFile; collectionName: string }
>();
for (const album of lib.albums.list()) {
for (const photo of album.photos.list()) {
if (!wanted.has(photo.fileID) || fileMap.has(photo.fileID))
continue;
const file = lib.getFile(album.collectionID, photo.fileID);
if (file) {
fileMap.set(photo.fileID, {
file,
collectionName: album.name,
});
}
}
}
for (const fileID of fileIDs) {
const entry = found.get(fileID);
const entry = fileMap.get(fileID);
if (!entry) {
results.push({
fileID,
@@ -258,8 +279,7 @@ export const fixMissingThumbnails = async (
continue;
}
const { file } = entry;
const collectionName = entry.collection.name;
const { file, collectionName } = entry;
const title = file.metadata.title;
const skipReason = reasonToSkip(file, userID);
@@ -277,10 +297,15 @@ export const fixMissingThumbnails = async (
const maxSize = file.thumbnail.size!;
try {
const photo = lib.photos.byID({ fileID });
if (!photo) {
throw new Error("file not present in the library cache");
}
log(
`[${collectionName}] Downloading ${title} for thumbnail generation...`,
);
const { path } = await lib.originalOf(file);
const { path } = await photo.original();
const fileBytes = new Uint8Array(readFileSync(path));
if (!isJpeg(fileBytes)) {
+1 -1
View File
@@ -418,7 +418,7 @@ describe("the first backup of the account", () => {
expect(downloads.sort()).toEqual([100, 200, 300]);
});
// The tree under "The backup directory" in the README. Each original is at
// The tree "Backup layout" in the README describes. Each original is at
// its save path, `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, with
// the extension of its title; the live photo is its image and its video,
// each with the extension it had inside the ZIP, and a `.livephoto.json`
+172 -305
View File
@@ -124,20 +124,35 @@ const FILES: Record<number, EnteFile[]> = {
// thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing
// the collection's updationTime as the server does. `beachInWork` adds
// beach.jpg (100) to Work as well, so that one file is in both albums.
// `refreshError` makes listing collections fail with that message.
// `newAlbums` adds the albums A (3) and B (4), in that order, which both hold
// shared.jpg (300). `refreshError` makes listing collections fail with that
// message.
const fakeClient = (
opts: {
failID?: number;
emptyThumbID?: number;
withNewFile?: boolean;
beachInWork?: boolean;
newAlbums?: boolean;
refreshError?: string;
} = {},
) => {
const collections = opts.withNewFile
let collections = opts.withNewFile
? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]]
: COLLECTIONS;
let files = FILES;
if (opts.newAlbums) {
collections = [
...collections,
{ ...collection(3, "A"), updationTime: 2 },
{ ...collection(4, "B"), updationTime: 2 },
];
files = {
...files,
3: [file(300, 3, "shared.jpg")],
4: [file(300, 4, "shared.jpg")],
};
}
if (opts.withNewFile) {
files = {
...files,
@@ -194,6 +209,9 @@ const FETCHED_BOTH_ALBUMS =
"Fetched the files of 1 of 2 collections\n" +
"Fetched the files of 2 of 2 collections\n";
// The same for an account with one album.
const FETCHED_ONE_ALBUM = "Fetched the files of 1 of 1 collections\n";
// Collects everything written to it.
class Output {
text = "";
@@ -566,6 +584,57 @@ describe("files", () => {
]);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS);
});
// `files 2` on an empty cache stores Work alone. The refresh that `files`
// then waits for must still store Vacation first, as the server lists it.
it("without a collection ID lists in the server's order after files 2 on an empty cache", async () => {
const ctx = context(fakeClient({ beachInWork: true }));
expect(await filesCommand(ctx, "2", {})).toBe(0);
stdout.text = "";
stderr.text = "";
expect(await filesCommand(ctx, undefined, { json: true })).toBe(0);
expect(
JSON.parse(stdout.text).map(
(row: { id: number; collectionID: number }) => [
row.id,
row.collectionID,
],
),
).toEqual([
[100, 1],
[101, 1],
[200, 2],
]);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS);
});
// After a refresh, the server adds A and then B. `files 4` must not store B
// ahead of A, so it waits for a refresh, which stores both in that order.
it("without a collection ID lists new albums in the server's order after files with the second one's ID", async () => {
expect(await filesCommand(context(), undefined, {})).toBe(0);
const ctx = context(fakeClient({ newAlbums: true }));
stdout.text = "";
expect(await filesCommand(ctx, "4", {})).toBe(0);
expect(stdout.text).toBe("300\timage\tshared.jpg\n");
stdout.text = "";
expect(await filesCommand(ctx, undefined, { json: true })).toBe(0);
expect(
JSON.parse(stdout.text).map(
(row: { id: number; collectionID: number }) => [
row.id,
row.collectionID,
],
),
).toEqual([
[100, 1],
[101, 1],
[200, 2],
[300, 3],
]);
});
});
describe("get and get-thumb", () => {
@@ -573,27 +642,27 @@ describe("get and get-thumb", () => {
const out = join(root, "diagram.png");
expect(await getCommand(context(), "200", { out })).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200));
expect(stderr.text).toBe(`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 () => {
const out = join(root, "thumb.jpg");
expect(await getThumbCommand(context(), "200", { out })).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200));
expect(stderr.text).toBe(`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 () => {
const out = join(root, "x");
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);
});
it("get-thumb exits 1 when no album has the file", async () => {
const out = join(root, "x");
expect(await getThumbCommand(context(), "999", { out })).toBe(1);
expect(stderr.text).toBe("File 999 not found\n");
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n");
expect(existsSync(out)).toBe(false);
});
});
@@ -641,7 +710,7 @@ describe("names from an album name or a title over 255 bytes", () => {
expect(Buffer.byteLength(name)).toBe(255);
expect(code).toBe(0);
expect(readdirSync(dir)).toEqual([name]);
expect(stderr.text).toBe(`7 bytes -> ${name}\n`);
expect(stderr.text).toBe(FETCHED_ONE_ALBUM + `7 bytes -> ${name}\n`);
});
it("get-thumb cuts the title short so that thumb_ and it fit in 255 bytes, and exits 0", async () => {
@@ -653,7 +722,7 @@ describe("names from an album name or a title over 255 bytes", () => {
expect(Buffer.byteLength(name)).toBe(255);
expect(code).toBe(0);
expect(readdirSync(dir)).toEqual([name]);
expect(stderr.text).toBe(`3 bytes -> ${name}\n`);
expect(stderr.text).toBe(FETCHED_ONE_ALBUM + `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 () => {
@@ -742,7 +811,8 @@ describe("a live photo", () => {
Buffer.from(VIDEO),
);
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`,
);
});
@@ -796,7 +866,8 @@ describe("a live photo", () => {
expect(readFileSync(out)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
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`,
);
});
@@ -810,7 +881,8 @@ describe("a live photo", () => {
expect(existsSync(out)).toBe(false);
expect(existsSync(join(root, "photo.mov"))).toBe(false);
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`,
);
});
@@ -1353,23 +1425,6 @@ describe("helper fix-missing-thumbnails", () => {
).toBe(1);
expect(stderr.text).toBe(last);
});
// beach.jpg (100) is in Vacation (1) and Work (2), both updated at the same
// time. The fixer names a file after the first album holding it in the
// order of `Library.albums.list()`. On a tie, the album with the higher ID
// comes first, so beach.jpg is named after Work.
it("names a photo in two albums after the same album on an empty cache as on a cache that holds the account", async () => {
const ctx = context(fakeClient({ beachInWork: true }));
const opts = { file: ["100"], json: true };
const inWork = [{ fileID: 100, collection: "Work", status: "skipped" }];
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(0);
expect(JSON.parse(stdout.text)).toMatchObject(inWork);
expect(await filesCommand(ctx, undefined, {})).toBe(0);
stdout.text = "";
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(0);
expect(JSON.parse(stdout.text)).toMatchObject(inWork);
});
});
describe("backup-metadata --exif", () => {
@@ -1399,8 +1454,8 @@ describe("backup-metadata --exif", () => {
});
// Each test first runs `files` so the cache holds the account as it was, then
// changes the server under it. (`collections` and `files` with a collection ID
// would not do: they store nothing.)
// 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", () => {
beforeEach(async () => {
expect(await filesCommand(context(), undefined, {})).toBe(0);
@@ -1586,83 +1641,22 @@ describe("an ID given on the command line", () => {
);
});
// An account with many albums (issues #210, #217 and #222). On an empty cache
// `collections`, `files` with a collection ID and `get` used to wait for a
// refresh that fetched and decrypted the files of every album, one album after
// another. So did `helper fix-missing-thumbnails --file` (issue #223). On such
// an account they seemed to hang. The account here has 40 albums of one photo
// each, encrypted as Ente's clients encrypt them, on the fake Ente server of
// `test/ente-server.ts`. The tests record the path and query of each request
// quak sends. The session is the one logging in to that server saves.
// 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. It printed nothing until then, so on such an
// account it seemed to hang. `files` with an album's ID did the same before it
// listed that album (issue #217). The account here has 40 albums of one photo
// each, encrypted as Ente's clients encrypt them. The fake Ente server of
// `test/ente-server.ts` serves them. A wrapper around it records the path and
// query of each request quak sends. The session is the one logging in to that
// server saves.
describe("an account with many albums", () => {
const ALBUMS = 40;
const PASSWORD = "many-albums";
const requests: string[] = [];
const paths: string[] = [];
let server: typeof globalThis.fetch;
let snapshot: ClientSnapshot;
// What `files` without a collection ID prints on an empty cache: every
// album's photo on stdout, and on stderr each album whose files its
// refresh fetched.
let everyPhoto = "";
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
everyPhoto += `${n * 100}\timage\tphoto-${n}.jpg\n`;
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
// The key of each album, by album ID.
const albumKeys = new Map<number, Uint8Array>();
// A photo in album `collectionID` as the server sends it, and its original
// encrypted as the server stores it. The original's bytes are the title.
// These tests fetch no thumbnail. The server records a thumbnail size, so
// `helper fix-missing-thumbnails` downloads the original before it skips
// the photo as not a JPEG.
const photoRow = (photo: {
id: number;
collectionID: number;
title: string;
updationTime: number;
}): { row: Record<string, unknown>; original: Uint8Array } => {
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const photoKeyBox = encryptSecretbox(
photoKey,
albumKeys.get(photo.collectionID)!,
);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title: photo.title,
fileType: 0,
creationTime: 1000,
modificationTime: 1000,
}),
),
photoKey,
);
const original = encryptStreamBlob(
new TextEncoder().encode(photo.title),
photoKey,
);
const row = {
id: photo.id,
collectionID: photo.collectionID,
ownerID: USER_ID,
encryptedKey: toBase64(photoKeyBox.ciphertext),
keyDecryptionNonce: toBase64(photoKeyBox.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
file: { decryptionHeader: toBase64(original.header) },
thumbnail: { decryptionHeader: "" },
info: { thumbSize: 1000 },
updationTime: photo.updationTime,
};
return { row, original: original.ciphertext };
};
// Album n is named `Album n` and holds one photo, n * 100, titled
// `photo-n.jpg`.
beforeAll(async () => {
@@ -1670,10 +1664,8 @@ describe("an account with many albums", () => {
const account = await buildAccount(PASSWORD);
const collections: Record<string, unknown>[] = [];
const filesByCollection: Record<number, Record<string, unknown>[]> = {};
const fileCiphertexts: Record<number, Uint8Array> = {};
for (let id = 1; id <= ALBUMS; id++) {
const albumKey = sodium.crypto_secretbox_keygen();
albumKeys.set(id, albumKey);
const albumKeyBox = encryptSecretbox(albumKey, account.masterKey);
const name = encryptSecretbox(
new TextEncoder().encode(`Album ${id}`),
@@ -1689,14 +1681,37 @@ describe("an account with many albums", () => {
type: "album",
updationTime: 1,
});
const { row, original } = photoRow({
id: id * 100,
collectionID: id,
title: `photo-${id}.jpg`,
updationTime: 1,
});
filesByCollection[id] = [row];
fileCiphertexts[id * 100] = original;
const 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,
@@ -1706,7 +1721,7 @@ describe("an account with many albums", () => {
collections,
filesByCollection,
encryptedMLData: {},
fileCiphertexts,
fileCiphertexts: {},
});
server = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
@@ -1716,7 +1731,7 @@ describe("an account with many albums", () => {
? input.href
: input.url;
const { pathname, search } = new URL(url);
requests.push(pathname + search);
paths.push(pathname + search);
return ente(input, init);
}) as typeof globalThis.fetch;
const client = await Client.login({
@@ -1728,17 +1743,16 @@ describe("an account with many albums", () => {
});
beforeEach(() => {
requests.length = 0;
paths.length = 0;
});
// A context whose saved session sends its requests to `fetch`, by default
// the fake server.
const accountContext = (fetch = server): CliContext => {
// 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 }),
loadSession: (path) => loadSession(path, { fetch: server }),
};
};
@@ -1751,219 +1765,72 @@ describe("an account with many albums", () => {
albums += `${id}\talbum\tAlbum ${id}\n`;
expect(stdout.text).toBe(albums);
expect(stderr.text).toBe("");
expect(requests).toEqual(["/collections/v2?sinceTime=0"]);
expect(paths).toEqual(["/collections/v2?sinceTime=0"]);
// It stored nothing that would make a refresh skip an album's files:
// `files` after it still lists every album's photo.
// It stored nothing that would make `refreshCollection()` skip an
// album's files: `files` after it still finds the album's photo.
stdout.text = "";
expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(stdout.text).toBe(everyPhoto);
expect(await filesCommand(ctx, "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
});
it("files with a collection ID asks for the album list and that album's files alone", async () => {
it("files fetches the files of the album it lists and of no other", async () => {
expect(await filesCommand(accountContext(), "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
expect(stderr.text).toBe("");
expect(requests).toEqual([
expect(paths).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=7&sinceTime=0",
]);
});
// Here the server sends album 7's files in two pages. Photo 700 changed
// between them, so both pages hold a copy of it.
it("files with a collection ID lists only the later copy of a photo sent in two pages", async () => {
const firstPage = [
photoRow({
id: 700,
collectionID: 7,
title: "photo-7.jpg",
updationTime: 1,
}).row,
photoRow({
id: 701,
collectionID: 7,
title: "photo-701.jpg",
updationTime: 1,
}).row,
];
const secondPage = [
photoRow({
id: 700,
collectionID: 7,
title: "photo-7-edited.jpg",
updationTime: 2,
}).row,
];
const paged = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : input);
if (
url.pathname !== "/collections/v2/diff" ||
url.searchParams.get("collectionID") !== "7"
) {
return server(input, init);
}
const first = url.searchParams.get("sinceTime") === "0";
return new Response(
JSON.stringify({
diff: first ? firstPage : secondPage,
hasMore: first,
}),
);
}) as typeof globalThis.fetch;
expect(await filesCommand(accountContext(paged), "7", {})).toBe(0);
// Photo 700 is listed once, with the later title, before photo 701.
expect(stdout.text).toBe(
"700\timage\tphoto-7-edited.jpg\n701\timage\tphoto-701.jpg\n",
);
});
// The cache stays empty, so a later `files` without a collection ID still
// fetches the files of all 40 albums.
it("files with a collection ID stores nothing in the cache", async () => {
// The first listing stored the album, so the second finds its stored copy
// current and asks for no album's files.
it("files run again for the same album sends no request for its files", 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);
paths.length = 0;
expect(await filesCommand(ctx, "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
expect(paths).toEqual(["/collections/v2?sinceTime=0"]);
});
// Photo 200 is in album 2, so `get` asks for no file of album 3 or later.
// The last request downloads the photo's original.
it("get on an empty cache asks for the albums' files only until one holds the photo", async () => {
const out = join(root, "photo.jpg");
expect(await getCommand(accountContext(), "200", { out })).toBe(0);
expect(readFileSync(out, "utf-8")).toBe("photo-2.jpg");
expect(stderr.text).toBe(`11 bytes -> ${out}\n`);
expect(requests).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=1&sinceTime=0",
"/collections/v2/diff?collectionID=2&sinceTime=0",
"/?fileID=200",
]);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
});
// `files` first fills the cache, so `get` waits for a refresh. No album has
// changed since, so the refresh asks for no album's files.
it("get on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
const ctx = accountContext();
expect(await filesCommand(ctx, undefined, {})).toBe(0);
requests.length = 0;
stderr.text = "";
const out = join(root, "photo.jpg");
expect(await getCommand(ctx, "4000", { out })).toBe(0);
expect(readFileSync(out, "utf-8")).toBe("photo-40.jpg");
expect(stderr.text).toBe(`12 bytes -> ${out}\n`);
expect(requests).toEqual([
"/collections/v2?sinceTime=1",
"/?fileID=4000",
]);
requests.length = 0;
stderr.text = "";
expect(await getCommand(ctx, "999", { out })).toBe(1);
expect(stderr.text).toBe("File 999 not found\n");
expect(requests).toEqual(["/collections/v2?sinceTime=1"]);
});
// The helper searches the most recently updated album first. All 40 were
// updated at the same time, and on a tie the higher ID comes first. Photo
// 3900 is in album 39, so the helper asks for no file of album 38 or
// earlier. The last request downloads the photo's original.
it("fix-missing-thumbnails --file on an empty cache asks for the albums' files only until one holds the photo", async () => {
const opts = { file: ["3900"], json: true };
expect(await fixMissingThumbnailsCommand(accountContext(), opts)).toBe(
0,
);
const reason =
"unsupported image format (only baseline JPEG can be regenerated)";
expect(JSON.parse(stdout.text)).toEqual([
{
fileID: 3900,
title: "photo-39.jpg",
collection: "Album 39",
status: "skipped",
reason,
},
]);
expect(stderr.text).toBe(
"[Album 39] Downloading photo-39.jpg for thumbnail generation...\n" +
`[Album 39] Skipping photo-39.jpg: ${reason}\n`,
);
expect(requests).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=40&sinceTime=0",
"/collections/v2/diff?collectionID=39&sinceTime=0",
"/?fileID=3900",
]);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
});
// `files` first fills the cache, so the helper waits for a refresh. No
// album has changed since, so the refresh asks for no album's files.
it("fix-missing-thumbnails --file on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
const ctx = accountContext();
expect(await filesCommand(ctx, undefined, {})).toBe(0);
requests.length = 0;
stdout.text = "";
stderr.text = "";
const opts = { file: ["3900"], json: true };
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(0);
const reason =
"unsupported image format (only baseline JPEG can be regenerated)";
expect(JSON.parse(stdout.text)).toEqual([
{
fileID: 3900,
title: "photo-39.jpg",
collection: "Album 39",
status: "skipped",
reason,
},
]);
expect(stderr.text).toBe(
"[Album 39] Downloading photo-39.jpg for thumbnail generation...\n" +
`[Album 39] Skipping photo-39.jpg: ${reason}\n`,
);
expect(requests).toEqual([
"/collections/v2?sinceTime=1",
"/?fileID=3900",
]);
requests.length = 0;
stdout.text = "";
const notFound = { file: ["999"], json: true };
expect(await fixMissingThumbnailsCommand(ctx, notFound)).toBe(1);
expect(JSON.parse(stdout.text)).toMatchObject([
{ fileID: 999, reason: "file not found in any collection" },
]);
expect(requests).toEqual(["/collections/v2?sinceTime=1"]);
});
it("files counts on stderr each album whose files its refresh fetched", async () => {
it("files with no album's ID counts on stderr each album whose files its refresh fetched", async () => {
expect(await filesCommand(accountContext(), undefined, {})).toBe(0);
expect(stdout.text).toBe(everyPhoto);
let photos = "";
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
photos += `${n * 100}\timage\tphoto-${n}.jpg\n`;
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
expect(stdout.text).toBe(photos);
expect(stderr.text).toBe(progress);
});
// `--json` shapes stdout only, so the refresh's progress is still counted
// on stderr.
it("files --json counts on stderr each album whose files its refresh fetched", async () => {
const opts = { json: true };
expect(await filesCommand(accountContext(), undefined, opts)).toBe(0);
it("files --json with no album's ID counts on stderr each album whose files its refresh fetched", async () => {
expect(
await filesCommand(accountContext(), undefined, { json: true }),
).toBe(0);
expect(JSON.parse(stdout.text)).toHaveLength(ALBUMS);
const rows = JSON.parse(stdout.text);
expect(rows).toHaveLength(ALBUMS);
expect(rows[6]).toEqual({
id: 700,
title: "photo-7.jpg",
fileType: "image",
creationTime: 1000,
collectionID: 7,
});
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
expect(stderr.text).toBe(progress);
});
});
+4 -6
View File
@@ -234,15 +234,13 @@ describe("-q", () => {
});
});
// No album holds file 999, so fixing it fails. The summary from
// `--- Done ---` to the end of the `Failed files:` list is left out. The
// line saying why the command failed is the fatal one, and it is printed.
// The line saying why the command failed is the fatal one, and it is
// printed.
it("prints only the line a failing command exits with", async () => {
const fix = ["helper", "fix-missing-thumbnails", "--file", "999"];
expect(await quak("-q", ...fix)).toEqual({
expect(await quak("-q", "files", "9")).toEqual({
code: 1,
stdout: "",
stderr: `quak: 1 file(s) failed; see the "Failed files:" list, or the JSON with --json\n`,
stderr: "Collection 9 not found\n",
});
});
+87 -139
View File
@@ -3,25 +3,25 @@
* issue #36, issue #52).
*
* The `collections`, `files`, `get`, and `get-thumb` commands must answer for
* current server state, not the local cache. `get`, `get-thumb` and `files`
* without a collection ID force a `Library.fresh()` round-trip before they
* read. The stand-in library below serves no cached record until `fresh()` has
* been awaited, so a helper that read without refreshing would come back empty
* and fail here. `collections` asks the library for the collection list alone
* (`freshCollections()`, issue #210). `files` with a collection ID asks for
* that list, then for that one collection's files (`freshFiles()`, issue
* #217). Neither may wait for a refresh, which would fetch the files of every
* collection. On an empty cache, `get` and `get-thumb` ask for that list, then
* for one collection's files at a time, until they find the file (issue #222).
* `helper fix-missing-thumbnails --file` does the same until it finds every
* file it names, reading the most recently updated collection first (issue
* #223).
* current server state, not the local cache. `files` without a collection ID,
* `get` and `get-thumb` force a `Library.fresh()` round-trip before they read.
* The stand-in library below serves nothing until `fresh()` has been awaited.
* A helper that read without refreshing would come back empty and fail here.
* `collections` asks the library for the collection list alone
* (`freshCollections()`, issue #210). It must not wait for a refresh, which
* would fetch the files of every collection. `files COLLECTION` reads that list
* too, then brings that one collection up to date (`refreshCollection()`,
* issue #217). The stand-in serves a collection's files once `fresh()` has run,
* or once `refreshCollection()` has resolved to `true` for that collection.
* When `refreshCollection()` resolves to `false`, `files COLLECTION` forces a
* refresh instead.
*
* `collections` and `files` also list in the order the library gives them, not
* `collections` and `files` also list in the library's enumeration order
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not
* the albums/photos projection's newest-first order. The fixtures are seeded in
* an order that a newest-first sort would rearrange, so a regression to the
* projection order would fail here too. Field values still come from the raw
* metadata via `cli-output.ts`.
* an enumeration order that a newest-first sort would rearrange, so a
* regression to the projection order would fail here too. Field values still
* come from the raw metadata via `cli-output.ts`.
*/
import { describe, it, expect } from "vitest";
@@ -30,10 +30,10 @@ import {
freshCollections,
freshFiles,
freshFile,
freshFilesByID,
type FreshReadLibrary,
} from "../../src/cli-read.js";
import { fileListRow } from "../../src/cli-output.js";
import type { Photo } from "../../src/library/index.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
const collection = (id: number, updationTime: number): Collection => ({
@@ -67,15 +67,20 @@ const file = (
updationTime: creationTime,
});
// A library that reveals its cached records only after `fresh()` has been
// awaited, and serves them in the enumeration order it was given.
// `freshCollections()` and `freshFiles()` answer in that order without a
// refresh.
// A library that reveals its records only after `fresh()` has been awaited, and
// serves them in the enumeration order it was given. `freshCollections()`
// answers the collection list in that order without a refresh. When
// `refreshCollection()` resolves to true, it reveals the files of that one
// collection.
// `photos.byID` returns a stand-in `Photo` carrying just the fileID the helper
// passes through.
class FakeLibrary implements FreshReadLibrary {
freshCalls = 0;
freshCollectionsCalls = 0;
// The ID of each collection `freshFiles()` was asked for.
freshFilesCalls: number[] = [];
refreshCollectionIDs: number[] = [];
// What `refreshCollection()` resolves to. With false it reveals nothing,
// as a library that has had a refresh does for a collection it lacks.
refreshCollectionHolds = true;
private refreshed = false;
constructor(
@@ -94,9 +99,14 @@ class FakeLibrary implements FreshReadLibrary {
return this.collections;
}
async freshFiles(collection: Collection): Promise<EnteFile[]> {
this.freshFilesCalls.push(collection.id);
return this.files.filter((f) => f.collectionID === collection.id);
async refreshCollection(collection: Collection): Promise<boolean> {
this.refreshCollectionIDs.push(collection.id);
return this.refreshCollectionHolds;
}
getCollection(id: number): Collection | undefined {
if (!this.refreshed) return undefined;
return this.collections.find((c) => c.id === id);
}
listCollections(): Collection[] {
@@ -104,7 +114,11 @@ class FakeLibrary implements FreshReadLibrary {
}
listFiles(collectionID: number): EnteFile[] {
return this.refreshed
const current =
this.refreshed ||
(this.refreshCollectionHolds &&
this.refreshCollectionIDs.includes(collectionID));
return current
? this.files.filter((f) => f.collectionID === collectionID)
: [];
}
@@ -113,6 +127,14 @@ class FakeLibrary implements FreshReadLibrary {
if (!this.refreshed) return undefined;
return this.files.find((f) => f.id === fileID);
}
photos = {
byID: ({ fileID }: { fileID: number }): Photo | undefined => {
if (!this.refreshed) return undefined;
if (!this.files.some((f) => f.id === fileID)) return undefined;
return { fileID } as unknown as Photo;
},
};
}
describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
@@ -136,9 +158,10 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
expect(rows.map((c) => c.id)).not.toEqual(newestFirst);
});
it("freshFiles with a collection ID reads that collection's files alone and keeps their order and raw fields", async () => {
// Given in the order 10, 11, 12; creationTimes ascending, so a
// newest-first sort would reverse them.
it("freshFiles brings the collection alone up to date, lists in enumeration order, keeps raw fields", async () => {
// Enumeration order by id 10, 11, 12; creationTimes ascending, so a
// newest-first sort would reverse them. Collection 2's file is not
// listed.
const files = [
file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001),
@@ -146,14 +169,14 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
file(20, 2, 1_700_000_000_000_003),
];
const lib = new FakeLibrary(
[collection(1, 100), collection(2, 200)],
[collection(1, 100), collection(2, 100)],
files,
);
const rows = await freshFiles(lib, 1);
expect(lib.freshCalls).toBe(0);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([1]);
expect(lib.refreshCollectionIDs).toEqual([1]);
expect(lib.freshCalls).toBe(0);
expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]);
// Field values come from raw metadata: microsecond creationTime and the
@@ -207,120 +230,45 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
]);
});
it("freshFiles forces a refresh when refreshCollection resolves to false", async () => {
const lib = new FakeLibrary(
[collection(1, 100)],
[file(10, 1, 1_700_000_000_000_000)],
);
lib.refreshCollectionHolds = false;
const rows = await freshFiles(lib, 1);
expect(lib.refreshCollectionIDs).toEqual([1]);
expect(lib.freshCalls).toBe(1);
expect(rows?.map((f) => f.id)).toEqual([10]);
});
it("freshFiles returns undefined for an unknown collection", async () => {
const lib = new FakeLibrary([collection(1, 100)], []);
const rows = await freshFiles(lib, 999);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.refreshCollectionIDs).toEqual([]);
expect(lib.freshCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(rows).toBeUndefined();
});
// Three collections, each holding one file.
const threeCollections = (): FakeLibrary =>
new FakeLibrary(
[collection(1, 100), collection(2, 200), collection(3, 300)],
[
file(10, 1, 1_700_000_000_000_000),
file(20, 2, 1_700_000_000_000_001),
file(30, 3, 1_700_000_000_000_002),
],
it("freshFile refreshes first, then resolves the photo and its raw record", async () => {
const f = file(10, 1, 1_700_000_000_000_000);
const lib = new FakeLibrary([collection(1, 100)], [f]);
const resolved = await freshFile(lib, 10);
expect(lib.freshCalls).toBe(1);
expect(resolved?.photo.fileID).toBe(10);
expect(resolved?.file.metadata.title).toBe("file-10.jpg");
expect(resolved?.file.metadata.creationTime).toBe(
1_700_000_000_000_000,
);
it("freshFile on an empty cache reads each collection's files in the server's order until one holds the file", async () => {
const lib = threeCollections();
const found = await freshFile(lib, 20);
expect(lib.freshCalls).toBe(0);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([1, 2]);
expect(found?.metadata.title).toBe("file-20.jpg");
expect(found?.metadata.creationTime).toBe(1_700_000_000_000_001);
});
// The stand-in's cache holds what the server holds once `fresh()` has run.
it("freshFile on a cache that holds the collections refreshes, then looks the file up in the cache", async () => {
const lib = threeCollections();
await lib.fresh();
const found = await freshFile(lib, 30);
expect(lib.freshCalls).toBe(2);
expect(lib.freshCollectionsCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(found?.collectionID).toBe(3);
});
it("freshFile on an empty cache returns undefined for an unknown file, after reading every collection's files", async () => {
const lib = threeCollections();
const found = await freshFile(lib, 404);
expect(lib.freshCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([1, 2, 3]);
expect(found).toBeUndefined();
});
// The server lists collections 1, 2 and 3, and collection 3 was updated
// most recently. File 11 is in collections 1 and 2.
const fileInTwoCollections = (): FakeLibrary =>
new FakeLibrary(
[collection(1, 100), collection(2, 200), collection(3, 300)],
[
file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001),
file(11, 2, 1_700_000_000_000_001),
file(20, 2, 1_700_000_000_000_002),
file(30, 3, 1_700_000_000_000_003),
],
);
it("freshFilesByID on an empty cache reads the most recently updated collection's files first, until it has found every file", async () => {
const lib = fileInTwoCollections();
const found = await freshFilesByID(lib, [11, 20]);
expect(lib.freshCalls).toBe(0);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([3, 2]);
expect(found.get(11)?.collection.id).toBe(2);
expect(found.get(11)?.file.collectionID).toBe(2);
expect(found.get(20)?.collection.id).toBe(2);
});
// Collection 1 was updated last, though collections 2 and 3 have higher
// IDs. File 11 is in collections 1 and 2.
it("freshFilesByID on an empty cache reads the most recently updated collection first, though its ID is the lowest", async () => {
const lib = new FakeLibrary(
[collection(1, 400), collection(2, 200), collection(3, 300)],
[
file(11, 1, 1_700_000_000_000_001),
file(11, 2, 1_700_000_000_000_001),
],
);
const found = await freshFilesByID(lib, [11]);
expect(lib.freshFilesCalls).toEqual([1]);
expect(found.get(11)?.collection.id).toBe(1);
expect(found.get(11)?.file.collectionID).toBe(1);
});
// The stand-in's cache holds what the server holds once `fresh()` has run.
it("freshFilesByID on a cache that holds the collections refreshes, then finds each file in the most recently updated collection that holds it", async () => {
const lib = fileInTwoCollections();
await lib.fresh();
const found = await freshFilesByID(lib, [11, 404]);
expect(lib.freshCalls).toBe(2);
expect(lib.freshCollectionsCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(found.get(11)?.collection.id).toBe(2);
expect(found.get(11)?.file.collectionID).toBe(2);
expect(found.has(404)).toBe(false);
});
it("freshFilesByID on an empty cache reads every collection's files when a file is in none of them", async () => {
const lib = fileInTwoCollections();
const found = await freshFilesByID(lib, [10, 404]);
expect(lib.freshFilesCalls).toEqual([3, 2, 1]);
expect(found.get(10)?.collection.id).toBe(1);
expect(found.has(404)).toBe(false);
it("freshFile returns undefined for an unknown file", async () => {
const lib = new FakeLibrary([collection(1, 100)], []);
const resolved = await freshFile(lib, 404);
expect(lib.freshCalls).toBe(1);
expect(resolved).toBeUndefined();
});
});
-6
View File
@@ -253,12 +253,6 @@ describe("Library content wiring", () => {
await expect(
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
).rejects.toThrow(/content cache/i);
await expect(lib.originalOf(file(1, 1))).rejects.toThrow(
/content cache/i,
);
await expect(lib.thumbnailOf(file(1, 1))).rejects.toThrow(
/content cache/i,
);
await lib.close();
});
});
-19
View File
@@ -375,25 +375,6 @@ describe("ContentCache.original / thumbnail", () => {
await cache.open();
await expect(cache.original(999)).rejects.toThrow(/unknown file/i);
});
// `getFile` knows no file here, so the cache can fetch only the file it is
// given.
it("originalOf and thumbnailOf fetch and cache the file they are given", async () => {
const { cache, source } = buildCache({ files: [] });
await cache.open();
const original = await cache.originalOf(file(5));
const thumbnail = await cache.thumbnailOf(file(5));
expect(source.originalCalls).toEqual([5]);
expect(source.thumbnailCalls).toEqual([5]);
expect(original.path).toBe(join(cacheDir, "originals", "5.jpg"));
expect(thumbnail.path).toBe(join(cacheDir, "thumbnails", "5.jpg"));
expect(cache.pathsFor(5)).toEqual({
originalPath: original.path,
thumbnailPath: thumbnail.path,
});
});
});
describe("ContentCache.ensureThumbnails", () => {
+273 -4
View File
@@ -17,6 +17,14 @@
* 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).
* 5. `refreshCollection()` brings one collection up to date in the store
* (issue #217). It leaves the stored cursor where it was, so the next
* refresh still fetches the files of every other collection that changed.
* It never runs at the same time as a refresh. After a refresh, it stores
* no collection the store does not hold: not a new one, and not one a
* refresh deleted. `close()` waits for it. A library opened on a cache that
* only it wrote awaits its first refresh. That refresh replaces what it
* stored, in the server's order.
*
* 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
@@ -30,6 +38,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { Library } from "../../src/library/index.js";
import { MetadataStore } from "../../src/library/store.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
@@ -144,6 +153,26 @@ const openSeeded = async (
return { client, lib };
};
// Hold the client's next files request until `release()` is called. `started`
// resolves once that request has been sent.
const holdNextFilesRequest = (
client: MockClient,
): { started: Promise<void>; release: () => void } => {
let release!: () => void;
const held = new Promise<void>((r) => (release = r));
let sent!: () => void;
const started = new Promise<void>((r) => (sent = r));
const realFilesSince = client.filesSince.bind(client);
client.filesSince = async (args) => {
client.filesSince = realFilesSince;
const page = await realFilesSince(args);
sent();
await held;
return page;
};
return { started, release };
};
describe("Library.fresh", () => {
let dir: string;
let cacheDirectory: string;
@@ -287,10 +316,10 @@ describe("Library.freshCollections", () => {
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.
// A refresh from a stored cursor past 0 fetches a collection's files only
// when the collection is new or its updationTime has advanced past the
// stored copy's. So does `refreshCollection()`. Neither would fetch the
// files of a collection stored here without them.
it("asks for the collection list alone and stores nothing", async () => {
const client = new MockClient();
const page = {
@@ -340,3 +369,243 @@ describe("Library.freshCollections", () => {
}
});
});
describe("Library.refreshCollection", () => {
let dir: string;
let cacheDirectory: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "quak-refresh-collection-"));
cacheDirectory = join(dir, "cache");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
// Collections 1 and 2 are stored at updationTime 100, with a stored cursor
// of 100, and then both change on the server.
it("fetches the files of one changed collection and leaves the others to the next refresh", async () => {
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100), collection(2, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
client.filesFor(2, {
files: [file(2001, 2, 90)],
deleted: [],
cursor: 90,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: SLOW_INTERVAL,
});
try {
client.filesCalls.length = 0;
client.filesFor(1, {
files: [file(1002, 1, 110)],
deleted: [],
cursor: 110,
});
client.filesFor(2, {
files: [file(2002, 2, 120)],
deleted: [],
cursor: 120,
});
// Collection 1's files alone, since the stored copy.
expect(await lib.refreshCollection(collection(1, 110))).toBe(true);
expect(client.filesCalls).toEqual([
{ collectionID: 1, sinceTime: 100 },
]);
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001, 1002]);
expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001]);
// The stored copy is now current, so no request.
await lib.refreshCollection(collection(1, 110));
expect(client.filesCalls).toHaveLength(1);
// The next refresh asks from the stored cursor, 100, so it still
// sees collection 2 changed and fetches its files.
client.collectionsQueue.push({
collections: [collection(1, 110), collection(2, 120)],
deleted: [],
cursor: 120,
});
await lib.fresh();
expect(client.collectionsSinceTimes).toEqual([0, 100]);
expect(client.filesCalls).toEqual([
{ collectionID: 1, sinceTime: 100 },
{ collectionID: 2, sinceTime: 100 },
]);
expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001, 2002]);
} finally {
await lib.close();
}
});
// Collection 1 is stored at 100. `refreshCollection()` asks for it at 110,
// which adds file 1002. While that files request waits, a fresh read
// starts a refresh. The server tells that refresh that collection 1 was
// deleted at 130.
it("holds off a refresh that starts during the files request, which then deletes the collection", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
const files = holdNextFilesRequest(client);
try {
client.filesFor(1, {
files: [file(1002, 1, 110)],
deleted: [],
cursor: 110,
});
client.collectionsQueue.push({
collections: [],
deleted: [1],
cursor: 130,
});
const refreshing = lib.refreshCollection(collection(1, 110));
await files.started;
const reading = lib.fresh();
// The refresh has not sent its collections request yet.
await new Promise((r) => setImmediate(r));
expect(client.collectionsSinceTimes).toEqual([0]);
files.release();
await refreshing;
await reading;
expect(client.collectionsSinceTimes).toEqual([0, 100]);
expect(lib.getCollection(1)).toBeUndefined();
expect(lib.listFiles(1)).toEqual([]);
} finally {
files.release();
await lib.close();
}
});
// `freshCollections()` lists collection 1 at 110. Before
// `refreshCollection()` is called, a refresh learns that it was deleted at
// 130.
it("stores nothing for a collection that a refresh deleted before the call", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
try {
client.collectionsQueue.push(
{ collections: [collection(1, 110)], deleted: [], cursor: 110 },
{ collections: [], deleted: [1], cursor: 130 },
);
const [listed] = await lib.freshCollections();
await lib.fresh();
client.filesCalls.length = 0;
expect(await lib.refreshCollection(listed)).toBe(false);
expect(client.filesCalls).toEqual([]);
expect(lib.getCollection(1)).toBeUndefined();
} finally {
await lib.close();
}
});
// A refresh has stored collection 1. Collection 2 is new on the server
// since then. Only the next refresh may add it, after collection 1.
it("stores nothing for a collection that is new since the last refresh", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
try {
client.filesCalls.length = 0;
expect(await lib.refreshCollection(collection(2, 110))).toBe(false);
expect(client.filesCalls).toEqual([]);
expect(lib.getCollection(2)).toBeUndefined();
} finally {
await lib.close();
}
});
// The caller may remove the cache directory once `close()` resolves, so
// the cache write must have landed by then.
it("close() waits for a refreshCollection() in flight", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
const files = holdNextFilesRequest(client);
client.filesFor(1, {
files: [file(1002, 1, 110)],
deleted: [],
cursor: 110,
});
const refreshing = lib.refreshCollection(collection(1, 110));
await files.started;
let closed = false;
const closing = lib.close().then(() => {
closed = true;
});
await new Promise((r) => setImmediate(r));
expect(closed).toBe(false);
files.release();
await closing;
const saved = await MetadataStore.load(
join(cacheDirectory, "metadata.json"),
);
expect(saved.listFiles(1).map((f) => f.id)).toEqual([1001, 1002]);
await refreshing;
});
// `quak files 2` on an empty cache stores collection 2 alone. A library
// opened later on that cache must not serve it as the whole account. Its
// first refresh replaces what the cache holds, in the server's order.
it("open() awaits the first refresh on a cache that only refreshCollection() wrote", async () => {
const client = new MockClient();
client.filesFor(2, {
files: [file(2001, 2, 90)],
deleted: [],
cursor: 90,
});
const first = await Library.open({
client,
cacheDirectory,
refreshOnOpen: false,
refreshIntervalSeconds: SLOW_INTERVAL,
});
expect(await first.refreshCollection(collection(2, 100))).toBe(true);
await first.close();
client.collectionsQueue.push({
collections: [collection(1, 100), collection(2, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
client.filesFor(2, {
files: [file(2001, 2, 90)],
deleted: [],
cursor: 90,
});
client.filesCalls.length = 0;
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: SLOW_INTERVAL,
});
try {
expect(lib.status().lastRefreshAt).toBeDefined();
expect(lib.listCollections().map((c) => c.id)).toEqual([1, 2]);
// It fetches collection 2's files again, from the beginning.
expect(client.filesCalls).toEqual([
{ collectionID: 1, sinceTime: 0 },
{ collectionID: 2, sinceTime: 0 },
]);
expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001]);
} finally {
await lib.close();
}
});
});
+3 -3
View File
@@ -20,9 +20,9 @@
* 5. `close()` stops the timer and is idempotent, and its promise resolves
* only once an in-flight refresh has written the cache file.
* 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id.
* 7. `open()` branches on the cache: an empty cache awaits the first refresh
* (it has nothing to serve yet); an existing cache serves its copy at once
* and refreshes in the background, so a slow or dead server never stalls
* 7. `open()` branches on the stored cursor. At 0, as on an empty cache, it
* awaits the first refresh. Past 0 it serves the cache's copy at once and
* refreshes in the background, so a slow or dead server never stalls
* opening.
* 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.
+2 -1
View File
@@ -4,7 +4,8 @@
* These are the surface the GUI consumes across Electron IPC. `snapshot()` is
* synchronous — it reads the in-RAM store and projects it into plain records
* (no keys) — and `subscribe({ onChange })` delivers a `LibraryChange` whenever
* a background refresh actually changes the derived records. The contracts:
* a refresh or `refreshCollection()` changes the derived records. The
* contracts:
*
* 1. `snapshot()` deduplicates a file across memberships into one record with
* every album id, orders photos newest first, and carries no key material.
+20 -1
View File
@@ -172,7 +172,7 @@ describe("MetadataStore", () => {
expect(store.listCollections()).toEqual([]);
});
it("loads an empty store when the list of what the refresh could not decrypt is damaged", async () => {
it("loads an empty store when the list of what did not decrypt is damaged", async () => {
// A backup reads that list on every run, so a damaged one that loaded
// would fail every backup until the cache was deleted. A string is
// damaged too, though JavaScript can loop over its characters.
@@ -266,4 +266,23 @@ describe("MetadataStore", () => {
expect(store.getFile(12345, 67890)).toBeUndefined();
expect(store.getFile(55555, 67890)?.key).toEqual(inB.key);
});
// A backup reports these in this order. It must not depend on whether a
// refresh or `Library.refreshCollection()` fetched each one first.
it("lists what did not decrypt by collection ID, then file ID", async () => {
const store = await MetadataStore.load(path);
store.putUndecryptable({ collectionID: 2, fileID: 7, error: "a" });
store.putUndecryptable({ collectionID: 3, error: "b" });
store.putUndecryptable({ collectionID: 1, fileID: 9, error: "c" });
store.putUndecryptable({ collectionID: 2, fileID: 5, error: "d" });
expect(
store.listUndecryptable().map((r) => [r.collectionID, r.fileID]),
).toEqual([
[1, 9],
[2, 5],
[2, 7],
[3, undefined],
]);
});
});
+9 -39
View File
@@ -6,12 +6,10 @@
* working thumbnails, others return 404 or empty bodies. The tests
* verify that the detection and repair logic handles each case correctly.
*
* `listMissingThumbnails` takes an open `Library` to enumerate the files. Both
* helpers take the `Client` for the API operations: the thumbnail existence
* check, and the encrypt-and-upload path. `fixMissingThumbnails` takes the
* record of each file, which these tests look up with `cachedFilesByID`. It
* reads each original through the library's content cache, by
* `lib.originalOf(file)`.
* As of issue #52 both helpers take an open `Library` for enumeration and the
* `Client` for the API operations that stay unchanged (the thumbnail existence
* check, and the encrypt-and-upload path). `fixMissingThumbnails` reads each
* original through the library's content cache (`photo.original()`).
*
* `fixMissingThumbnails` is the most complex function in quak: it
* downloads the original file, generates a JPEG thumbnail with jpeg-js,
@@ -43,7 +41,6 @@ import {
import { SRP, SrpServer } from "fast-srp-hap";
import { Client } from "../../src/client.js";
import { Library } from "../../src/library/index.js";
import { cachedFilesByID } from "../../src/cli-read.js";
import {
listMissingThumbnails,
fixMissingThumbnails,
@@ -659,12 +656,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client);
const results = await fixMissingThumbnails(
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
const results = await fixMissingThumbnails(lib, client, [101]);
lib.close();
expect(results.length).toBe(1);
@@ -701,12 +693,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client);
const results = await fixMissingThumbnails(
lib,
client,
[999],
cachedFilesByID(lib, [999]),
);
const results = await fixMissingThumbnails(lib, client, [999]);
lib.close();
expect(results.length).toBe(1);
@@ -728,7 +715,6 @@ describe("fixMissingThumbnails", () => {
lib,
client,
[101, 102],
cachedFilesByID(lib, [101, 102]),
(msg, level) => {
if (level === "error") errors.push(msg);
},
@@ -759,7 +745,6 @@ describe("fixMissingThumbnails", () => {
lib,
client,
[101, 103, 104],
cachedFilesByID(lib, [101, 103, 104]),
);
lib.close();
@@ -794,12 +779,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(counted.fetch);
const lib = await openLib(client);
const results = await fixMissingThumbnails(
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
const results = await fixMissingThumbnails(lib, client, [101]);
lib.close();
expect(results[0]!.status).toBe("skipped");
@@ -820,12 +800,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(counted.fetch);
const lib = await openLib(client);
const results = await fixMissingThumbnails(
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
const results = await fixMissingThumbnails(lib, client, [101]);
lib.close();
expect(results[0]!.status).toBe("skipped");
@@ -866,12 +841,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client);
const results = await fixMissingThumbnails(
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
const results = await fixMissingThumbnails(lib, client, [101]);
lib.close();
expect(results[0]!.status).toBe("fixed");