Compare commits

4 Commits
Author SHA1 Message Date
clawbot c5a588177c Thumbnail test on main: re-encode a small image so it cannot time out (closes #153)
check / check (push) Successful in 1m19s
The `main` half of the fix for the timed-out thumbnail test, carrying only the test change from `next` (d788c54). The test "re-encodes smaller until the thumbnail fits the recorded size" built a noisy 400x300 JPEG, which came close to vitest's 5 s limit and made `script/cibuild` fail on a busy host. It now uses a noisy 64x48 JPEG, keeps every assertion, and runs in about 1 s.

Model: opus-5-5
2026-10-02 04:22:27 +02:00
sneak 9e6e038545 Merge pull request 'next → main: pre-1.0 milestone' (#31) from next into main
check / check (push) Successful in 52s
Reviewed-on: #31
2026-09-29 03:12:31 +02:00
clawbot 1b7158fcd3 Test the live-photo writer's fsyncs (closes #130)
check / check (push) Successful in 43s
A test checks that the live-photo writer fsyncs the image's and the
video's temp files before renaming either into place, and fsyncs the
directory after both renames, as the writeAtomic test does for one file.

Model: opus-5-5
2026-09-28 18:45:48 +02:00
clawbot 9e6deb21eb 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. 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 18:06:01 +02:00
6 changed files with 119 additions and 10 deletions
+6 -2
View File
@@ -14,14 +14,18 @@ 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.
# Completed Steps
- 2026-09-28: Tested the live-photo writer's fsyncs (issue 130). A test checks
that the image's and the video's temp files are fsynced before either is
renamed into place, and the directory after both renames, as the `writeAtomic`
test does for one file.
- 2026-09-23: Live photos are stored as their image and their video (issue 107).
A live photo, which Ente stores as one ZIP, is unpacked as it downloads into
`<fileID>.<ext>` for the image and for the video, each with its extension from
+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"),
+18
View File
@@ -1909,6 +1909,24 @@ describe("downloadFile live photos", () => {
]);
});
it("fsyncs each part's temp file before the renames and the directory after", async () => {
const t = setup(livePhotoZip(), livePhoto);
await t.run();
// The same durability contract as `writeAtomic`, for two files: both
// temp files are on disk before either is renamed into place, and the
// directory is fsynced once both renames are done.
const [imageTmp, videoTmp] = renameHook.calls.map((c) => c.from);
expect(durabilityHook.events).toEqual([
`sync:w:${imageTmp}`,
`sync:w:${videoTmp}`,
`rename:${join(t.dir, "f.heic")}`,
`rename:${join(t.dir, "f.mov")}`,
`sync:r:${t.dir}`,
]);
});
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");
+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) => {
+5 -4
View File
@@ -787,12 +787,13 @@ describe("fixMissingThumbnails", () => {
});
it("re-encodes smaller until the thumbnail fits the recorded size", async () => {
// A noisy 400x300 JPEG, which the default encoding (quality 50, not
// A noisy 64x48 JPEG, which the default encoding (quality 50, not
// resized because it is under 720 px) cannot compress below the size
// recorded here: one byte less than that encoding's ciphertext.
// recorded here: one byte less than that encoding's ciphertext. It is
// small so that each encode is quick even on a busy host.
const fixMock = await buildThumbMock();
const w = 400;
const h = 300;
const w = 64;
const h = 48;
const noisy = new Uint8Array(
jpegJs.encode(
{