Store live photos as their image and their video (closes #107)
check / check (push) Successful in 41s

A live photo, which Ente stores as one ZIP, is unpacked as it downloads
into its image and its video, each `<fileID>.<ext>` with its extension
from the ZIP, beside `<fileID>.livephoto.json`, which names the two.
Both are checked against the recorded hash and renamed into place only
when both are complete. The backup and the content cache count a live
photo as stored only with both files, album folders link both,
`quak get` writes both, and the content result gives the video as
`videoPath`. A ZIP an earlier version stored is replaced.

Model: opus-5-5
This commit is contained in:
2026-09-23 12:50:02 +00:00
parent 04094a8cfb
commit a066134a17
15 changed files with 1340 additions and 337 deletions
+216 -22
View File
@@ -53,6 +53,13 @@ import { Library } 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,
VIDEO,
} from "../live-photo.js";
// `open` and `rename` are wrapped to record, in order, every fsync and rename,
// so a test can pin the sequence "fsync the temp file, rename, fsync the
@@ -740,6 +747,28 @@ describe("the refresh before a backup", () => {
});
});
// Every entry under collections/, one level of directories deep, with each
// symlink's target.
const tree = (outDir: string): string[] => {
const lines: string[] = [];
const list = (dir: string, prefix: string): void => {
for (const name of readdirSync(dir).sort()) {
const path = join(dir, name);
const st = lstatSync(path);
if (st.isSymbolicLink()) {
lines.push(`${prefix}${name} -> ${readlinkSync(path)}`);
} else if (st.isDirectory() && prefix === "") {
lines.push(`${name}/`);
list(path, `${name}/`);
} else {
lines.push(`${prefix}${name}`);
}
}
};
list(join(outDir, "collections"), "");
return lines;
};
// The album folders under collections/, driven through `runBackup` with a
// stand-in library whose albums a test changes between runs.
describe("backup album folders", () => {
@@ -763,28 +792,6 @@ describe("backup album folders", () => {
},
});
// Every entry under collections/, one level of directories deep, with each
// symlink's target.
const tree = (outDir: string): string[] => {
const lines: string[] = [];
const list = (dir: string, prefix: string): void => {
for (const name of readdirSync(dir).sort()) {
const path = join(dir, name);
const st = lstatSync(path);
if (st.isSymbolicLink()) {
lines.push(`${prefix}${name} -> ${readlinkSync(path)}`);
} else if (st.isDirectory() && prefix === "") {
lines.push(`${name}/`);
list(path, `${name}/`);
} else {
lines.push(`${prefix}${name}`);
}
}
};
list(join(outDir, "collections"), "");
return lines;
};
const albumID = (outDir: string, jsonName: string): number =>
JSON.parse(readFileSync(join(outDir, "collections", jsonName), "utf-8"))
.id;
@@ -1054,3 +1061,190 @@ describe("backup album folders", () => {
).toBe(json);
});
});
// A live photo, which Ente stores as one ZIP, is backed up as its image and
// its video, which a photo viewer can open, beside a JSON file naming them,
// and its album folder links both. These tests download a live photo ZIP
// through the real download layer (test/live-photo.ts).
describe("backup of live photos", () => {
// An account of one album, Trip (10), holding `files`.
class TripClient extends MockClient {
constructor(private readonly files: EnteFile[]) {
super();
}
override async collectionsSince(): Promise<CollectionsPage> {
return {
collections: [collection(10, "Trip")],
deleted: [],
cursor: 1,
};
}
override async filesSince(): Promise<FilesPage> {
return { files: this.files, deleted: [], cursor: 1 };
}
}
const open = (files: EnteFile[], bodies: Map<number, Uint8Array>) =>
openLibrary(cdnSource(bodies), new TripClient(files));
// What an earlier version stored for live photo 500: the ZIP under the
// image's name, and its link.
const earlierZIP = (outDir: string): void => {
mkdirSync(join(outDir, "originals"), { recursive: true });
mkdirSync(join(outDir, "collections", "Trip"), { recursive: true });
writeFileSync(join(outDir, "originals", "500.HEIC"), livePhotoZip());
symlinkSync(
"../../originals/500.HEIC",
join(outDir, "collections", "Trip", "IMG_0500.HEIC"),
);
};
const stored = ["500.heic", "500.json", "500.livephoto.json", "500.mov"];
const linked = [
"Trip/",
"Trip/IMG_0500.heic -> ../../originals/500.heic",
"Trip/IMG_0500.mov -> ../../originals/500.mov",
"Trip.json",
];
it("stores a live photo as its image and its video and links both", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const lib = await open([live], new Map([[500, body]]));
const outDir = join(root, "backup");
const originals = join(outDir, "originals");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
expect(readdirSync(originals).sort()).toEqual(stored);
expect(readFileSync(join(originals, "500.heic"))).toEqual(
Buffer.from(IMAGE),
);
expect(readFileSync(join(originals, "500.mov"))).toEqual(
Buffer.from(VIDEO),
);
expect(tree(outDir)).toEqual(linked);
// Both parts are there, so the next run fetches nothing.
const second = await lib.backup({ downloadDirectory: outDir });
expect(second).toMatchObject({ downloaded: 0, skipped: 1, failed: 0 });
await lib.close();
});
it("gives both links of each live photo their own names when titles clash", async () => {
const a = await asLivePhoto(file(500, 10, "IMG_0001.HEIC"));
const b = await asLivePhoto(file(501, 10, "IMG_0001.HEIC"));
const lib = await open(
[a.file, b.file],
new Map([
[500, a.body],
[501, b.body],
]),
);
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
expect(tree(outDir)).toEqual([
"Trip/",
"Trip/IMG_0001 (500).heic -> ../../originals/500.heic",
"Trip/IMG_0001 (500).mov -> ../../originals/500.mov",
"Trip/IMG_0001 (501).heic -> ../../originals/501.heic",
"Trip/IMG_0001 (501).mov -> ../../originals/501.mov",
"Trip.json",
]);
await lib.close();
});
it("replaces the ZIP an earlier version stored, and its link", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const outDir = join(root, "backup");
earlierZIP(outDir);
const lib = await open([live], new Map([[500, body]]));
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
expect(tree(outDir)).toEqual(linked);
await lib.close();
});
it("stores nothing for a live photo that fails its hash, and keeps what was there", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
livePhotoZip(),
"not:the recorded hash",
);
const outDir = join(root, "backup");
earlierZIP(outDir);
const lib = await open([live], new Map([[500, body]]));
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 0, failed: 1 });
expect(result.errors.map((e) => e.fileID)).toEqual([500]);
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
expect(readdirSync(join(outDir, "originals"))).toEqual(["500.HEIC"]);
expect(tree(outDir)).toEqual([
"Trip/",
"Trip/IMG_0500.HEIC -> ../../originals/500.HEIC",
"Trip.json",
]);
await lib.close();
});
it("copies both parts of a live photo the cache already holds", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const lib = await open([live], new Map([[500, body]]));
const cached = await lib.photos.byID({ fileID: 500 })!.original();
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
expect(readFileSync(join(outDir, "originals", "500.mov"))).toEqual(
Buffer.from(VIDEO),
);
expect(tree(outDir)).toEqual(linked);
// The cache keeps its own copy.
expect(existsSync(cached.videoPath!)).toBe(true);
await lib.close();
});
it("serves a live photo the backup stored to a library reading the backup", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const lib = await open([live], new Map([[500, body]]));
const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir });
await lib.close();
// Another cache over the same backup, whose server has nothing.
const reader = await Library.open({
client: new TripClient([live]),
cacheDirectory: join(root, "other-cache"),
downloadDirectory: outDir,
contentSource: cdnSource(new Map()),
refreshIntervalSeconds: 3600,
precacheThumbnails: false,
precacheOriginals: false,
});
const read = await reader.photos.byID({ fileID: 500 })!.original();
expect(read).toEqual({
path: join(outDir, "originals", "500.heic"),
videoPath: join(outDir, "originals", "500.mov"),
bytes: IMAGE.length,
});
await reader.close();
});
});
+124
View File
@@ -12,6 +12,7 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
@@ -22,6 +23,7 @@ import {
import { join } from "node:path";
import { tmpdir } from "node:os";
import { PassThrough } from "node:stream";
import * as jpegJs from "jpeg-js";
import {
describe,
it,
@@ -54,6 +56,14 @@ import type { ContentSource } from "../../src/library/content.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
import { init, toBase64 } from "../../src/crypto/index.js";
import { defaultCacheDirectory } from "../../src/library/index.js";
import {
asLivePhoto,
cdnSource,
IMAGE,
livePhotoHash,
livePhotoZip,
VIDEO,
} from "../live-photo.js";
const USER_ID = 42;
@@ -531,6 +541,120 @@ describe("get and get-thumb", () => {
});
});
// A live photo, which Ente stores as one ZIP, is written as its image and its
// video, which a photo viewer can open. The account here is Vacation holding
// one live photo, 300, downloaded through the real download layer
// (test/live-photo.ts).
describe("a live photo", () => {
const livePhotoClient = async (image = IMAGE): Promise<Client> => {
const { file: live, body } = await asLivePhoto(
file(300, 1, "IMG_0300.HEIC"),
livePhotoZip({ "image.heic": image, "video.mov": VIDEO }),
livePhotoHash(image, VIDEO),
);
const fake = {
whoami: () => ({ email: "cli@example.com", userID: USER_ID }),
collectionsSince: async () => ({
collections: [collection(1, "Vacation")],
deleted: [],
cursor: 1,
}),
filesSince: async () => ({ files: [live], deleted: [], cursor: 1 }),
contentSource: () => cdnSource(new Map([[300, body]])),
// The ML data request of `backup-metadata`: no file has any.
getApiClient: () => ({ postJSON: async () => ({ data: [] }) }),
};
return fake as unknown as Client;
};
it("get writes its image and video, named after the title with their own extensions", async () => {
const ctx = context(await livePhotoClient());
const dir = join(root, "cwd");
mkdirSync(dir);
const previous = process.cwd();
process.chdir(dir);
try {
expect(await getCommand(ctx, "300", {})).toBe(0);
} finally {
process.chdir(previous);
}
expect(readdirSync(dir).sort()).toEqual([
"IMG_0300.heic",
"IMG_0300.mov",
]);
expect(readFileSync(join(dir, "IMG_0300.heic"))).toEqual(
Buffer.from(IMAGE),
);
expect(readFileSync(join(dir, "IMG_0300.mov"))).toEqual(
Buffer.from(VIDEO),
);
expect(stderr.text).toBe(
`${IMAGE.length} bytes -> IMG_0300.heic\n` +
`${VIDEO.length} bytes -> IMG_0300.mov\n`,
);
});
it("get --out writes the image there and the video beside it", async () => {
const out = join(root, "photo.jpg");
const video = join(root, "photo.mov");
expect(
await getCommand(context(await livePhotoClient()), "300", { out }),
).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
expect(stderr.text).toBe(
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
);
});
it("get exits 1 and writes nothing when --out has the video's extension", async () => {
const out = join(root, "photo.MOV");
expect(
await getCommand(context(await livePhotoClient()), "300", { out }),
).toBe(1);
expect(existsSync(out)).toBe(false);
expect(existsSync(join(root, "photo.mov"))).toBe(false);
expect(stderr.text).toBe(
`File 300 is a live photo, and its video would also be written to ${out}\n`,
);
});
it("backup-metadata --exif reads its image", async () => {
// A 4x4 JPEG, whose size can only come from reading the image; the
// ZIP and the video are not JPEGs.
const jpeg = jpegJs.encode(
{ data: new Uint8Array(4 * 4 * 4), width: 4, height: 4 },
50,
).data;
const dir = join(root, "dump");
expect(
await backupMetadataCommand(
context(await livePhotoClient(new Uint8Array(jpeg))),
dir,
{ exif: true },
),
).toBe(0);
const record = JSON.parse(
readFileSync(
join(dir, "collections", "1-Vacation", "300.json"),
"utf-8",
),
);
expect(record.imageMetadata).toMatchObject({
format: "jpeg",
width: 4,
height: 4,
});
});
});
describe("backup", () => {
it("exits 0 and prints a summary when every file is saved", async () => {
const dir = join(root, "backup");
+155 -48
View File
@@ -55,6 +55,7 @@ import {
readFileSync,
rmSync,
mkdtempSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
@@ -81,6 +82,7 @@ import {
writeAtomic,
} from "../../src/download/index.js";
import type { EnteFile, FileMetadata } from "../../src/model/types.js";
import { IMAGE, livePhotoHash, livePhotoZip, VIDEO } from "../live-photo.js";
// ---------------------------------------------------------------------------
// Test helpers
@@ -106,6 +108,8 @@ import type { EnteFile, FileMetadata } from "../../src/model/types.js";
const renameHook = vi.hoisted(() => ({
calls: [] as { from: string; to: string; sourceExisted: boolean }[],
failWith: null as Error | null,
// When set, only a rename to this path fails.
failTo: null as string | null,
}));
/**
@@ -181,7 +185,10 @@ vi.mock("node:fs/promises", async (importOriginal) => {
sourceExisted: sourceExists(from),
});
durabilityHook.events.push(`rename:${to}`);
if (renameHook.failWith !== null) {
if (
renameHook.failWith !== null &&
(renameHook.failTo === null || renameHook.failTo === to)
) {
throw renameHook.failWith;
}
await actual.rename(from, to);
@@ -216,6 +223,7 @@ beforeEach(() => {
hashHook.lengths.length = 0;
renameHook.calls.length = 0;
renameHook.failWith = null;
renameHook.failTo = null;
durabilityHook.events.length = 0;
writeHook.writes.length = 0;
});
@@ -1642,38 +1650,35 @@ describe.each(entryPoints)("$name progress", ({ name, download }) => {
});
});
describe("downloadFile content hash", () => {
// Node's own BLAKE2b-512 is the reference, so these tests do not depend
// on the code under test to compute what they expect.
const blake2b = (bytes: Uint8Array): string =>
createHash("blake2b512").update(bytes).digest("base64");
// Node's own BLAKE2b-512 is the reference, so the tests below do not depend on
// the code under test to compute what they expect.
const blake2b = (bytes: Uint8Array): string =>
createHash("blake2b512").update(bytes).digest("base64");
// Serve `plaintext` encrypted as file 999 with the given metadata. Four
// responses are scripted so a retried mismatch would show in `requests`.
const setup = (plaintext: Uint8Array, metadata: Partial<FileMetadata>) => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptFileBody(plaintext, key);
const file = buildMockEnteFile(key, header, header);
file.metadata = { ...file.metadata, ...metadata };
const body = { kind: "body", bytes: ciphertext } as const;
const { fetch, requests } = scriptedCdnFetch(body, body, body, body);
const api = new ApiClient({ fetch, retry: { ...noWait, attempts: 4 } });
const dir = mkdtempSync(join(testDir, "hash-"));
const outPath = join(dir, "f.bin");
return {
run: () => downloadFile(api, file, outPath),
dir,
outPath,
requests,
};
// Serve `plaintext` encrypted as file 999 with the given metadata, to be
// written to `f.bin` in a fresh directory. Four responses are scripted so a
// retried failure would show in `requests`.
const setup = (plaintext: Uint8Array, metadata: Partial<FileMetadata>) => {
const key = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptFileBody(plaintext, key);
const file = buildMockEnteFile(key, header, header);
file.metadata = { ...file.metadata, ...metadata };
const body = { kind: "body", bytes: ciphertext } as const;
const { fetch, requests } = scriptedCdnFetch(body, body, body, body);
const api = new ApiClient({ fetch, retry: { ...noWait, attempts: 4 } });
const dir = mkdtempSync(join(testDir, "hash-"));
const outPath = join(dir, "f.bin");
return {
run: () => downloadFile(api, file, outPath),
api,
file,
dir,
outPath,
requests,
};
};
const livePhotoZip = zipSync({
"image.heic": patternBytes(500, 81),
"video.mov": patternBytes(900, 82),
});
const livePhotoHash = `${blake2b(patternBytes(500, 81))}:${blake2b(patternBytes(900, 82))}`;
describe("downloadFile content hash", () => {
it("stores a file whose hash matches", async () => {
const plaintext = patternBytes(700, 80);
const t = setup(plaintext, { hash: blake2b(plaintext) });
@@ -1706,17 +1711,18 @@ describe("downloadFile content hash", () => {
});
it("stores a live photo whose image and video hashes match", async () => {
const t = setup(livePhotoZip, {
const t = setup(livePhotoZip(), {
fileType: "livePhoto",
hash: livePhotoHash,
hash: livePhotoHash(),
});
await t.run();
expectSameBytes(readFileSync(t.outPath), livePhotoZip);
expect(readFileSync(join(t.dir, "f.heic"))).toEqual(Buffer.from(IMAGE));
expect(readFileSync(join(t.dir, "f.mov"))).toEqual(Buffer.from(VIDEO));
});
it("hashes a large live photo entry as it decompresses, never whole", async () => {
it("writes a large live photo entry as it decompresses, never whole", async () => {
// 64 MiB of zeros deflates to a few kilobytes, the shape of a ZIP
// that would exhaust memory if expanded whole.
const image = new Uint8Array(64 * 1024 * 1024);
@@ -1724,40 +1730,45 @@ describe("downloadFile content hash", () => {
const zip = zipSync({ "image.heic": image, "video.mov": video });
const t = setup(zip, {
fileType: "livePhoto",
hash: `${blake2b(image)}:${blake2b(video)}`,
hash: livePhotoHash(image, video),
});
await t.run();
expectSameBytes(readFileSync(t.outPath), zip);
expect(statSync(join(t.dir, "f.heic")).size).toBe(image.length);
expectSameBytes(readFileSync(join(t.dir, "f.mov")), video);
const hashed = hashHook.lengths.reduce((a, b) => a + b, 0);
expect(hashed).toBe(image.length + video.length);
expect(Math.max(...hashHook.lengths)).toBeLessThanOrEqual(
2 * STREAM_CHUNK_SIZE,
);
const written = writeHook.writes.filter((w) => w.path.endsWith(".tmp"));
expect(Math.max(...written.map((w) => w.length))).toBeLessThanOrEqual(
2 * STREAM_CHUNK_SIZE,
);
});
it("rejects a live photo whose hash does not match", async () => {
it("rejects a live photo whose hash does not match, keeping what was there", async () => {
// The whole ZIP's hash is not the recorded one: each part is hashed.
const t = setup(livePhotoZip, {
fileType: "livePhoto",
hash: blake2b(livePhotoZip),
});
const zip = livePhotoZip();
const t = setup(zip, { fileType: "livePhoto", hash: blake2b(zip) });
writeFileSync(t.outPath, "an earlier download");
await expect(t.run()).rejects.toThrow(
/file 999: content hash .* does not match/,
);
expect(readdirSync(t.dir)).toEqual([]);
expect(readdirSync(t.dir)).toEqual(["f.bin"]);
expect(readFileSync(t.outPath, "utf-8")).toBe("an earlier download");
});
it("rejects a live photo that is not a readable ZIP and does not retry", async () => {
// Bytes 8-9 of a ZIP entry's local header name its compression
// method; 99 is one no reader knows, so the entry cannot be read.
const zip = livePhotoZip.slice();
const zip = livePhotoZip();
zip[8] = 99;
zip[9] = 0;
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash });
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash() });
await expect(t.run()).rejects.toThrow(
/file 999: live photo is not a readable ZIP/,
@@ -1768,8 +1779,8 @@ describe("downloadFile content hash", () => {
});
it("rejects a live photo ZIP with no image entry", async () => {
const zip = zipSync({ "video.mov": patternBytes(900, 82) });
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash });
const zip = livePhotoZip({ "video.mov": VIDEO });
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash() });
await expect(t.run()).rejects.toThrow(
/file 999: live photo ZIP does not hold both an image and a video/,
@@ -1779,8 +1790,8 @@ describe("downloadFile content hash", () => {
});
it("rejects a live photo ZIP with no video entry", async () => {
const zip = zipSync({ "image.heic": patternBytes(500, 81) });
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash });
const zip = livePhotoZip({ "image.heic": IMAGE });
const t = setup(zip, { fileType: "livePhoto", hash: livePhotoHash() });
await expect(t.run()).rejects.toThrow(
/file 999: live photo ZIP does not hold both an image and a video/,
@@ -1789,3 +1800,99 @@ describe("downloadFile content hash", () => {
expect(readdirSync(t.dir)).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// Live photos
//
// A live photo arrives as a ZIP of its image and its video. It is written as
// those two files, which a photo viewer can open, each named after the
// destination with its own extension from the ZIP, the way Ente's clients name
// them when they save one.
// ---------------------------------------------------------------------------
describe("downloadFile live photos", () => {
const livePhoto = { fileType: "livePhoto", hash: livePhotoHash() } as const;
it("names the image and the video after the title when no outPath is given", async () => {
const t = setup(livePhotoZip(), {
...livePhoto,
title: "IMG_1234.HEIC",
});
const result = await inDirectory(t.dir, () =>
downloadFile(t.api, t.file),
);
// `bytesWritten` is the length of the decrypted ZIP.
expect(result).toEqual({
path: "IMG_1234.heic",
videoPath: "IMG_1234.mov",
bytesWritten: livePhotoZip().length,
});
expect(readdirSync(t.dir).sort()).toEqual([
"IMG_1234.heic",
"IMG_1234.mov",
]);
});
it("gives each part its own extension from the ZIP, letters and digits only", async () => {
const zip = livePhotoZip({ "image.JPG": IMAGE, "video.m-4v": VIDEO });
const t = setup(zip, livePhoto);
const result = await downloadFile(t.api, t.file, join(t.dir, "f.HEIC"));
expect(result.path).toBe(join(t.dir, "f.JPG"));
expect(result.videoPath).toBe(join(t.dir, "f.bin"));
expect(readdirSync(t.dir).sort()).toEqual(["f.JPG", "f.bin"]);
});
it("replaces what was at the destination, such as an earlier ZIP of the two", async () => {
const t = setup(livePhotoZip(), livePhoto);
writeFileSync(t.outPath, livePhotoZip());
await t.run();
expect(readdirSync(t.dir).sort()).toEqual(["f.heic", "f.mov"]);
});
it("renames the image and then the video into place, each from its own temp file", async () => {
const t = setup(livePhotoZip(), livePhoto);
await t.run();
expect(
renameHook.calls.map((c) => [
dirname(c.from),
c.to,
c.sourceExisted,
]),
).toEqual([
[t.dir, join(t.dir, "f.heic"), true],
[t.dir, join(t.dir, "f.mov"), true],
]);
});
it("stores neither part when the video cannot be renamed into place", async () => {
const t = setup(livePhotoZip(), livePhoto);
renameHook.failWith = new Error("simulated rename failure");
renameHook.failTo = join(t.dir, "f.mov");
await expect(t.run()).rejects.toThrow("simulated rename failure");
expect(readdirSync(t.dir)).toEqual([]);
});
it("refuses an image and a video with the same extension, storing nothing", async () => {
// On a file system that ignores case, the two would be one file.
const zip = livePhotoZip({ "image.mov": IMAGE, "video.MOV": VIDEO });
const t = setup(zip, livePhoto);
writeFileSync(t.outPath, "an earlier download");
await expect(t.run()).rejects.toThrow(
/file 999: live photo's image and video have the same extension/,
);
expect(readdirSync(t.dir)).toEqual(["f.bin"]);
expect(t.requests()).toBe(1);
});
});
+131
View File
@@ -31,6 +31,8 @@ import {
existsSync,
writeFileSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
@@ -44,6 +46,13 @@ import {
} from "../../src/library/content.js";
import { RequestPools } from "../../src/library/pools.js";
import type { EnteFile } from "../../src/model/types.js";
import {
asLivePhoto,
cdnSource,
IMAGE,
livePhotoZip,
VIDEO,
} from "../live-photo.js";
const file = (id: number, title = `file-${id}.jpg`): EnteFile => ({
id,
@@ -458,3 +467,125 @@ describe("ContentCache.ensureThumbnails", () => {
expect(results[1]?.error).toMatch(/unknown file/i);
});
});
// A live photo's original is two files, its image and its video, which a
// photo viewer can open, and a JSON file naming them: the two are named with
// the extensions from inside the ZIP, so the names alone do not say which is
// which. These tests download a live photo ZIP through the real download
// layer (test/live-photo.ts).
describe("ContentCache live photos", () => {
const originals = (): string => join(cacheDir, "originals");
// A cache over the stand-in server, which holds `bodies` by file ID.
const cacheOf = (
files: EnteFile[],
bodies: Map<number, Uint8Array>,
): ContentCache => buildCache({ files, source: cdnSource(bodies) }).cache;
it("stores a live photo as its image and its video and a JSON file naming them", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
const cache = cacheOf([live], new Map([[5, body]]));
await cache.open();
const result = await cache.original(5);
expect(result).toEqual({
path: join(originals(), "5.heic"),
videoPath: join(originals(), "5.mov"),
bytes: IMAGE.length,
});
expect(readFileSync(result.path)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(result.videoPath!)).toEqual(Buffer.from(VIDEO));
expect(statSync(result.videoPath!).mode & 0o777).toBe(0o600);
expect(
JSON.parse(
readFileSync(join(originals(), "5.livephoto.json"), "utf-8"),
),
).toEqual({ image: "5.heic", video: "5.mov" });
expect(cache.pathsFor(5)).toEqual({ originalPath: result.path });
});
it("serves a stored live photo from disk after the cache is opened again", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
const first = cacheOf([live], new Map([[5, body]]));
await first.open();
const stored = await first.original(5);
// This server has nothing, so a fetch would fail.
const second = cacheOf([live], new Map());
await second.open();
const events: string[] = [];
const served = await second.original(5, {
onProgress: (e) => events.push(e.status),
});
expect(served).toEqual(stored);
expect(events).toEqual(["skipped"]);
});
it("replaces a live photo an earlier version stored as a ZIP under the image's name", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
mkdirSync(originals(), { recursive: true });
writeFileSync(join(originals(), "5.HEIC"), livePhotoZip());
const cache = cacheOf([live], new Map([[5, body]]));
await cache.open();
const result = await cache.original(5);
expect(result.videoPath).toBe(join(originals(), "5.mov"));
expect(readdirSync(originals()).sort()).toEqual([
"5.heic",
"5.livephoto.json",
"5.mov",
]);
});
it("evicts a live photo's image, video and JSON file together", async () => {
const a = await asLivePhoto(file(5, "a.HEIC"));
const b = await asLivePhoto(file(6, "b.HEIC"));
const size = IMAGE.length + VIDEO.length;
const cache = new ContentCache({
pools: new RequestPools(),
source: cdnSource(
new Map([
[5, a.body],
[6, b.body],
]),
),
cacheDirectory: cacheDir,
getFile: (id) => [a.file, b.file].find((f) => f.id === id),
// Room for one live photo, on a disk with plenty free.
cacheOriginalsMaxBytes: size,
freeBelowBytes: 0,
statfs: async () => ({ bsize: 1, bavail: 1e12 }),
});
await cache.open();
await cache.original(5);
await cache.original(6);
expect(readdirSync(originals()).sort()).toEqual([
"6.heic",
"6.livephoto.json",
"6.mov",
]);
expect(cache.originalsStatus().usedBytes).toBe(size);
});
it("stores nothing when a live photo does not match its recorded hash", async () => {
const { file: live, body } = await asLivePhoto(
file(5, "IMG_5.HEIC"),
livePhotoZip(),
"not:the recorded hash",
);
const cache = cacheOf([live], new Map([[5, body]]));
await cache.open();
await expect(cache.original(5)).rejects.toThrow(
/file 5: content hash .* does not match/,
);
expect(readdirSync(originals())).toEqual([]);
expect(cache.pathsFor(5)).toEqual({});
});
});
+77
View File
@@ -0,0 +1,77 @@
/**
* Live photo fixtures for the download, content cache, backup and CLI tests.
*
* Ente stores a live photo as one ZIP holding its image and its video, which
* Ente's clients name `image.<ext>` and `video.<ext>`. The ZIP is built here
* with fflate from small fixed bytes and encrypted the way the server serves a
* file under 4 MiB, as one secretstream chunk. `cdnSource` serves it to the
* real download layer, so a test checks what quak stores for a real one.
*/
import { createHash } from "node:crypto";
import { zipSync } from "fflate";
import { ApiClient } from "../src/api/client.js";
import { encryptBlob, init, toBase64 } from "../src/crypto/index.js";
import {
makeDownloadContentSource,
type ContentSource,
} from "../src/library/content.js";
import type { EnteFile } from "../src/model/types.js";
export const IMAGE = new TextEncoder().encode("the still image");
export const VIDEO = new TextEncoder().encode("the few seconds of video");
// A live photo's ZIP; by default the entries an iPhone's live photo gets.
export const livePhotoZip = (
entries: Record<string, Uint8Array> = {
"image.heic": IMAGE,
"video.mov": VIDEO,
},
): Uint8Array => zipSync(entries);
const blake2b = (bytes: Uint8Array): string =>
createHash("blake2b512").update(bytes).digest("base64");
// The hash Ente's clients record for a live photo: the unkeyed BLAKE2b-512 of
// the image and of the video, each in standard base64, joined by a colon.
export const livePhotoHash = (image = IMAGE, video = VIDEO): string =>
`${blake2b(image)}:${blake2b(video)}`;
// `file` as a live photo whose original is `zip` and whose recorded hash is
// `hash`, and `body`, what the server serves for it: `zip` encrypted under the
// file's key and header.
export const asLivePhoto = async (
file: EnteFile,
zip = livePhotoZip(),
hash = livePhotoHash(),
): Promise<{ file: EnteFile; body: Uint8Array }> => {
await init();
const key = new Uint8Array(32).fill(file.id & 0xff);
const { header, ciphertext } = encryptBlob(zip, key);
return {
file: {
...file,
key,
metadata: { ...file.metadata, fileType: "livePhoto", hash },
file: { decryptionHeader: toBase64(header) },
},
body: ciphertext,
};
};
// A content source that downloads through the real download layer from a
// stand-in server, which serves `bodies` by file ID and a 404 for any other.
export const cdnSource = (bodies: Map<number, Uint8Array>): ContentSource =>
makeDownloadContentSource(
new ApiClient({
fetch: (async (url: string | URL) => {
const fileID = new URL(String(url)).searchParams.get("fileID");
const body = bodies.get(Number(fileID));
return body === undefined
? new Response("not found", { status: 404 })
: new Response(body);
}) as typeof globalThis.fetch,
retry: { attempts: 1 },
}),
);