Store live photos as their image and their video (closes #107)
check / check (push) Successful in 41s
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:
+216
-22
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user