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");
|
||||
|
||||
Reference in New Issue
Block a user