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
454 lines
17 KiB
TypeScript
454 lines
17 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`, `content()` and
|
|
* `exif()`.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
mkdtempSync,
|
|
readdirSync,
|
|
rmSync,
|
|
existsSync,
|
|
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 { asLivePhoto, cdnSource, IMAGE, livePhotoZip } from "../live-photo.js";
|
|
|
|
const USER_ID = 7;
|
|
|
|
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: 1,
|
|
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.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 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();
|
|
});
|
|
});
|