Remove the content cache's handling of an earlier version's live-photo ZIP (closes #151) #152
@@ -879,10 +879,7 @@ current account's records name.
|
||||
|
||||
A live photo's original is cached as at its save path: its image and its video,
|
||||
each `originals/<fileID>.<ext>` with its own extension, and
|
||||
`originals/<fileID>.livephoto.json` naming them; the two are evicted together. A
|
||||
live photo that an earlier version cached as its ZIP is not served: the library
|
||||
removes the ZIP when it opens the cache, and fetches the two files when the
|
||||
photo is next read or precached.
|
||||
`originals/<fileID>.livephoto.json` naming them; the two are evicted together.
|
||||
|
||||
A stored file appears only via an atomic temp-then-rename, so its presence means
|
||||
it is complete. Every downloaded original (by `quak get`, the cache, or
|
||||
|
||||
@@ -25,6 +25,10 @@ declares one.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-01: The content cache no longer looks for a live photo that an earlier
|
||||
version cached as one ZIP (issue 151). When the cache opens, a live photo's
|
||||
file that no JSON file names is now always left alone.
|
||||
|
||||
- 2026-10-01: `examples/download-albums.ts` logs in, opens the library, and for
|
||||
every album downloads each photo to its save path, writes the photo's record
|
||||
and EXIF fields to a JSON file beside it, and writes the album's photos to
|
||||
|
||||
+5
-38
@@ -29,14 +29,7 @@
|
||||
// the cache does not count as saved there, but is copied there rather than
|
||||
// fetched again.
|
||||
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
@@ -281,25 +274,6 @@ 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
|
||||
// save path stores one, a JSON file of this name beside them names both.
|
||||
@@ -713,8 +687,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
||||
await this.touch(cached.path);
|
||||
return { ...cached, bytes: size, cached: true };
|
||||
}
|
||||
// A recorded file that has since gone, or a live photo an earlier
|
||||
// version stored as one ZIP, re-fetches below.
|
||||
// A recorded file that has since gone re-fetches below.
|
||||
known.delete(fileID);
|
||||
}
|
||||
|
||||
@@ -983,20 +956,14 @@ 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 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.
|
||||
// file is not its original and is left alone: another process may
|
||||
// have just stored it and not yet written the JSON file.
|
||||
const livePhoto = names.has(livePhotoJSONName(String(id)))
|
||||
? readLivePhotoJSON(dir, String(id))
|
||||
: undefined;
|
||||
if (livePhoto !== undefined) {
|
||||
into.set(id, livePhoto);
|
||||
} else if (isLivePhoto(id)) {
|
||||
if (isZip(path)) {
|
||||
await rm(path, { force: true }).catch(() => undefined);
|
||||
}
|
||||
} else {
|
||||
} else if (!isLivePhoto(id)) {
|
||||
into.set(id, { path });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1883,7 +1883,7 @@ describe("downloadFile live photos", () => {
|
||||
expect(readdirSync(t.dir).sort()).toEqual(["f.JPG", "f.bin"]);
|
||||
});
|
||||
|
||||
it("replaces what was at the destination, such as an earlier ZIP of the two", async () => {
|
||||
it("replaces what was at the destination", async () => {
|
||||
const t = setup(livePhotoZip(), livePhoto);
|
||||
writeFileSync(t.outPath, livePhotoZip());
|
||||
|
||||
|
||||
@@ -176,52 +176,6 @@ 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(),
|
||||
|
||||
@@ -566,43 +566,6 @@ describe("ContentCache live photos", () => {
|
||||
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]]));
|
||||
|
||||
Reference in New Issue
Block a user