From 5d11889e1a016e0d94cf4a16d84deacf3c3595a6 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 23:18:16 +0000 Subject: [PATCH] Live photo download no longer removes what was at its destination The image and video are renamed into place, and the rename already replaces the image's name, so the removal and the test that planted a ZIP there are gone, along with the comments describing it. Adds a test for a live photo the content cache recorded with no video, because it opened before knowing the file is a live photo while no JSON file named its image and video: original() fetches both again and writes the JSON file. The comment on that check now names this case. Model: opus-5-5 --- src/download/index.ts | 8 +++----- src/library/content.ts | 5 ++++- test/download/download.test.ts | 9 --------- test/library/content.test.ts | 30 ++++++++++++++++++++++++++++++ 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/download/index.ts b/src/download/index.ts index 7729890..e27184b 100644 --- a/src/download/index.ts +++ b/src/download/index.ts @@ -361,8 +361,8 @@ const openPart = async ( // written unpacked: each part is named `destination` with the extension // replaced by its own entry's, and the two must differ ignoring case. When the // file records a hash, `:` must match it, each over that -// part's own bytes. Only then is whatever was at `destination` removed and the -// image, then the video, renamed into place; on any failure neither is stored. +// part's own bytes. Only then are the image, then the video, renamed into +// place; on any failure neither is stored. // // The ZIP is chosen by its uploader and may expand enormously, so each part is // written as it decompresses and never held, and the ZIP is refused once the @@ -486,7 +486,6 @@ const decryptLivePhoto = async ( await part.handle.sync(); await part.handle.close(); } - await rm(destination, { force: true }); await rename(image.tmpPath, path); try { await rename(video.tmpPath, videoPath); @@ -570,8 +569,7 @@ const fetchAndDecrypt = async ( }, api.getRetryOptions()); // Write `file`'s original to `outPath`. A live photo is written as its image -// and its video beside `outPath` instead, and whatever was at `outPath` is -// removed (see `decryptLivePhoto`). +// and its video beside `outPath` instead (see `decryptLivePhoto`). export const downloadFile = async ( api: ApiClient, file: EnteFile, diff --git a/src/library/content.ts b/src/library/content.ts index 213e4b8..383319b 100644 --- a/src/library/content.ts +++ b/src/library/content.ts @@ -687,7 +687,10 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI { await this.touch(cached.path); return { ...cached, bytes: size, cached: true }; } - // A recorded file that has since gone re-fetches below. + // A recorded file that has since gone re-fetches below. So does a + // live photo recorded with no video: the cache opened before the + // library's records said it is a live photo, while its image and + // video had no JSON file beside them yet. known.delete(fileID); } diff --git a/test/download/download.test.ts b/test/download/download.test.ts index 8a9d612..06788fd 100644 --- a/test/download/download.test.ts +++ b/test/download/download.test.ts @@ -1883,15 +1883,6 @@ describe("downloadFile live photos", () => { expect(readdirSync(t.dir).sort()).toEqual(["f.JPG", "f.bin"]); }); - it("replaces what was at the destination", 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); diff --git a/test/library/content.test.ts b/test/library/content.test.ts index b2c644a..b461742 100644 --- a/test/library/content.test.ts +++ b/test/library/content.test.ts @@ -596,6 +596,36 @@ describe("ContentCache live photos", () => { expect(second!.pathsFor(5)).toEqual({}); }); + it("fetches a live photo's image and video again when the cache opened before knowing it is a live photo and no JSON file names them", async () => { + const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC")); + mkdirSync(originals(), { recursive: true }); + writeFileSync(join(originals(), "5.heic"), "an image"); + writeFileSync(join(originals(), "5.mov"), "a video"); + const cache = cacheOf([live], new Map([[5, body]])); + // Opened without being told that file 5 is a live photo, the cache + // records one of the two files as its original, with no video. + await cache.open(); + + const events: string[] = []; + const result = await cache.original(5, { + onProgress: (e) => events.push(e.status), + }); + + expect(events.at(-1)).toBe("done"); + 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( + JSON.parse( + readFileSync(join(originals(), "5.livephoto.json"), "utf-8"), + ), + ).toEqual({ image: "5.heic", video: "5.mov" }); + }); + it.each(["missing", "empty"])( "fetches a live photo again when the video its JSON file names is %s", async (state) => {