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. A ZIP with a second image or video, or whose parts come to more than 20 times its size plus 16 MiB, is refused. 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 was merged in pull request #128.
This commit is contained in:
@@ -10,14 +10,21 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
existsSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Library } from "../../src/library/index.js";
|
||||
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, livePhotoZip } from "../live-photo.js";
|
||||
|
||||
const USER_ID = 7;
|
||||
|
||||
@@ -142,6 +149,52 @@ describe("Library content wiring", () => {
|
||||
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(),
|
||||
|
||||
@@ -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,221 @@ 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]]));
|
||||
// Opened without being told that file 5 is a live photo, the cache
|
||||
// records the ZIP, and does not serve it.
|
||||
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("removes a live photo's ZIP an earlier version stored when it opens, so the precache fetches the image and video", 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((fileID) => fileID === 5);
|
||||
|
||||
expect(readdirSync(originals())).toEqual([]);
|
||||
expect(cache.pathsFor(5)).toEqual({});
|
||||
const [fetched] = await cache.ensureOriginals({ fileIDs: [5] });
|
||||
expect(fetched).toEqual({
|
||||
fileID: 5,
|
||||
path: join(originals(), "5.heic"),
|
||||
});
|
||||
expect(cache.pathsFor(5)).toEqual({ originalPath: fetched!.path });
|
||||
});
|
||||
|
||||
it("leaves the image and video another process has just stored when it opens before their JSON file is written", async () => {
|
||||
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
|
||||
const server = cdnSource(new Map([[5, body]]));
|
||||
let second: ContentCache | undefined;
|
||||
// Once the image and video are in place, and before the first cache
|
||||
// writes the JSON file naming them, a second cache opens.
|
||||
const source: ContentSource = {
|
||||
original: async (args) => {
|
||||
const stored = await server.original(args);
|
||||
second = cacheOf([live], new Map());
|
||||
await second.open((fileID) => fileID === 5);
|
||||
return stored;
|
||||
},
|
||||
thumbnail: (args) => server.thumbnail(args),
|
||||
};
|
||||
const first = buildCache({ files: [live], source }).cache;
|
||||
await first.open((fileID) => fileID === 5);
|
||||
|
||||
const result = await first.original(5);
|
||||
|
||||
expect(readdirSync(originals()).sort()).toEqual([
|
||||
"5.heic",
|
||||
"5.livephoto.json",
|
||||
"5.mov",
|
||||
]);
|
||||
expect(readFileSync(result.path)).toEqual(Buffer.from(IMAGE));
|
||||
expect(readFileSync(result.videoPath!)).toEqual(Buffer.from(VIDEO));
|
||||
expect(second!.pathsFor(5)).toEqual({});
|
||||
});
|
||||
|
||||
it.each(["missing", "empty"])(
|
||||
"fetches a live photo again when the video its JSON file names is %s",
|
||||
async (state) => {
|
||||
const { file: live, body } = await asLivePhoto(
|
||||
file(5, "IMG_5.HEIC"),
|
||||
);
|
||||
const cache = cacheOf([live], new Map([[5, body]]));
|
||||
await cache.open();
|
||||
const stored = await cache.original(5);
|
||||
if (state === "missing") rmSync(stored.videoPath!);
|
||||
else writeFileSync(stored.videoPath!, "");
|
||||
|
||||
const events: string[] = [];
|
||||
const fetched = await cache.original(5, {
|
||||
onProgress: (e) => events.push(e.status),
|
||||
});
|
||||
|
||||
expect(events.at(-1)).toBe("done");
|
||||
expect(fetched).toEqual(stored);
|
||||
expect(readFileSync(fetched.videoPath!)).toEqual(
|
||||
Buffer.from(VIDEO),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not take a file outside originals/ that a live photo's JSON file names", async () => {
|
||||
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
|
||||
mkdirSync(originals(), { recursive: true });
|
||||
writeFileSync(join(cacheDir, "5.heic"), "not this photo");
|
||||
writeFileSync(join(originals(), "5.mov"), VIDEO);
|
||||
writeFileSync(
|
||||
join(originals(), "5.livephoto.json"),
|
||||
JSON.stringify({ image: "../5.heic", video: "5.mov" }),
|
||||
);
|
||||
const cache = cacheOf([live], new Map([[5, body]]));
|
||||
await cache.open((fileID) => fileID === 5);
|
||||
|
||||
const result = await cache.original(5);
|
||||
|
||||
expect(result.path).toBe(join(originals(), "5.heic"));
|
||||
expect(readFileSync(result.path)).toEqual(Buffer.from(IMAGE));
|
||||
expect(readFileSync(join(cacheDir, "5.heic"), "utf-8")).toBe(
|
||||
"not this photo",
|
||||
);
|
||||
});
|
||||
|
||||
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({});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user