Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot 76823bc374 Store live photos as their image and their video (closes #107)
check / check (push) Successful in 40s
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
2026-09-28 15:13:25 +00:00
4 changed files with 91 additions and 6 deletions
+1 -2
View File
@@ -14,8 +14,7 @@ pre-1.0
# Next Step
None: every issue still open is done on `next` or `next2` and waits for it to
reach `main`.
None: every issue still open is done on `next` and waits for it to reach `main`.
Tagging and releases are decided by sneak alone, and happen only when he
declares one.
+34 -4
View File
@@ -24,7 +24,14 @@
// does; thumbnails have none. On top of that this module refuses to record a
// stored file that came out empty.
import { existsSync, readFileSync, statSync } from "node:fs";
import {
closeSync,
existsSync,
openSync,
readFileSync,
readSync,
statSync,
} from "node:fs";
import {
chmod,
mkdir,
@@ -246,6 +253,25 @@ const fileSize = (path: string): number | undefined => {
const hasContent = (path: string | undefined): boolean =>
path !== undefined && (fileSize(path) ?? 0) > 0;
// Whether the file at `path` begins as a ZIP does, with `PK\x03\x04`. False
// when it cannot be read.
const isZip = (path: string): boolean => {
try {
const fd = openSync(path, "r");
try {
const head = Buffer.alloc(4);
return (
readSync(fd, head, 0, 4, 0) === 4 &&
head.toString("latin1") === "PK\x03\x04"
);
} finally {
closeSync(fd);
}
} catch {
return false;
}
};
// A live photo's image and video are named with the extensions from inside its
// ZIP, so their names alone do not say which is which. Wherever the cache or a
// backup stores one, a JSON file of this name beside them names both.
@@ -847,15 +873,19 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
if (id === undefined || !existsSync(path)) continue;
// A live photo's image and video are one entry, as the JSON file
// beside them names them. A live photo's file with no such JSON
// file, such as the ZIP an earlier version stored under the
// image's name, is not its original and is removed.
// file is not its original. If it is a ZIP, it is the one an
// earlier version stored under the image's name, and is removed.
// Any other is left alone: another process may have just stored
// it and not yet written the JSON file.
const livePhoto = names.has(livePhotoJSONName(id))
? readLivePhotoJSON(dir, id)
: undefined;
if (livePhoto !== undefined) {
into.set(id, livePhoto);
} else if (isLivePhoto(id)) {
await rm(path, { force: true }).catch(() => undefined);
if (isZip(path)) {
await rm(path, { force: true }).catch(() => undefined);
}
} else {
into.set(id, { path });
}
+26
View File
@@ -1262,6 +1262,32 @@ describe("backup of live photos", () => {
},
);
it.each(["missing", "empty"])(
"fetches a live photo again when the image its JSON file names is %s",
async (state) => {
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 });
const image = join(outDir, "originals", "500.heic");
if (state === "missing") rmSync(image);
else writeFileSync(image, "");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({
downloaded: 1,
skipped: 0,
failed: 0,
});
expect(readFileSync(image)).toEqual(Buffer.from(IMAGE));
expect(tree(outDir)).toEqual(linked);
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"),
+30
View File
@@ -560,6 +560,36 @@ describe("ContentCache live photos", () => {
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) => {