Compare commits
2
Commits
a3a7eeddb8
...
ff0bbb3155
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0bbb3155 | ||
|
|
d7f415fe29 |
@@ -7,11 +7,16 @@ import {
|
||||
} from "./auth/login.js";
|
||||
import { unwrapAuth } from "./auth/unwrap.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 {
|
||||
downloadFile as dlFile,
|
||||
downloadThumbnail as dlThumb,
|
||||
} from "./download/index.js";
|
||||
import {
|
||||
makeDownloadContentSource,
|
||||
type ContentSource,
|
||||
} from "./library/content.js";
|
||||
import type {
|
||||
Collection,
|
||||
EnteFile,
|
||||
@@ -140,6 +145,14 @@ export class Client {
|
||||
return this.api;
|
||||
}
|
||||
|
||||
// The content-cache byte source over this client's API: each fetch is the
|
||||
// download layer's request + streaming decrypt + atomic write. `Library`
|
||||
// calls this to enable the on-disk content cache.
|
||||
contentSource(): ContentSource {
|
||||
this.assertLoggedIn();
|
||||
return makeDownloadContentSource(this.api);
|
||||
}
|
||||
|
||||
private assertLoggedIn(): void {
|
||||
if (this.loggedOut) throw new Error("Client has been logged out");
|
||||
}
|
||||
@@ -273,6 +286,18 @@ export class Client {
|
||||
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(
|
||||
file: EnteFile,
|
||||
outPath?: string,
|
||||
|
||||
@@ -49,7 +49,27 @@ export {
|
||||
type PhotoFilter,
|
||||
type TimelineGroup,
|
||||
type GroupBy,
|
||||
type ContentSource,
|
||||
type ContentResult,
|
||||
type ContentEvent,
|
||||
type ContentOptions,
|
||||
type PhotoContent,
|
||||
type ThumbnailsAPI,
|
||||
type ThumbnailPriority,
|
||||
type EnsureOptions,
|
||||
type EnsureResult,
|
||||
type EnsureEvent,
|
||||
} from "./library/index.js";
|
||||
export {
|
||||
RequestPools,
|
||||
BoundedPool,
|
||||
DEFAULT_METADATA_CONCURRENCY,
|
||||
DEFAULT_CONTENT_CONCURRENCY,
|
||||
DEFAULT_THUMBNAIL_CONCURRENCY,
|
||||
type RequestPoolsOptions,
|
||||
type Priority,
|
||||
type RunOptions,
|
||||
} from "./library/pools.js";
|
||||
export type {
|
||||
AlbumRecord,
|
||||
PhotoRecord,
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
// The on-disk content and thumbnail cache keyed by fileID (issue #46).
|
||||
//
|
||||
// Layout under `cacheDirectory`: `originals/<fileID>.<ext>` and
|
||||
// `thumbnails/<fileID>.<ext>`, flat directories at 0700 with files at 0600.
|
||||
// Content appears only by the streaming atomic writer's rename (the download
|
||||
// layer, #40), so a file that exists is whole — "present means complete". The
|
||||
// directory listing taken at `open()` is the record of what is cached, and the
|
||||
// orphan temp files a crashed write may have left are reaped there.
|
||||
//
|
||||
// A fetch goes through the shared request pools (#45): the content pool for
|
||||
// originals, the thumbnail pool for thumbnails. The pool limits concurrency,
|
||||
// orders on-demand work ahead of background, and dedups by key so a fileID
|
||||
// requested twice while the first is still in flight downloads once.
|
||||
//
|
||||
// Integrity. The reused streaming decrypt is the enforced guarantee: every
|
||||
// chunk is authenticated and the writer renames the file into place only once
|
||||
// the stream ends on TAG_FINAL, so a truncated or corrupt fetch throws and
|
||||
// nothing is stored. On top of that this module refuses to record a stored file
|
||||
// that came out empty. The design also asks for a content-hash comparison
|
||||
// against `FileMetadata.hash` (with a `fileSize` fallback); that is deferred —
|
||||
// see the PR — because the exact hash construction cannot be confirmed against
|
||||
// the repo's fixtures and `FileBlob.size` is the encrypted object size, not the
|
||||
// decrypted length this layer has.
|
||||
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { chmod, mkdir, readdir, rm, stat } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
|
||||
import type { ApiClient } from "../api/client.js";
|
||||
import {
|
||||
downloadFile,
|
||||
downloadThumbnail,
|
||||
type ProgressCallback,
|
||||
} from "../download/index.js";
|
||||
import type { EnteFile } from "../model/types.js";
|
||||
import type { Priority, RequestPools } from "./pools.js";
|
||||
|
||||
const DIR_MODE = 0o700;
|
||||
const FILE_MODE = 0o600;
|
||||
const TEMP_PREFIX = ".quak-";
|
||||
const TEMP_SUFFIX = ".tmp";
|
||||
// Ente thumbnails are always JPEG, so the cache stores them with a fixed
|
||||
// extension rather than deriving one from the (image or video) title.
|
||||
const THUMBNAIL_EXT = ".jpg";
|
||||
|
||||
type Kind = "original" | "thumbnail";
|
||||
|
||||
// The priority a caller attaches to a thumbnail prefetch. The pool has two
|
||||
// tiers, so this three-value surface collapses onto them: only a currently
|
||||
// visible thumbnail preempts (on-demand); "ahead" prefetch and speculative
|
||||
// "background" work both yield to it.
|
||||
export type ThumbnailPriority = "visible" | "ahead" | "background";
|
||||
|
||||
const poolPriorityOf = (priority: ThumbnailPriority): Priority =>
|
||||
priority === "visible" ? "on-demand" : "background";
|
||||
|
||||
export interface ContentResult {
|
||||
path: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
// Progress for a single `original`/`thumbnail` call. A present file emits one
|
||||
// `skipped` event and nothing else; a fetched file emits `downloading` as
|
||||
// plaintext lands and a final `done`.
|
||||
export type ContentEvent =
|
||||
| { status: "skipped"; bytes: number }
|
||||
| { status: "downloading"; bytesDone: number }
|
||||
| { status: "done"; bytes: number };
|
||||
|
||||
export interface ContentOptions {
|
||||
onProgress?: (event: ContentEvent) => void;
|
||||
}
|
||||
|
||||
// The Photo-facing content surface (the read wrappers call these). The cache
|
||||
// implements it; a library opened without a content source leaves it absent.
|
||||
export interface PhotoContent {
|
||||
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
||||
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
|
||||
}
|
||||
|
||||
export interface EnsureResult {
|
||||
fileID: number;
|
||||
path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface EnsureEvent {
|
||||
fileID: number;
|
||||
status: "skipped" | "done" | "failed" | "aborted";
|
||||
path?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface EnsureOptions {
|
||||
fileIDs: number[];
|
||||
priority: ThumbnailPriority;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (event: EnsureEvent) => void;
|
||||
}
|
||||
|
||||
export interface ThumbnailsAPI {
|
||||
ensure(args: EnsureOptions): Promise<EnsureResult[]>;
|
||||
}
|
||||
|
||||
// The byte source the cache fetches through. The real implementation streams
|
||||
// and decrypts to the destination via the download layer; tests inject a
|
||||
// stand-in so the cache logic runs with no crypto and no network. Pool routing,
|
||||
// dedup, present-checks and integrity live in the cache, not here.
|
||||
export interface ContentSource {
|
||||
original(args: {
|
||||
file: EnteFile;
|
||||
destination: string;
|
||||
onProgress?: ProgressCallback;
|
||||
}): Promise<{ bytesWritten: number }>;
|
||||
thumbnail(args: {
|
||||
file: EnteFile;
|
||||
destination: string;
|
||||
onProgress?: ProgressCallback;
|
||||
}): Promise<{ bytesWritten: number }>;
|
||||
}
|
||||
|
||||
// The production source: each fetch is the download layer's request +
|
||||
// streaming decrypt + atomic write + retry as one unit.
|
||||
export const makeDownloadContentSource = (api: ApiClient): ContentSource => ({
|
||||
original: ({ file, destination, onProgress }) =>
|
||||
downloadFile(api, file, destination, onProgress),
|
||||
thumbnail: ({ file, destination, onProgress }) =>
|
||||
downloadThumbnail(api, file, destination, onProgress),
|
||||
});
|
||||
|
||||
export interface CachedPaths {
|
||||
originalPath?: string;
|
||||
thumbnailPath?: string;
|
||||
}
|
||||
|
||||
export interface ContentCacheOptions {
|
||||
pools: RequestPools;
|
||||
source: ContentSource;
|
||||
cacheDirectory: string;
|
||||
// The backup destination (issue-level `downloadDirectory`). An original
|
||||
// already stored there by a backup counts as present, so the cache serves
|
||||
// it rather than fetching a second copy.
|
||||
downloadDirectory?: string;
|
||||
// Resolve any membership of a file; every membership shares the underlying
|
||||
// content key, so any one decrypts the same bytes.
|
||||
getFile: (fileID: number) => EnteFile | undefined;
|
||||
}
|
||||
|
||||
// Thrown inside a pooled task to drop a queued fetch that was aborted before it
|
||||
// started running. Never escapes `ensureThumbnails`.
|
||||
class AbortDrop extends Error {
|
||||
constructor() {
|
||||
super("aborted");
|
||||
this.name = "AbortDrop";
|
||||
}
|
||||
}
|
||||
|
||||
const originalName = (file: EnteFile): string => {
|
||||
const ext = extname(file.metadata.title || "") || ".bin";
|
||||
return `${file.id}${ext}`;
|
||||
};
|
||||
|
||||
// The fileID a cache filename encodes, or undefined when the name is not one
|
||||
// the cache writes (`<digits><ext>`).
|
||||
const fileIDFromName = (name: string): number | undefined => {
|
||||
const base = name.slice(0, name.length - extname(name).length);
|
||||
if (!/^\d+$/.test(base)) return undefined;
|
||||
const id = Number(base);
|
||||
return Number.isSafeInteger(id) ? id : undefined;
|
||||
};
|
||||
|
||||
// Size of a regular file, or undefined if it is absent (or not a regular file).
|
||||
const fileSize = (path: string): number | undefined => {
|
||||
try {
|
||||
const s = statSync(path);
|
||||
return s.isFile() ? s.size : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
||||
private readonly pools: RequestPools;
|
||||
private readonly source: ContentSource;
|
||||
private readonly downloadDirectory?: string;
|
||||
private readonly getFile: (fileID: number) => EnteFile | undefined;
|
||||
private readonly originalsDir: string;
|
||||
private readonly thumbnailsDir: string;
|
||||
// fileID -> absolute path of the cached bytes, seeded from the directory
|
||||
// listing at open() and extended as fetches store new files.
|
||||
private readonly originals = new Map<number, string>();
|
||||
private readonly thumbnails = new Map<number, string>();
|
||||
|
||||
constructor(opts: ContentCacheOptions) {
|
||||
this.pools = opts.pools;
|
||||
this.source = opts.source;
|
||||
this.downloadDirectory = opts.downloadDirectory;
|
||||
this.getFile = opts.getFile;
|
||||
this.originalsDir = join(opts.cacheDirectory, "originals");
|
||||
this.thumbnailsDir = join(opts.cacheDirectory, "thumbnails");
|
||||
}
|
||||
|
||||
// Prepare the cache directories, reap orphan temp files, and take the
|
||||
// record of what is already cached. Called once before the cache serves.
|
||||
async open(): Promise<void> {
|
||||
await this.ensureDir(this.originalsDir);
|
||||
await this.ensureDir(this.thumbnailsDir);
|
||||
await this.scan(this.originalsDir, this.originals);
|
||||
await this.scan(this.thumbnailsDir, this.thumbnails);
|
||||
}
|
||||
|
||||
// The cache paths known for a file, for the record projection to expose as
|
||||
// `originalPath`/`thumbnailPath`.
|
||||
pathsFor(fileID: number): CachedPaths {
|
||||
const out: CachedPaths = {};
|
||||
const original = this.originals.get(fileID);
|
||||
if (original !== undefined) out.originalPath = original;
|
||||
const thumbnail = this.thumbnails.get(fileID);
|
||||
if (thumbnail !== undefined) out.thumbnailPath = thumbnail;
|
||||
return out;
|
||||
}
|
||||
|
||||
async original(
|
||||
fileID: number,
|
||||
opts?: ContentOptions,
|
||||
): Promise<ContentResult> {
|
||||
return this.get(fileID, "original", "on-demand", opts?.onProgress);
|
||||
}
|
||||
|
||||
async thumbnail(
|
||||
fileID: number,
|
||||
opts?: ContentOptions,
|
||||
): Promise<ContentResult> {
|
||||
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
|
||||
}
|
||||
|
||||
async ensure(args: EnsureOptions): Promise<EnsureResult[]> {
|
||||
return this.ensureThumbnails(args);
|
||||
}
|
||||
|
||||
async ensureThumbnails(args: EnsureOptions): Promise<EnsureResult[]> {
|
||||
const priority = poolPriorityOf(args.priority);
|
||||
// Dedup the request list so a repeated fileID is fetched once and
|
||||
// reported once, in first-requested order.
|
||||
const seen = new Set<number>();
|
||||
const unique: number[] = [];
|
||||
for (const id of args.fileIDs) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
unique.push(id);
|
||||
}
|
||||
}
|
||||
return Promise.all(
|
||||
unique.map((fileID) =>
|
||||
this.ensureOne(fileID, priority, args.signal, args.onProgress),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureOne(
|
||||
fileID: number,
|
||||
priority: Priority,
|
||||
signal: AbortSignal | undefined,
|
||||
onProgress: ((event: EnsureEvent) => void) | undefined,
|
||||
): Promise<EnsureResult> {
|
||||
try {
|
||||
const result = await this.acquire(
|
||||
fileID,
|
||||
"thumbnail",
|
||||
priority,
|
||||
signal,
|
||||
);
|
||||
const status = result.cached ? "skipped" : "done";
|
||||
onProgress?.({ fileID, status, path: result.path });
|
||||
return { fileID, path: result.path };
|
||||
} catch (err) {
|
||||
if (err instanceof AbortDrop) {
|
||||
onProgress?.({ fileID, status: "aborted" });
|
||||
return { fileID, error: "aborted" };
|
||||
}
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
onProgress?.({ fileID, status: "failed", error });
|
||||
return { fileID, error };
|
||||
}
|
||||
}
|
||||
|
||||
private async get(
|
||||
fileID: number,
|
||||
kind: Kind,
|
||||
priority: Priority,
|
||||
onProgress: ((event: ContentEvent) => void) | undefined,
|
||||
): Promise<ContentResult> {
|
||||
const onByte: ProgressCallback | undefined = onProgress
|
||||
? (bytesDone) => onProgress({ status: "downloading", bytesDone })
|
||||
: undefined;
|
||||
const result = await this.acquire(fileID, kind, priority, undefined, {
|
||||
onByte,
|
||||
});
|
||||
onProgress?.(
|
||||
result.cached
|
||||
? { status: "skipped", bytes: result.bytes }
|
||||
: { status: "done", bytes: result.bytes },
|
||||
);
|
||||
return { path: result.path, bytes: result.bytes };
|
||||
}
|
||||
|
||||
// The core: return the cached path if present, else fetch through the pool,
|
||||
// store, and return it. `cached` distinguishes a present hit (no network,
|
||||
// no download event) from a fresh fetch.
|
||||
private async acquire(
|
||||
fileID: number,
|
||||
kind: Kind,
|
||||
priority: Priority,
|
||||
signal: AbortSignal | undefined,
|
||||
opts?: { onByte?: ProgressCallback },
|
||||
): Promise<{ path: string; bytes: number; cached: boolean }> {
|
||||
const file = this.getFile(fileID);
|
||||
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
|
||||
|
||||
const known = kind === "original" ? this.originals : this.thumbnails;
|
||||
const cached = known.get(fileID);
|
||||
if (cached !== undefined) {
|
||||
const size = fileSize(cached);
|
||||
if (size !== undefined && size > 0)
|
||||
return { path: cached, bytes: size, cached: true };
|
||||
// A recorded file that has since gone re-fetches below.
|
||||
known.delete(fileID);
|
||||
}
|
||||
|
||||
// An original a backup already stored counts as present.
|
||||
if (kind === "original" && this.downloadDirectory !== undefined) {
|
||||
const backupPath = join(
|
||||
this.downloadDirectory,
|
||||
"originals",
|
||||
originalName(file),
|
||||
);
|
||||
const size = fileSize(backupPath);
|
||||
if (size !== undefined && size > 0) {
|
||||
this.originals.set(fileID, backupPath);
|
||||
return { path: backupPath, bytes: size, cached: true };
|
||||
}
|
||||
}
|
||||
|
||||
const dir =
|
||||
kind === "original" ? this.originalsDir : this.thumbnailsDir;
|
||||
const dest =
|
||||
kind === "original"
|
||||
? join(dir, originalName(file))
|
||||
: join(dir, `${fileID}${THUMBNAIL_EXT}`);
|
||||
const pool =
|
||||
kind === "original" ? this.pools.content : this.pools.thumbnails;
|
||||
|
||||
return pool.run(
|
||||
async () => {
|
||||
// Dropping queued work on abort: a task still waiting for a slot
|
||||
// when the signal fired sees it here and never touches the
|
||||
// network. A task already past this point is in flight and runs
|
||||
// to completion.
|
||||
if (signal?.aborted) throw new AbortDrop();
|
||||
|
||||
await this.download(file, dest, kind, opts?.onByte);
|
||||
await chmod(dest, FILE_MODE);
|
||||
const size = (await stat(dest)).size;
|
||||
if (size === 0) {
|
||||
throw new Error(
|
||||
`content cache: ${kind} ${fileID} stored empty`,
|
||||
);
|
||||
}
|
||||
known.set(fileID, dest);
|
||||
return { path: dest, bytes: size, cached: false };
|
||||
},
|
||||
{ priority, key: fileID },
|
||||
);
|
||||
}
|
||||
|
||||
private async download(
|
||||
file: EnteFile,
|
||||
destination: string,
|
||||
kind: Kind,
|
||||
onProgress: ProgressCallback | undefined,
|
||||
): Promise<number> {
|
||||
const args = { file, destination, onProgress };
|
||||
const result =
|
||||
kind === "original"
|
||||
? await this.source.original(args)
|
||||
: await this.source.thumbnail(args);
|
||||
return result.bytesWritten;
|
||||
}
|
||||
|
||||
private async ensureDir(dir: string): Promise<void> {
|
||||
// chmod after mkdir so the mode is tightened even when the directory
|
||||
// already existed with a looser one; mkdir alone would not.
|
||||
await mkdir(dir, { recursive: true, mode: DIR_MODE });
|
||||
await chmod(dir, DIR_MODE);
|
||||
}
|
||||
|
||||
private async scan(dir: string, into: Map<number, string>): Promise<void> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (name.startsWith(TEMP_PREFIX) && name.endsWith(TEMP_SUFFIX)) {
|
||||
await rm(join(dir, name), { force: true }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const id = fileIDFromName(name);
|
||||
const path = join(dir, name);
|
||||
if (id !== undefined && existsSync(path)) into.set(id, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
+219
-9
@@ -23,6 +23,8 @@ import { join } from "node:path";
|
||||
import envPaths from "env-paths";
|
||||
|
||||
import { MetadataStore } from "./store.js";
|
||||
import { MLDataStore } from "./mldata.js";
|
||||
import { RequestPools } from "./pools.js";
|
||||
import {
|
||||
deriveRecords,
|
||||
snapshotFrom,
|
||||
@@ -39,6 +41,13 @@ import {
|
||||
type PhotosAPI,
|
||||
type TimelineAPI,
|
||||
} from "./read.js";
|
||||
import {
|
||||
ContentCache,
|
||||
type ContentSource,
|
||||
type ThumbnailsAPI,
|
||||
type EnsureOptions,
|
||||
type EnsureResult,
|
||||
} from "./content.js";
|
||||
|
||||
export {
|
||||
Album,
|
||||
@@ -50,7 +59,20 @@ export {
|
||||
type TimelineGroup,
|
||||
type GroupBy,
|
||||
} from "./read.js";
|
||||
export {
|
||||
type ContentSource,
|
||||
type ContentResult,
|
||||
type ContentEvent,
|
||||
type ContentOptions,
|
||||
type PhotoContent,
|
||||
type ThumbnailsAPI,
|
||||
type ThumbnailPriority,
|
||||
type EnsureOptions,
|
||||
type EnsureResult,
|
||||
type EnsureEvent,
|
||||
} from "./content.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";
|
||||
|
||||
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
|
||||
@@ -66,14 +88,29 @@ export interface LibraryClient {
|
||||
collectionKey: Uint8Array;
|
||||
sinceTime: number;
|
||||
}): 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>>;
|
||||
// The byte source for the on-disk content cache. Optional so a mock client
|
||||
// that only serves metadata still satisfies the interface; when absent (and
|
||||
// no explicit `contentSource` is passed to `open`) the content cache is
|
||||
// disabled and `Photo.original`/`thumbnail` and `thumbnails.ensure` throw.
|
||||
contentSource?(): ContentSource;
|
||||
}
|
||||
|
||||
// A single refresh cycle's progress. "started" fires before the network work,
|
||||
// then exactly one of "done" or "failed"; "failed" carries the error message.
|
||||
// A progress event for one unit of background work. A metadata "refresh" or an
|
||||
// ML "fetchMLData" pass each fire "started" before their network work and then
|
||||
// exactly one of "done" or "failed"; "failed" carries the error message and
|
||||
// an ML "done" reports how many payloads it stored.
|
||||
export interface RefreshEvent {
|
||||
operation: "refresh";
|
||||
operation: "refresh" | "fetchMLData";
|
||||
status: "started" | "done" | "failed";
|
||||
error?: string;
|
||||
fetched?: number;
|
||||
}
|
||||
|
||||
export type RefreshProgressCallback = (event: RefreshEvent) => void;
|
||||
@@ -83,11 +120,18 @@ export interface LibraryOptions {
|
||||
// Where `metadata.json` lives. Defaults to the env-paths cache directory
|
||||
// plus the user id, so each account has its own cache.
|
||||
cacheDirectory?: string;
|
||||
// Persistent backup destination for later phases (backup, thumbnails); the
|
||||
// refresh loop does not use it.
|
||||
// Persistent backup destination. The refresh loop does not use it; the
|
||||
// content cache treats an original already stored there as present.
|
||||
downloadDirectory?: string;
|
||||
refreshIntervalSeconds?: number;
|
||||
onProgress?: RefreshProgressCallback;
|
||||
// The bounded request pools (issue #45), shared by the ML-data fetch (the
|
||||
// metadata pool) and the content cache. Defaults to a fresh set at the
|
||||
// design's caps.
|
||||
pools?: RequestPools;
|
||||
// Overrides the client's own `contentSource()`; mainly for tests that drive
|
||||
// the cache with a stand-in source.
|
||||
contentSource?: ContentSource;
|
||||
}
|
||||
|
||||
export interface LibraryStatus {
|
||||
@@ -100,6 +144,15 @@ export interface LibraryStatus {
|
||||
// The message from the most recent refresh, set only while that refresh
|
||||
// failed; cleared by the next success.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -113,18 +166,32 @@ export class Library {
|
||||
readonly albums: AlbumsAPI;
|
||||
readonly photos: PhotosAPI;
|
||||
readonly timeline: TimelineAPI;
|
||||
// The thumbnail-prefetch surface (issue #46): drives the thumbnail pool
|
||||
// with priority, dedup, and abort.
|
||||
readonly thumbnails: ThumbnailsAPI;
|
||||
|
||||
private readonly client: LibraryClient;
|
||||
private readonly store: MetadataStore;
|
||||
// The on-disk content cache, or undefined when no content source is
|
||||
// available (a metadata-only client with no explicit source).
|
||||
private readonly cache?: ContentCache;
|
||||
private readonly userID: number;
|
||||
private readonly intervalMs: number;
|
||||
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 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 lastRefreshAt?: number;
|
||||
private lastError?: string;
|
||||
private lastMLFetchAt?: number;
|
||||
private lastMLError?: string;
|
||||
// The plain-record projection as of the last refresh, and the GUI change
|
||||
// subscribers. A refresh that alters the projection notifies each with the
|
||||
// delta; `lastRecords` is kept current every refresh so a subscriber that
|
||||
@@ -144,6 +211,9 @@ export class Library {
|
||||
downloadDirectory?: string;
|
||||
intervalMs: number;
|
||||
onProgress?: RefreshProgressCallback;
|
||||
pools: RequestPools;
|
||||
mldata?: MLDataStore;
|
||||
cache?: ContentCache;
|
||||
}) {
|
||||
this.client = args.client;
|
||||
this.store = args.store;
|
||||
@@ -152,14 +222,29 @@ export class Library {
|
||||
this.downloadDirectory = args.downloadDirectory;
|
||||
this.intervalMs = args.intervalMs;
|
||||
this.onProgress = args.onProgress;
|
||||
this.pools = args.pools;
|
||||
this.mldata = args.mldata;
|
||||
this.cache = args.cache;
|
||||
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.albums = makeAlbumsAPI(derive, this.cache);
|
||||
this.photos = makePhotosAPI(derive, this.cache);
|
||||
this.timeline = makeTimelineAPI(derive);
|
||||
this.thumbnails = {
|
||||
ensure: (opts: EnsureOptions): Promise<EnsureResult[]> => {
|
||||
if (!this.cache) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"thumbnails.ensure requires a library opened with a content cache",
|
||||
),
|
||||
);
|
||||
}
|
||||
return this.cache.ensureThumbnails(opts);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Load the cache and start the refresh loop. With an empty cache the first
|
||||
@@ -181,6 +266,33 @@ export class Library {
|
||||
(opts.refreshIntervalSeconds ?? DEFAULT_REFRESH_INTERVAL_SECONDS) *
|
||||
1000;
|
||||
|
||||
// One request-pool set serves both the ML-data fetch and the content
|
||||
// cache, so both honour the same concurrency caps.
|
||||
const pools = opts.pools ?? new RequestPools();
|
||||
|
||||
// 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;
|
||||
|
||||
// Build the content cache from an explicit source or the client's own,
|
||||
// and take its record of what is already cached (and reap orphan temp
|
||||
// files) before the first projection, so cached paths are present from
|
||||
// the start and the first refresh raises no spurious path-change diff.
|
||||
const source = opts.contentSource ?? opts.client.contentSource?.();
|
||||
let cache: ContentCache | undefined;
|
||||
if (source) {
|
||||
cache = new ContentCache({
|
||||
pools,
|
||||
source,
|
||||
cacheDirectory,
|
||||
downloadDirectory: opts.downloadDirectory,
|
||||
getFile: (fileID) => store.getFileByID(fileID),
|
||||
});
|
||||
await cache.open();
|
||||
}
|
||||
|
||||
const lib = new Library({
|
||||
client: opts.client,
|
||||
store,
|
||||
@@ -189,6 +301,9 @@ export class Library {
|
||||
downloadDirectory: opts.downloadDirectory,
|
||||
intervalMs,
|
||||
onProgress: opts.onProgress,
|
||||
pools,
|
||||
mldata,
|
||||
cache,
|
||||
});
|
||||
|
||||
if (store.loadedFromDisk) {
|
||||
@@ -248,12 +363,17 @@ export class Library {
|
||||
for (const c of collections) {
|
||||
files += this.store.listFiles(c.id).length;
|
||||
}
|
||||
const ml = this.mldata?.stats();
|
||||
return {
|
||||
userID: this.store.userID,
|
||||
collections: collections.length,
|
||||
files,
|
||||
lastRefreshAt: this.lastRefreshAt,
|
||||
lastError: this.lastError,
|
||||
lastMLFetchAt: this.lastMLFetchAt,
|
||||
lastMLError: this.lastMLError,
|
||||
mlStored: ml?.stored,
|
||||
mlIndexed: ml?.indexed,
|
||||
closed: this.closed,
|
||||
};
|
||||
}
|
||||
@@ -288,6 +408,11 @@ export class Library {
|
||||
this.lastRefreshAt = Date.now();
|
||||
this.lastError = undefined;
|
||||
this.emit({ operation: "refresh", status: "done" });
|
||||
// Backfill ML data for the files this refresh knows about. It runs
|
||||
// outside the refresh's success/failure so a fetch or disk problem
|
||||
// there never marks the metadata refresh failed, and it is not
|
||||
// awaited so it never stalls the refresh interval.
|
||||
void this.runMLFetch();
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
this.lastError = error;
|
||||
@@ -388,12 +513,97 @@ export class Library {
|
||||
}
|
||||
}
|
||||
|
||||
// Gather every file membership and project the store into by-id records.
|
||||
// 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,
|
||||
// filling each record's cache paths from the content cache when present.
|
||||
private deriveNow(): DerivedRecords {
|
||||
const collections = this.store.listCollections();
|
||||
const files: EnteFile[] = [];
|
||||
for (const c of collections) files.push(...this.store.listFiles(c.id));
|
||||
return deriveRecords(collections, files);
|
||||
const cache = this.cache;
|
||||
return deriveRecords(
|
||||
collections,
|
||||
files,
|
||||
cache ? (fileID) => cache.pathsFor(fileID) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private notify(change: LibraryChange): void {
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
// 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 agrees with the payloads in
|
||||
// both directions: every id it names must still be present, its vector file
|
||||
// must be exactly the size the id count and embedding length imply, and no
|
||||
// embedding-bearing payload on disk may be missing from it. 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;
|
||||
|
||||
// The reverse must hold too. A payload carrying an embedding but absent
|
||||
// from the index means the index is stale — realistically the process
|
||||
// died after storeFetched renamed the payloads into place but before it
|
||||
// rewrote clip.json/clip.f32. Loading such an index as "consistent"
|
||||
// would drop those embeddings for good (neededFor sees the payloads
|
||||
// present and never refetches), so treat it as a disagreement and
|
||||
// rebuild. Only present ids the index omits are read; a payload
|
||||
// legitimately without an embedding stays out and forces no rebuild.
|
||||
const indexed = new Set(meta.fileIDs);
|
||||
for (const id of this.present) {
|
||||
if (indexed.has(id)) continue;
|
||||
const payload = await this.readPayload(id);
|
||||
if (payload && clipEmbedding(payload)) 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
+45
-10
@@ -10,10 +10,14 @@
|
||||
// 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.
|
||||
//
|
||||
// A `Photo` also fetches its own bytes: `original()` and `thumbnail()` go
|
||||
// through the on-disk content cache (issue #46), the one place in this module
|
||||
// that is not synchronous and RAM-only. A library opened without a content
|
||||
// source leaves that cache absent, and those two methods then throw.
|
||||
|
||||
import type { CollectionType, FileType } from "../model/types.js";
|
||||
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
||||
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
||||
|
||||
// Newest first, with fileID as a stable tiebreak so equal-timed files order
|
||||
@@ -29,7 +33,10 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
|
||||
// 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) {}
|
||||
constructor(
|
||||
private readonly rec: PhotoRecord,
|
||||
private readonly content?: PhotoContent,
|
||||
) {}
|
||||
|
||||
get fileID(): number {
|
||||
return this.rec.fileID;
|
||||
@@ -71,6 +78,27 @@ export class Photo {
|
||||
record(): PhotoRecord {
|
||||
return this.rec;
|
||||
}
|
||||
|
||||
// Fetch and cache the full-resolution original, returning its on-disk path
|
||||
// and byte length. Served from the cache (or the backup download directory)
|
||||
// when already present, otherwise fetched through the content pool.
|
||||
async original(opts?: ContentOptions): Promise<ContentResult> {
|
||||
return this.contentOrThrow().original(this.rec.fileID, opts);
|
||||
}
|
||||
|
||||
// As `original`, for the thumbnail, through the thumbnail pool.
|
||||
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
|
||||
return this.contentOrThrow().thumbnail(this.rec.fileID, opts);
|
||||
}
|
||||
|
||||
private contentOrThrow(): PhotoContent {
|
||||
if (!this.content) {
|
||||
throw new Error(
|
||||
"Photo content requires a library opened with a content cache",
|
||||
);
|
||||
}
|
||||
return this.content;
|
||||
}
|
||||
}
|
||||
|
||||
// A single album. `photos.list()` returns the album's photos as wrappers,
|
||||
@@ -79,6 +107,7 @@ export class Album {
|
||||
constructor(
|
||||
private readonly rec: AlbumRecord,
|
||||
private readonly records: DerivedRecords,
|
||||
private readonly content?: PhotoContent,
|
||||
) {}
|
||||
|
||||
get collectionID(): number {
|
||||
@@ -112,7 +141,7 @@ export class Album {
|
||||
const out: Photo[] = [];
|
||||
for (const id of this.rec.fileIDs) {
|
||||
const p = this.records.photos.get(id);
|
||||
if (p) out.push(new Photo(p));
|
||||
if (p) out.push(new Photo(p, this.content));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -164,17 +193,20 @@ export interface TimelineAPI {
|
||||
groups(args: { groupBy: GroupBy; filter?: PhotoFilter }): TimelineGroup[];
|
||||
}
|
||||
|
||||
export const makeAlbumsAPI = (derive: () => DerivedRecords): AlbumsAPI => ({
|
||||
export const makeAlbumsAPI = (
|
||||
derive: () => DerivedRecords,
|
||||
content?: PhotoContent,
|
||||
): AlbumsAPI => ({
|
||||
list: (): Album[] => {
|
||||
const records = derive();
|
||||
return [...records.albums.values()]
|
||||
.sort(byNewestAlbum)
|
||||
.map((rec) => new Album(rec, records));
|
||||
.map((rec) => new Album(rec, records, content));
|
||||
},
|
||||
byID: ({ collectionID }): Album | undefined => {
|
||||
const records = derive();
|
||||
const rec = records.albums.get(collectionID);
|
||||
return rec ? new Album(rec, records) : undefined;
|
||||
return rec ? new Album(rec, records, content) : undefined;
|
||||
},
|
||||
byName: ({ albumName }): Album | undefined => {
|
||||
const records = derive();
|
||||
@@ -183,14 +215,17 @@ export const makeAlbumsAPI = (derive: () => DerivedRecords): AlbumsAPI => ({
|
||||
const match = [...records.albums.values()]
|
||||
.sort(byNewestAlbum)
|
||||
.find((rec) => rec.name === albumName);
|
||||
return match ? new Album(match, records) : undefined;
|
||||
return match ? new Album(match, records, content) : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
export const makePhotosAPI = (derive: () => DerivedRecords): PhotosAPI => ({
|
||||
export const makePhotosAPI = (
|
||||
derive: () => DerivedRecords,
|
||||
content?: PhotoContent,
|
||||
): PhotosAPI => ({
|
||||
byID: ({ fileID }): Photo | undefined => {
|
||||
const rec = derive().photos.get(fileID);
|
||||
return rec ? new Photo(rec) : undefined;
|
||||
return rec ? new Photo(rec, content) : undefined;
|
||||
},
|
||||
records: ({ fileIDs }): PhotoRecord[] => {
|
||||
const { photos } = derive();
|
||||
|
||||
@@ -166,11 +166,20 @@ const toAlbumRecord = (
|
||||
};
|
||||
};
|
||||
|
||||
// The cache paths known for a file, so the projection can expose them on the
|
||||
// record without the read layer reaching into the content cache itself.
|
||||
export type CachedPathLookup = (fileID: number) => {
|
||||
originalPath?: string;
|
||||
thumbnailPath?: string;
|
||||
};
|
||||
|
||||
// Project the decrypted collections and file memberships into by-id records.
|
||||
// `files` is every membership (a file appears once per collection it is in).
|
||||
// `cachedPaths`, when given, fills each record's cache paths.
|
||||
export const deriveRecords = (
|
||||
collections: Collection[],
|
||||
files: EnteFile[],
|
||||
cachedPaths?: CachedPathLookup,
|
||||
): DerivedRecords => {
|
||||
const byFileID = new Map<number, EnteFile[]>();
|
||||
for (const f of files) {
|
||||
@@ -183,6 +192,13 @@ export const deriveRecords = (
|
||||
const takenAtByFile = new Map<number, number>();
|
||||
for (const [fileID, memberships] of byFileID) {
|
||||
const record = toPhotoRecord(fileID, memberships);
|
||||
if (cachedPaths) {
|
||||
const paths = cachedPaths(fileID);
|
||||
if (paths.originalPath !== undefined)
|
||||
record.originalPath = paths.originalPath;
|
||||
if (paths.thumbnailPath !== undefined)
|
||||
record.thumbnailPath = paths.thumbnailPath;
|
||||
}
|
||||
photos.set(fileID, record);
|
||||
takenAtByFile.set(fileID, record.takenAt);
|
||||
}
|
||||
|
||||
@@ -179,6 +179,16 @@ export class MetadataStore {
|
||||
return this.files.get(fileKey(collectionID, fileID));
|
||||
}
|
||||
|
||||
// Any membership of a file, or undefined. Every membership re-wraps the
|
||||
// same underlying content key, so any one is enough to fetch the bytes;
|
||||
// the content cache resolves a fileID to a file this way.
|
||||
getFileByID(fileID: number): EnteFile | undefined {
|
||||
for (const file of this.files.values()) {
|
||||
if (file.id === fileID) return file;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
listFiles(collectionID: number): EnteFile[] {
|
||||
return [...this.files.values()].filter(
|
||||
(f) => f.collectionID === collectionID,
|
||||
|
||||
+3
-48
@@ -1,4 +1,3 @@
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -11,7 +10,7 @@ import { tmpdir } from "node:os";
|
||||
import * as jpeg from "jpeg-js";
|
||||
import exifReader from "exif-reader";
|
||||
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";
|
||||
|
||||
export type ProgressCallback = (message: string) => void;
|
||||
@@ -24,50 +23,6 @@ export interface MetadataBackupOptions {
|
||||
const sanitizePath = (name: string): string =>
|
||||
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
|
||||
// data buffer (starting after the APP1 length field, at the "Exif\0\0"
|
||||
// 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)...");
|
||||
const mlDataMap = await fetchMLDataForFiles(
|
||||
client,
|
||||
const mlDataMap = await fetchMLData(
|
||||
client.getApiClient(),
|
||||
[...fileKeys.keys()],
|
||||
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,152 @@
|
||||
/**
|
||||
* Integration between `Library` and the content cache (issue #46).
|
||||
*
|
||||
* The cache itself is covered in `content.test.ts`; this file locks the wiring:
|
||||
* `Library.open` builds the cache from a content source, `lib.photos` hands out
|
||||
* `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and
|
||||
* a cached path shows up on the projected record. A library opened without a
|
||||
* content source leaves those methods throwing rather than silently doing
|
||||
* nothing.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Library } from "../../src/library/index.js";
|
||||
import type { ContentSource } from "../../src/library/content.js";
|
||||
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
||||
import type { Collection, EnteFile } from "../../src/model/types.js";
|
||||
|
||||
const USER_ID = 7;
|
||||
|
||||
const collection = (id: number): Collection => ({
|
||||
id,
|
||||
ownerID: USER_ID,
|
||||
key: new Uint8Array([id & 0xff]),
|
||||
name: `album-${id}`,
|
||||
type: "album",
|
||||
updationTime: 1,
|
||||
isShared: false,
|
||||
});
|
||||
|
||||
const file = (id: number, collectionID: number): EnteFile => ({
|
||||
id,
|
||||
collectionID,
|
||||
ownerID: USER_ID,
|
||||
key: new Uint8Array([id & 0xff]),
|
||||
metadata: {
|
||||
title: `file-${id}.jpg`,
|
||||
fileType: "image",
|
||||
creationTime: 1,
|
||||
modificationTime: 1,
|
||||
},
|
||||
file: { decryptionHeader: "aGVhZGVy" },
|
||||
thumbnail: { decryptionHeader: "dGh1bWI=" },
|
||||
updationTime: 1,
|
||||
});
|
||||
|
||||
// A metadata-only client serving one album with one file, once.
|
||||
class MockClient {
|
||||
served = false;
|
||||
whoami(): { email: string; userID: number } {
|
||||
return { email: "u@example.com", userID: USER_ID };
|
||||
}
|
||||
async collectionsSince(): Promise<CollectionsPage> {
|
||||
if (this.served) return { collections: [], deleted: [], cursor: 1 };
|
||||
this.served = true;
|
||||
return { collections: [collection(1)], deleted: [], cursor: 1 };
|
||||
}
|
||||
async filesSince(): Promise<FilesPage> {
|
||||
return { files: [file(1, 1)], deleted: [], cursor: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// A content source that writes a marker file and counts thumbnail fetches.
|
||||
const stubSource = (): ContentSource & { thumbCalls: () => number } => {
|
||||
let thumbCalls = 0;
|
||||
return {
|
||||
thumbCalls: () => thumbCalls,
|
||||
original: async ({ destination }) => {
|
||||
writeFileSync(destination, "orig-bytes");
|
||||
return { bytesWritten: 10 };
|
||||
},
|
||||
thumbnail: async ({ destination }) => {
|
||||
thumbCalls++;
|
||||
writeFileSync(destination, "thumb");
|
||||
return { bytesWritten: 5 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "quak-content-lib-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root && existsSync(root))
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("Library content wiring", () => {
|
||||
it("fetches a thumbnail through a Photo and records its cache path", async () => {
|
||||
const source = stubSource();
|
||||
const lib = await Library.open({
|
||||
client: new MockClient(),
|
||||
cacheDirectory: join(root, "cache"),
|
||||
contentSource: source,
|
||||
refreshIntervalSeconds: 3600,
|
||||
});
|
||||
|
||||
const photo = lib.photos.byID({ fileID: 1 });
|
||||
expect(photo).toBeDefined();
|
||||
const result = await photo!.thumbnail();
|
||||
expect(source.thumbCalls()).toBe(1);
|
||||
expect(result.path).toBe(join(root, "cache", "thumbnails", "1.jpg"));
|
||||
expect(existsSync(result.path)).toBe(true);
|
||||
|
||||
// The cached path is now on the projected record.
|
||||
expect(lib.photos.byID({ fileID: 1 })!.record().thumbnailPath).toBe(
|
||||
result.path,
|
||||
);
|
||||
lib.close();
|
||||
});
|
||||
|
||||
it("drives thumbnails.ensure through the cache", async () => {
|
||||
const source = stubSource();
|
||||
const lib = await Library.open({
|
||||
client: new MockClient(),
|
||||
cacheDirectory: join(root, "cache"),
|
||||
contentSource: source,
|
||||
refreshIntervalSeconds: 3600,
|
||||
});
|
||||
|
||||
const results = await lib.thumbnails.ensure({
|
||||
fileIDs: [1],
|
||||
priority: "visible",
|
||||
});
|
||||
expect(results).toEqual([
|
||||
{ fileID: 1, path: join(root, "cache", "thumbnails", "1.jpg") },
|
||||
]);
|
||||
lib.close();
|
||||
});
|
||||
|
||||
it("throws from content methods when opened without a content source", async () => {
|
||||
const lib = await Library.open({
|
||||
client: new MockClient(),
|
||||
cacheDirectory: join(root, "cache"),
|
||||
refreshIntervalSeconds: 3600,
|
||||
});
|
||||
|
||||
await expect(
|
||||
lib.photos.byID({ fileID: 1 })!.thumbnail(),
|
||||
).rejects.toThrow(/content cache/i);
|
||||
await expect(
|
||||
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
|
||||
).rejects.toThrow(/content cache/i);
|
||||
lib.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* Tests for the on-disk content and thumbnail cache (issue #46).
|
||||
*
|
||||
* The cache keys stored bytes by `fileID` under `cacheDirectory`:
|
||||
* `originals/<fileID>.<ext>` and `thumbnails/<fileID>.<ext>`. Its contract:
|
||||
*
|
||||
* 1. **Fetch once, then serve from disk.** The first `original`/`thumbnail`
|
||||
* fetches through the request pool and stores the bytes; the next finds the
|
||||
* file present and returns its path with a single `skipped` event and no
|
||||
* network. A file already sitting in the backup `downloadDirectory` counts
|
||||
* as present too.
|
||||
* 2. **Present-means-complete.** Content appears only by the streaming atomic
|
||||
* writer's rename, so a file that exists is whole. The directory listing
|
||||
* taken at `open()` is the record of what is cached, and the orphan temp
|
||||
* files a crashed write may have left are reaped there.
|
||||
* 3. **`thumbnails.ensure` drives the thumbnail pool with priority, dedup, and
|
||||
* abort.** A `fileID` asked for twice downloads once; a visible request is
|
||||
* served ahead of a background one; and an `AbortSignal` drops work still
|
||||
* queued while letting an in-flight fetch finish.
|
||||
*
|
||||
* The `ContentSource` is a stand-in: it writes deterministic bytes to the
|
||||
* destination and returns the count, so the cache logic is exercised with no
|
||||
* crypto and no network. Ordering tests gate the stand-in on explicit deferreds
|
||||
* and assert the persisted result, never a bare call or a timer.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
existsSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
ContentCache,
|
||||
type ContentSource,
|
||||
type EnsureEvent,
|
||||
} from "../../src/library/content.js";
|
||||
import { RequestPools } from "../../src/library/pools.js";
|
||||
import type { EnteFile } from "../../src/model/types.js";
|
||||
|
||||
const file = (id: number, title = `file-${id}.jpg`): EnteFile => ({
|
||||
id,
|
||||
collectionID: 1,
|
||||
ownerID: 1,
|
||||
key: new Uint8Array([id & 0xff]),
|
||||
metadata: {
|
||||
title,
|
||||
fileType: "image",
|
||||
creationTime: 0,
|
||||
modificationTime: 0,
|
||||
},
|
||||
file: { decryptionHeader: "aGVhZGVy" },
|
||||
thumbnail: { decryptionHeader: "dGh1bWI=" },
|
||||
updationTime: 0,
|
||||
});
|
||||
|
||||
// A deferred with externally callable resolve, used to gate the stand-in source
|
||||
// so ordering is controlled by the test rather than by timing.
|
||||
const deferred = (): { promise: Promise<void>; resolve: () => void } => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
// A ContentSource that writes `${kind}:${fileID}` bytes to the destination and
|
||||
// records every call. `gate` optionally blocks a call until released, and
|
||||
// `completed` records the order in which fetches finished — the observable used
|
||||
// by the priority and abort tests instead of a timer.
|
||||
class StubSource implements ContentSource {
|
||||
originalCalls: number[] = [];
|
||||
thumbnailCalls: number[] = [];
|
||||
completed: number[] = [];
|
||||
emptyFor = new Set<number>();
|
||||
gates = new Map<number, Promise<void>>();
|
||||
|
||||
private async run(
|
||||
kind: "original" | "thumbnail",
|
||||
file: EnteFile,
|
||||
destination: string,
|
||||
): Promise<{ bytesWritten: number }> {
|
||||
const gate = this.gates.get(file.id);
|
||||
if (gate) await gate;
|
||||
const bytes = this.emptyFor.has(file.id)
|
||||
? new Uint8Array(0)
|
||||
: new TextEncoder().encode(`${kind}:${file.id}`);
|
||||
writeFileSync(destination, bytes);
|
||||
this.completed.push(file.id);
|
||||
return { bytesWritten: bytes.length };
|
||||
}
|
||||
|
||||
async original(args: {
|
||||
file: EnteFile;
|
||||
destination: string;
|
||||
}): Promise<{ bytesWritten: number }> {
|
||||
this.originalCalls.push(args.file.id);
|
||||
return this.run("original", args.file, args.destination);
|
||||
}
|
||||
|
||||
async thumbnail(args: {
|
||||
file: EnteFile;
|
||||
destination: string;
|
||||
}): Promise<{ bytesWritten: number }> {
|
||||
this.thumbnailCalls.push(args.file.id);
|
||||
return this.run("thumbnail", args.file, args.destination);
|
||||
}
|
||||
}
|
||||
|
||||
let root: string;
|
||||
let cacheDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "quak-content-"));
|
||||
cacheDir = join(root, "cache");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root && existsSync(root))
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const buildCache = (
|
||||
args: {
|
||||
source?: ContentSource;
|
||||
files?: EnteFile[];
|
||||
pools?: RequestPools;
|
||||
downloadDirectory?: string;
|
||||
} = {},
|
||||
): { cache: ContentCache; source: StubSource } => {
|
||||
const source = (args.source as StubSource) ?? new StubSource();
|
||||
const byID = new Map<number, EnteFile>();
|
||||
for (const f of args.files ?? [file(1), file(2), file(3)])
|
||||
byID.set(f.id, f);
|
||||
const cache = new ContentCache({
|
||||
pools: args.pools ?? new RequestPools(),
|
||||
source,
|
||||
cacheDirectory: cacheDir,
|
||||
downloadDirectory: args.downloadDirectory,
|
||||
getFile: (id) => byID.get(id),
|
||||
});
|
||||
return { cache, source };
|
||||
};
|
||||
|
||||
describe("ContentCache.open", () => {
|
||||
it("creates the cache directories with 0700 permissions", async () => {
|
||||
const { cache } = buildCache();
|
||||
await cache.open();
|
||||
|
||||
const originals = join(cacheDir, "originals");
|
||||
const thumbnails = join(cacheDir, "thumbnails");
|
||||
expect(existsSync(originals)).toBe(true);
|
||||
expect(existsSync(thumbnails)).toBe(true);
|
||||
expect(statSync(originals).mode & 0o777).toBe(0o700);
|
||||
expect(statSync(thumbnails).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
it("reaps orphan temp files but keeps complete content", async () => {
|
||||
const originals = join(cacheDir, "originals");
|
||||
const thumbnails = join(cacheDir, "thumbnails");
|
||||
mkdirSync(originals, { recursive: true });
|
||||
mkdirSync(thumbnails, { recursive: true });
|
||||
const orphan = join(originals, ".quak-abc123.tmp");
|
||||
const complete = join(originals, "1.jpg");
|
||||
const thumb = join(thumbnails, "2.jpg");
|
||||
writeFileSync(orphan, "half-written");
|
||||
writeFileSync(complete, "whole");
|
||||
writeFileSync(thumb, "whole-thumb");
|
||||
|
||||
const { cache } = buildCache();
|
||||
await cache.open();
|
||||
|
||||
expect(existsSync(orphan)).toBe(false);
|
||||
expect(existsSync(complete)).toBe(true);
|
||||
expect(existsSync(thumb)).toBe(true);
|
||||
});
|
||||
|
||||
it("records already-cached files so their paths appear in pathsFor", async () => {
|
||||
const originals = join(cacheDir, "originals");
|
||||
const thumbnails = join(cacheDir, "thumbnails");
|
||||
mkdirSync(originals, { recursive: true });
|
||||
mkdirSync(thumbnails, { recursive: true });
|
||||
writeFileSync(join(originals, "1.jpg"), "orig");
|
||||
writeFileSync(join(thumbnails, "1.jpg"), "thumb");
|
||||
|
||||
const { cache } = buildCache();
|
||||
await cache.open();
|
||||
|
||||
expect(cache.pathsFor(1)).toEqual({
|
||||
originalPath: join(originals, "1.jpg"),
|
||||
thumbnailPath: join(thumbnails, "1.jpg"),
|
||||
});
|
||||
expect(cache.pathsFor(2)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentCache.original / thumbnail", () => {
|
||||
it("fetches once, then serves the cached file with a single skipped event", async () => {
|
||||
const { cache, source } = buildCache();
|
||||
await cache.open();
|
||||
|
||||
const events: string[] = [];
|
||||
const first = await cache.original(1, {
|
||||
onProgress: (e) => events.push(e.status),
|
||||
});
|
||||
expect(source.originalCalls).toEqual([1]);
|
||||
expect(first.path).toBe(join(cacheDir, "originals", "1.jpg"));
|
||||
expect(first.bytes).toBe("original:1".length);
|
||||
expect(existsSync(first.path)).toBe(true);
|
||||
expect(statSync(first.path).mode & 0o777).toBe(0o600);
|
||||
expect(cache.pathsFor(1).originalPath).toBe(first.path);
|
||||
|
||||
const skips: string[] = [];
|
||||
const second = await cache.original(1, {
|
||||
onProgress: (e) => skips.push(e.status),
|
||||
});
|
||||
// No second download, and exactly one skipped event.
|
||||
expect(source.originalCalls).toEqual([1]);
|
||||
expect(second.path).toBe(first.path);
|
||||
expect(skips).toEqual(["skipped"]);
|
||||
});
|
||||
|
||||
it("serves a file already present in the download directory without fetching", async () => {
|
||||
const downloadDirectory = join(root, "backup");
|
||||
mkdirSync(join(downloadDirectory, "originals"), { recursive: true });
|
||||
const backupPath = join(downloadDirectory, "originals", "1.jpg");
|
||||
writeFileSync(backupPath, "from-backup");
|
||||
|
||||
const { cache, source } = buildCache({ downloadDirectory });
|
||||
await cache.open();
|
||||
|
||||
const events: EnsureEvent["status"][] = [];
|
||||
const result = await cache.original(1, {
|
||||
onProgress: (e) => events.push(e.status),
|
||||
});
|
||||
|
||||
expect(source.originalCalls).toEqual([]);
|
||||
expect(result.path).toBe(backupPath);
|
||||
expect(result.bytes).toBe("from-backup".length);
|
||||
expect(events).toEqual(["skipped"]);
|
||||
});
|
||||
|
||||
it("fetches and caches a thumbnail", async () => {
|
||||
const { cache, source } = buildCache();
|
||||
await cache.open();
|
||||
|
||||
const result = await cache.thumbnail(2);
|
||||
expect(source.thumbnailCalls).toEqual([2]);
|
||||
expect(result.path).toBe(join(cacheDir, "thumbnails", "2.jpg"));
|
||||
expect(existsSync(result.path)).toBe(true);
|
||||
expect(cache.pathsFor(2).thumbnailPath).toBe(result.path);
|
||||
});
|
||||
|
||||
it("shares one download between concurrent callers for the same file", async () => {
|
||||
const { cache, source } = buildCache();
|
||||
await cache.open();
|
||||
const gate = deferred();
|
||||
source.gates.set(1, gate.promise);
|
||||
|
||||
const a = cache.original(1);
|
||||
const b = cache.original(1);
|
||||
gate.resolve();
|
||||
const [ra, rb] = await Promise.all([a, b]);
|
||||
|
||||
expect(source.originalCalls).toEqual([1]);
|
||||
expect(ra.path).toBe(rb.path);
|
||||
});
|
||||
|
||||
it("does not record a path when the fetched file is empty", async () => {
|
||||
const { cache, source } = buildCache();
|
||||
source.emptyFor.add(1);
|
||||
await cache.open();
|
||||
|
||||
await expect(cache.original(1)).rejects.toThrow(/empty/i);
|
||||
expect(cache.pathsFor(1).originalPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an unknown file", async () => {
|
||||
const { cache } = buildCache({ files: [] });
|
||||
await cache.open();
|
||||
await expect(cache.original(999)).rejects.toThrow(/unknown file/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContentCache.ensureThumbnails", () => {
|
||||
it("downloads once for a file listed twice and reports every id", async () => {
|
||||
const { cache, source } = buildCache();
|
||||
await cache.open();
|
||||
|
||||
const results = await cache.ensureThumbnails({
|
||||
fileIDs: [1, 1, 2],
|
||||
priority: "visible",
|
||||
});
|
||||
|
||||
expect(source.thumbnailCalls.sort()).toEqual([1, 2]);
|
||||
expect(results).toEqual([
|
||||
{ fileID: 1, path: join(cacheDir, "thumbnails", "1.jpg") },
|
||||
{ fileID: 2, path: join(cacheDir, "thumbnails", "2.jpg") },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips present files and reports a skipped event", async () => {
|
||||
const thumbnails = join(cacheDir, "thumbnails");
|
||||
mkdirSync(thumbnails, { recursive: true });
|
||||
writeFileSync(join(thumbnails, "1.jpg"), "present");
|
||||
|
||||
const { cache, source } = buildCache();
|
||||
await cache.open();
|
||||
|
||||
const events: EnsureEvent[] = [];
|
||||
const results = await cache.ensureThumbnails({
|
||||
fileIDs: [1, 2],
|
||||
priority: "ahead",
|
||||
onProgress: (e) => events.push(e),
|
||||
});
|
||||
|
||||
expect(source.thumbnailCalls).toEqual([2]);
|
||||
expect(results).toEqual([
|
||||
{ fileID: 1, path: join(thumbnails, "1.jpg") },
|
||||
{ fileID: 2, path: join(thumbnails, "2.jpg") },
|
||||
]);
|
||||
expect(events).toContainEqual({
|
||||
fileID: 1,
|
||||
status: "skipped",
|
||||
path: join(thumbnails, "1.jpg"),
|
||||
});
|
||||
});
|
||||
|
||||
it("serves a visible request ahead of an already-queued background one", async () => {
|
||||
// One thumbnail slot, so exactly one fetch runs at a time and the rest
|
||||
// wait in the pool. A background fetch takes the slot; a background and
|
||||
// a visible fetch queue behind it. When the slot frees, the pool must
|
||||
// pick the visible (on-demand) request ahead of the background one that
|
||||
// was submitted first. The completion order is the observable.
|
||||
const pools = new RequestPools({ thumbnailConcurrency: 1 });
|
||||
const { cache, source } = buildCache({ pools });
|
||||
await cache.open();
|
||||
|
||||
const gateA = deferred();
|
||||
const gateB = deferred();
|
||||
const gateC = deferred();
|
||||
source.gates.set(1, gateA.promise);
|
||||
source.gates.set(2, gateB.promise);
|
||||
source.gates.set(3, gateC.promise);
|
||||
|
||||
const bgFirst = cache.ensureThumbnails({
|
||||
fileIDs: [1],
|
||||
priority: "background",
|
||||
});
|
||||
// Let fetch 1 take the only slot before the others queue.
|
||||
await Promise.resolve();
|
||||
const bgSecond = cache.ensureThumbnails({
|
||||
fileIDs: [2],
|
||||
priority: "background",
|
||||
});
|
||||
const visible = cache.ensureThumbnails({
|
||||
fileIDs: [3],
|
||||
priority: "visible",
|
||||
});
|
||||
|
||||
gateA.resolve();
|
||||
gateC.resolve();
|
||||
gateB.resolve();
|
||||
await Promise.all([bgFirst, bgSecond, visible]);
|
||||
|
||||
// 1 ran first (it held the slot). Of the two that were queued, the
|
||||
// visible id 3 was served before the background id 2.
|
||||
expect(source.completed).toEqual([1, 3, 2]);
|
||||
});
|
||||
|
||||
it("drops queued work on abort but keeps an in-flight fetch", async () => {
|
||||
const pools = new RequestPools({ thumbnailConcurrency: 1 });
|
||||
const { cache, source } = buildCache({ pools });
|
||||
await cache.open();
|
||||
|
||||
const gate = deferred();
|
||||
source.gates.set(1, gate.promise);
|
||||
const controller = new AbortController();
|
||||
|
||||
const pending = cache.ensureThumbnails({
|
||||
fileIDs: [1, 2],
|
||||
priority: "ahead",
|
||||
signal: controller.signal,
|
||||
});
|
||||
// Fetch 1 is in flight (holds the slot); 2 is queued.
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
gate.resolve();
|
||||
|
||||
const results = await pending;
|
||||
|
||||
// The in-flight fetch finished and is kept; the queued one was dropped
|
||||
// before it ran.
|
||||
expect(source.thumbnailCalls).toEqual([1]);
|
||||
expect(results).toEqual([
|
||||
{ fileID: 1, path: join(cacheDir, "thumbnails", "1.jpg") },
|
||||
{ fileID: 2, error: "aborted" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("captures a per-file failure without failing the batch", async () => {
|
||||
const { cache } = buildCache({ files: [file(1)] });
|
||||
await cache.open();
|
||||
|
||||
const results = await cache.ensureThumbnails({
|
||||
fileIDs: [1, 2],
|
||||
priority: "background",
|
||||
});
|
||||
|
||||
expect(results[0]).toEqual({
|
||||
fileID: 1,
|
||||
path: join(cacheDir, "thumbnails", "1.jpg"),
|
||||
});
|
||||
expect(results[1]?.fileID).toBe(2);
|
||||
expect(results[1]?.error).toMatch(/unknown file/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 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("rebuilds the index when a payload on disk is missing from it", 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 crash between storeFetched renaming a payload into place and
|
||||
// rewriting the index leaves the payload complete on disk but absent
|
||||
// from clip.json. Write a second payload directly to reproduce that
|
||||
// torn state without touching the index.
|
||||
writeFileSync(
|
||||
join(dir, "200.json"),
|
||||
JSON.stringify(payload([1, -2, 0.5])),
|
||||
);
|
||||
|
||||
// Reopening self-heals with no manual delete: the index is rebuilt from
|
||||
// the payloads to include the orphaned embedding.
|
||||
const reopened = await MLDataStore.open(dir);
|
||||
const index = reopened.getIndex();
|
||||
expect(index.fileIDs).toEqual([100, 200]);
|
||||
expect([...index.embeddings]).toEqual([0.5, 0.25, 0.75, 1, -2, 0.5]);
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user