Plain-record library snapshot and change subscription (closes #43)
check / check (push) Successful in 30s

Adds a synchronous, key-free snapshot() returning LibrarySnapshot (one PhotoRecord per fileID, deduped, newest first) with edited-name/time precedence (pubMagicMetadata over basic metadata) in milliseconds, plus AlbumRecord (favorites identified by type). subscribe({onChange}) delivers LibraryChange (changed/removed albums and files, refreshedAt) only when a refresh changes something; unsubscribe stops delivery. Built on the existing refresh loop; served from RAM, safe to send over IPC.

Model: opus-4-8
This commit was merged in pull request #62.
This commit is contained in:
2026-09-22 15:22:52 +02:00
parent 57e0c69651
commit fbb8ae44a7
5 changed files with 912 additions and 0 deletions
+6
View File
@@ -42,6 +42,12 @@ export {
type RefreshEvent,
type RefreshProgressCallback,
} from "./library/index.js";
export type {
AlbumRecord,
PhotoRecord,
LibrarySnapshot,
LibraryChange,
} from "./library/records.js";
export { decryptCollection, decryptFile } from "./model/index.js";
export { downloadFile, downloadThumbnail } from "./download/index.js";
export type {
+70
View File
@@ -23,6 +23,14 @@ import { join } from "node:path";
import envPaths from "env-paths";
import { MetadataStore } from "./store.js";
import {
deriveRecords,
snapshotFrom,
diffRecords,
type DerivedRecords,
type LibrarySnapshot,
type LibraryChange,
} from "./records.js";
import type { CollectionsPage, FilesPage } from "../client.js";
import type { Collection, EnteFile } from "../model/types.js";
@@ -91,6 +99,12 @@ export class Library {
private closed = false;
private lastRefreshAt?: number;
private lastError?: string;
// The plain-record projection as of the last refresh, and the GUI change
// subscribers. A refresh that alters the projection notifies each with the
// delta; `lastRecords` is kept current every refresh so a subscriber that
// joins later diffs against the state its own `snapshot()` already returned.
private readonly subscribers = new Set<(change: LibraryChange) => void>();
private lastRecords: DerivedRecords;
// RAM holds changes disk has not yet accepted (an earlier save failed).
// Cleared only when a save actually succeeds; keeps the store trying to
// persist and the failure visible in `status()` until then.
@@ -112,6 +126,7 @@ export class Library {
this.downloadDirectory = args.downloadDirectory;
this.intervalMs = args.intervalMs;
this.onProgress = args.onProgress;
this.lastRecords = this.deriveNow();
}
// Load the cache and start the refresh loop. With an empty cache the first
@@ -172,6 +187,28 @@ export class Library {
return this.store.getFile(collectionID, fileID);
}
// A synchronous, RAM-only projection of the whole library into plain
// records (no keys), the surface the GUI reads across IPC. Photos are
// deduplicated to one record per file and ordered newest first.
snapshot(): LibrarySnapshot {
return snapshotFrom(this.deriveNow(), Date.now());
}
// Deliver a `LibraryChange` whenever a refresh alters the projection. A
// refresh that changes nothing delivers nothing. The returned handle's
// `unsubscribe` stops delivery.
subscribe(args: { onChange: (change: LibraryChange) => void }): {
unsubscribe: () => void;
} {
const { onChange } = args;
this.subscribers.add(onChange);
return {
unsubscribe: () => {
this.subscribers.delete(onChange);
},
};
}
status(): LibraryStatus {
let files = 0;
const collections = this.store.listCollections();
@@ -292,6 +329,20 @@ export class Library {
if (changed) this.unsaved = true;
// Reproject and notify subscribers of the delta. This tracks RAM (what
// reads see), so it fires whether or not the save below succeeds; a
// save failure surfaces separately through `status().lastError`.
// `lastRecords` advances every changed refresh so the next diff is
// against current state.
if (changed) {
const next = this.deriveNow();
if (this.subscribers.size > 0) {
const change = diffRecords(this.lastRecords, next, Date.now());
if (change) this.notify(change);
}
this.lastRecords = next;
}
// Persist whenever RAM holds changes disk has not accepted — including
// changes an earlier cycle staged whose save failed. `unsaved` clears
// only once a save lands, so a save failure both stays visible through
@@ -304,6 +355,25 @@ export class Library {
}
}
// Gather every file membership and project the store into by-id records.
private deriveNow(): DerivedRecords {
const collections = this.store.listCollections();
const files: EnteFile[] = [];
for (const c of collections) files.push(...this.store.listFiles(c.id));
return deriveRecords(collections, files);
}
private notify(change: LibraryChange): void {
for (const onChange of this.subscribers) {
// A misbehaving subscriber must not break the loop or its peers.
try {
onChange(change);
} catch {
// ignore
}
}
}
private emit(event: RefreshEvent): void {
if (!this.onProgress) return;
// A misbehaving callback must not break the refresh loop.
+254
View File
@@ -0,0 +1,254 @@
// Plain records projected from the decrypted store, and the diff between two
// projections. These are the library's GUI-facing surface: they hold no key
// material and no binary, so they survive `structuredClone`/JSON across the
// Electron IPC boundary where methods and file keys cannot go (design #36,
// owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in
// the main process; the window only ever sees these records.
//
// Ente holds edited/basic times in microseconds; records expose `takenAt` in
// milliseconds. The magic-metadata field names below are the ones the Ente
// clients write, confirmed against the repo's own fixtures: `w`/`h` in
// test/cli/metadata-backup.test.ts, `visibility` in test/library/store.test.ts.
import type {
Collection,
CollectionType,
EnteFile,
FileType,
} from "../model/types.js";
// Ente private-magic-metadata visibility values.
const VISIBILITY_ARCHIVED = 1;
const VISIBILITY_HIDDEN = 2;
// A single photo, deduplicated across the collections it belongs to. No key,
// no binary: safe to send to a window.
export interface PhotoRecord {
fileID: number;
// Every collection this file is a member of, ascending.
albumIDs: number[];
// `pubMagicMetadata.editedName` when the user renamed the file, else the
// basic-metadata title.
title: string;
// Milliseconds. `pubMagicMetadata.editedTime` when the user edited the
// date, else basic-metadata `creationTime`.
takenAt: number;
fileType: FileType;
caption?: string;
width?: number;
height?: number;
latitude?: number;
longitude?: number;
isArchived: boolean;
isHidden: boolean;
// Local cache paths, set once a later phase caches the bytes; unset here.
thumbnailPath?: string;
originalPath?: string;
}
export interface AlbumRecord {
collectionID: number;
name: string;
// `favorites` identifies the account's favorites album.
type: CollectionType;
isShared: boolean;
updationTime: number;
// The album's files, newest first.
fileIDs: number[];
}
export interface LibrarySnapshot {
albums: AlbumRecord[];
photos: PhotoRecord[];
// Wall-clock milliseconds when the snapshot was taken.
takenAt: number;
}
export interface LibraryChange {
// Full records for albums/photos added or changed by the refresh.
albumsChanged: AlbumRecord[];
photosChanged: PhotoRecord[];
fileIDsRemoved: number[];
albumIDsRemoved: number[];
// Wall-clock milliseconds of the refresh that produced this change.
refreshedAt: number;
}
// The by-id projection of the store at one moment; the source for both
// `snapshotFrom` (sorted arrays for the GUI) and `diffRecords` (change sets).
export interface DerivedRecords {
albums: Map<number, AlbumRecord>;
photos: Map<number, PhotoRecord>;
}
const asString = (v: unknown): string | undefined =>
typeof v === "string" && v.length > 0 ? v : undefined;
const asNumber = (v: unknown): number | undefined =>
typeof v === "number" && Number.isFinite(v) ? v : undefined;
const microsToMillis = (micros: number): number => Math.floor(micros / 1000);
// Newest first, with fileID as a stable tiebreak so equal-timed files order
// deterministically.
const byNewestPhoto = (a: PhotoRecord, b: PhotoRecord): number =>
b.takenAt - a.takenAt || b.fileID - a.fileID;
// Build one PhotoRecord from every membership of a file. The memberships share
// the same underlying file, so metadata is read from a single representative
// (the most recently synced, lowest collection id to break ties); `albumIDs`
// gathers them all.
const toPhotoRecord = (
fileID: number,
memberships: EnteFile[],
): PhotoRecord => {
const albumIDs = memberships
.map((m) => m.collectionID)
.sort((a, b) => a - b);
const rep = memberships.reduce((best, m) =>
m.updationTime > best.updationTime ||
(m.updationTime === best.updationTime &&
m.collectionID < best.collectionID)
? m
: best,
);
const pub = rep.pubMagicMetadata ?? {};
const priv = rep.magicMetadata ?? {};
const takenAtMicros = asNumber(pub.editedTime) ?? rep.metadata.creationTime;
const visibility = asNumber(priv.visibility);
const record: PhotoRecord = {
fileID,
albumIDs,
title: asString(pub.editedName) ?? rep.metadata.title,
takenAt: microsToMillis(takenAtMicros),
fileType: rep.metadata.fileType,
isArchived: visibility === VISIBILITY_ARCHIVED,
isHidden: visibility === VISIBILITY_HIDDEN,
};
const caption = asString(pub.caption);
if (caption !== undefined) record.caption = caption;
const width = asNumber(pub.w);
if (width !== undefined) record.width = width;
const height = asNumber(pub.h);
if (height !== undefined) record.height = height;
if (rep.metadata.latitude !== undefined)
record.latitude = rep.metadata.latitude;
if (rep.metadata.longitude !== undefined)
record.longitude = rep.metadata.longitude;
return record;
};
const toAlbumRecord = (
collection: Collection,
files: EnteFile[],
takenAtByFile: Map<number, number>,
): AlbumRecord => {
const fileIDs = files
.filter((f) => f.collectionID === collection.id)
.map((f) => f.id)
.sort(
(a, b) =>
(takenAtByFile.get(b) ?? 0) - (takenAtByFile.get(a) ?? 0) ||
b - a,
);
return {
collectionID: collection.id,
name: collection.name,
type: collection.type,
isShared: collection.isShared,
updationTime: collection.updationTime,
fileIDs,
};
};
// Project the decrypted collections and file memberships into by-id records.
// `files` is every membership (a file appears once per collection it is in).
export const deriveRecords = (
collections: Collection[],
files: EnteFile[],
): DerivedRecords => {
const byFileID = new Map<number, EnteFile[]>();
for (const f of files) {
const arr = byFileID.get(f.id);
if (arr) arr.push(f);
else byFileID.set(f.id, [f]);
}
const photos = new Map<number, PhotoRecord>();
const takenAtByFile = new Map<number, number>();
for (const [fileID, memberships] of byFileID) {
const record = toPhotoRecord(fileID, memberships);
photos.set(fileID, record);
takenAtByFile.set(fileID, record.takenAt);
}
const albums = new Map<number, AlbumRecord>();
for (const c of collections) {
albums.set(c.id, toAlbumRecord(c, files, takenAtByFile));
}
return { albums, photos };
};
// Sorted, GUI-ready arrays: albums newest updated first, photos newest first.
export const snapshotFrom = (
records: DerivedRecords,
takenAt: number,
): LibrarySnapshot => ({
albums: [...records.albums.values()].sort(
(a, b) =>
b.updationTime - a.updationTime || b.collectionID - a.collectionID,
),
photos: [...records.photos.values()].sort(byNewestPhoto),
takenAt,
});
// Records compare by value; they are plain and built with a fixed key order, so
// a serialized form is a sound equality key.
const same = (a: unknown, b: unknown): boolean =>
JSON.stringify(a) === JSON.stringify(b);
const diffMap = <T>(
prev: Map<number, T>,
next: Map<number, T>,
): { changed: T[]; removed: number[] } => {
const changed: T[] = [];
for (const [id, record] of next) {
const before = prev.get(id);
if (before === undefined || !same(before, record)) changed.push(record);
}
const removed: number[] = [];
for (const id of prev.keys()) if (!next.has(id)) removed.push(id);
removed.sort((a, b) => a - b);
return { changed, removed };
};
// The change between two projections, or undefined when nothing changed.
export const diffRecords = (
prev: DerivedRecords,
next: DerivedRecords,
refreshedAt: number,
): LibraryChange | undefined => {
const albums = diffMap(prev.albums, next.albums);
const photos = diffMap(prev.photos, next.photos);
if (
albums.changed.length === 0 &&
albums.removed.length === 0 &&
photos.changed.length === 0 &&
photos.removed.length === 0
) {
return undefined;
}
return {
albumsChanged: albums.changed,
photosChanged: photos.changed,
fileIDsRemoved: photos.removed,
albumIDsRemoved: albums.removed,
refreshedAt,
};
};