Compare commits
2
Commits
6fe099e524
...
263b6afa19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
263b6afa19 | ||
|
|
c5c1f387df |
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user