Compare commits

...
2 Commits
Author SHA1 Message Date
sneak 263b6afa19 Fetch, store, and index per-file ML data and CLIP embeddings (closes #49)
check / check (push) Successful in 15s
Ente's "magic" search data (face detections + CLIP embeddings) is now
fetched, decrypted, and cached under cacheDirectory/mldata/, never in
metadata.json.

A new mldata-fetch module holds the /files/data/fetch (type mldata)
decrypt+gunzip, reused by both the metadata backup and the library.
MLDataStore writes one payload file per fileID by rename (present means
complete) and maintains a derived index: clip.json (fileIDs in order +
embedding length) and clip.f32 (embeddings packed as one Float32Array,
loaded in a single read). The index is rebuilt from the payloads when
missing or structurally inconsistent with the files present, and appended
to as payloads arrive; fetched.json records each file's fetch-time
updationTime for refetch decisions.

After each refresh the library fetches, through the #45 metadata pool, the
ML data for every known file absent from mldata/ or whose updationTime
advanced, reporting via onProgress (operation fetchMLData) and status().
RAM holds only the id list and Float32Array; payloads are read on demand.

Model: opus-4-8
2026-09-22 15:25:56 +00:00
clawbot c5c1f387df In-process read surface: albums, photos, and timeline grouping (closes #44)
check / check (push) Successful in 25s
Adds the in-process read surface, served from RAM with args-object signatures: albums (list/byName/byID), photos (byID/records -> plain PhotoRecord[]), thin Album/Photo wrappers, and timeline.groups (day/week/month) with a PhotoFilter (albumID/text/fileTypes/hasLocation/includeArchived; hidden excluded). Week keys use ISO YYYY-Www; each file appears once per group, newest first. Built on the #43 snapshot projection; no network.

Model: opus-4-8
2026-09-22 17:21:48 +02:00
9 changed files with 1954 additions and 51 deletions
+13
View File
@@ -7,6 +7,7 @@ import {
} from "./auth/login.js"; } from "./auth/login.js";
import { unwrapAuth } from "./auth/unwrap.js"; import { unwrapAuth } from "./auth/unwrap.js";
import { init, fromBase64, toBase64 } from "./crypto/index.js"; import { init, fromBase64, toBase64 } from "./crypto/index.js";
import { fetchMLDataBatch, type MLData } from "./mldata-fetch.js";
import { decryptCollection, decryptFile } from "./model/index.js"; import { decryptCollection, decryptFile } from "./model/index.js";
import { import {
downloadFile as dlFile, downloadFile as dlFile,
@@ -273,6 +274,18 @@ export class Client {
return files; return files;
} }
// Fetch machine-learning data (face detections + CLIP embeddings) for up
// to a batch of files, each decrypted with its own key. One request; the
// library batches at `MLDATA_BATCH_SIZE` and schedules each batch through
// its metadata request pool.
async fetchMLData(args: {
fileIDs: number[];
fileKeys: Map<number, Uint8Array>;
}): Promise<Map<number, MLData>> {
this.assertLoggedIn();
return fetchMLDataBatch(this.api, args.fileIDs, args.fileKeys);
}
async downloadFile( async downloadFile(
file: EnteFile, file: EnteFile,
outPath?: string, outPath?: string,
+8
View File
@@ -36,11 +36,19 @@ export {
export { export {
Library, Library,
DEFAULT_REFRESH_INTERVAL_SECONDS, DEFAULT_REFRESH_INTERVAL_SECONDS,
Album,
Photo,
type LibraryClient, type LibraryClient,
type LibraryOptions, type LibraryOptions,
type LibraryStatus, type LibraryStatus,
type RefreshEvent, type RefreshEvent,
type RefreshProgressCallback, type RefreshProgressCallback,
type AlbumsAPI,
type PhotosAPI,
type TimelineAPI,
type PhotoFilter,
type TimelineGroup,
type GroupBy,
} from "./library/index.js"; } from "./library/index.js";
export type { export type {
AlbumRecord, AlbumRecord,
+170 -3
View File
@@ -23,6 +23,8 @@ import { join } from "node:path";
import envPaths from "env-paths"; import envPaths from "env-paths";
import { MetadataStore } from "./store.js"; import { MetadataStore } from "./store.js";
import { MLDataStore } from "./mldata.js";
import { RequestPools } from "./pools.js";
import { import {
deriveRecords, deriveRecords,
snapshotFrom, snapshotFrom,
@@ -31,7 +33,27 @@ import {
type LibrarySnapshot, type LibrarySnapshot,
type LibraryChange, type LibraryChange,
} from "./records.js"; } from "./records.js";
import {
makeAlbumsAPI,
makePhotosAPI,
makeTimelineAPI,
type AlbumsAPI,
type PhotosAPI,
type TimelineAPI,
} from "./read.js";
export {
Album,
Photo,
type AlbumsAPI,
type PhotosAPI,
type TimelineAPI,
type PhotoFilter,
type TimelineGroup,
type GroupBy,
} from "./read.js";
import type { CollectionsPage, FilesPage } from "../client.js"; import type { CollectionsPage, FilesPage } from "../client.js";
import { MLDATA_BATCH_SIZE, type MLData } from "../mldata-fetch.js";
import type { Collection, EnteFile } from "../model/types.js"; import type { Collection, EnteFile } from "../model/types.js";
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3; export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
@@ -47,14 +69,24 @@ export interface LibraryClient {
collectionKey: Uint8Array; collectionKey: Uint8Array;
sinceTime: number; sinceTime: number;
}): Promise<FilesPage>; }): Promise<FilesPage>;
// Fetch ML data (face detections + CLIP embeddings) for up to a batch of
// files. Optional: a client without it simply disables ML fetching, leaving
// the metadata refresh untouched.
fetchMLData?(args: {
fileIDs: number[];
fileKeys: Map<number, Uint8Array>;
}): Promise<Map<number, MLData>>;
} }
// A single refresh cycle's progress. "started" fires before the network work, // A progress event for one unit of background work. A metadata "refresh" or an
// then exactly one of "done" or "failed"; "failed" carries the error message. // ML "fetchMLData" pass each fire "started" before their network work and then
// exactly one of "done" or "failed"; "failed" carries the error message and
// an ML "done" reports how many payloads it stored.
export interface RefreshEvent { export interface RefreshEvent {
operation: "refresh"; operation: "refresh" | "fetchMLData";
status: "started" | "done" | "failed"; status: "started" | "done" | "failed";
error?: string; error?: string;
fetched?: number;
} }
export type RefreshProgressCallback = (event: RefreshEvent) => void; export type RefreshProgressCallback = (event: RefreshEvent) => void;
@@ -69,6 +101,9 @@ export interface LibraryOptions {
downloadDirectory?: string; downloadDirectory?: string;
refreshIntervalSeconds?: number; refreshIntervalSeconds?: number;
onProgress?: RefreshProgressCallback; onProgress?: RefreshProgressCallback;
// The bounded request pools (issue #45). ML data is fetched through the
// metadata pool. Defaults to a fresh set at the design's caps.
pools?: RequestPools;
} }
export interface LibraryStatus { export interface LibraryStatus {
@@ -81,6 +116,15 @@ export interface LibraryStatus {
// The message from the most recent refresh, set only while that refresh // The message from the most recent refresh, set only while that refresh
// failed; cleared by the next success. // failed; cleared by the next success.
lastError?: string; lastError?: string;
// Wall-clock ms of the last ML fetch pass that succeeded, or undefined if
// none has yet (or ML fetching is disabled).
lastMLFetchAt?: number;
// The most recent ML fetch pass's error, set only while it failed.
lastMLError?: string;
// ML payloads stored on disk and CLIP embeddings in the index; undefined
// when ML fetching is disabled.
mlStored?: number;
mlIndexed?: number;
closed: boolean; closed: boolean;
} }
@@ -88,17 +132,32 @@ export class Library {
readonly cacheDirectory: string; readonly cacheDirectory: string;
readonly downloadDirectory?: string; readonly downloadDirectory?: string;
// The in-process read surface (issue #44). Each namespace answers
// synchronously from the live record projection; no read touches the
// network.
readonly albums: AlbumsAPI;
readonly photos: PhotosAPI;
readonly timeline: TimelineAPI;
private readonly client: LibraryClient; private readonly client: LibraryClient;
private readonly store: MetadataStore; private readonly store: MetadataStore;
private readonly userID: number; private readonly userID: number;
private readonly intervalMs: number; private readonly intervalMs: number;
private readonly onProgress?: RefreshProgressCallback; private readonly onProgress?: RefreshProgressCallback;
private readonly pools: RequestPools;
// The ML-data cache, present only when the client can fetch ML data.
private readonly mldata?: MLDataStore;
private timer?: ReturnType<typeof setTimeout>; private timer?: ReturnType<typeof setTimeout>;
private refreshing = false; private refreshing = false;
// Guards the ML fetch pass so a slow backfill never runs twice at once; a
// refresh whose pass is still running kicks nothing new.
private mlFetching = false;
private closed = false; private closed = false;
private lastRefreshAt?: number; private lastRefreshAt?: number;
private lastError?: string; private lastError?: string;
private lastMLFetchAt?: number;
private lastMLError?: string;
// The plain-record projection as of the last refresh, and the GUI change // The plain-record projection as of the last refresh, and the GUI change
// subscribers. A refresh that alters the projection notifies each with the // subscribers. A refresh that alters the projection notifies each with the
// delta; `lastRecords` is kept current every refresh so a subscriber that // delta; `lastRecords` is kept current every refresh so a subscriber that
@@ -118,6 +177,8 @@ export class Library {
downloadDirectory?: string; downloadDirectory?: string;
intervalMs: number; intervalMs: number;
onProgress?: RefreshProgressCallback; onProgress?: RefreshProgressCallback;
pools: RequestPools;
mldata?: MLDataStore;
}) { }) {
this.client = args.client; this.client = args.client;
this.store = args.store; this.store = args.store;
@@ -126,7 +187,16 @@ export class Library {
this.downloadDirectory = args.downloadDirectory; this.downloadDirectory = args.downloadDirectory;
this.intervalMs = args.intervalMs; this.intervalMs = args.intervalMs;
this.onProgress = args.onProgress; this.onProgress = args.onProgress;
this.pools = args.pools;
this.mldata = args.mldata;
this.lastRecords = this.deriveNow(); this.lastRecords = this.deriveNow();
// The read namespaces derive fresh from the store on each call, so they
// always reflect the latest refresh.
const derive = (): DerivedRecords => this.deriveNow();
this.albums = makeAlbumsAPI(derive);
this.photos = makePhotosAPI(derive);
this.timeline = makeTimelineAPI(derive);
} }
// Load the cache and start the refresh loop. With an empty cache the first // Load the cache and start the refresh loop. With an empty cache the first
@@ -148,6 +218,12 @@ export class Library {
(opts.refreshIntervalSeconds ?? DEFAULT_REFRESH_INTERVAL_SECONDS) * (opts.refreshIntervalSeconds ?? DEFAULT_REFRESH_INTERVAL_SECONDS) *
1000; 1000;
// The ML cache only earns its keep when the client can fetch ML data;
// a client without that capability opens no `mldata/` directory.
const mldata = opts.client.fetchMLData
? await MLDataStore.open(join(cacheDirectory, "mldata"))
: undefined;
const lib = new Library({ const lib = new Library({
client: opts.client, client: opts.client,
store, store,
@@ -156,6 +232,8 @@ export class Library {
downloadDirectory: opts.downloadDirectory, downloadDirectory: opts.downloadDirectory,
intervalMs, intervalMs,
onProgress: opts.onProgress, onProgress: opts.onProgress,
pools: opts.pools ?? new RequestPools(),
mldata,
}); });
if (store.loadedFromDisk) { if (store.loadedFromDisk) {
@@ -215,12 +293,17 @@ export class Library {
for (const c of collections) { for (const c of collections) {
files += this.store.listFiles(c.id).length; files += this.store.listFiles(c.id).length;
} }
const ml = this.mldata?.stats();
return { return {
userID: this.store.userID, userID: this.store.userID,
collections: collections.length, collections: collections.length,
files, files,
lastRefreshAt: this.lastRefreshAt, lastRefreshAt: this.lastRefreshAt,
lastError: this.lastError, lastError: this.lastError,
lastMLFetchAt: this.lastMLFetchAt,
lastMLError: this.lastMLError,
mlStored: ml?.stored,
mlIndexed: ml?.indexed,
closed: this.closed, closed: this.closed,
}; };
} }
@@ -255,6 +338,11 @@ export class Library {
this.lastRefreshAt = Date.now(); this.lastRefreshAt = Date.now();
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
// outside the refresh's success/failure so a fetch or disk problem
// there never marks the metadata refresh failed, and it is not
// awaited so it never stalls the refresh interval.
void this.runMLFetch();
} catch (err) { } catch (err) {
const error = err instanceof Error ? err.message : String(err); const error = err instanceof Error ? err.message : String(err);
this.lastError = error; this.lastError = error;
@@ -355,6 +443,85 @@ export class Library {
} }
} }
// One ML fetch pass: fetch, decrypt and store the ML data for every file
// the store knows about that is not cached (or whose `updationTime` has
// advanced), through the metadata pool, and update the CLIP index. Guarded
// so passes never overlap; a failure is reported, not thrown.
private async runMLFetch(): Promise<void> {
const mldata = this.mldata;
// Bind so the call keeps the client as its receiver when invoked
// through the pool below.
const fetchMLData = this.client.fetchMLData?.bind(this.client);
if (!mldata || !fetchMLData || this.closed || this.mlFetching) return;
const files = this.uniqueFiles();
const needed = mldata.neededFor(files);
if (needed.length === 0) return;
this.mlFetching = true;
this.emit({ operation: "fetchMLData", status: "started" });
try {
const fileKeys = new Map<number, Uint8Array>();
const updation = new Map<number, number>();
for (const f of files) {
fileKeys.set(f.id, f.key);
updation.set(f.id, f.updationTime);
}
let stored = 0;
for (let i = 0; i < needed.length; i += MLDATA_BATCH_SIZE) {
if (this.closed) break;
const batch = needed.slice(i, i + MLDATA_BATCH_SIZE);
const payloads = await this.pools.metadata.run(
() => fetchMLData({ fileIDs: batch, fileKeys }),
{ priority: "background" },
);
stored += (await mldata.storeFetched(payloads, updation))
.stored;
}
this.lastMLFetchAt = Date.now();
this.lastMLError = undefined;
this.emit({
operation: "fetchMLData",
status: "done",
fetched: stored,
});
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
this.lastMLError = error;
this.emit({ operation: "fetchMLData", status: "failed", error });
} finally {
this.mlFetching = false;
}
}
// The distinct files the store holds, one entry per fileID (a file in
// several collections shares its ML data), each carrying the key and the
// newest `updationTime` seen across its memberships.
private uniqueFiles(): {
id: number;
key: Uint8Array;
updationTime: number;
}[] {
const byID = new Map<
number,
{ id: number; key: Uint8Array; updationTime: number }
>();
for (const collection of this.store.listCollections()) {
for (const f of this.store.listFiles(collection.id)) {
const seen = byID.get(f.id);
if (seen === undefined || f.updationTime > seen.updationTime)
byID.set(f.id, {
id: f.id,
key: f.key,
updationTime: f.updationTime,
});
}
}
return [...byID.values()];
}
// Gather every file membership and project the store into by-id records. // Gather every file membership and project the store into by-id records.
private deriveNow(): DerivedRecords { private deriveNow(): DerivedRecords {
const collections = this.store.listCollections(); const collections = this.store.listCollections();
+361
View File
@@ -0,0 +1,361 @@
// The on-disk cache of Ente's per-file machine-learning data and the CLIP
// index derived from it (issue #49).
//
// Under `<cacheDirectory>/mldata/` this keeps:
//
// - `<fileID>.json` — one decrypted, gunzipped payload per file, written by
// rename. Its presence means it is complete: a torn write never leaves a
// half-file, so the set of these files is the source of truth for what is
// cached. The full payload (face boxes, landmarks, embeddings) is read back
// from here on demand and never held in RAM.
//
// - `clip.f32` + `clip.json` — the derived index the content search runs on.
// `clip.json` lists the indexed fileIDs in order plus the embedding length;
// `clip.f32` is those CLIP embeddings packed as one `Float32Array`, so the
// index loads in a single read with no per-vector parse. The index is
// rebuilt from the payloads whenever it is missing or structurally
// disagrees with the files present, and appended to as new payloads arrive.
//
// - `fetched.json` — a small map of fileID to the `updationTime` it was
// fetched at. This is best-effort bookkeeping for refetch decisions (a file
// whose `updationTime` later advances is refetched); the payloads, not this
// file, remain the record of what is cached, so losing it only forgoes
// update-driven refetch until the next fetch rewrites it.
//
// In RAM this holds only the id list and the packed `Float32Array`.
import { mkdir, readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { writeAtomic } from "../download/index.js";
import type { MLData } from "../mldata-fetch.js";
const CLIP_VECTORS = "clip.f32";
const CLIP_INDEX = "clip.json";
const FETCHED = "fetched.json";
// A payload file is named for its fileID alone; the derived files above are
// not, so this pattern picks out payloads and nothing else.
const PAYLOAD_RE = /^(\d+)\.json$/;
const BYTES_PER_FLOAT = 4;
// The on-disk form of `clip.json`.
interface ClipIndexFile {
fileIDs: number[];
embeddingLength: number;
}
// A file the model knows about, for deciding what to fetch.
export interface MLDataFile {
id: number;
updationTime: number;
}
// The RAM index the search reads: `fileIDs[i]` owns the `embeddingLength`
// floats of `embeddings` starting at `i * embeddingLength`.
export interface MLIndex {
fileIDs: number[];
embeddingLength: number;
embeddings: Float32Array;
}
// Pull the CLIP embedding out of a payload, or undefined when it is absent or
// misshapen. Kept strict so a bad payload is skipped rather than corrupting the
// packed index.
const clipEmbedding = (payload: MLData): number[] | undefined => {
const clip = payload.clip;
if (typeof clip !== "object" || clip === null) return undefined;
const embedding = (clip as { embedding?: unknown }).embedding;
if (!Array.isArray(embedding)) return undefined;
if (embedding.some((v) => typeof v !== "number" || !Number.isFinite(v)))
return undefined;
return embedding as number[];
};
export class MLDataStore {
readonly dir: string;
// fileIDs whose payload JSON is present on disk (present means complete).
private readonly present = new Set<number>();
// fileID -> updationTime it was fetched at.
private readonly fetched = new Map<number, number>();
// The packed index and where each id sits in it.
private ids: number[] = [];
private embeddingLength = 0;
private embeddings = new Float32Array(0);
private readonly pos = new Map<number, number>();
private constructor(dir: string) {
this.dir = dir;
}
// Open (creating the directory) and load the id list and packed index into
// RAM, rebuilding the index from the payloads when it is missing or does
// not match the files present.
static async open(dir: string): Promise<MLDataStore> {
const store = new MLDataStore(dir);
await mkdir(dir, { recursive: true });
await store.loadPresent();
await store.loadFetched();
if (!(await store.tryLoadIndex())) await store.rebuildIndex();
return store;
}
// The fileIDs among `files` that must be fetched: every file with no
// payload yet (first run, then new files), plus any whose `updationTime`
// has advanced past the one its cached payload was fetched at. Returned
// sorted and unique.
neededFor(files: MLDataFile[]): number[] {
const latest = new Map<number, number>();
for (const f of files) {
const seen = latest.get(f.id);
if (seen === undefined || f.updationTime > seen)
latest.set(f.id, f.updationTime);
}
const needed: number[] = [];
for (const [id, updationTime] of latest) {
if (!this.present.has(id)) {
needed.push(id);
continue;
}
const at = this.fetched.get(id);
if (at !== undefined && updationTime > at) needed.push(id);
}
return needed.sort((a, b) => a - b);
}
// Store a batch of fetched payloads: write one file per id, fold their CLIP
// embeddings into the packed index (in place for a refetch, appended for a
// new file), and persist the derived files. Returns how many payloads were
// stored and how many ids the index now holds.
async storeFetched(
payloads: Map<number, MLData>,
updation: Map<number, number>,
): Promise<{ stored: number; indexed: number }> {
if (payloads.size === 0) return { stored: 0, indexed: this.ids.length };
for (const [id, payload] of payloads) {
await this.writePayload(id, payload);
this.present.add(id);
const at = updation.get(id);
if (at !== undefined) this.fetched.set(id, at);
}
const updates: { at: number; vector: number[] }[] = [];
const appends: { id: number; vector: number[] }[] = [];
for (const [id, payload] of payloads) {
const vector = clipEmbedding(payload);
if (!vector) continue;
if (this.embeddingLength === 0 && this.ids.length === 0)
this.embeddingLength = vector.length;
// The index is fixed-width; a vector of another length (never seen
// from Ente's CLIP model) is stored but left out of the index.
if (vector.length !== this.embeddingLength) continue;
const at = this.pos.get(id);
if (at !== undefined) updates.push({ at, vector });
else appends.push({ id, vector });
}
for (const { at, vector } of updates)
this.embeddings.set(vector, at * this.embeddingLength);
if (appends.length > 0) {
const length = this.embeddingLength;
const grown = new Float32Array(
this.embeddings.length + appends.length * length,
);
grown.set(this.embeddings);
let offset = this.embeddings.length;
for (const { id, vector } of appends) {
grown.set(vector, offset);
this.pos.set(id, this.ids.length);
this.ids.push(id);
offset += length;
}
this.embeddings = grown;
}
await this.persistIndex();
await this.persistFetched();
return { stored: payloads.size, indexed: this.ids.length };
}
// The packed index the search runs on. The id list is copied so callers
// cannot disturb the store's own order; the embeddings are the live buffer.
getIndex(): MLIndex {
return {
fileIDs: [...this.ids],
embeddingLength: this.embeddingLength,
embeddings: this.embeddings,
};
}
// The full payload for a file, read from disk, or undefined when it is not
// cached or does not parse.
async readPayload(fileID: number): Promise<MLData | undefined> {
if (!this.present.has(fileID)) return undefined;
let raw: string;
try {
raw = await readFile(this.payloadPath(fileID), "utf-8");
} catch {
return undefined;
}
try {
return JSON.parse(raw) as MLData;
} catch {
return undefined;
}
}
stats(): { stored: number; indexed: number } {
return { stored: this.present.size, indexed: this.ids.length };
}
private payloadPath(id: number): string {
return join(this.dir, `${id}.json`);
}
private async writePayload(id: number, payload: MLData): Promise<void> {
await writeAtomic(
this.payloadPath(id),
new TextEncoder().encode(JSON.stringify(payload)),
);
}
private async loadPresent(): Promise<void> {
let names: string[];
try {
names = await readdir(this.dir);
} catch {
return;
}
for (const name of names) {
const match = PAYLOAD_RE.exec(name);
if (match) this.present.add(Number(match[1]));
}
}
private async loadFetched(): Promise<void> {
let raw: string;
try {
raw = await readFile(join(this.dir, FETCHED), "utf-8");
} catch {
return;
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
for (const [key, value] of Object.entries(parsed)) {
const id = Number(key);
if (
Number.isInteger(id) &&
typeof value === "number" &&
this.present.has(id)
)
this.fetched.set(id, value);
}
} catch {
// Corrupt bookkeeping degrades refetch decisions, never fails open.
}
}
// Load the packed index if it is present and consistent with the payloads:
// its ids must all still be present and its vector file must be exactly the
// size the id count and embedding length imply. Returns whether it loaded.
private async tryLoadIndex(): Promise<boolean> {
let metaRaw: string;
try {
metaRaw = await readFile(join(this.dir, CLIP_INDEX), "utf-8");
} catch {
return false;
}
let meta: ClipIndexFile;
try {
meta = JSON.parse(metaRaw) as ClipIndexFile;
} catch {
return false;
}
if (
!Array.isArray(meta.fileIDs) ||
typeof meta.embeddingLength !== "number"
)
return false;
if (meta.fileIDs.some((id) => !this.present.has(id))) return false;
let bytes: Buffer;
try {
bytes = await readFile(join(this.dir, CLIP_VECTORS));
} catch {
return false;
}
const expected =
meta.fileIDs.length * meta.embeddingLength * BYTES_PER_FLOAT;
if (bytes.byteLength !== expected) return false;
// One read, no parse: copy into an aligned buffer and view it as
// floats. The copy is needed because a Buffer from the pool can start
// at an offset a Float32Array cannot be laid over.
const aligned = new Uint8Array(bytes.byteLength);
aligned.set(bytes);
this.embeddings = new Float32Array(aligned.buffer);
this.embeddingLength = meta.embeddingLength;
this.ids = [...meta.fileIDs];
this.pos.clear();
this.ids.forEach((id, i) => this.pos.set(id, i));
return true;
}
// Rebuild the packed index by reading every payload present, then persist
// it. Payloads without a CLIP embedding (or of an unexpected length) are
// simply not indexed.
private async rebuildIndex(): Promise<void> {
this.ids = [];
this.pos.clear();
this.embeddingLength = 0;
const vectors: number[][] = [];
for (const id of [...this.present].sort((a, b) => a - b)) {
const payload = await this.readPayload(id);
if (!payload) continue;
const vector = clipEmbedding(payload);
if (!vector) continue;
if (this.embeddingLength === 0)
this.embeddingLength = vector.length;
if (vector.length !== this.embeddingLength) continue;
this.pos.set(id, this.ids.length);
this.ids.push(id);
vectors.push(vector);
}
const length = this.embeddingLength;
const packed = new Float32Array(this.ids.length * length);
vectors.forEach((vector, i) => packed.set(vector, i * length));
this.embeddings = packed;
await this.persistIndex();
}
private async persistIndex(): Promise<void> {
const meta: ClipIndexFile = {
fileIDs: this.ids,
embeddingLength: this.embeddingLength,
};
await writeAtomic(
join(this.dir, CLIP_INDEX),
new TextEncoder().encode(JSON.stringify(meta)),
);
await writeAtomic(
join(this.dir, CLIP_VECTORS),
new Uint8Array(
this.embeddings.buffer,
this.embeddings.byteOffset,
this.embeddings.byteLength,
),
);
}
private async persistFetched(): Promise<void> {
const record: Record<string, number> = {};
for (const [id, at] of this.fetched) record[id] = at;
await writeAtomic(
join(this.dir, FETCHED),
new TextEncoder().encode(JSON.stringify(record)),
);
}
}
+344
View File
@@ -0,0 +1,344 @@
// The in-process read surface over the local cache (issue #44).
//
// A CLI or an in-process script reads albums, photos, and a grouped timeline
// through `lib.albums`, `lib.photos`, and `lib.timeline`. Every call is
// answered synchronously from the same plain-record projection the GUI reads
// (`deriveRecords`, issue #43); nothing here touches the network. Every method
// takes a single named-argument object.
//
// The `Album` and `Photo` classes are thin, in-process-only wrappers over
// those records: a caller that holds an object reference gets typed field
// access and, for an album, its photos. They are not sent across IPC — the
// plain records are the serializable surface, and `record()` returns one.
// Content-fetch methods (`Photo.original` / `thumbnail`) belong to a later
// unit; this surface is read-only.
import type { CollectionType, FileType } from "../model/types.js";
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
// Newest first, with fileID as a stable tiebreak so equal-timed files order
// deterministically — the same order the record projection uses.
const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
b.takenAt - a.takenAt || b.fileID - a.fileID;
// Albums newest updated first, collection id breaking ties. This is the order
// `albums.list` returns and the order `byName` resolves a name collision in.
const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
b.updationTime - a.updationTime || b.collectionID - a.collectionID;
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
// underlying plain record for callers that need the IPC-safe value.
export class Photo {
constructor(private readonly rec: PhotoRecord) {}
get fileID(): number {
return this.rec.fileID;
}
get albumIDs(): number[] {
return this.rec.albumIDs;
}
get title(): string {
return this.rec.title;
}
get takenAt(): number {
return this.rec.takenAt;
}
get fileType(): FileType {
return this.rec.fileType;
}
get caption(): string | undefined {
return this.rec.caption;
}
get width(): number | undefined {
return this.rec.width;
}
get height(): number | undefined {
return this.rec.height;
}
get latitude(): number | undefined {
return this.rec.latitude;
}
get longitude(): number | undefined {
return this.rec.longitude;
}
get isArchived(): boolean {
return this.rec.isArchived;
}
get isHidden(): boolean {
return this.rec.isHidden;
}
record(): PhotoRecord {
return this.rec;
}
}
// A single album. `photos.list()` returns the album's photos as wrappers,
// newest first (the record already stores `fileIDs` in that order).
export class Album {
constructor(
private readonly rec: AlbumRecord,
private readonly records: DerivedRecords,
) {}
get collectionID(): number {
return this.rec.collectionID;
}
get name(): string {
return this.rec.name;
}
get type(): CollectionType {
return this.rec.type;
}
get isShared(): boolean {
return this.rec.isShared;
}
get updationTime(): number {
return this.rec.updationTime;
}
get fileIDs(): number[] {
return this.rec.fileIDs;
}
get photos(): { list: () => Photo[] } {
return { list: (): Photo[] => this.listPhotos() };
}
record(): AlbumRecord {
return this.rec;
}
private listPhotos(): Photo[] {
const out: Photo[] = [];
for (const id of this.rec.fileIDs) {
const p = this.records.photos.get(id);
if (p) out.push(new Photo(p));
}
return out;
}
}
export interface AlbumsAPI {
list(): Album[];
byName(args: { albumName: string }): Album | undefined;
byID(args: { collectionID: number }): Album | undefined;
}
export interface PhotosAPI {
byID(args: { fileID: number }): Photo | undefined;
// Plain records for the requested ids, in the order requested, each id at
// most once, unknown ids dropped.
records(args: { fileIDs: number[] }): PhotoRecord[];
}
export type GroupBy = "day" | "week" | "month";
// A filter over the timeline. All fields are optional and combine with AND.
// Hidden photos are never included, regardless of this filter.
export interface PhotoFilter {
// Keep only photos that belong to this album.
albumID?: number;
// Case-insensitive substring of the title, caption, or any album name the
// photo belongs to.
text?: string;
// Keep only photos of one of these types.
fileTypes?: FileType[];
// `true` keeps only geotagged photos; `false` keeps only those without a
// location; omitted places no constraint.
hasLocation?: boolean;
// Archived photos are excluded unless this is `true`. Defaults to `false`.
includeArchived?: boolean;
}
export interface TimelineGroup {
// The period's identity: `YYYY-MM-DD` for day, `YYYY-Www` (ISO 8601 week,
// e.g. `2025-W32`) for week, and `YYYY-MM` for month.
key: string;
// Local-time milliseconds at the start of the period.
startsAt: number;
// The period's files, newest first, each file once.
fileIDs: number[];
}
export interface TimelineAPI {
groups(args: { groupBy: GroupBy; filter?: PhotoFilter }): TimelineGroup[];
}
export const makeAlbumsAPI = (derive: () => DerivedRecords): AlbumsAPI => ({
list: (): Album[] => {
const records = derive();
return [...records.albums.values()]
.sort(byNewestAlbum)
.map((rec) => new Album(rec, records));
},
byID: ({ collectionID }): Album | undefined => {
const records = derive();
const rec = records.albums.get(collectionID);
return rec ? new Album(rec, records) : undefined;
},
byName: ({ albumName }): Album | undefined => {
const records = derive();
// Names are not unique in Ente; resolve a collision deterministically
// to the newest-updated album, matching `list` order.
const match = [...records.albums.values()]
.sort(byNewestAlbum)
.find((rec) => rec.name === albumName);
return match ? new Album(match, records) : undefined;
},
});
export const makePhotosAPI = (derive: () => DerivedRecords): PhotosAPI => ({
byID: ({ fileID }): Photo | undefined => {
const rec = derive().photos.get(fileID);
return rec ? new Photo(rec) : undefined;
},
records: ({ fileIDs }): PhotoRecord[] => {
const { photos } = derive();
const seen = new Set<number>();
const out: PhotoRecord[] = [];
for (const id of fileIDs) {
if (seen.has(id)) continue;
const rec = photos.get(id);
if (rec) {
out.push(rec);
seen.add(id);
}
}
return out;
},
});
export const makeTimelineAPI = (derive: () => DerivedRecords): TimelineAPI => ({
groups: ({ groupBy, filter }): TimelineGroup[] => {
const records = derive();
return groupPhotos(filterPhotos(records, filter), groupBy);
},
});
// Apply a `PhotoFilter` to the projection. Hidden photos are always dropped;
// archived photos are dropped unless `includeArchived` asks for them.
const filterPhotos = (
records: DerivedRecords,
filter?: PhotoFilter,
): PhotoRecord[] => {
const f = filter ?? {};
const includeArchived = f.includeArchived ?? false;
const needle = f.text?.toLowerCase();
const out: PhotoRecord[] = [];
for (const rec of records.photos.values()) {
if (rec.isHidden) continue;
if (rec.isArchived && !includeArchived) continue;
if (f.albumID !== undefined && !rec.albumIDs.includes(f.albumID))
continue;
if (f.fileTypes !== undefined && !f.fileTypes.includes(rec.fileType))
continue;
if (f.hasLocation !== undefined) {
const has =
rec.latitude !== undefined && rec.longitude !== undefined;
if (has !== f.hasLocation) continue;
}
if (needle !== undefined && !matchesText(rec, needle, records))
continue;
out.push(rec);
}
return out;
};
const matchesText = (
rec: PhotoRecord,
needle: string,
records: DerivedRecords,
): boolean => {
if (rec.title.toLowerCase().includes(needle)) return true;
if (rec.caption !== undefined && rec.caption.toLowerCase().includes(needle))
return true;
for (const id of rec.albumIDs) {
const album = records.albums.get(id);
if (album && album.name.toLowerCase().includes(needle)) return true;
}
return false;
};
// Bucket photos into periods, groups newest first, members newest first.
const groupPhotos = (
photos: PhotoRecord[],
groupBy: GroupBy,
): TimelineGroup[] => {
const buckets = new Map<
string,
{ startsAt: number; recs: PhotoRecord[] }
>();
for (const rec of photos) {
const { key, startsAt } = periodOf(rec.takenAt, groupBy);
const bucket = buckets.get(key);
if (bucket) bucket.recs.push(rec);
else buckets.set(key, { startsAt, recs: [rec] });
}
const groups: TimelineGroup[] = [];
for (const [key, bucket] of buckets) {
bucket.recs.sort(byNewest);
groups.push({
key,
startsAt: bucket.startsAt,
fileIDs: bucket.recs.map((r) => r.fileID),
});
}
groups.sort((a, b) => b.startsAt - a.startsAt);
return groups;
};
const pad = (n: number): string => String(n).padStart(2, "0");
const dateKey = (d: Date): string =>
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
// The ISO 8601 week key `YYYY-Www` for the week starting at the given Monday.
// The week-year is the year of that week's Thursday, so it can differ from the
// calendar year at the January/December boundary (e.g. 2024-12-30 is 2025-W01).
const isoWeekKey = (monday: Date): string => {
const thursday = new Date(
monday.getFullYear(),
monday.getMonth(),
monday.getDate() + 3,
);
const isoYear = thursday.getFullYear();
// Thursday of ISO week 1 is the Thursday of the week containing January 4.
const jan4 = new Date(isoYear, 0, 4);
const week1Thursday = new Date(
isoYear,
0,
4 + 3 - ((jan4.getDay() + 6) % 7),
);
const week =
1 +
Math.round((thursday.getTime() - week1Thursday.getTime()) / WEEK_MS);
return `${isoYear}-W${pad(week)}`;
};
// The period a millisecond instant falls in, in local time. Weeks start on
// Monday. `Date` normalizes out-of-range day arguments, so the week's Monday
// is correct across month and year boundaries.
const periodOf = (
takenAt: number,
groupBy: GroupBy,
): { key: string; startsAt: number } => {
const d = new Date(takenAt);
const year = d.getFullYear();
const month = d.getMonth();
const day = d.getDate();
if (groupBy === "month") {
const start = new Date(year, month, 1);
return { key: `${year}-${pad(month + 1)}`, startsAt: start.getTime() };
}
if (groupBy === "week") {
// getDay(): 0=Sunday..6=Saturday; shift so Monday is the week start.
const fromMonday = (d.getDay() + 6) % 7;
const start = new Date(year, month, day - fromMonday);
return { key: isoWeekKey(start), startsAt: start.getTime() };
}
const start = new Date(year, month, day);
return { key: dateKey(start), startsAt: start.getTime() };
};
+3 -48
View File
@@ -1,4 +1,3 @@
import { gunzipSync } from "node:zlib";
import { import {
mkdirSync, mkdirSync,
mkdtempSync, mkdtempSync,
@@ -11,7 +10,7 @@ import { tmpdir } from "node:os";
import * as jpeg from "jpeg-js"; import * as jpeg from "jpeg-js";
import exifReader from "exif-reader"; import exifReader from "exif-reader";
import type { Client } from "./client.js"; import type { Client } from "./client.js";
import { decryptBlob, fromBase64 } from "./crypto/index.js"; import { fetchMLData } from "./mldata-fetch.js";
import type { EnteFile } from "./model/types.js"; import type { EnteFile } from "./model/types.js";
export type ProgressCallback = (message: string) => void; export type ProgressCallback = (message: string) => void;
@@ -24,50 +23,6 @@ export interface MetadataBackupOptions {
const sanitizePath = (name: string): string => const sanitizePath = (name: string): string =>
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_"); name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
interface RawRemoteFileData {
fileID: number;
encryptedData: string;
decryptionHeader: string;
updatedAt?: number;
}
const fetchMLDataForFiles = async (
client: Client,
fileIDs: number[],
fileKeys: Map<number, Uint8Array>,
): Promise<Map<number, Record<string, unknown>>> => {
const api = client.getApiClient();
const result = new Map<number, Record<string, unknown>>();
const batchSize = 200;
for (let i = 0; i < fileIDs.length; i += batchSize) {
const batch = fileIDs.slice(i, i + batchSize);
const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>(
"/files/data/fetch",
{ type: "mldata", fileIDs: batch },
);
for (const entry of data ?? []) {
const key = fileKeys.get(entry.fileID);
if (!key) continue;
try {
const decrypted = decryptBlob(
fromBase64(entry.encryptedData),
fromBase64(entry.decryptionHeader),
key,
);
const jsonStr = gunzipSync(Buffer.from(decrypted)).toString(
"utf-8",
);
result.set(entry.fileID, JSON.parse(jsonStr));
} catch {
// Corrupted ML data for this file; skip it
}
}
}
return result;
};
// Extract the raw EXIF APP1 segment from JPEG bytes. Returns the EXIF // Extract the raw EXIF APP1 segment from JPEG bytes. Returns the EXIF
// data buffer (starting after the APP1 length field, at the "Exif\0\0" // data buffer (starting after the APP1 length field, at the "Exif\0\0"
// header) or undefined if no APP1 marker is found. // header) or undefined if no APP1 marker is found.
@@ -228,8 +183,8 @@ export const runMetadataBackup = async (
} }
log("Fetching ML data (face detections, CLIP embeddings)..."); log("Fetching ML data (face detections, CLIP embeddings)...");
const mlDataMap = await fetchMLDataForFiles( const mlDataMap = await fetchMLData(
client, client.getApiClient(),
[...fileKeys.keys()], [...fileKeys.keys()],
fileKeys, fileKeys,
); );
+93
View File
@@ -0,0 +1,93 @@
// Fetch and decrypt Ente's per-file machine-learning data ("magic" search
// data: face detections + CLIP embeddings).
//
// The data lives behind `/files/data/fetch` with `type: "mldata"`. Each entry
// comes back encrypted under the file's own key and gzipped; decrypting and
// gunzipping yields the JSON payload
// `{ face: { faces: [...] }, clip: { embedding } }`. Ente caps a request at 200
// ids, so `fetchMLData` batches for callers that want many at once while
// `fetchMLDataBatch` is the single-request unit the library submits to its
// request pool.
import { gunzipSync } from "node:zlib";
import type { ApiClient } from "./api/client.js";
import { decryptBlob, fromBase64 } from "./crypto/index.js";
// The most ids one `/files/data/fetch` request may carry.
export const MLDATA_BATCH_SIZE = 200;
// The decrypted, gunzipped per-file payload. Its concrete shape is Ente's; the
// store keeps the whole object verbatim and each consumer reads the fields it
// needs, so it stays an open record rather than a fixed interface.
export type MLData = Record<string, unknown>;
interface RawRemoteFileData {
fileID: number;
encryptedData: string;
decryptionHeader: string;
updatedAt?: number;
}
// Decrypt one entry with its file key and gunzip the JSON payload. Returns
// undefined when the key is unknown or the entry does not decrypt/parse, so one
// corrupt file never fails a whole batch.
const decodeEntry = (
entry: RawRemoteFileData,
key: Uint8Array | undefined,
): MLData | undefined => {
if (!key) return undefined;
try {
const decrypted = decryptBlob(
fromBase64(entry.encryptedData),
fromBase64(entry.decryptionHeader),
key,
);
const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8");
return JSON.parse(json) as MLData;
} catch {
return undefined;
}
};
// Fetch ML data for up to `MLDATA_BATCH_SIZE` ids in a single request. This is
// the unit the request pools schedule; callers with more ids split them into
// batches and submit each batch to the pool.
export const fetchMLDataBatch = async (
api: ApiClient,
fileIDs: number[],
fileKeys: Map<number, Uint8Array>,
): Promise<Map<number, MLData>> => {
const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>(
"/files/data/fetch",
{ type: "mldata", fileIDs },
);
const result = new Map<number, MLData>();
for (const entry of data ?? []) {
const payload = decodeEntry(entry, fileKeys.get(entry.fileID));
if (payload) result.set(entry.fileID, payload);
}
return result;
};
// Fetch ML data for arbitrarily many ids, batching at `MLDATA_BATCH_SIZE`. Used
// by the one-shot metadata backup; the library fetches through its request pool
// with `fetchMLDataBatch` instead.
export const fetchMLData = async (
api: ApiClient,
fileIDs: number[],
fileKeys: Map<number, Uint8Array>,
): Promise<Map<number, MLData>> => {
const result = new Map<number, MLData>();
for (let i = 0; i < fileIDs.length; i += MLDATA_BATCH_SIZE) {
const batch = fileIDs.slice(i, i + MLDATA_BATCH_SIZE);
for (const [id, payload] of await fetchMLDataBatch(
api,
batch,
fileKeys,
)) {
result.set(id, payload);
}
}
return result;
};
+425
View File
@@ -0,0 +1,425 @@
/**
* Tests for the ML-data cache and its derived CLIP index (issue #49).
*
* Two layers are exercised:
*
* 1. `MLDataStore` on its own: storing one payload file per fileID (present
* means complete), building a `clip.f32` + `clip.json` index that reloads
* in a single read, rebuilding that index from the payloads when it is
* missing or disagrees with the files present, appending as new payloads
* arrive, overwriting a refetched file in place, and deciding what to
* (re)fetch as `updationTime` advances.
*
* 2. `Library` wiring: after each refresh the library fetches ML data through
* the metadata pool for every known file not yet cached, is incremental on
* later refreshes, and refetches a file whose `updationTime` advanced.
*
* Embedding values are chosen to be exactly representable as float32 so the
* round-trip through `clip.f32` compares equal.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MLDataStore } from "../../src/library/mldata.js";
import { Library } from "../../src/library/index.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { MLData } from "../../src/mldata-fetch.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
// A payload shaped like Ente's: a CLIP embedding plus face data that only the
// on-disk payload carries (never the RAM index).
const payload = (embedding: number[]): MLData => ({
face: {
faces: [{ faceID: "f", detection: { box: { x: 0.5 } } }],
},
clip: { embedding },
});
describe("MLDataStore", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "quak-mldata-"));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("stores one payload file per fileID and builds a one-read index", async () => {
const store = await MLDataStore.open(dir);
const res = await store.storeFetched(
new Map([
[100, payload([0.5, 0.25, 0.75])],
[200, payload([1, -2, 0.5])],
]),
new Map([
[100, 10],
[200, 20],
]),
);
expect(res).toEqual({ stored: 2, indexed: 2 });
// One payload file per fileID, and the derived index files.
expect(existsSync(join(dir, "100.json"))).toBe(true);
expect(existsSync(join(dir, "200.json"))).toBe(true);
expect(existsSync(join(dir, "clip.f32"))).toBe(true);
expect(existsSync(join(dir, "clip.json"))).toBe(true);
// Reopening loads the index from disk in one read.
const reopened = await MLDataStore.open(dir);
const index = reopened.getIndex();
expect(index.fileIDs).toEqual([100, 200]);
expect(index.embeddingLength).toBe(3);
expect([...index.embeddings]).toEqual([0.5, 0.25, 0.75, 1, -2, 0.5]);
// The full payload (face boxes) is read back from disk on demand.
const full = await reopened.readPayload(100);
expect(full?.face).toBeDefined();
expect(await reopened.readPayload(999)).toBeUndefined();
});
it("rebuilds the index from payloads when it is missing", async () => {
const store = await MLDataStore.open(dir);
await store.storeFetched(
new Map([[100, payload([0.5, 0.25, 0.75])]]),
new Map([[100, 10]]),
);
// The derived index is lost but the payloads survive.
rmSync(join(dir, "clip.f32"));
rmSync(join(dir, "clip.json"));
const reopened = await MLDataStore.open(dir);
const index = reopened.getIndex();
expect(index.fileIDs).toEqual([100]);
expect([...index.embeddings]).toEqual([0.5, 0.25, 0.75]);
expect(existsSync(join(dir, "clip.f32"))).toBe(true);
});
it("rebuilds the index when it disagrees with the files present", async () => {
const store = await MLDataStore.open(dir);
await store.storeFetched(
new Map([
[100, payload([0.5, 0.25, 0.75])],
[200, payload([1, -2, 0.5])],
]),
new Map([
[100, 10],
[200, 20],
]),
);
// A payload disappears out from under the index (leaving it referencing
// a file no longer present); the index must be rebuilt from what is
// actually on disk.
rmSync(join(dir, "200.json"));
const reopened = await MLDataStore.open(dir);
expect(reopened.getIndex().fileIDs).toEqual([100]);
});
it("appends new payloads and overwrites a refetched file in place", async () => {
const store = await MLDataStore.open(dir);
await store.storeFetched(
new Map([[100, payload([0.5, 0.25, 0.75])]]),
new Map([[100, 10]]),
);
// A later batch adds a new file: appended after the first.
await store.storeFetched(
new Map([[200, payload([1, -2, 0.5])]]),
new Map([[200, 20]]),
);
// Refetching 100 (its embedding changed) updates it in place, not a
// duplicate row.
await store.storeFetched(
new Map([[100, payload([9, 9, 9])]]),
new Map([[100, 30]]),
);
const index = store.getIndex();
expect(index.fileIDs).toEqual([100, 200]);
expect([...index.embeddings]).toEqual([9, 9, 9, 1, -2, 0.5]);
});
it("keeps a payload without a CLIP embedding out of the index", async () => {
const store = await MLDataStore.open(dir);
const res = await store.storeFetched(
new Map<number, MLData>([[100, { face: { faces: [] } }]]),
new Map([[100, 10]]),
);
expect(res.stored).toBe(1);
expect(res.indexed).toBe(0);
// The payload is still cached (present means complete).
expect(existsSync(join(dir, "100.json"))).toBe(true);
expect(store.getIndex().fileIDs).toEqual([]);
});
it("fetches only what is missing or has a newer updationTime", async () => {
const store = await MLDataStore.open(dir);
await store.storeFetched(
new Map([[100, payload([0.5, 0.25, 0.75])]]),
new Map([[100, 10]]),
);
// 100 is cached and current; 200 has never been fetched.
expect(
store.neededFor([
{ id: 100, updationTime: 10 },
{ id: 200, updationTime: 5 },
]),
).toEqual([200]);
// 100's updationTime advanced past what it was fetched at: refetch.
expect(store.neededFor([{ id: 100, updationTime: 15 }])).toEqual([100]);
// Nothing advanced: nothing to fetch.
expect(store.neededFor([{ id: 100, updationTime: 10 }])).toEqual([]);
});
it("survives a corrupt index without losing the payloads", async () => {
const store = await MLDataStore.open(dir);
await store.storeFetched(
new Map([[100, payload([0.5, 0.25, 0.75])]]),
new Map([[100, 10]]),
);
writeFileSync(join(dir, "clip.json"), "not json");
const reopened = await MLDataStore.open(dir);
expect(reopened.getIndex().fileIDs).toEqual([100]);
});
});
// --- Library wiring ---------------------------------------------------------
const USER_ID = 42;
const FAST_INTERVAL = 0.02;
const collection = (id: number, updationTime: number): Collection => ({
id,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
name: `album-${id}`,
type: "album",
updationTime,
isShared: false,
});
const file = (
id: number,
collectionID: number,
updationTime: number,
): EnteFile => ({
id,
collectionID,
ownerID: USER_ID,
key: new Uint8Array([id & 0xff]),
metadata: {
title: `file-${id}.jpg`,
fileType: "image",
creationTime: updationTime,
modificationTime: updationTime,
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime,
});
// A mock client that serves scripted collection/file pages and per-file ML
// payloads, recording every ML fetch request so incremental behaviour is
// provable.
class MLMockClient {
userID = USER_ID;
collectionsQueue: CollectionsPage[] = [];
filesByCollection = new Map<number, FilesPage[]>();
mlByFile = new Map<number, MLData>();
mlFetchCalls: number[][] = [];
whoami(): { email: string; userID: number } {
return { email: "user@example.com", userID: this.userID };
}
async collectionsSince(args: {
sinceTime: number;
}): Promise<CollectionsPage> {
return (
this.collectionsQueue.shift() ?? {
collections: [],
deleted: [],
cursor: args.sinceTime,
}
);
}
async filesSince(args: {
collectionID: number;
collectionKey: Uint8Array;
sinceTime: number;
}): Promise<FilesPage> {
const queue = this.filesByCollection.get(args.collectionID);
return (
queue?.shift() ?? {
files: [],
deleted: [],
cursor: args.sinceTime,
}
);
}
async fetchMLData(args: {
fileIDs: number[];
fileKeys: Map<number, Uint8Array>;
}): Promise<Map<number, MLData>> {
this.mlFetchCalls.push([...args.fileIDs]);
const result = new Map<number, MLData>();
for (const id of args.fileIDs) {
const p = this.mlByFile.get(id);
if (p) result.set(id, p);
}
return result;
}
filesFor(collectionID: number, ...pages: FilesPage[]): void {
this.filesByCollection.set(collectionID, pages);
}
}
describe("Library ML-data fetch on refresh", () => {
let dir: string;
let cacheDirectory: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "quak-lib-mldata-"));
cacheDirectory = join(dir, "cache");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("fetches, stores and indexes ML data for known files, then is incremental", async () => {
const client = new MLMockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90), file(1002, 1, 95)],
deleted: [],
cursor: 95,
});
client.mlByFile.set(1001, payload([0.5, 0.25, 0.75]));
client.mlByFile.set(1002, payload([1, -2, 0.5]));
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: FAST_INTERVAL,
});
try {
// Wait on `lastMLFetchAt`, set only once the pass has persisted the
// index and payloads — not on the in-RAM counts, which advance
// before `storeFetched` writes to disk, so the reopen below reads
// the committed index rather than racing the write.
await vi.waitFor(
() => {
expect(lib.status().lastMLFetchAt).toBeGreaterThan(0);
expect(lib.status().mlIndexed).toBe(2);
expect(lib.status().mlStored).toBe(2);
},
{ timeout: 2000, interval: 5 },
);
// Both files were fetched, in one batch.
expect(client.mlFetchCalls.flat().sort((a, b) => a - b)).toEqual([
1001, 1002,
]);
const callsAfterFirst = client.mlFetchCalls.length;
// The index is on disk and reloads to the same shape.
const reopened = await MLDataStore.open(
join(cacheDirectory, "mldata"),
);
expect(reopened.getIndex().fileIDs).toEqual([1001, 1002]);
// Later refreshes with nothing new must not refetch.
await new Promise((r) => setTimeout(r, FAST_INTERVAL * 1000 * 5));
expect(client.mlFetchCalls.length).toBe(callsAfterFirst);
} finally {
lib.close();
}
});
it("refetches a file whose updationTime advanced", async () => {
const client = new MLMockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
client.mlByFile.set(1001, payload([0.5, 0.25, 0.75]));
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: FAST_INTERVAL,
});
try {
// Wait on `lastMLFetchAt`, set only after the first pass has
// persisted, not on `mlIndexed`, which is bumped in RAM before the
// write lands.
await vi.waitFor(
() => expect(lib.status().lastMLFetchAt).toBeGreaterThan(0),
{ timeout: 2000, interval: 5 },
);
const callsBefore = client.mlFetchCalls.length;
// The file changes on the server (updationTime advances) with a new
// embedding; the next refresh must refetch it.
client.mlByFile.set(1001, payload([9, 9, 9]));
client.collectionsQueue.push({
collections: [collection(1, 200)],
deleted: [],
cursor: 200,
});
client.filesFor(1, {
files: [file(1001, 1, 190)],
deleted: [],
cursor: 190,
});
// Poll the persisted index itself, not the fetch-call log: a call
// is recorded the instant the mock is entered, but `storeFetched`
// rewrites `clip.f32` only after it resolves, so an earlier reopen
// would read the pre-refetch vector. Reopening reads only committed
// (atomically renamed) files, so this sees the new embedding once —
// and only once — the store has written it.
await vi.waitFor(
async () => {
const reopened = await MLDataStore.open(
join(cacheDirectory, "mldata"),
);
expect([...reopened.getIndex().embeddings]).toEqual([
9, 9, 9,
]);
},
{ timeout: 2000, interval: 20 },
);
// The refetch really went back to the server for 1001.
expect(client.mlFetchCalls.length).toBeGreaterThan(callsBefore);
expect(client.mlFetchCalls.flat()).toContain(1001);
} finally {
lib.close();
}
});
});
+537
View File
@@ -0,0 +1,537 @@
/**
* Tests for the in-process read surface (issue #44).
*
* Phase 1 (#43) projected the decrypted store into plain `AlbumRecord` /
* `PhotoRecord` values. This phase adds the read API a CLI or in-process script
* uses, all served from RAM with no network:
*
* - `lib.albums` — `list` / `byName` / `byID`, returning thin `Album` wrappers.
* - `lib.photos` — `byID` (a `Photo` wrapper) and `records` (plain records).
* - `lib.timeline.groups` — photos bucketed by local day / week / month.
*
* Every call takes a single named-argument object; there are no positional
* arguments. The wrapper classes are for in-process callers only (they hold
* object identity, not JSON); the plain records remain the IPC-safe surface.
* Content-fetch methods (`Photo.original` / `thumbnail`) are a later unit and
* deliberately absent here — this surface is read-only.
*
* The detailed cases drive the API factories directly over a hand-built
* projection (`deriveRecords`), which keeps them free of disk and timers. A
* final section opens a real `Library` to prove the namespaces are wired to the
* live store and that a read never touches the client.
*/
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
deriveRecords,
type DerivedRecords,
} from "../../src/library/records.js";
import {
Album,
Photo,
makeAlbumsAPI,
makePhotosAPI,
makeTimelineAPI,
type TimelineGroup,
} from "../../src/library/read.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 { Collection, EnteFile } from "../../src/model/types.js";
const OWNER = 42;
// Ente stores times in microseconds; records expose milliseconds. These
// helpers keep the fixtures readable: `ms(...)` picks an epoch-millisecond
// instant, `micros(...)` is what the fixture stores so the derived record's
// `takenAt` comes back as the same millisecond value.
const ms = (epochMillis: number): number => epochMillis;
const micros = (epochMillis: number): number => epochMillis * 1000;
const collection = (
id: number,
opts: Partial<Collection> = {},
): Collection => ({
id,
ownerID: OWNER,
key: new Uint8Array([id & 0xff, 1, 2, 3]),
name: `album-${id}`,
type: "album",
updationTime: micros(1_700_000_000_000),
isShared: false,
...opts,
});
const file = (
id: number,
collectionID: number,
opts: Partial<EnteFile> & {
creationTime?: number;
title?: string;
fileType?: EnteFile["metadata"]["fileType"];
latitude?: number;
longitude?: number;
} = {},
): EnteFile => {
const { creationTime, title, fileType, latitude, longitude, ...rest } =
opts;
const metadata: EnteFile["metadata"] = {
title: title ?? `file-${id}.jpg`,
fileType: fileType ?? "image",
creationTime: creationTime ?? micros(1_700_000_000_000),
modificationTime: micros(1_700_000_000_000),
};
if (latitude !== undefined) metadata.latitude = latitude;
if (longitude !== undefined) metadata.longitude = longitude;
return {
id,
collectionID,
ownerID: OWNER,
key: new Uint8Array([id & 0xff, 9, 8, 7]),
metadata,
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime: micros(1_700_000_000_000),
...rest,
};
};
// Build the three API objects over one fixed projection, the way `Library`
// wires them over its live store.
const apis = (records: DerivedRecords) => {
const derive = () => records;
return {
albums: makeAlbumsAPI(derive),
photos: makePhotosAPI(derive),
timeline: makeTimelineAPI(derive),
};
};
// Every file id present across all timeline groups, in group-then-member order.
const allFileIDs = (groups: TimelineGroup[]): number[] =>
groups.flatMap((g) => g.fileIDs);
describe("lib.albums", () => {
it("lists albums as Album wrappers, newest updated first", () => {
const records = deriveRecords(
[
collection(1, { updationTime: micros(1_700_000_000_000) }),
collection(2, { updationTime: micros(1_705_000_000_000) }),
],
[file(10, 1), file(20, 2)],
);
const albums = apis(records).albums.list();
expect(albums.every((a) => a instanceof Album)).toBe(true);
// Collection 2 updated later, so it sorts ahead of collection 1.
expect(albums.map((a) => a.collectionID)).toEqual([2, 1]);
});
it("exposes album fields and its photos newest first", () => {
const records = deriveRecords(
[
collection(7, {
name: "Trip",
type: "favorites",
isShared: true,
}),
],
[
file(1, 7, { creationTime: micros(1_600_000_000_000) }),
file(2, 7, { creationTime: micros(1_800_000_000_000) }),
file(3, 7, { creationTime: micros(1_700_000_000_000) }),
],
);
const album = apis(records).albums.byID({ collectionID: 7 })!;
expect(album.name).toBe("Trip");
expect(album.type).toBe("favorites");
expect(album.isShared).toBe(true);
expect(album.fileIDs).toEqual([2, 3, 1]);
const photos = album.photos.list();
expect(photos.every((p) => p instanceof Photo)).toBe(true);
expect(photos.map((p) => p.fileID)).toEqual([2, 3, 1]);
// record() hands back the plain, JSON-safe projection.
expect("key" in album.record()).toBe(false);
});
it("finds an album by exact name and returns undefined when absent", () => {
const records = deriveRecords(
[
collection(1, { name: "Berlin" }),
collection(2, { name: "Paris" }),
],
[file(10, 1), file(20, 2)],
);
const { albums } = apis(records);
expect(albums.byName({ albumName: "Paris" })?.collectionID).toBe(2);
expect(albums.byName({ albumName: "paris" })).toBeUndefined();
expect(albums.byName({ albumName: "Nowhere" })).toBeUndefined();
});
it("returns undefined for an unknown collection id", () => {
const records = deriveRecords([collection(1)], [file(10, 1)]);
expect(
apis(records).albums.byID({ collectionID: 999 }),
).toBeUndefined();
});
});
describe("lib.photos", () => {
it("byID returns a Photo wrapper carrying the mapped fields", () => {
const records = deriveRecords(
[collection(1)],
[
file(1001, 1, {
title: "IMG.jpg",
fileType: "video",
creationTime: micros(1_699_000_000_000),
latitude: 52.52,
longitude: 13.405,
pubMagicMetadata: { caption: "at the lake" },
}),
],
);
const photo = apis(records).photos.byID({ fileID: 1001 })!;
expect(photo).toBeInstanceOf(Photo);
expect(photo.title).toBe("IMG.jpg");
expect(photo.fileType).toBe("video");
expect(photo.takenAt).toBe(ms(1_699_000_000_000));
expect(photo.caption).toBe("at the lake");
expect(photo.latitude).toBeCloseTo(52.52);
expect(photo.isArchived).toBe(false);
expect(photo.isHidden).toBe(false);
// The wrapper hands back the plain record, IPC-safe.
expect("key" in photo.record()).toBe(false);
});
it("byID returns undefined for an unknown file id", () => {
const records = deriveRecords([collection(1)], [file(1, 1)]);
expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined();
});
it("records() returns plain records in requested order, deduped, skipping unknowns", () => {
const records = deriveRecords(
[collection(1)],
[file(1, 1), file(2, 1), file(3, 1)],
);
const out = apis(records).photos.records({
fileIDs: [3, 1, 3, 999, 2],
});
// Requested order preserved; the repeated 3 appears once; 999 is dropped.
expect(out.map((r) => r.fileID)).toEqual([3, 1, 2]);
// Plain records, not wrappers, and JSON round-trips whole.
expect(out[0]).not.toBeInstanceOf(Photo);
expect(JSON.parse(JSON.stringify(out[0]))).toEqual(out[0]);
});
it("emits one record for a file even when it belongs to several albums", () => {
// File 1001 is a member of collections 1 and 2.
const records = deriveRecords(
[collection(1), collection(2)],
[file(1001, 1), file(1001, 2)],
);
const out = apis(records).photos.records({ fileIDs: [1001, 1001] });
expect(out).toHaveLength(1);
expect(out[0]!.albumIDs).toEqual([1, 2]);
});
});
describe("lib.timeline grouping", () => {
// Group keys and `startsAt` are computed in local time. Pinning the zone to
// UTC makes the expected values exact and lets the fixtures use `Date.UTC`.
const savedTZ = process.env.TZ;
beforeAll(() => {
process.env.TZ = "UTC";
});
afterAll(() => {
if (savedTZ === undefined) delete process.env.TZ;
else process.env.TZ = savedTZ;
});
it("buckets by local day, newest group and newest member first", () => {
const records = deriveRecords(
[collection(1)],
[
file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 9)) }),
file(2, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 18)) }),
file(3, 1, { creationTime: micros(Date.UTC(2024, 0, 16, 12)) }),
file(4, 1, { creationTime: micros(Date.UTC(2024, 1, 1, 12)) }),
],
);
const groups = apis(records).timeline.groups({ groupBy: "day" });
expect(groups.map((g) => g.key)).toEqual([
"2024-02-01",
"2024-01-16",
"2024-01-15",
]);
// Group start is local midnight of the day.
expect(groups[2]!.startsAt).toBe(Date.UTC(2024, 0, 15));
// Within the 2024-01-15 group, the later photo (id 2) is first.
expect(groups[2]!.fileIDs).toEqual([2, 1]);
});
it("buckets by week with weeks starting on Monday", () => {
// 2024-01-15 is a Monday; the week runs through Sunday 2024-01-21.
const records = deriveRecords(
[collection(1)],
[
file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 15, 12)) }), // Mon
file(2, 1, { creationTime: micros(Date.UTC(2024, 0, 17, 12)) }), // Wed
file(3, 1, { creationTime: micros(Date.UTC(2024, 0, 21, 12)) }), // Sun
file(4, 1, { creationTime: micros(Date.UTC(2024, 0, 22, 12)) }), // next Mon
],
);
const groups = apis(records).timeline.groups({ groupBy: "week" });
// ISO week keys: 2024-01-15 is in 2024-W03, the next Monday in 2024-W04.
expect(groups.map((g) => g.key)).toEqual(["2024-W04", "2024-W03"]);
const first = groups.find((g) => g.key === "2024-W03")!;
expect(first.startsAt).toBe(Date.UTC(2024, 0, 15));
// The Sunday belongs to the Monday-started week, not the next one.
expect(first.fileIDs.sort((a, b) => a - b)).toEqual([1, 2, 3]);
});
it("assigns a Sunday to the preceding Monday's week across a month boundary", () => {
// 2024-01-14 is a Sunday; its week started Monday 2024-01-08.
const records = deriveRecords(
[collection(1)],
[file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 14, 12)) })],
);
const groups = apis(records).timeline.groups({ groupBy: "week" });
expect(groups.map((g) => g.key)).toEqual(["2024-W02"]);
expect(groups[0]!.startsAt).toBe(Date.UTC(2024, 0, 8));
});
it("buckets by month", () => {
const records = deriveRecords(
[collection(1)],
[
file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 3, 12)) }),
file(2, 1, { creationTime: micros(Date.UTC(2024, 0, 28, 12)) }),
file(3, 1, { creationTime: micros(Date.UTC(2024, 1, 9, 12)) }),
],
);
const groups = apis(records).timeline.groups({ groupBy: "month" });
expect(groups.map((g) => g.key)).toEqual(["2024-02", "2024-01"]);
expect(groups[1]!.startsAt).toBe(Date.UTC(2024, 0, 1));
expect(groups[1]!.fileIDs).toEqual([2, 1]);
});
it("lists each file once even when it belongs to several albums", () => {
// File 1001 is in collections 1 and 2 but must appear once in a group.
const records = deriveRecords(
[collection(1), collection(2)],
[
file(1001, 1, {
creationTime: micros(Date.UTC(2024, 0, 15, 12)),
}),
file(1001, 2, {
creationTime: micros(Date.UTC(2024, 0, 15, 12)),
}),
],
);
const groups = apis(records).timeline.groups({ groupBy: "day" });
expect(allFileIDs(groups)).toEqual([1001]);
});
});
describe("lib.timeline uses local time, not UTC", () => {
const savedTZ = process.env.TZ;
afterAll(() => {
if (savedTZ === undefined) delete process.env.TZ;
else process.env.TZ = savedTZ;
});
it("buckets by the viewer's local day", () => {
// Kolkata is UTC+5:30 with no DST. An instant at 2024-01-14T20:00Z is
// 2024-01-15 01:30 local, so it belongs to the local day 2024-01-15.
process.env.TZ = "Asia/Kolkata";
const records = deriveRecords(
[collection(1)],
[file(1, 1, { creationTime: micros(Date.UTC(2024, 0, 14, 20)) })],
);
const groups = apis(records).timeline.groups({ groupBy: "day" });
expect(groups[0]!.key).toBe("2024-01-15");
// Local midnight of 2024-01-15, which is 2024-01-14T18:30Z.
expect(groups[0]!.startsAt).toBe(new Date(2024, 0, 15).getTime());
expect(groups[0]!.startsAt).toBe(Date.UTC(2024, 0, 14, 18, 30));
});
});
describe("PhotoFilter", () => {
// A fixture spanning albums, file types, geotags, captions, and the two
// visibility states, all on the same local day so grouping is incidental.
const day = (h: number): number => micros(Date.UTC(2024, 2, 4, h));
const records = (): DerivedRecords =>
deriveRecords(
[
collection(1, { name: "Holidays" }),
collection(2, { name: "Work" }),
],
[
file(1, 1, {
title: "Beach sunset",
creationTime: day(1),
latitude: 1,
longitude: 2,
}),
file(2, 1, {
title: "clip.mov",
fileType: "video",
creationTime: day(2),
}),
file(3, 2, {
title: "invoice scan",
creationTime: day(3),
pubMagicMetadata: { caption: "SUNSET colours" },
}),
file(4, 2, {
title: "archived note",
creationTime: day(4),
magicMetadata: { visibility: 1 },
}),
file(5, 2, {
title: "secret",
creationTime: day(5),
magicMetadata: { visibility: 2 },
}),
],
);
const idsWith = (
filter: Parameters<
ReturnType<typeof apis>["timeline"]["groups"]
>[0]["filter"],
): number[] =>
allFileIDs(
apis(records()).timeline.groups({ groupBy: "day", filter }),
).sort((a, b) => a - b);
it("never includes hidden photos and excludes archived by default", () => {
// No filter: hidden (5) always gone, archived (4) gone unless asked for.
expect(idsWith(undefined)).toEqual([1, 2, 3]);
});
it("includes archived photos when includeArchived is set, hidden still never", () => {
expect(idsWith({ includeArchived: true })).toEqual([1, 2, 3, 4]);
});
it("filters by album membership", () => {
expect(idsWith({ albumID: 1 })).toEqual([1, 2]);
expect(idsWith({ albumID: 2 })).toEqual([3]);
});
it("filters by file type", () => {
expect(idsWith({ fileTypes: ["video"] })).toEqual([2]);
expect(idsWith({ fileTypes: ["image", "video"] })).toEqual([1, 2, 3]);
});
it("filters by presence or absence of location", () => {
expect(idsWith({ hasLocation: true })).toEqual([1]);
expect(idsWith({ hasLocation: false })).toEqual([2, 3]);
});
it("matches text case-insensitively against title, caption, and album name", () => {
// Title match (case-insensitive): "Beach sunset".
expect(idsWith({ text: "SUNSET" })).toEqual([1, 3]);
// Caption-only match: file 3's caption is "SUNSET colours".
expect(idsWith({ text: "colours" })).toEqual([3]);
// Album-name match: everything in "Holidays".
expect(idsWith({ text: "holiday" })).toEqual([1, 2]);
});
it("combines filters", () => {
// Images in album 1 with a location: only file 1.
expect(
idsWith({ albumID: 1, fileTypes: ["image"], hasLocation: true }),
).toEqual([1]);
});
});
/**
* A minimal mock `Client`, enough for `Library.open` to run its refresh loop.
* The queues are empty, so the background refresh over a seeded cache changes
* nothing; the counters prove that a read never calls the client.
*/
class MockClient {
userID = OWNER;
collectionsCalls = 0;
filesCalls = 0;
whoami(): { email: string; userID: number } {
return { email: "user@example.com", userID: this.userID };
}
async collectionsSince(args: {
sinceTime: number;
}): Promise<CollectionsPage> {
this.collectionsCalls++;
return { collections: [], deleted: [], cursor: args.sinceTime };
}
async filesSince(args: {
collectionID: number;
collectionKey: Uint8Array;
sinceTime: number;
}): Promise<FilesPage> {
this.filesCalls++;
return { files: [], deleted: [], cursor: args.sinceTime };
}
}
describe("Library exposes the read surface over its live store", () => {
let dir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), "quak-read-"));
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
});
it("serves albums, photos, and timeline from RAM without calling the client", async () => {
const cacheDirectory = join(dir, "cache");
const path = join(cacheDirectory, "metadata.json");
// Seed a cache as a prior run left it, so open() serves it at once.
const seed = await MetadataStore.load(path);
seed.userID = OWNER;
seed.collectionsSinceTime = 100;
seed.putCollection(collection(1, { name: "Seeded" }));
seed.putFile(
file(1001, 1, { creationTime: micros(Date.UTC(2024, 5, 1, 12)) }),
);
await seed.save();
const client = new MockClient();
// A long interval keeps the background timer from firing during the test.
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: 3600,
});
try {
const collectionsBefore = client.collectionsCalls;
const filesBefore = client.filesCalls;
expect(lib.albums.list().map((a) => a.name)).toEqual(["Seeded"]);
expect(
lib.albums.byName({ albumName: "Seeded" })?.collectionID,
).toBe(1);
expect(lib.photos.byID({ fileID: 1001 })?.fileID).toBe(1001);
expect(
lib.photos.records({ fileIDs: [1001] }).map((r) => r.fileID),
).toEqual([1001]);
const groups = lib.timeline.groups({ groupBy: "month" });
expect(allFileIDs(groups)).toEqual([1001]);
// Reads are answered from RAM: no read called the client.
expect(client.collectionsCalls).toBe(collectionsBefore);
expect(client.filesCalls).toBe(filesBefore);
} finally {
lib.close();
}
});
});