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