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
830 lines
30 KiB
TypeScript
830 lines
30 KiB
TypeScript
/**
|
|
* Integration between `Library` and the content cache (issue #46).
|
|
*
|
|
* The cache itself is covered in `content.test.ts`; this file locks the wiring:
|
|
* `Library.open` builds the cache from a content source, `lib.photos` hands out
|
|
* `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. It also covers a `Photo`'s `savePath`, `isLocal`, `download()`,
|
|
* `content()`, `exif()` and the methods that each return one EXIF field.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import {
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
existsSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
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 { readPhotoExif, type PhotoExif } from "../../src/exif.js";
|
|
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
|
import {
|
|
asLivePhoto,
|
|
cdnSource,
|
|
IMAGE,
|
|
livePhotoHash,
|
|
livePhotoZip,
|
|
VIDEO,
|
|
} from "../live-photo.js";
|
|
|
|
const USER_ID = 7;
|
|
|
|
// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente
|
|
// stores times, so the machine's time zone cannot move it to another day; it
|
|
// is saved in the folder `DAY`.
|
|
const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000;
|
|
const DAY = join("2026", "2026-03", "2026-03-01");
|
|
|
|
const collection = (id: number): Collection => ({
|
|
id,
|
|
ownerID: USER_ID,
|
|
key: new Uint8Array([id & 0xff]),
|
|
name: `album-${id}`,
|
|
type: "album",
|
|
updationTime: 1,
|
|
isShared: false,
|
|
});
|
|
|
|
const file = (id: number, collectionID: number): EnteFile => ({
|
|
id,
|
|
collectionID,
|
|
ownerID: USER_ID,
|
|
key: new Uint8Array([id & 0xff]),
|
|
metadata: {
|
|
title: `file-${id}.jpg`,
|
|
fileType: "image",
|
|
creationTime: TAKEN,
|
|
modificationTime: 1,
|
|
},
|
|
file: { decryptionHeader: "aGVhZGVy" },
|
|
thumbnail: { decryptionHeader: "dGh1bWI=" },
|
|
updationTime: 1,
|
|
});
|
|
|
|
// A metadata-only client serving one album with one file, once.
|
|
class MockClient {
|
|
served = false;
|
|
whoami(): { email: string; userID: number } {
|
|
return { email: "u@example.com", userID: USER_ID };
|
|
}
|
|
async collectionsSince(): Promise<CollectionsPage> {
|
|
if (this.served) return { collections: [], deleted: [], cursor: 1 };
|
|
this.served = true;
|
|
return { collections: [collection(1)], deleted: [], cursor: 1 };
|
|
}
|
|
async filesSince(): Promise<FilesPage> {
|
|
return { files: [file(1, 1)], deleted: [], cursor: 1 };
|
|
}
|
|
}
|
|
|
|
// 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 }) => {
|
|
originalCalls++;
|
|
writeFileSync(destination, original);
|
|
return { bytesWritten: original.length };
|
|
},
|
|
thumbnail: async ({ destination }) => {
|
|
thumbCalls++;
|
|
writeFileSync(destination, "thumb");
|
|
return { bytesWritten: 5 };
|
|
},
|
|
};
|
|
};
|
|
|
|
let root: string;
|
|
|
|
beforeEach(() => {
|
|
root = mkdtempSync(join(tmpdir(), "quak-content-lib-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (root && existsSync(root))
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("Library content wiring", () => {
|
|
it("fetches a thumbnail through a Photo and records its cache path", async () => {
|
|
const source = stubSource();
|
|
const lib = await Library.open({
|
|
client: new MockClient(),
|
|
cacheDirectory: join(root, "cache"),
|
|
contentSource: source,
|
|
refreshIntervalSeconds: 3600,
|
|
// On-demand wiring only; the background precache (#48) is covered
|
|
// in precache.test.ts and would race the exact-count assertions.
|
|
precacheThumbnails: false,
|
|
precacheOriginals: false,
|
|
});
|
|
|
|
const photo = lib.photos.byID({ fileID: 1 });
|
|
expect(photo).toBeDefined();
|
|
const result = await photo!.thumbnail();
|
|
expect(source.thumbCalls()).toBe(1);
|
|
expect(result.path).toBe(join(root, "cache", "thumbnails", "1.jpg"));
|
|
expect(existsSync(result.path)).toBe(true);
|
|
|
|
// The cached path is now on the projected record.
|
|
expect(lib.photos.byID({ fileID: 1 })!.record().thumbnailPath).toBe(
|
|
result.path,
|
|
);
|
|
await lib.close();
|
|
});
|
|
|
|
it("drives thumbnails.ensure through the cache", async () => {
|
|
const source = stubSource();
|
|
const lib = await Library.open({
|
|
client: new MockClient(),
|
|
cacheDirectory: join(root, "cache"),
|
|
contentSource: source,
|
|
refreshIntervalSeconds: 3600,
|
|
// On-demand wiring only; the background precache (#48) is covered
|
|
// in precache.test.ts and would race the exact-count assertions.
|
|
precacheThumbnails: false,
|
|
precacheOriginals: false,
|
|
});
|
|
|
|
const results = await lib.thumbnails.ensure({
|
|
fileIDs: [1],
|
|
priority: "visible",
|
|
});
|
|
expect(results).toEqual([
|
|
{ fileID: 1, path: join(root, "cache", "thumbnails", "1.jpg") },
|
|
]);
|
|
await lib.close();
|
|
});
|
|
|
|
it("does not take a live photo's image or video with no JSON file as its original when it opens, and precaches both", async () => {
|
|
const { file: live, body } = await asLivePhoto(file(1, 1));
|
|
class LiveClient extends MockClient {
|
|
override async filesSince(): Promise<FilesPage> {
|
|
return { files: [live], deleted: [], cursor: 1 };
|
|
}
|
|
}
|
|
const originals = join(root, "cache", "originals");
|
|
const open = (opts: Partial<LibraryOptions>): Promise<Library> =>
|
|
Library.open({
|
|
client: new LiveClient(),
|
|
cacheDirectory: join(root, "cache"),
|
|
contentSource: cdnSource(new Map([[1, body]])),
|
|
refreshIntervalSeconds: 3600,
|
|
precacheThumbnails: false,
|
|
precacheOriginals: false,
|
|
...opts,
|
|
});
|
|
// A first run records the library, so the next one knows that file 1
|
|
// is a live photo when it opens the cache.
|
|
await (await open({})).close();
|
|
writeFileSync(join(originals, "1.heic"), "an image");
|
|
writeFileSync(join(originals, "1.mov"), "a video");
|
|
|
|
let precached!: () => void;
|
|
const done = new Promise<void>((r) => (precached = r));
|
|
const lib = await open({
|
|
precacheOriginals: true,
|
|
onProgress: (e) => {
|
|
if (e.operation === "precacheOriginals" && e.status === "done")
|
|
precached();
|
|
},
|
|
});
|
|
expect(
|
|
lib.photos.byID({ fileID: 1 })!.record().originalPath,
|
|
).toBeUndefined();
|
|
await done;
|
|
|
|
expect(readdirSync(originals).sort()).toEqual([
|
|
"1.heic",
|
|
"1.livephoto.json",
|
|
"1.mov",
|
|
]);
|
|
expect(readFileSync(join(originals, "1.heic"))).toEqual(
|
|
Buffer.from(IMAGE),
|
|
);
|
|
expect(readFileSync(join(originals, "1.mov"))).toEqual(
|
|
Buffer.from(VIDEO),
|
|
);
|
|
expect(
|
|
JSON.parse(
|
|
readFileSync(join(originals, "1.livephoto.json"), "utf-8"),
|
|
),
|
|
).toEqual({ image: "1.heic", video: "1.mov" });
|
|
expect(lib.photos.byID({ fileID: 1 })!.record().originalPath).toBe(
|
|
join(originals, "1.heic"),
|
|
);
|
|
await lib.close();
|
|
});
|
|
|
|
it("throws from content methods when opened without a content source", async () => {
|
|
const lib = await Library.open({
|
|
client: new MockClient(),
|
|
cacheDirectory: join(root, "cache"),
|
|
refreshIntervalSeconds: 3600,
|
|
});
|
|
|
|
await expect(
|
|
lib.photos.byID({ fileID: 1 })!.thumbnail(),
|
|
).rejects.toThrow(/content cache/i);
|
|
await expect(
|
|
lib.photos.byID({ fileID: 1 })!.download(),
|
|
).rejects.toThrow(/content cache/i);
|
|
await expect(
|
|
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
|
|
).rejects.toThrow(/content cache/i);
|
|
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 `Photo`'s typed
|
|
// methods return: 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 exifreader 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", DAY, "2026-03-01.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("saves under photos/ in the working directory at open without a download directory", async () => {
|
|
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
const lib = await open({ downloadDirectory: undefined });
|
|
cwd.mockRestore();
|
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
expect(lib.downloadDirectory).toBe(join(root, "photos"));
|
|
expect(photo.savePath).toBe(
|
|
join(root, "photos", DAY, "2026-03-01.1.jpg"),
|
|
);
|
|
expect(photo.isLocal).toBe(false);
|
|
await lib.close();
|
|
});
|
|
|
|
it("refuses an empty download directory", async () => {
|
|
await expect(open({ downloadDirectory: "" })).rejects.toThrow(
|
|
/downloadDirectory is empty/,
|
|
);
|
|
});
|
|
|
|
it("keeps a photo's save path after a refresh removes its file", async () => {
|
|
// The same account, whose album is deleted on the second refresh.
|
|
class AlbumDeletedClient extends MockClient {
|
|
override async collectionsSince(): Promise<CollectionsPage> {
|
|
if (!this.served) return super.collectionsSince();
|
|
return { collections: [], deleted: [1], cursor: 2 };
|
|
}
|
|
}
|
|
const lib = await open({ client: new AlbumDeletedClient() });
|
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
await photo.download();
|
|
|
|
await lib.fresh();
|
|
|
|
expect(lib.photos.byID({ fileID: 1 })).toBeUndefined();
|
|
expect(photo.savePath).toBe(
|
|
join(root, "backup", DAY, "2026-03-01.1.jpg"),
|
|
);
|
|
expect(photo.isLocal).toBe(true);
|
|
await lib.close();
|
|
});
|
|
|
|
it("dates the save path and download() by the membership its takenAt comes from", async () => {
|
|
// One file in two albums. The date edited in Ente has reached album 2's
|
|
// copy, synced later, but album 1 still has an earlier edit.
|
|
const older = file(1, 1);
|
|
older.pubMagicMetadata = {
|
|
editedTime: new Date(2026, 1, 1, 12).getTime() * 1000,
|
|
};
|
|
const newer = file(1, 2);
|
|
newer.updationTime = 2;
|
|
newer.pubMagicMetadata = {
|
|
editedTime: new Date(2026, 3, 15, 12).getTime() * 1000,
|
|
};
|
|
const client = {
|
|
whoami: () => ({ email: "u@example.com", userID: USER_ID }),
|
|
collectionsSince: async (): Promise<CollectionsPage> => ({
|
|
collections: [collection(1), collection(2)],
|
|
deleted: [],
|
|
cursor: 1,
|
|
}),
|
|
filesSince: async (args: {
|
|
collectionID: number;
|
|
}): Promise<FilesPage> => ({
|
|
files: [args.collectionID === 1 ? older : newer],
|
|
deleted: [],
|
|
cursor: 1,
|
|
}),
|
|
};
|
|
const lib = await open({ client });
|
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
|
|
expect(photo.takenAt).toBe(new Date(2026, 3, 15, 12).getTime());
|
|
expect(photo.savePath).toBe(
|
|
join(
|
|
root,
|
|
"backup",
|
|
"2026",
|
|
"2026-04",
|
|
"2026-04-15",
|
|
"2026-04-15.1.jpg",
|
|
),
|
|
);
|
|
|
|
// download() writes to that same path, so the photo is then local.
|
|
const saved = await photo.download();
|
|
expect(saved.path).toBe(photo.savePath);
|
|
expect(photo.isLocal).toBe(true);
|
|
expect(existsSync(join(root, "backup", "2026", "2026-02"))).toBe(false);
|
|
await lib.close();
|
|
});
|
|
|
|
it("downloads a photo held across a refresh that edits its date to the save path it names", async () => {
|
|
// The same account, whose second refresh brings a date edited in Ente.
|
|
const edited = file(1, 1);
|
|
edited.updationTime = 2;
|
|
edited.pubMagicMetadata = {
|
|
editedTime: new Date(2026, 3, 15, 12).getTime() * 1000,
|
|
};
|
|
class DateEditedClient extends MockClient {
|
|
refreshes = 0;
|
|
override async collectionsSince(): Promise<CollectionsPage> {
|
|
this.refreshes++;
|
|
return {
|
|
collections: [
|
|
{ ...collection(1), updationTime: this.refreshes },
|
|
],
|
|
deleted: [],
|
|
cursor: this.refreshes,
|
|
};
|
|
}
|
|
override async filesSince(): Promise<FilesPage> {
|
|
return {
|
|
files: [this.refreshes === 1 ? file(1, 1) : edited],
|
|
deleted: [],
|
|
cursor: this.refreshes,
|
|
};
|
|
}
|
|
}
|
|
const lib = await open({ client: new DateEditedClient() });
|
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
|
|
await lib.fresh();
|
|
expect(lib.photos.byID({ fileID: 1 })!.takenAt).toBe(
|
|
new Date(2026, 3, 15, 12).getTime(),
|
|
);
|
|
|
|
const saved = await photo.download();
|
|
expect(saved.path).toBe(photo.savePath);
|
|
expect(saved.path).toBe(join(root, "backup", DAY, "2026-03-01.1.jpg"));
|
|
expect(photo.isLocal).toBe(true);
|
|
await lib.close();
|
|
});
|
|
|
|
it("has a 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).toBe(
|
|
join(root, "backup", DAY, "2026-03-01.1.jpg"),
|
|
);
|
|
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 day = join(root, "backup", DAY);
|
|
// 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(day, "2026-03-01.1.jpg"));
|
|
|
|
await lib.backup();
|
|
expect(photo.savePath).toBe(join(day, "2026-03-01.1.heic"));
|
|
expect(photo.isLocal).toBe(true);
|
|
expect(await photo.content()).toEqual(Buffer.from(IMAGE));
|
|
await lib.close();
|
|
});
|
|
|
|
it("downloads an original the cache holds by copying it, without fetching it again", async () => {
|
|
const source = stubSource();
|
|
const lib = await open({ contentSource: source });
|
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
await photo.original();
|
|
expect(source.originalCalls()).toBe(1);
|
|
expect(photo.isLocal).toBe(false);
|
|
|
|
const saved = await photo.download();
|
|
|
|
expect(source.originalCalls()).toBe(1);
|
|
expect(saved).toEqual({
|
|
path: photo.savePath,
|
|
bytes: "orig-bytes".length,
|
|
});
|
|
expect(readFileSync(photo.savePath, "utf-8")).toBe("orig-bytes");
|
|
expect(photo.isLocal).toBe(true);
|
|
// The cache keeps its own copy.
|
|
expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe(
|
|
true,
|
|
);
|
|
await lib.close();
|
|
});
|
|
|
|
it("downloads an original the cache does not hold straight to its save path", async () => {
|
|
const source = stubSource();
|
|
const lib = await open({ contentSource: source });
|
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
|
|
const saved = await photo.download();
|
|
|
|
expect(source.originalCalls()).toBe(1);
|
|
expect(saved.path).toBe(join(root, "backup", DAY, "2026-03-01.1.jpg"));
|
|
expect(readFileSync(saved.path, "utf-8")).toBe("orig-bytes");
|
|
expect(photo.isLocal).toBe(true);
|
|
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
|
|
await lib.close();
|
|
});
|
|
|
|
it("does nothing when download() finds the original already at its save path", async () => {
|
|
const source = stubSource();
|
|
const lib = await open({ contentSource: source });
|
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
const first = await photo.download();
|
|
const written = statSync(first.path).ino;
|
|
|
|
const second = await photo.download();
|
|
|
|
expect(second).toEqual(first);
|
|
expect(source.originalCalls()).toBe(1);
|
|
expect(statSync(second.path).ino).toBe(written);
|
|
await lib.close();
|
|
});
|
|
|
|
it("downloads a live photo the cache holds as its image, its video and the JSON file naming them", 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 })!;
|
|
await photo.original();
|
|
const day = join(root, "backup", DAY);
|
|
|
|
const saved = await photo.download();
|
|
|
|
expect(saved).toEqual({
|
|
path: join(day, "2026-03-01.1.heic"),
|
|
videoPath: join(day, "2026-03-01.1.mov"),
|
|
bytes: IMAGE.length,
|
|
});
|
|
expect(readdirSync(day).sort()).toEqual([
|
|
"2026-03-01.1.heic",
|
|
"2026-03-01.1.livephoto.json",
|
|
"2026-03-01.1.mov",
|
|
]);
|
|
expect(
|
|
JSON.parse(
|
|
readFileSync(join(day, "2026-03-01.1.livephoto.json"), "utf-8"),
|
|
),
|
|
).toEqual({ image: "2026-03-01.1.heic", video: "2026-03-01.1.mov" });
|
|
expect(photo.savePath).toBe(saved.path);
|
|
expect(photo.isLocal).toBe(true);
|
|
await lib.close();
|
|
});
|
|
|
|
// Every tag in JPEG_WITH_EXIF, by name, in the order of its IFDs.
|
|
const jpegTags = [
|
|
"Make",
|
|
"Model",
|
|
"Orientation",
|
|
"Exif IFD Pointer",
|
|
"GPS Info IFD Pointer",
|
|
"ExposureTime",
|
|
"FNumber",
|
|
"ISOSpeedRatings",
|
|
"DateTimeOriginal",
|
|
"OffsetTimeOriginal",
|
|
"FocalLength",
|
|
"LensModel",
|
|
"GPSLatitudeRef",
|
|
"GPSLatitude",
|
|
"GPSLongitudeRef",
|
|
"GPSLongitude",
|
|
"GPSAltitudeRef",
|
|
"GPSAltitude",
|
|
];
|
|
// HEIC_WITH_EXIF holds those and the tags exiftool adds to every file.
|
|
const heicTags = [
|
|
...jpegTags,
|
|
"YCbCrPositioning",
|
|
"ExifVersion",
|
|
"ComponentsConfiguration",
|
|
"ColorSpace",
|
|
"GPSVersionID",
|
|
];
|
|
|
|
it.each([
|
|
[
|
|
"JPEG",
|
|
JPEG_WITH_EXIF,
|
|
jpegTags,
|
|
{
|
|
"Exif IFD Pointer": { value: 88 },
|
|
GPSLatitudeRef: { value: ["N"], description: "North latitude" },
|
|
},
|
|
],
|
|
[
|
|
"HEIC",
|
|
HEIC_WITH_EXIF,
|
|
heicTags,
|
|
{
|
|
ColorSpace: { value: 0xffff, description: "Uncalibrated" },
|
|
ExifVersion: { description: "0232" },
|
|
},
|
|
],
|
|
])(
|
|
"returns every EXIF tag of a %s original, by name",
|
|
async (_, bytes, names, others) => {
|
|
const lib = await open({ contentSource: stubSource(bytes) });
|
|
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
|
|
expect(Object.keys(exif).sort()).toEqual([...names].sort());
|
|
// Tags outside the thirteen fields, as exifreader decodes them.
|
|
expect(exif).toMatchObject(others);
|
|
await lib.close();
|
|
},
|
|
);
|
|
|
|
it("picks the common EXIF fields from a JPEG original's tags", async () => {
|
|
const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) });
|
|
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
|
|
expect(readPhotoExif(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();
|
|
});
|
|
|
|
// The fields picked from HEIC_WITH_EXIF's tags, and from JPEG_WITH_EXIF's,
|
|
// which hold the same values.
|
|
const heicFields: PhotoExif = {
|
|
make: "Canon",
|
|
model: "EOS R5",
|
|
lensModel: "RF50mm F1.8 STM",
|
|
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,
|
|
};
|
|
|
|
it("picks the same common EXIF fields from a HEIC original's tags", async () => {
|
|
const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) });
|
|
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
|
|
expect(readPhotoExif(exif)).toStrictEqual(heicFields);
|
|
await lib.close();
|
|
});
|
|
|
|
it("reads the EXIF of a live photo whose image is a HEIC", async () => {
|
|
const { file: live, body } = await asLivePhoto(
|
|
file(1, 1),
|
|
livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }),
|
|
livePhotoHash(HEIC_WITH_EXIF, VIDEO),
|
|
);
|
|
const lib = await open({
|
|
client: new FilesClient([live]),
|
|
contentSource: cdnSource(new Map([[1, body]])),
|
|
});
|
|
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
|
|
expect(readPhotoExif(exif)).toStrictEqual(heicFields);
|
|
await lib.close();
|
|
});
|
|
|
|
// The build's type check, not this test, makes sure `Photo` has a method
|
|
// for every `PhotoExif` field, whatever the fixtures hold: `Photo`
|
|
// implements a type with one method per field. This test checks that each
|
|
// method gives the field picked from the tags exif() returns.
|
|
it.each([
|
|
["JPEG", JPEG_WITH_EXIF],
|
|
["HEIC", HEIC_WITH_EXIF],
|
|
])(
|
|
"has a method for each field, agreeing with the tags exif() returns, for a %s",
|
|
async (_, bytes) => {
|
|
const lib = await open({ contentSource: stubSource(bytes) });
|
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
const fields = readPhotoExif(await photo.exif());
|
|
// The file holds every field, so every method is checked.
|
|
expect(fields).toStrictEqual(heicFields);
|
|
for (const [field, value] of Object.entries(fields)) {
|
|
expect(await photo[field as keyof PhotoExif]()).toStrictEqual(
|
|
value,
|
|
);
|
|
}
|
|
await lib.close();
|
|
},
|
|
);
|
|
|
|
it("returns no EXIF tags for an original that is not an image", async () => {
|
|
const lib = await open();
|
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
|
expect(await photo.exif()).toStrictEqual({});
|
|
expect(await photo.dateTimeOriginal()).toBeUndefined();
|
|
await lib.close();
|
|
});
|
|
|
|
it("returns no EXIF tags 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 tags 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();
|
|
});
|
|
});
|