Compare commits
2
Commits
a3a7eeddb8
...
ff0bbb3155
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0bbb3155 | ||
|
|
d7f415fe29 |
@@ -7,11 +7,16 @@ import {
|
|||||||
} from "./auth/login.js";
|
} from "./auth/login.js";
|
||||||
import { unwrapAuth } from "./auth/unwrap.js";
|
import { unwrapAuth } from "./auth/unwrap.js";
|
||||||
import { init, fromBase64, toBase64 } from "./crypto/index.js";
|
import { init, fromBase64, toBase64 } from "./crypto/index.js";
|
||||||
|
import { fetchMLDataBatch, type MLData } from "./mldata-fetch.js";
|
||||||
import { decryptCollection, decryptFile } from "./model/index.js";
|
import { decryptCollection, decryptFile } from "./model/index.js";
|
||||||
import {
|
import {
|
||||||
downloadFile as dlFile,
|
downloadFile as dlFile,
|
||||||
downloadThumbnail as dlThumb,
|
downloadThumbnail as dlThumb,
|
||||||
} from "./download/index.js";
|
} from "./download/index.js";
|
||||||
|
import {
|
||||||
|
makeDownloadContentSource,
|
||||||
|
type ContentSource,
|
||||||
|
} from "./library/content.js";
|
||||||
import type {
|
import type {
|
||||||
Collection,
|
Collection,
|
||||||
EnteFile,
|
EnteFile,
|
||||||
@@ -140,6 +145,14 @@ export class Client {
|
|||||||
return this.api;
|
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 {
|
private assertLoggedIn(): void {
|
||||||
if (this.loggedOut) throw new Error("Client has been logged out");
|
if (this.loggedOut) throw new Error("Client has been logged out");
|
||||||
}
|
}
|
||||||
@@ -273,6 +286,18 @@ export class Client {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch machine-learning data (face detections + CLIP embeddings) for up
|
||||||
|
// to a batch of files, each decrypted with its own key. One request; the
|
||||||
|
// library batches at `MLDATA_BATCH_SIZE` and schedules each batch through
|
||||||
|
// its metadata request pool.
|
||||||
|
async fetchMLData(args: {
|
||||||
|
fileIDs: number[];
|
||||||
|
fileKeys: Map<number, Uint8Array>;
|
||||||
|
}): Promise<Map<number, MLData>> {
|
||||||
|
this.assertLoggedIn();
|
||||||
|
return fetchMLDataBatch(this.api, args.fileIDs, args.fileKeys);
|
||||||
|
}
|
||||||
|
|
||||||
async downloadFile(
|
async downloadFile(
|
||||||
file: EnteFile,
|
file: EnteFile,
|
||||||
outPath?: string,
|
outPath?: string,
|
||||||
|
|||||||
@@ -49,7 +49,27 @@ export {
|
|||||||
type PhotoFilter,
|
type PhotoFilter,
|
||||||
type TimelineGroup,
|
type TimelineGroup,
|
||||||
type GroupBy,
|
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";
|
} 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 {
|
export type {
|
||||||
AlbumRecord,
|
AlbumRecord,
|
||||||
PhotoRecord,
|
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 envPaths from "env-paths";
|
||||||
|
|
||||||
import { MetadataStore } from "./store.js";
|
import { MetadataStore } from "./store.js";
|
||||||
|
import { MLDataStore } from "./mldata.js";
|
||||||
|
import { RequestPools } from "./pools.js";
|
||||||
import {
|
import {
|
||||||
deriveRecords,
|
deriveRecords,
|
||||||
snapshotFrom,
|
snapshotFrom,
|
||||||
@@ -39,6 +41,13 @@ import {
|
|||||||
type PhotosAPI,
|
type PhotosAPI,
|
||||||
type TimelineAPI,
|
type TimelineAPI,
|
||||||
} from "./read.js";
|
} from "./read.js";
|
||||||
|
import {
|
||||||
|
ContentCache,
|
||||||
|
type ContentSource,
|
||||||
|
type ThumbnailsAPI,
|
||||||
|
type EnsureOptions,
|
||||||
|
type EnsureResult,
|
||||||
|
} from "./content.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Album,
|
Album,
|
||||||
@@ -50,7 +59,20 @@ export {
|
|||||||
type TimelineGroup,
|
type TimelineGroup,
|
||||||
type GroupBy,
|
type GroupBy,
|
||||||
} from "./read.js";
|
} 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 type { CollectionsPage, FilesPage } from "../client.js";
|
||||||
|
import { MLDATA_BATCH_SIZE, type MLData } from "../mldata-fetch.js";
|
||||||
import type { Collection, EnteFile } from "../model/types.js";
|
import type { Collection, EnteFile } from "../model/types.js";
|
||||||
|
|
||||||
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
|
export const DEFAULT_REFRESH_INTERVAL_SECONDS = 3;
|
||||||
@@ -66,14 +88,29 @@ export interface LibraryClient {
|
|||||||
collectionKey: Uint8Array;
|
collectionKey: Uint8Array;
|
||||||
sinceTime: number;
|
sinceTime: number;
|
||||||
}): Promise<FilesPage>;
|
}): Promise<FilesPage>;
|
||||||
|
// Fetch ML data (face detections + CLIP embeddings) for up to a batch of
|
||||||
|
// files. Optional: a client without it simply disables ML fetching, leaving
|
||||||
|
// the metadata refresh untouched.
|
||||||
|
fetchMLData?(args: {
|
||||||
|
fileIDs: number[];
|
||||||
|
fileKeys: Map<number, Uint8Array>;
|
||||||
|
}): Promise<Map<number, MLData>>;
|
||||||
|
// 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,
|
// A progress event for one unit of background work. A metadata "refresh" or an
|
||||||
// then exactly one of "done" or "failed"; "failed" carries the error message.
|
// ML "fetchMLData" pass each fire "started" before their network work and then
|
||||||
|
// exactly one of "done" or "failed"; "failed" carries the error message and
|
||||||
|
// an ML "done" reports how many payloads it stored.
|
||||||
export interface RefreshEvent {
|
export interface RefreshEvent {
|
||||||
operation: "refresh";
|
operation: "refresh" | "fetchMLData";
|
||||||
status: "started" | "done" | "failed";
|
status: "started" | "done" | "failed";
|
||||||
error?: string;
|
error?: string;
|
||||||
|
fetched?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RefreshProgressCallback = (event: RefreshEvent) => void;
|
export type RefreshProgressCallback = (event: RefreshEvent) => void;
|
||||||
@@ -83,11 +120,18 @@ export interface LibraryOptions {
|
|||||||
// Where `metadata.json` lives. Defaults to the env-paths cache directory
|
// Where `metadata.json` lives. Defaults to the env-paths cache directory
|
||||||
// plus the user id, so each account has its own cache.
|
// plus the user id, so each account has its own cache.
|
||||||
cacheDirectory?: string;
|
cacheDirectory?: string;
|
||||||
// Persistent backup destination for later phases (backup, thumbnails); the
|
// Persistent backup destination. The refresh loop does not use it; the
|
||||||
// refresh loop does not use it.
|
// content cache treats an original already stored there as present.
|
||||||
downloadDirectory?: string;
|
downloadDirectory?: string;
|
||||||
refreshIntervalSeconds?: number;
|
refreshIntervalSeconds?: number;
|
||||||
onProgress?: RefreshProgressCallback;
|
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 {
|
export interface LibraryStatus {
|
||||||
@@ -100,6 +144,15 @@ export interface LibraryStatus {
|
|||||||
// The message from the most recent refresh, set only while that refresh
|
// The message from the most recent refresh, set only while that refresh
|
||||||
// failed; cleared by the next success.
|
// failed; cleared by the next success.
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
|
// Wall-clock ms of the last ML fetch pass that succeeded, or undefined if
|
||||||
|
// none has yet (or ML fetching is disabled).
|
||||||
|
lastMLFetchAt?: number;
|
||||||
|
// The most recent ML fetch pass's error, set only while it failed.
|
||||||
|
lastMLError?: string;
|
||||||
|
// ML payloads stored on disk and CLIP embeddings in the index; undefined
|
||||||
|
// when ML fetching is disabled.
|
||||||
|
mlStored?: number;
|
||||||
|
mlIndexed?: number;
|
||||||
closed: boolean;
|
closed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,18 +166,32 @@ export class Library {
|
|||||||
readonly albums: AlbumsAPI;
|
readonly albums: AlbumsAPI;
|
||||||
readonly photos: PhotosAPI;
|
readonly photos: PhotosAPI;
|
||||||
readonly timeline: TimelineAPI;
|
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 client: LibraryClient;
|
||||||
private readonly store: MetadataStore;
|
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 userID: number;
|
||||||
private readonly intervalMs: number;
|
private readonly intervalMs: number;
|
||||||
private readonly onProgress?: RefreshProgressCallback;
|
private readonly onProgress?: RefreshProgressCallback;
|
||||||
|
private readonly pools: RequestPools;
|
||||||
|
// The ML-data cache, present only when the client can fetch ML data.
|
||||||
|
private readonly mldata?: MLDataStore;
|
||||||
|
|
||||||
private timer?: ReturnType<typeof setTimeout>;
|
private timer?: ReturnType<typeof setTimeout>;
|
||||||
private refreshing = false;
|
private refreshing = false;
|
||||||
|
// Guards the ML fetch pass so a slow backfill never runs twice at once; a
|
||||||
|
// refresh whose pass is still running kicks nothing new.
|
||||||
|
private mlFetching = false;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private lastRefreshAt?: number;
|
private lastRefreshAt?: number;
|
||||||
private lastError?: string;
|
private lastError?: string;
|
||||||
|
private lastMLFetchAt?: number;
|
||||||
|
private lastMLError?: string;
|
||||||
// The plain-record projection as of the last refresh, and the GUI change
|
// The plain-record projection as of the last refresh, and the GUI change
|
||||||
// subscribers. A refresh that alters the projection notifies each with the
|
// subscribers. A refresh that alters the projection notifies each with the
|
||||||
// delta; `lastRecords` is kept current every refresh so a subscriber that
|
// delta; `lastRecords` is kept current every refresh so a subscriber that
|
||||||
@@ -144,6 +211,9 @@ export class Library {
|
|||||||
downloadDirectory?: string;
|
downloadDirectory?: string;
|
||||||
intervalMs: number;
|
intervalMs: number;
|
||||||
onProgress?: RefreshProgressCallback;
|
onProgress?: RefreshProgressCallback;
|
||||||
|
pools: RequestPools;
|
||||||
|
mldata?: MLDataStore;
|
||||||
|
cache?: ContentCache;
|
||||||
}) {
|
}) {
|
||||||
this.client = args.client;
|
this.client = args.client;
|
||||||
this.store = args.store;
|
this.store = args.store;
|
||||||
@@ -152,14 +222,29 @@ export class Library {
|
|||||||
this.downloadDirectory = args.downloadDirectory;
|
this.downloadDirectory = args.downloadDirectory;
|
||||||
this.intervalMs = args.intervalMs;
|
this.intervalMs = args.intervalMs;
|
||||||
this.onProgress = args.onProgress;
|
this.onProgress = args.onProgress;
|
||||||
|
this.pools = args.pools;
|
||||||
|
this.mldata = args.mldata;
|
||||||
|
this.cache = args.cache;
|
||||||
this.lastRecords = this.deriveNow();
|
this.lastRecords = this.deriveNow();
|
||||||
|
|
||||||
// The read namespaces derive fresh from the store on each call, so they
|
// The read namespaces derive fresh from the store on each call, so they
|
||||||
// always reflect the latest refresh.
|
// always reflect the latest refresh.
|
||||||
const derive = (): DerivedRecords => this.deriveNow();
|
const derive = (): DerivedRecords => this.deriveNow();
|
||||||
this.albums = makeAlbumsAPI(derive);
|
this.albums = makeAlbumsAPI(derive, this.cache);
|
||||||
this.photos = makePhotosAPI(derive);
|
this.photos = makePhotosAPI(derive, this.cache);
|
||||||
this.timeline = makeTimelineAPI(derive);
|
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
|
// 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) *
|
(opts.refreshIntervalSeconds ?? DEFAULT_REFRESH_INTERVAL_SECONDS) *
|
||||||
1000;
|
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({
|
const lib = new Library({
|
||||||
client: opts.client,
|
client: opts.client,
|
||||||
store,
|
store,
|
||||||
@@ -189,6 +301,9 @@ export class Library {
|
|||||||
downloadDirectory: opts.downloadDirectory,
|
downloadDirectory: opts.downloadDirectory,
|
||||||
intervalMs,
|
intervalMs,
|
||||||
onProgress: opts.onProgress,
|
onProgress: opts.onProgress,
|
||||||
|
pools,
|
||||||
|
mldata,
|
||||||
|
cache,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (store.loadedFromDisk) {
|
if (store.loadedFromDisk) {
|
||||||
@@ -248,12 +363,17 @@ export class Library {
|
|||||||
for (const c of collections) {
|
for (const c of collections) {
|
||||||
files += this.store.listFiles(c.id).length;
|
files += this.store.listFiles(c.id).length;
|
||||||
}
|
}
|
||||||
|
const ml = this.mldata?.stats();
|
||||||
return {
|
return {
|
||||||
userID: this.store.userID,
|
userID: this.store.userID,
|
||||||
collections: collections.length,
|
collections: collections.length,
|
||||||
files,
|
files,
|
||||||
lastRefreshAt: this.lastRefreshAt,
|
lastRefreshAt: this.lastRefreshAt,
|
||||||
lastError: this.lastError,
|
lastError: this.lastError,
|
||||||
|
lastMLFetchAt: this.lastMLFetchAt,
|
||||||
|
lastMLError: this.lastMLError,
|
||||||
|
mlStored: ml?.stored,
|
||||||
|
mlIndexed: ml?.indexed,
|
||||||
closed: this.closed,
|
closed: this.closed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -288,6 +408,11 @@ export class Library {
|
|||||||
this.lastRefreshAt = Date.now();
|
this.lastRefreshAt = Date.now();
|
||||||
this.lastError = undefined;
|
this.lastError = undefined;
|
||||||
this.emit({ operation: "refresh", status: "done" });
|
this.emit({ operation: "refresh", status: "done" });
|
||||||
|
// Backfill ML data for the files this refresh knows about. It runs
|
||||||
|
// outside the refresh's success/failure so a fetch or disk problem
|
||||||
|
// there never marks the metadata refresh failed, and it is not
|
||||||
|
// awaited so it never stalls the refresh interval.
|
||||||
|
void this.runMLFetch();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = err instanceof Error ? err.message : String(err);
|
const error = err instanceof Error ? err.message : String(err);
|
||||||
this.lastError = error;
|
this.lastError = error;
|
||||||
@@ -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 {
|
private deriveNow(): DerivedRecords {
|
||||||
const collections = this.store.listCollections();
|
const collections = this.store.listCollections();
|
||||||
const files: EnteFile[] = [];
|
const files: EnteFile[] = [];
|
||||||
for (const c of collections) files.push(...this.store.listFiles(c.id));
|
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 {
|
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
|
// 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
|
// access and, for an album, its photos. They are not sent across IPC — the
|
||||||
// plain records are the serializable surface, and `record()` returns one.
|
// 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 { CollectionType, FileType } from "../model/types.js";
|
||||||
|
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
||||||
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
||||||
|
|
||||||
// Newest first, with fileID as a stable tiebreak so equal-timed files order
|
// 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
|
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
|
||||||
// underlying plain record for callers that need the IPC-safe value.
|
// underlying plain record for callers that need the IPC-safe value.
|
||||||
export class Photo {
|
export class Photo {
|
||||||
constructor(private readonly rec: PhotoRecord) {}
|
constructor(
|
||||||
|
private readonly rec: PhotoRecord,
|
||||||
|
private readonly content?: PhotoContent,
|
||||||
|
) {}
|
||||||
|
|
||||||
get fileID(): number {
|
get fileID(): number {
|
||||||
return this.rec.fileID;
|
return this.rec.fileID;
|
||||||
@@ -71,6 +78,27 @@ export class Photo {
|
|||||||
record(): PhotoRecord {
|
record(): PhotoRecord {
|
||||||
return this.rec;
|
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,
|
// A single album. `photos.list()` returns the album's photos as wrappers,
|
||||||
@@ -79,6 +107,7 @@ export class Album {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly rec: AlbumRecord,
|
private readonly rec: AlbumRecord,
|
||||||
private readonly records: DerivedRecords,
|
private readonly records: DerivedRecords,
|
||||||
|
private readonly content?: PhotoContent,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get collectionID(): number {
|
get collectionID(): number {
|
||||||
@@ -112,7 +141,7 @@ export class Album {
|
|||||||
const out: Photo[] = [];
|
const out: Photo[] = [];
|
||||||
for (const id of this.rec.fileIDs) {
|
for (const id of this.rec.fileIDs) {
|
||||||
const p = this.records.photos.get(id);
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -164,17 +193,20 @@ export interface TimelineAPI {
|
|||||||
groups(args: { groupBy: GroupBy; filter?: PhotoFilter }): TimelineGroup[];
|
groups(args: { groupBy: GroupBy; filter?: PhotoFilter }): TimelineGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makeAlbumsAPI = (derive: () => DerivedRecords): AlbumsAPI => ({
|
export const makeAlbumsAPI = (
|
||||||
|
derive: () => DerivedRecords,
|
||||||
|
content?: PhotoContent,
|
||||||
|
): AlbumsAPI => ({
|
||||||
list: (): Album[] => {
|
list: (): Album[] => {
|
||||||
const records = derive();
|
const records = derive();
|
||||||
return [...records.albums.values()]
|
return [...records.albums.values()]
|
||||||
.sort(byNewestAlbum)
|
.sort(byNewestAlbum)
|
||||||
.map((rec) => new Album(rec, records));
|
.map((rec) => new Album(rec, records, content));
|
||||||
},
|
},
|
||||||
byID: ({ collectionID }): Album | undefined => {
|
byID: ({ collectionID }): Album | undefined => {
|
||||||
const records = derive();
|
const records = derive();
|
||||||
const rec = records.albums.get(collectionID);
|
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 => {
|
byName: ({ albumName }): Album | undefined => {
|
||||||
const records = derive();
|
const records = derive();
|
||||||
@@ -183,14 +215,17 @@ export const makeAlbumsAPI = (derive: () => DerivedRecords): AlbumsAPI => ({
|
|||||||
const match = [...records.albums.values()]
|
const match = [...records.albums.values()]
|
||||||
.sort(byNewestAlbum)
|
.sort(byNewestAlbum)
|
||||||
.find((rec) => rec.name === albumName);
|
.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 => {
|
byID: ({ fileID }): Photo | undefined => {
|
||||||
const rec = derive().photos.get(fileID);
|
const rec = derive().photos.get(fileID);
|
||||||
return rec ? new Photo(rec) : undefined;
|
return rec ? new Photo(rec, content) : undefined;
|
||||||
},
|
},
|
||||||
records: ({ fileIDs }): PhotoRecord[] => {
|
records: ({ fileIDs }): PhotoRecord[] => {
|
||||||
const { photos } = derive();
|
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.
|
// Project the decrypted collections and file memberships into by-id records.
|
||||||
// `files` is every membership (a file appears once per collection it is in).
|
// `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 = (
|
export const deriveRecords = (
|
||||||
collections: Collection[],
|
collections: Collection[],
|
||||||
files: EnteFile[],
|
files: EnteFile[],
|
||||||
|
cachedPaths?: CachedPathLookup,
|
||||||
): DerivedRecords => {
|
): DerivedRecords => {
|
||||||
const byFileID = new Map<number, EnteFile[]>();
|
const byFileID = new Map<number, EnteFile[]>();
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
@@ -183,6 +192,13 @@ export const deriveRecords = (
|
|||||||
const takenAtByFile = new Map<number, number>();
|
const takenAtByFile = new Map<number, number>();
|
||||||
for (const [fileID, memberships] of byFileID) {
|
for (const [fileID, memberships] of byFileID) {
|
||||||
const record = toPhotoRecord(fileID, memberships);
|
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);
|
photos.set(fileID, record);
|
||||||
takenAtByFile.set(fileID, record.takenAt);
|
takenAtByFile.set(fileID, record.takenAt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,16 @@ export class MetadataStore {
|
|||||||
return this.files.get(fileKey(collectionID, fileID));
|
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[] {
|
listFiles(collectionID: number): EnteFile[] {
|
||||||
return [...this.files.values()].filter(
|
return [...this.files.values()].filter(
|
||||||
(f) => f.collectionID === collectionID,
|
(f) => f.collectionID === collectionID,
|
||||||
|
|||||||
+3
-48
@@ -1,4 +1,3 @@
|
|||||||
import { gunzipSync } from "node:zlib";
|
|
||||||
import {
|
import {
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
@@ -11,7 +10,7 @@ import { tmpdir } from "node:os";
|
|||||||
import * as jpeg from "jpeg-js";
|
import * as jpeg from "jpeg-js";
|
||||||
import exifReader from "exif-reader";
|
import exifReader from "exif-reader";
|
||||||
import type { Client } from "./client.js";
|
import type { Client } from "./client.js";
|
||||||
import { decryptBlob, fromBase64 } from "./crypto/index.js";
|
import { fetchMLData } from "./mldata-fetch.js";
|
||||||
import type { EnteFile } from "./model/types.js";
|
import type { EnteFile } from "./model/types.js";
|
||||||
|
|
||||||
export type ProgressCallback = (message: string) => void;
|
export type ProgressCallback = (message: string) => void;
|
||||||
@@ -24,50 +23,6 @@ export interface MetadataBackupOptions {
|
|||||||
const sanitizePath = (name: string): string =>
|
const sanitizePath = (name: string): string =>
|
||||||
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
|
name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_");
|
||||||
|
|
||||||
interface RawRemoteFileData {
|
|
||||||
fileID: number;
|
|
||||||
encryptedData: string;
|
|
||||||
decryptionHeader: string;
|
|
||||||
updatedAt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchMLDataForFiles = async (
|
|
||||||
client: Client,
|
|
||||||
fileIDs: number[],
|
|
||||||
fileKeys: Map<number, Uint8Array>,
|
|
||||||
): Promise<Map<number, Record<string, unknown>>> => {
|
|
||||||
const api = client.getApiClient();
|
|
||||||
const result = new Map<number, Record<string, unknown>>();
|
|
||||||
const batchSize = 200;
|
|
||||||
|
|
||||||
for (let i = 0; i < fileIDs.length; i += batchSize) {
|
|
||||||
const batch = fileIDs.slice(i, i + batchSize);
|
|
||||||
const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>(
|
|
||||||
"/files/data/fetch",
|
|
||||||
{ type: "mldata", fileIDs: batch },
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const entry of data ?? []) {
|
|
||||||
const key = fileKeys.get(entry.fileID);
|
|
||||||
if (!key) continue;
|
|
||||||
try {
|
|
||||||
const decrypted = decryptBlob(
|
|
||||||
fromBase64(entry.encryptedData),
|
|
||||||
fromBase64(entry.decryptionHeader),
|
|
||||||
key,
|
|
||||||
);
|
|
||||||
const jsonStr = gunzipSync(Buffer.from(decrypted)).toString(
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
result.set(entry.fileID, JSON.parse(jsonStr));
|
|
||||||
} catch {
|
|
||||||
// Corrupted ML data for this file; skip it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract the raw EXIF APP1 segment from JPEG bytes. Returns the EXIF
|
// Extract the raw EXIF APP1 segment from JPEG bytes. Returns the EXIF
|
||||||
// data buffer (starting after the APP1 length field, at the "Exif\0\0"
|
// data buffer (starting after the APP1 length field, at the "Exif\0\0"
|
||||||
// header) or undefined if no APP1 marker is found.
|
// header) or undefined if no APP1 marker is found.
|
||||||
@@ -228,8 +183,8 @@ export const runMetadataBackup = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
log("Fetching ML data (face detections, CLIP embeddings)...");
|
log("Fetching ML data (face detections, CLIP embeddings)...");
|
||||||
const mlDataMap = await fetchMLDataForFiles(
|
const mlDataMap = await fetchMLData(
|
||||||
client,
|
client.getApiClient(),
|
||||||
[...fileKeys.keys()],
|
[...fileKeys.keys()],
|
||||||
fileKeys,
|
fileKeys,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Fetch and decrypt Ente's per-file machine-learning data ("magic" search
|
||||||
|
// data: face detections + CLIP embeddings).
|
||||||
|
//
|
||||||
|
// The data lives behind `/files/data/fetch` with `type: "mldata"`. Each entry
|
||||||
|
// comes back encrypted under the file's own key and gzipped; decrypting and
|
||||||
|
// gunzipping yields the JSON payload
|
||||||
|
// `{ face: { faces: [...] }, clip: { embedding } }`. Ente caps a request at 200
|
||||||
|
// ids, so `fetchMLData` batches for callers that want many at once while
|
||||||
|
// `fetchMLDataBatch` is the single-request unit the library submits to its
|
||||||
|
// request pool.
|
||||||
|
|
||||||
|
import { gunzipSync } from "node:zlib";
|
||||||
|
|
||||||
|
import type { ApiClient } from "./api/client.js";
|
||||||
|
import { decryptBlob, fromBase64 } from "./crypto/index.js";
|
||||||
|
|
||||||
|
// The most ids one `/files/data/fetch` request may carry.
|
||||||
|
export const MLDATA_BATCH_SIZE = 200;
|
||||||
|
|
||||||
|
// The decrypted, gunzipped per-file payload. Its concrete shape is Ente's; the
|
||||||
|
// store keeps the whole object verbatim and each consumer reads the fields it
|
||||||
|
// needs, so it stays an open record rather than a fixed interface.
|
||||||
|
export type MLData = Record<string, unknown>;
|
||||||
|
|
||||||
|
interface RawRemoteFileData {
|
||||||
|
fileID: number;
|
||||||
|
encryptedData: string;
|
||||||
|
decryptionHeader: string;
|
||||||
|
updatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt one entry with its file key and gunzip the JSON payload. Returns
|
||||||
|
// undefined when the key is unknown or the entry does not decrypt/parse, so one
|
||||||
|
// corrupt file never fails a whole batch.
|
||||||
|
const decodeEntry = (
|
||||||
|
entry: RawRemoteFileData,
|
||||||
|
key: Uint8Array | undefined,
|
||||||
|
): MLData | undefined => {
|
||||||
|
if (!key) return undefined;
|
||||||
|
try {
|
||||||
|
const decrypted = decryptBlob(
|
||||||
|
fromBase64(entry.encryptedData),
|
||||||
|
fromBase64(entry.decryptionHeader),
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
const json = gunzipSync(Buffer.from(decrypted)).toString("utf-8");
|
||||||
|
return JSON.parse(json) as MLData;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch ML data for up to `MLDATA_BATCH_SIZE` ids in a single request. This is
|
||||||
|
// the unit the request pools schedule; callers with more ids split them into
|
||||||
|
// batches and submit each batch to the pool.
|
||||||
|
export const fetchMLDataBatch = async (
|
||||||
|
api: ApiClient,
|
||||||
|
fileIDs: number[],
|
||||||
|
fileKeys: Map<number, Uint8Array>,
|
||||||
|
): Promise<Map<number, MLData>> => {
|
||||||
|
const { data } = await api.postJSON<{ data: RawRemoteFileData[] }>(
|
||||||
|
"/files/data/fetch",
|
||||||
|
{ type: "mldata", fileIDs },
|
||||||
|
);
|
||||||
|
const result = new Map<number, MLData>();
|
||||||
|
for (const entry of data ?? []) {
|
||||||
|
const payload = decodeEntry(entry, fileKeys.get(entry.fileID));
|
||||||
|
if (payload) result.set(entry.fileID, payload);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch ML data for arbitrarily many ids, batching at `MLDATA_BATCH_SIZE`. Used
|
||||||
|
// by the one-shot metadata backup; the library fetches through its request pool
|
||||||
|
// with `fetchMLDataBatch` instead.
|
||||||
|
export const fetchMLData = async (
|
||||||
|
api: ApiClient,
|
||||||
|
fileIDs: number[],
|
||||||
|
fileKeys: Map<number, Uint8Array>,
|
||||||
|
): Promise<Map<number, MLData>> => {
|
||||||
|
const result = new Map<number, MLData>();
|
||||||
|
for (let i = 0; i < fileIDs.length; i += MLDATA_BATCH_SIZE) {
|
||||||
|
const batch = fileIDs.slice(i, i + MLDATA_BATCH_SIZE);
|
||||||
|
for (const [id, payload] of await fetchMLDataBatch(
|
||||||
|
api,
|
||||||
|
batch,
|
||||||
|
fileKeys,
|
||||||
|
)) {
|
||||||
|
result.set(id, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -0,0 +1,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