check / check (push) Failing after 59s
`photo.exif()` now returns every EXIF tag in the file, as the owner ruled, typed `ExifTags`: each tag keyed by name with `exifreader`'s `id`, `value`, `description` and `computed`. A tag with no name is keyed `undefined-` plus its number, and the embedded thumbnail's tags sit under `Thumbnail`. The thirteen typed methods stay, with the same names and types; each now picks its field from `exif()`'s tags. GPS latitude and longitude are worked out from the GPS tags and their reference tags, and a position with no reference tags gives neither. `backup-metadata --exif` output is unchanged. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
304 rindas
11 KiB
TypeScript
304 rindas
11 KiB
TypeScript
/**
|
|
* The example script `examples/download-albums.ts` (issue #144), run twice
|
|
* against a stand-in account, as a user would run it twice.
|
|
*
|
|
* The account has two albums sharing one photo, and one of its photos is a
|
|
* live photo whose image is a HEIC with EXIF. The first run puts every
|
|
* original at its save path, writes each photo's metadata beside it and each
|
|
* album's photos under `albums/`. Before the second run the account gains an
|
|
* album holding a new photo. The second run downloads that photo and writes
|
|
* its files and the new album's, and fetches nothing else and changes no
|
|
* other file.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
existsSync,
|
|
mkdtempSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
statSync,
|
|
utimesSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { downloadAlbums } from "../../examples/download-albums.js";
|
|
import { Library, type ContentSource } from "../../src/index.js";
|
|
import type { CollectionsPage, FilesPage } from "../../src/client.js";
|
|
import type { Collection, EnteFile } from "../../src/model/types.js";
|
|
import { readAllExifTags } from "../../src/exif.js";
|
|
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
|
import {
|
|
asLivePhoto,
|
|
cdnSource,
|
|
livePhotoHash,
|
|
livePhotoZip,
|
|
VIDEO,
|
|
} from "../live-photo.js";
|
|
|
|
const USER_ID = 7;
|
|
|
|
// Every photo is taken at noon local time on 2026-03-01, so the machine's time
|
|
// zone cannot move it to another day; it is saved in the folder `DAY`. Ente
|
|
// stores times in microseconds.
|
|
const TAKEN_MS = new Date(2026, 2, 1, 12).getTime();
|
|
const DAY = join("2026", "2026-03", "2026-03-01");
|
|
|
|
const collection = (id: number, name: string): Collection => ({
|
|
id,
|
|
ownerID: USER_ID,
|
|
key: new Uint8Array([id]),
|
|
name,
|
|
type: "album",
|
|
updationTime: 1,
|
|
isShared: false,
|
|
});
|
|
|
|
const file = (id: number, collectionID: number): EnteFile => ({
|
|
id,
|
|
collectionID,
|
|
ownerID: USER_ID,
|
|
key: new Uint8Array([id]),
|
|
metadata: {
|
|
title: `file-${id}.jpg`,
|
|
fileType: "image",
|
|
creationTime: TAKEN_MS * 1000,
|
|
modificationTime: TAKEN_MS * 1000,
|
|
},
|
|
file: { decryptionHeader: "aGVhZGVy" },
|
|
thumbnail: { decryptionHeader: "dGh1bWI=" },
|
|
updationTime: 1,
|
|
});
|
|
|
|
let root: string;
|
|
|
|
beforeEach(() => {
|
|
root = mkdtempSync(join(tmpdir(), "quak-download-albums-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (root && existsSync(root))
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
// Every file and directory under `dir`, by path.
|
|
const entries = (dir: string): string[] =>
|
|
readdirSync(dir, { recursive: true, encoding: "utf-8" });
|
|
|
|
// Set the modification time of everything under `dir` to the epoch, so that
|
|
// anything written there afterwards has a later one, however soon it comes.
|
|
const backdate = (dir: string): void => {
|
|
for (const name of entries(dir)) utimesSync(join(dir, name), 0, 0);
|
|
};
|
|
|
|
// The modification time of everything under `dir`, by path.
|
|
const mtimes = (dir: string): Map<string, number> =>
|
|
new Map(
|
|
entries(dir).map((name) => [name, statSync(join(dir, name)).mtimeMs]),
|
|
);
|
|
|
|
const readJSON = (path: string): unknown =>
|
|
JSON.parse(readFileSync(path, "utf-8"));
|
|
|
|
describe("examples/download-albums.ts", () => {
|
|
it("downloads every album's photos with their metadata, and on a second run only what the account gained", async () => {
|
|
// Album 1, "Trip", holds photos 1 and 2. Album 2, "Family", holds
|
|
// photo 2 and photo 3, a live photo.
|
|
const live = await asLivePhoto(
|
|
file(3, 2),
|
|
livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }),
|
|
livePhotoHash(HEIC_WITH_EXIF, VIDEO),
|
|
);
|
|
const collections = [collection(1, "Trip"), collection(2, "Family")];
|
|
const filesByAlbum = new Map<number, EnteFile[]>([
|
|
[1, [file(1, 1), file(2, 1)]],
|
|
[2, [file(2, 2), live.file]],
|
|
]);
|
|
const client = {
|
|
whoami: () => ({ email: "u@example.com", userID: USER_ID }),
|
|
collectionsSince: async (): Promise<CollectionsPage> => ({
|
|
collections: [...collections],
|
|
deleted: [],
|
|
cursor: 1,
|
|
}),
|
|
filesSince: async (args: {
|
|
collectionID: number;
|
|
}): Promise<FilesPage> => ({
|
|
files: filesByAlbum.get(args.collectionID) ?? [],
|
|
deleted: [],
|
|
cursor: 1,
|
|
}),
|
|
};
|
|
|
|
// Photo 3 comes from a stand-in server, encrypted as Ente serves a
|
|
// live photo. Any other original is a few bytes naming its photo.
|
|
// `calls` counts every fetch, thumbnails included.
|
|
const server = cdnSource(new Map([[3, live.body]]));
|
|
let calls = 0;
|
|
const source: ContentSource = {
|
|
original: async (args) => {
|
|
calls++;
|
|
if (args.file.id === 3) return server.original(args);
|
|
const bytes = `original-${args.file.id}`;
|
|
writeFileSync(args.destination, bytes);
|
|
return { bytesWritten: bytes.length };
|
|
},
|
|
thumbnail: async (args) => {
|
|
calls++;
|
|
return server.thumbnail(args);
|
|
},
|
|
};
|
|
|
|
const dir = join(root, "photos");
|
|
const open = (): Promise<Library> =>
|
|
Library.open({
|
|
client,
|
|
cacheDirectory: join(root, "cache"),
|
|
downloadDirectory: dir,
|
|
contentSource: source,
|
|
refreshIntervalSeconds: 3600,
|
|
precacheThumbnails: false,
|
|
precacheOriginals: false,
|
|
});
|
|
|
|
const first = await open();
|
|
// The cache already holds photo 1's original, so its record names a
|
|
// cache path, which the metadata leaves out. download() copies it
|
|
// from the cache rather than fetching it again.
|
|
await first.photos.byID({ fileID: 1 })!.original();
|
|
expect(await downloadAlbums(first, dir)).toEqual({
|
|
downloaded: 3,
|
|
alreadyLocal: 0,
|
|
});
|
|
await first.close();
|
|
expect(calls).toBe(3);
|
|
|
|
// Each original at its save path, a live photo as its image, its video
|
|
// and the file naming them, and each photo's metadata beside it.
|
|
const day = join(dir, DAY);
|
|
expect(readdirSync(day).sort()).toEqual([
|
|
"2026-03-01.1.jpg",
|
|
"2026-03-01.1.jpg.json",
|
|
"2026-03-01.2.jpg",
|
|
"2026-03-01.2.jpg.json",
|
|
"2026-03-01.3.heic",
|
|
"2026-03-01.3.heic.json",
|
|
"2026-03-01.3.livephoto.json",
|
|
"2026-03-01.3.mov",
|
|
]);
|
|
expect(readFileSync(join(day, "2026-03-01.1.jpg"), "utf-8")).toBe(
|
|
"original-1",
|
|
);
|
|
expect(readFileSync(join(day, "2026-03-01.2.jpg"), "utf-8")).toBe(
|
|
"original-2",
|
|
);
|
|
expect(readFileSync(join(day, "2026-03-01.3.heic"))).toEqual(
|
|
Buffer.from(HEIC_WITH_EXIF),
|
|
);
|
|
expect(readFileSync(join(day, "2026-03-01.3.mov"))).toEqual(
|
|
Buffer.from(VIDEO),
|
|
);
|
|
|
|
// The metadata is the photo's record and its EXIF tags. The originals
|
|
// of photos 1 and 2 are not image data, so they have no EXIF tags.
|
|
// Photo 3's are every tag of its image, as `photo.exif()` returns
|
|
// them. `record` holds the fields the three records share.
|
|
const record = {
|
|
takenAt: TAKEN_MS,
|
|
modifiedAt: TAKEN_MS,
|
|
fileType: "image",
|
|
isArchived: false,
|
|
isHidden: false,
|
|
};
|
|
expect(readJSON(join(day, "2026-03-01.1.jpg.json"))).toEqual({
|
|
...record,
|
|
fileID: 1,
|
|
albumIDs: [1],
|
|
title: "file-1.jpg",
|
|
exif: {},
|
|
});
|
|
expect(readJSON(join(day, "2026-03-01.2.jpg.json"))).toEqual({
|
|
...record,
|
|
fileID: 2,
|
|
albumIDs: [1, 2],
|
|
title: "file-2.jpg",
|
|
exif: {},
|
|
});
|
|
expect(readJSON(join(day, "2026-03-01.3.heic.json"))).toEqual({
|
|
...record,
|
|
fileID: 3,
|
|
albumIDs: [2],
|
|
title: "file-3.jpg",
|
|
fileType: "livePhoto",
|
|
hash: livePhotoHash(HEIC_WITH_EXIF, VIDEO),
|
|
exif: readAllExifTags(HEIC_WITH_EXIF),
|
|
});
|
|
|
|
// Each album's photos, newest first, by save path relative to `dir`.
|
|
// Photo 2 is in both.
|
|
expect(readdirSync(join(dir, "albums")).sort()).toEqual([
|
|
"1.json",
|
|
"2.json",
|
|
]);
|
|
expect(readJSON(join(dir, "albums", "1.json"))).toEqual({
|
|
collectionID: 1,
|
|
name: "Trip",
|
|
savePaths: [
|
|
join(DAY, "2026-03-01.2.jpg"),
|
|
join(DAY, "2026-03-01.1.jpg"),
|
|
],
|
|
});
|
|
expect(readJSON(join(dir, "albums", "2.json"))).toEqual({
|
|
collectionID: 2,
|
|
name: "Family",
|
|
savePaths: [
|
|
join(DAY, "2026-03-01.3.heic"),
|
|
join(DAY, "2026-03-01.2.jpg"),
|
|
],
|
|
});
|
|
|
|
// Before the second run the account gains album 3, "Garden", holding
|
|
// a new photo 4. The second run, with a newly opened library, opens
|
|
// the cache written by the first and still downloads photo 4. It finds
|
|
// the other photos already local and fetches nothing else.
|
|
collections.push(collection(3, "Garden"));
|
|
filesByAlbum.set(3, [file(4, 3)]);
|
|
backdate(dir);
|
|
const before = mtimes(dir);
|
|
const second = await open();
|
|
expect(await downloadAlbums(second, dir)).toEqual({
|
|
downloaded: 1,
|
|
alreadyLocal: 3,
|
|
});
|
|
await second.close();
|
|
expect(calls).toBe(4);
|
|
expect(readFileSync(join(day, "2026-03-01.4.jpg"), "utf-8")).toBe(
|
|
"original-4",
|
|
);
|
|
expect(readJSON(join(dir, "albums", "3.json"))).toEqual({
|
|
collectionID: 3,
|
|
name: "Garden",
|
|
savePaths: [join(DAY, "2026-03-01.4.jpg")],
|
|
});
|
|
|
|
// The second run adds only photo 4's files and album 3's, and
|
|
// rewrites, renames or removes no file from the first run. The two
|
|
// directories that gain a file are the only other changes.
|
|
const after = mtimes(dir);
|
|
expect(
|
|
[...after.keys()].filter((name) => !before.has(name)).sort(),
|
|
).toEqual([
|
|
join(DAY, "2026-03-01.4.jpg"),
|
|
join(DAY, "2026-03-01.4.jpg.json"),
|
|
join("albums", "3.json"),
|
|
]);
|
|
for (const [name, mtime] of before) {
|
|
if (name === DAY || name === "albums") continue;
|
|
expect(after.get(name), name).toBe(mtime);
|
|
}
|
|
});
|
|
});
|