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
+38 -8
View File
@@ -224,6 +224,7 @@ quak/
backup.ts resilient full-account backup with dedup
metadata-backup.ts
backup-metadata: the metadata quak keeps, as JSON
exif.ts EXIF read from a JPEG's bytes
mldata-fetch.ts fetch + decrypt per-file ML data
filename.ts safe file names from server metadata
errors.ts error types shared across layers
@@ -692,16 +693,42 @@ https://git.eeqj.de/sneak/quak/issues/75).
An `Album` exposes its record fields and `album.photos.list()` → `Photo[]`
(newest first). A `Photo` exposes its record fields other than `thumbnailPath`
and `originalPath`, `photo.record()` → `PhotoRecord`, and two content methods:
and `originalPath`, and `photo.year`, the local-time year of `takenAt`, all as
synchronous getters read from RAM; `photo.record()` → `PhotoRecord`. Two more
synchronous getters look at the disk and never touch the network:
- `photo.savePath` → `string | undefined` — where `lib.backup()` writes the
original, `originals/<fileID>.<ext>` under the `downloadDirectory` the library
was opened with, 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, found
inside the live photo. `undefined` when the library has no `downloadDirectory`
or no content source.
- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`.
Four async methods may download:
- `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the
full-resolution file. For a live photo, `path` and `bytes` are its image's and
`videoPath` is its video.
- `await photo.thumbnail(opts?)` → `{ path, bytes }`.
- `await photo.content(opts?)` → `Uint8Array` — the original's bytes, read
through `original()`; for a live photo, its image's.
- `await photo.exif(opts?)` → `PhotoExif` — `make`, `model`, `lensModel`,
`dateTimeOriginal`, `offsetTimeOriginal`, `exposureTime`, `fNumber`, `iso`,
`focalLength`, `orientation`, `gpsLatitude`, `gpsLongitude` and `gpsAltitude`,
each absent when the file lacks it. GPS values are signed decimal degrees and
metres. `dateTimeOriginal` is the camera's clock reading held in the `Date`'s
UTC fields; `offsetTimeOriginal`, when present, is that clock's offset from
UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video
gives `{}` without being downloaded.
Both serve from the on-disk content cache when the bytes are present and
otherwise fetch through the pools; `opts.onProgress` reports per-file progress.
They throw when the library was opened without a content source.
They serve from the on-disk content cache when the bytes are present and
otherwise fetch through the pools; `original()`, `content()` and `exif()` also
serve an original the backup has already stored. `opts.onProgress` reports
per-file progress. They throw when the library was opened without a content
source. An original that `content()` or `exif()` downloads lands in the cache,
which does not make `isLocal` true; only `lib.backup()` does.
Lower-level accessors that return decrypted model objects (which hold key
material) are also available: `listCollections()`, `getCollection(id)`,
@@ -713,10 +740,12 @@ material) are also available: `listCollections()`, `getCollection(id)`,
The GUI-facing records hold no key material and no binary, so they survive
`structuredClone`/JSON across the Electron IPC boundary:
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` (milliseconds),
`fileType`, optional `caption` / `width` / `height` / `latitude` /
`longitude`, `isArchived`, `isHidden`, and `thumbnailPath` / `originalPath`
once the bytes are cached (for a live photo, `originalPath` is its image).
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` and `modifiedAt`
(milliseconds), `fileType`, optional `caption` / `width` / `height` /
`latitude` / `longitude`, optional `hash` (the content hash recorded at
upload; very old files have none), `isArchived`, `isHidden`, and
`thumbnailPath` / `originalPath` once the bytes are cached (for a live photo,
`originalPath` is its image).
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and
`fileIDs` (newest first).
- `LibrarySnapshot`: `{ albums, photos, takenAt }`.
@@ -811,6 +840,7 @@ from a very old client, is stored unchecked.
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
`LibraryChange`
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
- `src/exif.ts`: `PhotoExif`
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
- `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
+10
View File
@@ -25,6 +25,16 @@ declares one.
# Completed Steps
- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`,
`modifiedAt`, `hash` and `year` (issue 141). `savePath` is where
`lib.backup()` writes the original under the library's download directory; for
a live photo not yet stored, it carries the title's extension, and the backup
may store the image under a different one. `isLocal` says whether the whole
original is there. Both look only at the disk. `content()` returns the
original's bytes and `exif()` the common EXIF fields of a JPEG; both may
download the original, and `exif()` downloads no video. `PhotoRecord` gains
`modifiedAt` and `hash`, and the JPEG EXIF scan moved to `src/exif.ts`.
- 2026-09-29: The "Workflow" list at the top of this file now says to branch
from `next` and open a pull request that targets `next`, that the repository
manager squash-merges a reviewed pull request into `next`, and that only sneak
+145
View File
@@ -0,0 +1,145 @@
// EXIF in a JPEG's bytes. `backup-metadata --exif` records the whole EXIF block
// it finds; `Photo.exif()` returns the common fields picked from it here.
import exifReader from "exif-reader";
// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment
// data, starting at the "Exif\0\0" header) when there is one, nothing when the
// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is
// malformed. Each segment length is checked against the bytes that remain and
// each step moves forward by at least 4 bytes, so the scan ends on any input.
export const extractExifFromJpeg = (
buf: Uint8Array,
): { exif?: Buffer; error?: string } => {
if (buf[0] !== 0xff || buf[1] !== 0xd8) return {};
let offset = 2;
while (offset < buf.length) {
if (offset + 2 > buf.length)
return { error: `truncated segment marker at byte ${offset}` };
if (buf[offset] !== 0xff)
return { error: `no segment marker at byte ${offset}` };
const marker = buf[offset + 1]!;
if (marker === 0xda) return {}; // start of scan, no more markers
if (offset + 4 > buf.length)
return { error: `truncated segment length at byte ${offset}` };
const len = (buf[offset + 2]! << 8) | buf[offset + 3]!;
// The length counts its own two bytes, so anything under 2 is invalid.
if (len < 2)
return {
error: `segment length ${len} at byte ${offset} is too small`,
};
if (offset + 2 + len > buf.length)
return {
error: `segment length ${len} at byte ${offset} runs past the end of the file`,
};
if (marker === 0xe1) {
// APP1 — check for "Exif\0\0" header. A length under 8 cannot hold
// the six-byte header, so the segment is not EXIF; below 6 the
// bytes compared would also lie past the segment.
if (
len >= 8 &&
buf[offset + 4] === 0x45 &&
buf[offset + 5] === 0x78 &&
buf[offset + 6] === 0x69 &&
buf[offset + 7] === 0x66
) {
return {
exif: Buffer.from(
buf.buffer,
buf.byteOffset + offset + 4,
len - 2,
),
};
}
}
offset += 2 + len;
}
return { error: "file ends before the image data" };
};
// The common EXIF fields of an original. Each is absent when the file lacks it.
export interface PhotoExif {
make?: string;
model?: string;
lensModel?: string;
// When the photo was taken, by the camera's clock. EXIF writes this as text
// with no time zone, and exif-reader reads that text as if it were UTC: the
// Date's UTC fields are the clock reading, which is the moment it was taken
// only when the clock was set to UTC.
dateTimeOriginal?: Date;
// The camera clock's offset from UTC, such as "+02:00".
offsetTimeOriginal?: string;
// Seconds.
exposureTime?: number;
fNumber?: number;
iso?: number;
// Millimetres.
focalLength?: number;
// The EXIF orientation code, 1 to 8.
orientation?: number;
// Decimal degrees, negative south of the equator and west of Greenwich.
gpsLatitude?: number;
gpsLongitude?: number;
// Metres, negative below sea level.
gpsAltitude?: number;
}
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 asDate = (v: unknown): Date | undefined =>
v instanceof Date && !Number.isNaN(v.getTime()) ? v : undefined;
// EXIF writes a GPS coordinate as three numbers: degrees, minutes and seconds.
// This is them as decimal degrees, negated when `negative`.
const asDegrees = (v: unknown, negative: boolean): number | undefined => {
if (!Array.isArray(v) || v.length !== 3) return undefined;
const [d, m, s] = v.map(asNumber);
if (d === undefined || m === undefined || s === undefined) return undefined;
const degrees = d + m / 60 + s / 3600;
return negative ? -degrees : degrees;
};
// The common fields of a JPEG's EXIF block: `{}` when the bytes are not a JPEG,
// have no EXIF block, or exif-reader cannot parse it.
export const readPhotoExif = (bytes: Uint8Array): PhotoExif => {
const { exif } = extractExifFromJpeg(bytes);
if (exif === undefined) return {};
let tags: ReturnType<typeof exifReader>;
try {
tags = exifReader(exif);
} catch {
return {};
}
const image = tags.Image ?? {};
const photo = tags.Photo ?? {};
const gps = tags.GPSInfo ?? {};
const altitude = asNumber(gps.GPSAltitude);
const fields: PhotoExif = {
make: asString(image.Make),
model: asString(image.Model),
lensModel: asString(photo.LensModel),
dateTimeOriginal: asDate(photo.DateTimeOriginal),
offsetTimeOriginal: asString(photo.OffsetTimeOriginal),
exposureTime: asNumber(photo.ExposureTime),
fNumber: asNumber(photo.FNumber),
iso: asNumber(photo.ISOSpeedRatings),
focalLength: asNumber(photo.FocalLength),
orientation: asNumber(image.Orientation),
gpsLatitude: asDegrees(gps.GPSLatitude, gps.GPSLatitudeRef === "S"),
gpsLongitude: asDegrees(gps.GPSLongitude, gps.GPSLongitudeRef === "W"),
// A GPSAltitudeRef of 1 means the altitude is below sea level.
gpsAltitude:
altitude !== undefined && gps.GPSAltitudeRef === 1
? -altitude
: altitude,
};
// Leave out what the file lacks, so a missing field is absent rather than
// present and undefined.
return Object.fromEntries(
Object.entries(fields).filter(([, v]) => v !== undefined),
) as PhotoExif;
};
+1
View File
@@ -84,6 +84,7 @@ export type {
LibrarySnapshot,
LibraryChange,
} from "./library/records.js";
export type { PhotoExif } from "./exif.js";
export { decryptCollection, decryptFile } from "./model/index.js";
export { downloadFile, downloadThumbnail } from "./download/index.js";
export type {
+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;
};
+1 -54
View File
@@ -3,6 +3,7 @@ import { join } from "node:path";
import * as jpeg from "jpeg-js";
import exifReader from "exif-reader";
import type { Client } from "./client.js";
import { extractExifFromJpeg } from "./exif.js";
import type { Library, Photo } from "./library/index.js";
import { sanitizeFileName } from "./filename.js";
import {
@@ -19,60 +20,6 @@ export interface MetadataBackupOptions {
onProgress?: ProgressCallback;
}
// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment
// data, starting at the "Exif\0\0" header) when there is one, nothing when the
// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is
// malformed. Each segment length is checked against the bytes that remain and
// each step moves forward by at least 4 bytes, so the scan ends on any input.
export const extractExifFromJpeg = (
buf: Uint8Array,
): { exif?: Buffer; error?: string } => {
if (buf[0] !== 0xff || buf[1] !== 0xd8) return {};
let offset = 2;
while (offset < buf.length) {
if (offset + 2 > buf.length)
return { error: `truncated segment marker at byte ${offset}` };
if (buf[offset] !== 0xff)
return { error: `no segment marker at byte ${offset}` };
const marker = buf[offset + 1]!;
if (marker === 0xda) return {}; // start of scan, no more markers
if (offset + 4 > buf.length)
return { error: `truncated segment length at byte ${offset}` };
const len = (buf[offset + 2]! << 8) | buf[offset + 3]!;
// The length counts its own two bytes, so anything under 2 is invalid.
if (len < 2)
return {
error: `segment length ${len} at byte ${offset} is too small`,
};
if (offset + 2 + len > buf.length)
return {
error: `segment length ${len} at byte ${offset} runs past the end of the file`,
};
if (marker === 0xe1) {
// APP1 — check for "Exif\0\0" header. A length under 8 cannot hold
// the six-byte header, so the segment is not EXIF; below 6 the
// bytes compared would also lie past the segment.
if (
len >= 8 &&
buf[offset + 4] === 0x45 &&
buf[offset + 5] === 0x78 &&
buf[offset + 6] === 0x69 &&
buf[offset + 7] === 0x66
) {
return {
exif: Buffer.from(
buf.buffer,
buf.byteOffset + offset + 4,
len - 2,
),
};
}
}
offset += 2 + len;
}
return { error: "file ends before the image data" };
};
// Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment
// is malformed or cannot be parsed, the record carries the reason in
// `exifError`.
+2 -4
View File
@@ -8,10 +8,8 @@
*/
import { describe, expect, it } from "vitest";
import {
extractExifFromJpeg,
extractImageMetadata,
} from "../../src/metadata-backup.js";
import { extractExifFromJpeg } from "../../src/exif.js";
import { extractImageMetadata } from "../../src/metadata-backup.js";
const SOI = [0xff, 0xd8]; // start of image
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops
+246 -6
View File
@@ -6,7 +6,8 @@
* `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and
* a cached path shows up on the projected record. A library opened without a
* content source leaves those methods throwing rather than silently doing
* nothing.
* nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `content()` and
* `exif()`.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
@@ -24,7 +25,7 @@ import { Library, type LibraryOptions } from "../../src/library/index.js";
import type { ContentSource } from "../../src/library/content.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
import { asLivePhoto, cdnSource, livePhotoZip } from "../live-photo.js";
import { asLivePhoto, cdnSource, IMAGE, livePhotoZip } from "../live-photo.js";
const USER_ID = 7;
@@ -70,14 +71,23 @@ class MockClient {
}
}
// A content source that writes a marker file and counts thumbnail fetches.
const stubSource = (): ContentSource & { thumbCalls: () => number } => {
// A content source that writes `original` as every original and a marker file
// as every thumbnail, and counts the fetches of each.
const stubSource = (
original: string | Uint8Array = "orig-bytes",
): ContentSource & {
originalCalls: () => number;
thumbCalls: () => number;
} => {
let originalCalls = 0;
let thumbCalls = 0;
return {
originalCalls: () => originalCalls,
thumbCalls: () => thumbCalls,
original: async ({ destination }) => {
writeFileSync(destination, "orig-bytes");
return { bytesWritten: 10 };
originalCalls++;
writeFileSync(destination, original);
return { bytesWritten: original.length };
},
thumbnail: async ({ destination }) => {
thumbCalls++;
@@ -211,3 +221,233 @@ describe("Library content wiring", () => {
await lib.close();
});
});
// Big-endian bytes for the hand-built JPEG below.
const u16 = (n: number): number[] => [n >> 8, n & 0xff];
const u32 = (n: number): number[] => [...u16(n >>> 16), ...u16(n & 0xffff)];
const ascii = (s: string): number[] => [...new TextEncoder().encode(s), 0];
const rational = (num: number, den: number): number[] => [
...u32(num),
...u32(den),
];
// One IFD entry: tag, type (1 BYTE, 2 ASCII, 3 SHORT, 4 LONG, 5 RATIONAL),
// count, then the value when it fits in 4 bytes, else its offset.
const entry = (
tag: number,
type: number,
count: number,
value: number[],
): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value];
// The TIFF block of a JPEG's EXIF segment, holding every field `exif()` picks:
// the camera in the first IFD, the exposure in the Exif IFD, and a GPS position
// of 40°26'46" N, 79°58'56" W, 12.5 m below sea level. Offsets count from the
// start of this block.
const TIFF = [
...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF
...u32(8), // the first IFD's offset
// The first IFD, at 8: five entries, then no next IFD.
...u16(5),
...entry(0x010f, 2, 6, u32(74)), // Make
...entry(0x0110, 2, 7, u32(80)), // Model
...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation
...entry(0x8769, 4, 1, u32(88)), // the Exif IFD's offset
...entry(0x8825, 4, 1, u32(246)), // the GPS IFD's offset
...u32(0),
...ascii("Canon"), // at 74
...ascii("EOS R5"), // at 80
0, // a pad byte
// The Exif IFD, at 88: seven entries, then no next IFD.
...u16(7),
...entry(0x829a, 5, 1, u32(178)), // ExposureTime
...entry(0x829d, 5, 1, u32(186)), // FNumber
...entry(0x8827, 3, 1, [...u16(400), 0, 0]), // ISOSpeedRatings
...entry(0x9003, 2, 20, u32(194)), // DateTimeOriginal
...entry(0x9011, 2, 7, u32(214)), // OffsetTimeOriginal
...entry(0x920a, 5, 1, u32(222)), // FocalLength
...entry(0xa434, 2, 16, u32(230)), // LensModel
...u32(0),
...rational(1, 250), // at 178
...rational(28, 10), // at 186
...ascii("2021:07:15 14:30:00"), // at 194
...ascii("+02:00"), // at 214
0, // a pad byte
...rational(50, 1), // at 222
...ascii("RF50mm F1.8 STM"), // at 230
// The GPS IFD, at 246: six entries, then no next IFD.
...u16(6),
...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef
...entry(0x0002, 5, 3, u32(324)), // GPSLatitude
...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef
...entry(0x0004, 5, 3, u32(348)), // GPSLongitude
...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level
...entry(0x0006, 5, 1, u32(372)), // GPSAltitude
...u32(0),
...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 324
...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 348
...rational(25, 2), // at 372
];
const JPEG_WITH_EXIF = new Uint8Array([
...[0xff, 0xd8], // start of image
...[0xff, 0xe1, ...u16(2 + 6 + TIFF.length)], // APP1 and its length
...[...ascii("Exif"), 0], // "Exif\0\0"
...TIFF,
...[0xff, 0xda, 0x00, 0x02], // start of scan
]);
// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF
// byte order belongs, so exif-reader cannot parse it.
const JPEG_WITH_BAD_EXIF = new Uint8Array([
...[0xff, 0xd8], // start of image
...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length
...[...ascii("Exif"), 0], // "Exif\0\0"
...[0x58, 0x58], // "XX"
...[0xff, 0xda, 0x00, 0x02], // start of scan
]);
describe("Photo save path, local copy, content and EXIF", () => {
// The same account, with `files` in its album instead.
class FilesClient extends MockClient {
constructor(private readonly files: EnteFile[]) {
super();
}
override async filesSince(): Promise<FilesPage> {
return { files: this.files, deleted: [], cursor: 1 };
}
}
const open = (opts: Partial<LibraryOptions> = {}): Promise<Library> =>
Library.open({
client: new MockClient(),
cacheDirectory: join(root, "cache"),
downloadDirectory: join(root, "backup"),
contentSource: stubSource(),
refreshIntervalSeconds: 3600,
precacheThumbnails: false,
precacheOriginals: false,
...opts,
});
it("names where a backup writes the original, which is local once the backup has written it", async () => {
const lib = await open();
const photo = lib.photos.byID({ fileID: 1 })!;
const savePath = join(root, "backup", "originals", "1.jpg");
expect(photo.savePath).toBe(savePath);
expect(photo.isLocal).toBe(false);
await lib.backup();
expect(photo.savePath).toBe(savePath);
expect(existsSync(savePath)).toBe(true);
expect(photo.isLocal).toBe(true);
await lib.close();
});
it("returns the original's bytes, and a copy only in the cache is not local", async () => {
const lib = await open();
const photo = lib.photos.byID({ fileID: 1 })!;
expect(await photo.content()).toEqual(Buffer.from("orig-bytes"));
expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe(
true,
);
expect(existsSync(photo.savePath!)).toBe(false);
expect(photo.isLocal).toBe(false);
await lib.close();
});
it("has no save path and is not local without a download directory", async () => {
const lib = await open({ downloadDirectory: undefined });
const photo = lib.photos.byID({ fileID: 1 })!;
expect(photo.savePath).toBeUndefined();
expect(photo.isLocal).toBe(false);
await lib.close();
});
it("has no save path and is not local without a content source", async () => {
const lib = await open({ contentSource: undefined });
const photo = lib.photos.byID({ fileID: 1 })!;
expect(photo.savePath).toBeUndefined();
expect(photo.isLocal).toBe(false);
await lib.close();
});
it("gives a live photo's image as its save path once a backup has stored it", async () => {
const { file: live, body } = await asLivePhoto(file(1, 1));
const lib = await open({
client: new FilesClient([live]),
contentSource: cdnSource(new Map([[1, body]])),
});
const photo = lib.photos.byID({ fileID: 1 })!;
const originals = join(root, "backup", "originals");
// Until then the name comes from the title, file-1.jpg; the backup
// stores the image with the extension found inside the live photo.
expect(photo.savePath).toBe(join(originals, "1.jpg"));
await lib.backup();
expect(photo.savePath).toBe(join(originals, "1.heic"));
expect(photo.isLocal).toBe(true);
expect(await photo.content()).toEqual(Buffer.from(IMAGE));
await lib.close();
});
it("reads the common EXIF fields of a JPEG original", async () => {
const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) });
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({
make: "Canon",
model: "EOS R5",
lensModel: "RF50mm F1.8 STM",
// The camera's clock reading, held in the Date's UTC fields.
dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)),
offsetTimeOriginal: "+02:00",
exposureTime: 1 / 250,
fNumber: 2.8,
iso: 400,
focalLength: 50,
orientation: 6,
gpsLatitude: 40 + 26 / 60 + 46 / 3600,
gpsLongitude: -(79 + 58 / 60 + 56 / 3600),
gpsAltitude: -12.5,
});
await lib.close();
});
it("returns no EXIF fields for an original that is not a JPEG", async () => {
const lib = await open();
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
await lib.close();
});
it("returns no EXIF fields for a JPEG whose EXIF cannot be parsed", async () => {
const lib = await open({
contentSource: stubSource(JPEG_WITH_BAD_EXIF),
});
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
await lib.close();
});
it("throws from exif() on a video without a content source, as the other content methods do", async () => {
const video = file(1, 1);
video.metadata.fileType = "video";
const lib = await open({
client: new FilesClient([video]),
contentSource: undefined,
});
await expect(lib.photos.byID({ fileID: 1 })!.exif()).rejects.toThrow(
/content cache/i,
);
await lib.close();
});
it("returns no EXIF fields for a video, without fetching it", async () => {
const video = file(1, 1);
video.metadata.fileType = "video";
const source = stubSource(JPEG_WITH_EXIF);
const lib = await open({
client: new FilesClient([video]),
contentSource: source,
});
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
expect(source.originalCalls()).toBe(0);
await lib.close();
});
});
+23
View File
@@ -209,6 +209,29 @@ describe("lib.photos", () => {
expect("key" in photo.record()).toBe(false);
});
it("byID exposes modifiedAt, hash and the year taken", () => {
// Mid-July, so the year is 2021 in every time zone.
const takenAt = Date.UTC(2021, 6, 15, 12);
const records = deriveRecords(
[collection(1)],
[
file(1001, 1, {
metadata: {
title: "IMG.jpg",
fileType: "image",
creationTime: micros(takenAt),
modificationTime: micros(1_700_000_123_456),
hash: "aGFzaA==",
},
}),
],
);
const photo = apis(records).photos.byID({ fileID: 1001 })!;
expect(photo.modifiedAt).toBe(ms(1_700_000_123_456));
expect(photo.hash).toBe("aGFzaA==");
expect(photo.year).toBe(2021);
});
it("byID returns undefined for an unknown file id", () => {
const records = deriveRecords([collection(1)], [file(1, 1)]);
expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined();
+1
View File
@@ -159,6 +159,7 @@ describe("deriveRecords: photo mapping", () => {
expect("width" in rec).toBe(false);
expect("height" in rec).toBe(false);
expect("latitude" in rec).toBe(false);
expect("hash" in rec).toBe(false);
});
it("reads archived and hidden from private magicMetadata.visibility", () => {