Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141)
check / check (push) Successful in 1m24s

`Photo` gains:

- `savePath` and `isLocal`: synchronous, disk only. Where `lib.backup()` writes the original under the library's `downloadDirectory`, and whether all of it is there; a copy only in the cache does not count.
- `content()` and `exif()`: async, may download. `exif()` reads the common EXIF fields of a JPEG and returns `{}` for anything else.
- The getters `modifiedAt` and `hash`, also on `PhotoRecord`, and `year`.

For a live photo the backup has not stored yet, `savePath` carries the title's extension, and the image may be stored under a different one. The JPEG EXIF scan moved to `src/exif.ts`. The exported `PhotoContent` interface gains `savePath` and `isLocal`.

Judgement call: `iso` is read only when the file stores it as a single number.

Model: opus-5-5
This commit was merged in pull request #142.
This commit is contained in:
2026-10-01 17:58:23 +02:00
parent e6825abcdb
commit 2b598d3622
12 changed files with 566 additions and 86 deletions
+30
View File
@@ -108,6 +108,11 @@ export interface ContentOptions {
export interface PhotoContent {
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
// Where a backup stores the original, whether or not it is there yet. For
// a live photo not yet stored, it carries the title's extension, and the
// backup may store the image under a different one.
savePath(fileID: number): string | undefined;
isLocal(fileID: number): boolean;
}
export interface EnsureResult {
@@ -435,6 +440,31 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
}
// Where a backup to the download directory stores the file's original,
// whether or not it is there yet: for a live photo already stored, its
// image. For a live photo not yet stored, it carries the title's
// extension, and the backup may store the image under a different one.
// Undefined with no download directory.
savePath(fileID: number): string | undefined {
const file = this.getFile(fileID);
if (this.downloadDirectory === undefined || file === undefined)
return undefined;
const dir = join(this.downloadDirectory, "originals");
return (
storedOriginal(dir, file)?.path ?? join(dir, nameInOriginals(file))
);
}
// Whether the whole original is in the download directory, as a backup
// stores it. A copy only in the cache does not count.
isLocal(fileID: number): boolean {
const file = this.getFile(fileID);
if (this.downloadDirectory === undefined || file === undefined)
return false;
const dir = join(this.downloadDirectory, "originals");
return storedOriginal(dir, file) !== undefined;
}
// Get an original for a backup. One not present anywhere is written
// straight to `destination` and recorded there, so no second copy lands
// in the cache; one already present is returned where it is.
+57 -10
View File
@@ -11,11 +11,15 @@
// 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()` 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.
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`,
// `content()` and `exif()` 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.
import { readFile } from "node:fs/promises";
import { readPhotoExif, type PhotoExif } from "../exif.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";
@@ -35,7 +39,7 @@ const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
export class Photo {
constructor(
private readonly rec: PhotoRecord,
private readonly content?: PhotoContent,
private readonly cache?: PhotoContent,
) {}
get fileID(): number {
@@ -50,6 +54,13 @@ export class Photo {
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;
}
@@ -68,6 +79,9 @@ export class Photo {
get longitude(): number | undefined {
return this.rec.longitude;
}
get hash(): string | undefined {
return this.rec.hash;
}
get isArchived(): boolean {
return this.rec.isArchived;
}
@@ -75,6 +89,22 @@ export class Photo {
return this.rec.isHidden;
}
// Where `lib.backup()` stores the original in the library's download
// directory, whether or not it is there yet: for a live photo already
// stored, its image. For a live photo not yet stored, it carries the
// title's extension, and the backup may store the image under a different
// one. Undefined when the library has no download directory or no content
// cache.
get savePath(): string | undefined {
return this.cache?.savePath(this.rec.fileID);
}
// Whether the whole original is at `savePath`. A copy only in the cache
// does not count.
get isLocal(): boolean {
return this.cache?.isLocal(this.rec.fileID) ?? false;
}
record(): PhotoRecord {
return this.rec;
}
@@ -84,21 +114,38 @@ export class Photo {
// `videoPath`. 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);
return this.cacheOrThrow().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);
return this.cacheOrThrow().thumbnail(this.rec.fileID, opts);
}
private contentOrThrow(): PhotoContent {
if (!this.content) {
// 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);
}
// The common EXIF fields of the original, read from `content()`, so this
// may download it. Only a JPEG's EXIF is read; 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<PhotoExif> {
this.cacheOrThrow();
if (this.rec.fileType === "video") return {};
return readPhotoExif(await this.content(opts));
}
private cacheOrThrow(): PhotoContent {
if (!this.cache) {
throw new Error(
"Photo content requires a library opened with a content cache",
);
}
return this.content;
return this.cache;
}
}
+12 -4
View File
@@ -5,10 +5,11 @@
// 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.
// 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,
@@ -33,12 +34,17 @@ export interface PhotoRecord {
// 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.
@@ -125,6 +131,7 @@ const toPhotoRecord = (
albumIDs,
title: asString(pub.editedName) ?? rep.metadata.title,
takenAt: microsToMillis(takenAtMicros),
modifiedAt: microsToMillis(rep.metadata.modificationTime),
fileType: rep.metadata.fileType,
isArchived: visibility === VISIBILITY_ARCHIVED,
isHidden: visibility === VISIBILITY_HIDDEN,
@@ -140,6 +147,7 @@ const toPhotoRecord = (
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;
};