check / check (push) Successful in 1m25s
Originals are saved at `{downloadDirectory}/YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.{fileID}{ext}`. The date is the photo's `takenAt` in local time, and `downloadDirectory` defaults to `./photos`, resolved when the library opens. The old `originals/` layout is gone.
`photo.download()` writes the original to `savePath`. It copies from the cache when the cache holds the original, and fetches otherwise. `lib.backup()` uses the same path and rule, and every album in `collections/` links to it. `isLocal` is true only when the original is at `savePath`.
For a file in several albums, one rule picks the copy everything uses: the most recently synced, with the lowest album ID breaking a tie.
Model: opus-5-5
729 lines
27 KiB
TypeScript
729 lines
27 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("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();
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|