A Photo now keeps the file its record was made from (the record projection holds one membership of each file), so savePath and isLocal still answer after a refresh removes the file. Library.open rejects an empty downloadDirectory. A backup clears leftover temp files in every date folder under its directory, not only in those of the files in its scope. placeOriginal no longer deletes what is at the save path before copying a live photo, and the tests that depended on that are removed. The TODO.md entry for issue 143 describes only the current layout. Model: opus-5-5
637 lines
23 KiB
TypeScript
637 lines
23 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()` and `exif()`.
|
|
*/
|
|
|
|
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 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("removes a live photo's ZIP an earlier version cached when it opens, and precaches its image and video", 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.jpg"), livePhotoZip());
|
|
|
|
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(existsSync(join(originals, "1.jpg"))).toBe(false);
|
|
await done;
|
|
|
|
expect(readdirSync(originals).sort()).toEqual([
|
|
"1.heic",
|
|
"1.livephoto.json",
|
|
"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 `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 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("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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
// What exif() returns for HEIC_WITH_EXIF, which holds the same values as
|
|
// JPEG_WITH_EXIF.
|
|
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("reads the same common EXIF fields from a HEIC original", async () => {
|
|
const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) });
|
|
expect(await lib.photos.byID({ fileID: 1 })!.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]])),
|
|
});
|
|
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual(
|
|
heicFields,
|
|
);
|
|
await lib.close();
|
|
});
|
|
|
|
it("returns no EXIF fields for an original that is not an image", 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();
|
|
});
|
|
});
|