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

A Photo now has savePath and isLocal, which look only at the disk;
content() and exif(), which may download the original; and modifiedAt,
hash, fileSize and year. PhotoRecord gains modifiedAt, hash and fileSize.
The JPEG EXIF scan moves from metadata-backup.ts to the new src/exif.ts,
so the read surface does not import the backup command.

Model: opus-5-5
This commit is contained in:
2026-10-01 15:03:08 +00:00
parent e6825abcdb
commit bef47e64fa
12 changed files with 506 additions and 86 deletions
+56 -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,12 @@ export class Photo {
get longitude(): number | undefined {
return this.rec.longitude;
}
get hash(): string | undefined {
return this.rec.hash;
}
get fileSize(): number | undefined {
return this.rec.fileSize;
}
get isArchived(): boolean {
return this.rec.isArchived;
}
@@ -75,6 +92,20 @@ export class Photo {
return this.rec.isHidden;
}
// The path `lib.backup()` writes the original to in the library's download
// directory, whether or not it is there yet; for a live photo, its image.
// 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 +115,36 @@ 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.
async exif(opts?: ContentOptions): Promise<PhotoExif> {
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;
}
}