check / check (push) Successful in 1m25s
Originals are saved at `{downloadDirectory}/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.{fileID}{ext}`. The date is the photo's `takenAt` in local time, and `downloadDirectory` defaults to `./photos`, resolved when the library opens. The old `originals/` layout is gone.
`photo.download()` writes the original to `savePath`. It copies from the cache when the cache holds the original, and fetches otherwise. `lib.backup()` uses the same path and rule, and every album in `collections/` links to it. `isLocal` is true only when the original is at `savePath`.
For a file in several albums, one rule picks the copy everything uses: the most recently synced, with the lowest album ID breaking a tie.
Model: opus-5-5
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
// 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` and
|
|
// `modifiedAt` 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;
|
|
// Milliseconds. Basic-metadata `modificationTime`.
|
|
modifiedAt: number;
|
|
fileType: FileType;
|
|
caption?: string;
|
|
width?: number;
|
|
height?: number;
|
|
latitude?: number;
|
|
longitude?: number;
|
|
// The content hash the uploader recorded (`FileMetadata.hash`); files from
|
|
// very old clients have none.
|
|
hash?: string;
|
|
isArchived: boolean;
|
|
isHidden: boolean;
|
|
// Local cache paths, set once a later phase caches the bytes; unset here.
|
|
// A live photo's `originalPath` is its image.
|
|
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>;
|
|
// The membership each photo's record is read from, for its `Photo`'s save
|
|
// path. It holds the file's key, so it stays in this process: no snapshot
|
|
// or change carries it.
|
|
files: Map<number, EnteFile>;
|
|
}
|
|
|
|
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;
|
|
|
|
// A photo's `takenAt` in milliseconds: `pubMagicMetadata.editedTime` when the
|
|
// user edited the date, else basic-metadata `creationTime`.
|
|
export const takenAtOf = (file: EnteFile): number =>
|
|
microsToMillis(
|
|
asNumber(file.pubMagicMetadata?.editedTime) ??
|
|
file.metadata.creationTime,
|
|
);
|
|
|
|
// The membership a file's record is read from: the most recently synced, lowest
|
|
// collection id to break ties. Whatever dates a file's save path takes this
|
|
// membership too, so the path always carries the record's `takenAt`.
|
|
export const representative = (memberships: EnteFile[]): EnteFile =>
|
|
memberships.reduce((best, m) =>
|
|
m.updationTime > best.updationTime ||
|
|
(m.updationTime === best.updationTime &&
|
|
m.collectionID < best.collectionID)
|
|
? m
|
|
: best,
|
|
);
|
|
|
|
// Build one PhotoRecord from every membership of a file. The memberships share
|
|
// the same underlying file, so metadata is read from a single representative;
|
|
// `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 = representative(memberships);
|
|
|
|
const pub = rep.pubMagicMetadata ?? {};
|
|
const priv = rep.magicMetadata ?? {};
|
|
|
|
const visibility = asNumber(priv.visibility);
|
|
|
|
const record: PhotoRecord = {
|
|
fileID,
|
|
albumIDs,
|
|
title: asString(pub.editedName) ?? rep.metadata.title,
|
|
takenAt: takenAtOf(rep),
|
|
modifiedAt: microsToMillis(rep.metadata.modificationTime),
|
|
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;
|
|
if (rep.metadata.hash !== undefined) record.hash = rep.metadata.hash;
|
|
|
|
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,
|
|
};
|
|
};
|
|
|
|
// The cache paths known for a file, so the projection can expose them on the
|
|
// record without the read layer reaching into the content cache itself.
|
|
export type CachedPathLookup = (fileID: number) => {
|
|
originalPath?: string;
|
|
thumbnailPath?: string;
|
|
};
|
|
|
|
// Project the decrypted collections and file memberships into by-id records.
|
|
// `files` is every membership (a file appears once per collection it is in).
|
|
// `cachedPaths`, when given, fills each record's cache paths.
|
|
export const deriveRecords = (
|
|
collections: Collection[],
|
|
files: EnteFile[],
|
|
cachedPaths?: CachedPathLookup,
|
|
): DerivedRecords => {
|
|
const byFileID = new Map<number, EnteFile[]>();
|
|
for (const f of files) {
|
|
const arr = byFileID.get(f.id);
|
|
if (arr) arr.push(f);
|
|
else byFileID.set(f.id, [f]);
|
|
}
|
|
|
|
const photos = new Map<number, PhotoRecord>();
|
|
const photoFiles = new Map<number, EnteFile>();
|
|
const takenAtByFile = new Map<number, number>();
|
|
for (const [fileID, memberships] of byFileID) {
|
|
const record = toPhotoRecord(fileID, memberships);
|
|
if (cachedPaths) {
|
|
const paths = cachedPaths(fileID);
|
|
if (paths.originalPath !== undefined)
|
|
record.originalPath = paths.originalPath;
|
|
if (paths.thumbnailPath !== undefined)
|
|
record.thumbnailPath = paths.thumbnailPath;
|
|
}
|
|
photos.set(fileID, record);
|
|
photoFiles.set(fileID, representative(memberships));
|
|
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, files: photoFiles };
|
|
};
|
|
|
|
// 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,
|
|
};
|
|
};
|