Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141)
check / check (push) Successful in 1m18s
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:
+56
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user