/** * 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 { if (this.served) return { collections: [], deleted: [], cursor: 1 }; this.served = true; return { collections: [collection(1)], deleted: [], cursor: 1 }; } async filesSince(): Promise { 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 { return { files: [live], deleted: [], cursor: 1 }; } } const originals = join(root, "cache", "originals"); const open = (opts: Partial): Promise => 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((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 { return { files: this.files, deleted: [], cursor: 1 }; } } const open = (opts: Partial = {}): Promise => 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(); }); });