Example script: download every album's photos and metadata (closes #144) #150

Merged
clawbot merged 2 commits from issue-144-download-albums-example into next 2026-10-02 00:33:01 +02:00
3 changed files with 52 additions and 17 deletions
Showing only changes of commit b42c5b0e0d - Show all commits
+4 -2
View File
@@ -91,8 +91,10 @@ yarn build
QUAK_EMAIL=… QUAK_PASSWORD=… node dist/examples/download-albums.js [dir] QUAK_EMAIL=… QUAK_PASSWORD=… node dist/examples/download-albums.js [dir]
``` ```
It asks on the terminal for a two-factor or email code when the account requires It opens the library with `precacheThumbnails` and `precacheOriginals` off, as
one, and writes: `quak backup` does, so the only file content it fetches is the originals it
saves. It asks on the terminal for a two-factor or email code when the account
requires one, and writes:
- each photo's original at its save path under `dir`, as `photo.download()` - each photo's original at its save path under `dir`, as `photo.download()`
writes it: `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, and for a live writes it: `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, and for a live
+4 -1
View File
@@ -36,7 +36,10 @@ export async function downloadAlbums(
let alreadyLocal = 0; let alreadyLocal = 0;
// A photo in several albums is handled once. // A photo in several albums is handled once.
const done = new Set<number>(); const done = new Set<number>();
for (const album of lib.albums.list()) { // fresh() waits for a refresh from the server and throws if it fails, so
// albums and photos added since the cache was last written are included.
const { albums } = await lib.fresh();
for (const album of albums.list()) {
const savePaths: string[] = []; const savePaths: string[] = [];
for (const photo of album.photos.list()) { for (const photo of album.photos.list()) {
if (!done.has(photo.fileID)) { if (!done.has(photo.fileID)) {
+44 -14
View File
@@ -5,8 +5,10 @@
* The account has two albums sharing one photo, and one of its photos is a * 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 * 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 * original at its save path, writes each photo's metadata beside it and each
* album's photos under `albums/`. The second run fetches nothing and changes * album's photos under `albums/`. Before the second run the account gains an
* no file. * 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 { describe, it, expect, beforeEach, afterEach } from "vitest";
@@ -101,7 +103,7 @@ const readJSON = (path: string): unknown =>
JSON.parse(readFileSync(path, "utf-8")); JSON.parse(readFileSync(path, "utf-8"));
describe("examples/download-albums.ts", () => { describe("examples/download-albums.ts", () => {
it("downloads every album's photos with their metadata, and nothing on a second run", async () => { 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 // Album 1, "Trip", holds photos 1 and 2. Album 2, "Family", holds
// photo 2 and photo 3, a live photo. // photo 2 and photo 3, a live photo.
const live = await asLivePhoto( const live = await asLivePhoto(
@@ -109,20 +111,22 @@ describe("examples/download-albums.ts", () => {
livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }), livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }),
livePhotoHash(HEIC_WITH_EXIF, 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 = { const client = {
whoami: () => ({ email: "u@example.com", userID: USER_ID }), whoami: () => ({ email: "u@example.com", userID: USER_ID }),
collectionsSince: async (): Promise<CollectionsPage> => ({ collectionsSince: async (): Promise<CollectionsPage> => ({
collections: [collection(1, "Trip"), collection(2, "Family")], collections: [...collections],
deleted: [], deleted: [],
cursor: 1, cursor: 1,
}), }),
filesSince: async (args: { filesSince: async (args: {
collectionID: number; collectionID: number;
}): Promise<FilesPage> => ({ }): Promise<FilesPage> => ({
files: files: filesByAlbum.get(args.collectionID) ?? [],
args.collectionID === 1
? [file(1, 1), file(2, 1)]
: [file(2, 2), live.file],
deleted: [], deleted: [],
cursor: 1, cursor: 1,
}), }),
@@ -270,18 +274,44 @@ describe("examples/download-albums.ts", () => {
], ],
}); });
// The second run, with a newly opened library, finds every photo // Before the second run the account gains album 3, "Garden", holding
// already local, fetches nothing, and writes, renames or adds no // a new photo 4. The second run, with a newly opened library, opens
// file. // 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); backdate(dir);
const before = mtimes(dir); const before = mtimes(dir);
const second = await open(); const second = await open();
expect(await downloadAlbums(second, dir)).toEqual({ expect(await downloadAlbums(second, dir)).toEqual({
downloaded: 0, downloaded: 1,
alreadyLocal: 3, alreadyLocal: 3,
}); });
await second.close(); await second.close();
expect(calls).toBe(3); expect(calls).toBe(4);
expect(mtimes(dir)).toEqual(before); 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);
}
}); });
}); });