check / check (push) Waiting to run
`photo.exif()` returns `ExifTags`: every EXIF tag exifreader reads from the file, keyed by tag name, each with exifreader's `id`, `value`, `description` and `computed`. The embedded thumbnail's tags are under `Thumbnail`, without the thumbnail image. A file with no EXIF, or a video, gives `{}`.
The thirteen typed methods stay. Each picks its field from the tags `exif()` returns through `readPhotoExif`, which now takes the tags instead of the bytes. GPS latitude and longitude are worked out from their tags and reference tags, since exifreader's computed position is not among the tags.
Also closes #148.
Model: opus-5-5
538 lines
19 KiB
TypeScript
538 lines
19 KiB
TypeScript
// The in-process read surface over the local cache (issue #44).
|
|
//
|
|
// A CLI or an in-process script reads albums, photos, and a grouped timeline
|
|
// through `lib.albums`, `lib.photos`, and `lib.timeline`. Every call is
|
|
// answered synchronously from the same plain-record projection the GUI reads
|
|
// (`deriveRecords`, issue #43); nothing here touches the network. Every method
|
|
// takes a single named-argument object.
|
|
//
|
|
// The `Album` and `Photo` classes are thin, in-process-only wrappers over
|
|
// those records: a caller that holds an object reference gets typed field
|
|
// access and, for an album, its photos. They are not sent across IPC — the
|
|
// plain records are the serializable surface, and `record()` returns one.
|
|
//
|
|
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`,
|
|
// `download()`, `content()`, `exif()` and the methods that each return one
|
|
// EXIF field go through the on-disk content cache (issue #46), and are
|
|
// the one place in this module that may touch the network. A library opened
|
|
// without a content source leaves that cache absent, and those methods then
|
|
// throw. `savePath` and `isLocal` look only at the disk and need no cache.
|
|
|
|
import { readFile } from "node:fs/promises";
|
|
|
|
import {
|
|
readAllExifTags,
|
|
readPhotoExif,
|
|
type ExifTags,
|
|
type PhotoExif,
|
|
} from "../exif.js";
|
|
import type { CollectionType, EnteFile, FileType } from "../model/types.js";
|
|
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
|
|
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
|
|
|
|
// Where a photo's original is saved, and whether all of it is there. The
|
|
// library answers both from the disk, with or without a content cache.
|
|
export interface SavePathLookup {
|
|
savePath(file: EnteFile): string;
|
|
isLocal(file: EnteFile): boolean;
|
|
}
|
|
|
|
// Newest first, with fileID as a stable tiebreak so equal-timed files order
|
|
// deterministically — the same order the record projection uses.
|
|
const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
|
|
b.takenAt - a.takenAt || b.fileID - a.fileID;
|
|
|
|
// Albums newest updated first, collection id breaking ties. This is the order
|
|
// `albums.list` returns and the order `byName` resolves a name collision in.
|
|
const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
|
|
b.updationTime - a.updationTime || b.collectionID - a.collectionID;
|
|
|
|
// A method for each `PhotoExif` field, named after it, taking the options
|
|
// `exif()` takes and giving that field. `Photo` implements it, so the build's
|
|
// type check fails when `PhotoExif` has a field `Photo` has no method for.
|
|
type PhotoExifMethods = {
|
|
[K in keyof PhotoExif]-?: (opts?: ContentOptions) => Promise<PhotoExif[K]>;
|
|
};
|
|
|
|
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
|
|
// underlying plain record for callers that need the IPC-safe value. `file` is
|
|
// the membership the record is read from, so the save path carries the date of
|
|
// `takenAt` and stays known after a refresh removes the file from the library.
|
|
export class Photo implements PhotoExifMethods {
|
|
constructor(
|
|
private readonly rec: PhotoRecord,
|
|
private readonly file: EnteFile,
|
|
private readonly saves: SavePathLookup,
|
|
private readonly cache?: PhotoContent,
|
|
) {}
|
|
|
|
get fileID(): number {
|
|
return this.rec.fileID;
|
|
}
|
|
get albumIDs(): number[] {
|
|
return this.rec.albumIDs;
|
|
}
|
|
get title(): string {
|
|
return this.rec.title;
|
|
}
|
|
get takenAt(): number {
|
|
return this.rec.takenAt;
|
|
}
|
|
get modifiedAt(): number {
|
|
return this.rec.modifiedAt;
|
|
}
|
|
// The local-time year of `takenAt`.
|
|
get year(): number {
|
|
return new Date(this.rec.takenAt).getFullYear();
|
|
}
|
|
get fileType(): FileType {
|
|
return this.rec.fileType;
|
|
}
|
|
get caption(): string | undefined {
|
|
return this.rec.caption;
|
|
}
|
|
get width(): number | undefined {
|
|
return this.rec.width;
|
|
}
|
|
get height(): number | undefined {
|
|
return this.rec.height;
|
|
}
|
|
get latitude(): number | undefined {
|
|
return this.rec.latitude;
|
|
}
|
|
get longitude(): number | undefined {
|
|
return this.rec.longitude;
|
|
}
|
|
get hash(): string | undefined {
|
|
return this.rec.hash;
|
|
}
|
|
get isArchived(): boolean {
|
|
return this.rec.isArchived;
|
|
}
|
|
get isHidden(): boolean {
|
|
return this.rec.isHidden;
|
|
}
|
|
|
|
// Where `download()` and `lib.backup()` put the original under the
|
|
// library's download directory, whether or not it is there yet:
|
|
// `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext>`. For a live photo
|
|
// already stored, its image. For a live photo not yet stored, it carries
|
|
// the title's extension, and the image may be stored under a different
|
|
// one.
|
|
get savePath(): string {
|
|
return this.saves.savePath(this.file);
|
|
}
|
|
|
|
// Whether the whole original is at `savePath`. A copy only in the cache
|
|
// does not count.
|
|
get isLocal(): boolean {
|
|
return this.saves.isLocal(this.file);
|
|
}
|
|
|
|
record(): PhotoRecord {
|
|
return this.rec;
|
|
}
|
|
|
|
// Fetch and cache the full-resolution original, returning its on-disk path
|
|
// and byte length; for a live photo, its image's, and its video's path as
|
|
// `videoPath`. Served from the cache (or the save path) when already
|
|
// present, otherwise fetched through the content pool.
|
|
async original(opts?: ContentOptions): Promise<ContentResult> {
|
|
return this.cacheOrThrow().original(this.rec.fileID, opts);
|
|
}
|
|
|
|
// Put the original at `savePath` and return it there, as `original()`
|
|
// does. When it is already there, nothing is written. When the cache holds
|
|
// it, it is copied from there; otherwise it is fetched straight to
|
|
// `savePath`.
|
|
async download(): Promise<ContentResult> {
|
|
return this.cacheOrThrow().download(this.file);
|
|
}
|
|
|
|
// As `original`, for the thumbnail, through the thumbnail pool.
|
|
async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
|
|
return this.cacheOrThrow().thumbnail(this.rec.fileID, opts);
|
|
}
|
|
|
|
// The original's bytes, read from where `original()` puts it. For a live
|
|
// photo, its image's.
|
|
async content(opts?: ContentOptions): Promise<Uint8Array> {
|
|
const { path } = await this.original(opts);
|
|
return readFile(path);
|
|
}
|
|
|
|
// Every EXIF tag of the original, keyed by name (see `ExifTags`), read
|
|
// from `content()`, so this may download it. EXIF is read from any image
|
|
// format exifreader reads, JPEG and HEIC/HEIF among them; any other file
|
|
// gives `{}`, and a video gives it without fetching anything. Like the
|
|
// other content methods, it throws when there is no content cache, video
|
|
// or not.
|
|
async exif(opts?: ContentOptions): Promise<ExifTags> {
|
|
this.cacheOrThrow();
|
|
if (this.rec.fileType === "video") return {};
|
|
return readAllExifTags(await this.content(opts));
|
|
}
|
|
|
|
// One field each, named and typed as in `PhotoExif`, picked from the tags
|
|
// `exif()` returns, and undefined when the file lacks it. Each call runs
|
|
// `exif()`, which reads the original again.
|
|
async make(opts?: ContentOptions): Promise<PhotoExif["make"]> {
|
|
return readPhotoExif(await this.exif(opts)).make;
|
|
}
|
|
async model(opts?: ContentOptions): Promise<PhotoExif["model"]> {
|
|
return readPhotoExif(await this.exif(opts)).model;
|
|
}
|
|
async lensModel(opts?: ContentOptions): Promise<PhotoExif["lensModel"]> {
|
|
return readPhotoExif(await this.exif(opts)).lensModel;
|
|
}
|
|
async dateTimeOriginal(
|
|
opts?: ContentOptions,
|
|
): Promise<PhotoExif["dateTimeOriginal"]> {
|
|
return readPhotoExif(await this.exif(opts)).dateTimeOriginal;
|
|
}
|
|
async offsetTimeOriginal(
|
|
opts?: ContentOptions,
|
|
): Promise<PhotoExif["offsetTimeOriginal"]> {
|
|
return readPhotoExif(await this.exif(opts)).offsetTimeOriginal;
|
|
}
|
|
async exposureTime(
|
|
opts?: ContentOptions,
|
|
): Promise<PhotoExif["exposureTime"]> {
|
|
return readPhotoExif(await this.exif(opts)).exposureTime;
|
|
}
|
|
async fNumber(opts?: ContentOptions): Promise<PhotoExif["fNumber"]> {
|
|
return readPhotoExif(await this.exif(opts)).fNumber;
|
|
}
|
|
async iso(opts?: ContentOptions): Promise<PhotoExif["iso"]> {
|
|
return readPhotoExif(await this.exif(opts)).iso;
|
|
}
|
|
async focalLength(
|
|
opts?: ContentOptions,
|
|
): Promise<PhotoExif["focalLength"]> {
|
|
return readPhotoExif(await this.exif(opts)).focalLength;
|
|
}
|
|
async orientation(
|
|
opts?: ContentOptions,
|
|
): Promise<PhotoExif["orientation"]> {
|
|
return readPhotoExif(await this.exif(opts)).orientation;
|
|
}
|
|
async gpsLatitude(
|
|
opts?: ContentOptions,
|
|
): Promise<PhotoExif["gpsLatitude"]> {
|
|
return readPhotoExif(await this.exif(opts)).gpsLatitude;
|
|
}
|
|
async gpsLongitude(
|
|
opts?: ContentOptions,
|
|
): Promise<PhotoExif["gpsLongitude"]> {
|
|
return readPhotoExif(await this.exif(opts)).gpsLongitude;
|
|
}
|
|
async gpsAltitude(
|
|
opts?: ContentOptions,
|
|
): Promise<PhotoExif["gpsAltitude"]> {
|
|
return readPhotoExif(await this.exif(opts)).gpsAltitude;
|
|
}
|
|
|
|
private cacheOrThrow(): PhotoContent {
|
|
if (!this.cache) {
|
|
throw new Error(
|
|
"Photo content requires a library opened with a content cache",
|
|
);
|
|
}
|
|
return this.cache;
|
|
}
|
|
}
|
|
|
|
// A single album. `photos.list()` returns the album's photos as wrappers,
|
|
// newest first (the record already stores `fileIDs` in that order).
|
|
export class Album {
|
|
constructor(
|
|
private readonly rec: AlbumRecord,
|
|
private readonly records: DerivedRecords,
|
|
private readonly saves: SavePathLookup,
|
|
private readonly content?: PhotoContent,
|
|
) {}
|
|
|
|
get collectionID(): number {
|
|
return this.rec.collectionID;
|
|
}
|
|
get name(): string {
|
|
return this.rec.name;
|
|
}
|
|
get type(): CollectionType {
|
|
return this.rec.type;
|
|
}
|
|
get isShared(): boolean {
|
|
return this.rec.isShared;
|
|
}
|
|
get updationTime(): number {
|
|
return this.rec.updationTime;
|
|
}
|
|
get fileIDs(): number[] {
|
|
return this.rec.fileIDs;
|
|
}
|
|
|
|
get photos(): { list: () => Photo[] } {
|
|
return { list: (): Photo[] => this.listPhotos() };
|
|
}
|
|
|
|
record(): AlbumRecord {
|
|
return this.rec;
|
|
}
|
|
|
|
private listPhotos(): Photo[] {
|
|
const out: Photo[] = [];
|
|
for (const id of this.rec.fileIDs) {
|
|
const p = this.records.photos.get(id);
|
|
const file = this.records.files.get(id);
|
|
if (p && file) {
|
|
out.push(new Photo(p, file, this.saves, this.content));
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
}
|
|
|
|
export interface AlbumsAPI {
|
|
list(): Album[];
|
|
byName(args: { albumName: string }): Album | undefined;
|
|
byID(args: { collectionID: number }): Album | undefined;
|
|
}
|
|
|
|
export interface PhotosAPI {
|
|
byID(args: { fileID: number }): Photo | undefined;
|
|
// Plain records for the requested ids, in the order requested, each id at
|
|
// most once, unknown ids dropped.
|
|
records(args: { fileIDs: number[] }): PhotoRecord[];
|
|
}
|
|
|
|
export type GroupBy = "day" | "week" | "month";
|
|
|
|
// A filter over the timeline. All fields are optional and combine with AND.
|
|
// Hidden photos are never included, regardless of this filter.
|
|
export interface PhotoFilter {
|
|
// Keep only photos that belong to this album.
|
|
albumID?: number;
|
|
// Case-insensitive substring of the title, caption, or any album name the
|
|
// photo belongs to.
|
|
text?: string;
|
|
// Keep only photos of one of these types.
|
|
fileTypes?: FileType[];
|
|
// `true` keeps only geotagged photos; `false` keeps only those without a
|
|
// location; omitted places no constraint.
|
|
hasLocation?: boolean;
|
|
// Archived photos are excluded unless this is `true`. Defaults to `false`.
|
|
includeArchived?: boolean;
|
|
}
|
|
|
|
export interface TimelineGroup {
|
|
// The period's identity: `YYYY-MM-DD` for day, `YYYY-Www` (ISO 8601 week,
|
|
// e.g. `2025-W32`) for week, and `YYYY-MM` for month.
|
|
key: string;
|
|
// Local-time milliseconds at the start of the period.
|
|
startsAt: number;
|
|
// The period's files, newest first, each file once.
|
|
fileIDs: number[];
|
|
}
|
|
|
|
export interface TimelineAPI {
|
|
groups(args: { groupBy: GroupBy; filter?: PhotoFilter }): TimelineGroup[];
|
|
}
|
|
|
|
// The surface `Library.fresh()` resolves to (issue #75). It is the same three
|
|
// read namespaces as the default `albums`/`photos`/`timeline`, handed back only
|
|
// after a forced refresh has brought the local copy current.
|
|
export interface FreshReads {
|
|
albums: AlbumsAPI;
|
|
photos: PhotosAPI;
|
|
timeline: TimelineAPI;
|
|
}
|
|
|
|
export const makeAlbumsAPI = (
|
|
derive: () => DerivedRecords,
|
|
saves: SavePathLookup,
|
|
content?: PhotoContent,
|
|
): AlbumsAPI => ({
|
|
list: (): Album[] => {
|
|
const records = derive();
|
|
return [...records.albums.values()]
|
|
.sort(byNewestAlbum)
|
|
.map((rec) => new Album(rec, records, saves, content));
|
|
},
|
|
byID: ({ collectionID }): Album | undefined => {
|
|
const records = derive();
|
|
const rec = records.albums.get(collectionID);
|
|
return rec ? new Album(rec, records, saves, content) : undefined;
|
|
},
|
|
byName: ({ albumName }): Album | undefined => {
|
|
const records = derive();
|
|
// Names are not unique in Ente; resolve a collision deterministically
|
|
// to the newest-updated album, matching `list` order.
|
|
const match = [...records.albums.values()]
|
|
.sort(byNewestAlbum)
|
|
.find((rec) => rec.name === albumName);
|
|
return match ? new Album(match, records, saves, content) : undefined;
|
|
},
|
|
});
|
|
|
|
export const makePhotosAPI = (
|
|
derive: () => DerivedRecords,
|
|
saves: SavePathLookup,
|
|
content?: PhotoContent,
|
|
): PhotosAPI => ({
|
|
byID: ({ fileID }): Photo | undefined => {
|
|
const records = derive();
|
|
const rec = records.photos.get(fileID);
|
|
const file = records.files.get(fileID);
|
|
return rec && file ? new Photo(rec, file, saves, content) : undefined;
|
|
},
|
|
records: ({ fileIDs }): PhotoRecord[] => {
|
|
const { photos } = derive();
|
|
const seen = new Set<number>();
|
|
const out: PhotoRecord[] = [];
|
|
for (const id of fileIDs) {
|
|
if (seen.has(id)) continue;
|
|
const rec = photos.get(id);
|
|
if (rec) {
|
|
out.push(rec);
|
|
seen.add(id);
|
|
}
|
|
}
|
|
return out;
|
|
},
|
|
});
|
|
|
|
export const makeTimelineAPI = (derive: () => DerivedRecords): TimelineAPI => ({
|
|
groups: ({ groupBy, filter }): TimelineGroup[] => {
|
|
const records = derive();
|
|
return groupPhotos(filterPhotos(records, filter), groupBy);
|
|
},
|
|
});
|
|
|
|
// Apply a `PhotoFilter` to the projection. Hidden photos are always dropped;
|
|
// archived photos are dropped unless `includeArchived` asks for them.
|
|
const filterPhotos = (
|
|
records: DerivedRecords,
|
|
filter?: PhotoFilter,
|
|
): PhotoRecord[] => {
|
|
const f = filter ?? {};
|
|
const includeArchived = f.includeArchived ?? false;
|
|
const needle = f.text?.toLowerCase();
|
|
const out: PhotoRecord[] = [];
|
|
for (const rec of records.photos.values()) {
|
|
if (rec.isHidden) continue;
|
|
if (rec.isArchived && !includeArchived) continue;
|
|
if (f.albumID !== undefined && !rec.albumIDs.includes(f.albumID))
|
|
continue;
|
|
if (f.fileTypes !== undefined && !f.fileTypes.includes(rec.fileType))
|
|
continue;
|
|
if (f.hasLocation !== undefined) {
|
|
const has =
|
|
rec.latitude !== undefined && rec.longitude !== undefined;
|
|
if (has !== f.hasLocation) continue;
|
|
}
|
|
if (needle !== undefined && !matchesText(rec, needle, records))
|
|
continue;
|
|
out.push(rec);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const matchesText = (
|
|
rec: PhotoRecord,
|
|
needle: string,
|
|
records: DerivedRecords,
|
|
): boolean => {
|
|
if (rec.title.toLowerCase().includes(needle)) return true;
|
|
if (rec.caption !== undefined && rec.caption.toLowerCase().includes(needle))
|
|
return true;
|
|
for (const id of rec.albumIDs) {
|
|
const album = records.albums.get(id);
|
|
if (album && album.name.toLowerCase().includes(needle)) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Bucket photos into periods, groups newest first, members newest first.
|
|
const groupPhotos = (
|
|
photos: PhotoRecord[],
|
|
groupBy: GroupBy,
|
|
): TimelineGroup[] => {
|
|
const buckets = new Map<
|
|
string,
|
|
{ startsAt: number; recs: PhotoRecord[] }
|
|
>();
|
|
for (const rec of photos) {
|
|
const { key, startsAt } = periodOf(rec.takenAt, groupBy);
|
|
const bucket = buckets.get(key);
|
|
if (bucket) bucket.recs.push(rec);
|
|
else buckets.set(key, { startsAt, recs: [rec] });
|
|
}
|
|
const groups: TimelineGroup[] = [];
|
|
for (const [key, bucket] of buckets) {
|
|
bucket.recs.sort(byNewest);
|
|
groups.push({
|
|
key,
|
|
startsAt: bucket.startsAt,
|
|
fileIDs: bucket.recs.map((r) => r.fileID),
|
|
});
|
|
}
|
|
groups.sort((a, b) => b.startsAt - a.startsAt);
|
|
return groups;
|
|
};
|
|
|
|
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
|
|
const dateKey = (d: Date): string =>
|
|
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
|
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
// The ISO 8601 week key `YYYY-Www` for the week starting at the given Monday.
|
|
// The week-year is the year of that week's Thursday, so it can differ from the
|
|
// calendar year at the January/December boundary (e.g. 2024-12-30 is 2025-W01).
|
|
const isoWeekKey = (monday: Date): string => {
|
|
const thursday = new Date(
|
|
monday.getFullYear(),
|
|
monday.getMonth(),
|
|
monday.getDate() + 3,
|
|
);
|
|
const isoYear = thursday.getFullYear();
|
|
// Thursday of ISO week 1 is the Thursday of the week containing January 4.
|
|
const jan4 = new Date(isoYear, 0, 4);
|
|
const week1Thursday = new Date(
|
|
isoYear,
|
|
0,
|
|
4 + 3 - ((jan4.getDay() + 6) % 7),
|
|
);
|
|
const week =
|
|
1 +
|
|
Math.round((thursday.getTime() - week1Thursday.getTime()) / WEEK_MS);
|
|
return `${isoYear}-W${pad(week)}`;
|
|
};
|
|
|
|
// The period a millisecond instant falls in, in local time. Weeks start on
|
|
// Monday. `Date` normalizes out-of-range day arguments, so the week's Monday
|
|
// is correct across month and year boundaries.
|
|
const periodOf = (
|
|
takenAt: number,
|
|
groupBy: GroupBy,
|
|
): { key: string; startsAt: number } => {
|
|
const d = new Date(takenAt);
|
|
const year = d.getFullYear();
|
|
const month = d.getMonth();
|
|
const day = d.getDate();
|
|
|
|
if (groupBy === "month") {
|
|
const start = new Date(year, month, 1);
|
|
return { key: `${year}-${pad(month + 1)}`, startsAt: start.getTime() };
|
|
}
|
|
if (groupBy === "week") {
|
|
// getDay(): 0=Sunday..6=Saturday; shift so Monday is the week start.
|
|
const fromMonday = (d.getDay() + 6) % 7;
|
|
const start = new Date(year, month, day - fromMonday);
|
|
return { key: isoWeekKey(start), startsAt: start.getTime() };
|
|
}
|
|
const start = new Date(year, month, day);
|
|
return { key: dateKey(start), startsAt: start.getTime() };
|
|
};
|