From b42c5b0e0def88baca09cd1250d1e62064be52e2 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 1 Oct 2026 22:10:08 +0000 Subject: [PATCH] download-albums example: walk the albums from lib.fresh() downloadAlbums now waits for a refresh from the server through lib.fresh() before walking the albums, so albums and photos added since the cache was last written are downloaded, and a failed refresh throws instead of reporting an empty or stale library as done. The test's stand-in account gains an album holding a new photo before the second run, and that run must download it. The README's "Examples" section says the script opens the library with the thumbnail and originals precache off, as `quak backup` does. Model: opus-5-5 --- README.md | 6 ++- examples/download-albums.ts | 5 ++- test/examples/download-albums.test.ts | 58 ++++++++++++++++++++------- 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f184a5a..12c99e2 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,10 @@ yarn build 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 -one, and writes: +It opens the library with `precacheThumbnails` and `precacheOriginals` off, as +`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()` writes it: `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD..`, and for a live diff --git a/examples/download-albums.ts b/examples/download-albums.ts index 4ab712a..7175f4f 100644 --- a/examples/download-albums.ts +++ b/examples/download-albums.ts @@ -36,7 +36,10 @@ export async function downloadAlbums( let alreadyLocal = 0; // A photo in several albums is handled once. const done = new Set(); - 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[] = []; for (const photo of album.photos.list()) { if (!done.has(photo.fileID)) { diff --git a/test/examples/download-albums.test.ts b/test/examples/download-albums.test.ts index 114bca6..8607b03 100644 --- a/test/examples/download-albums.test.ts +++ b/test/examples/download-albums.test.ts @@ -5,8 +5,10 @@ * 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/`. The second run fetches nothing and changes - * no file. + * 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"; @@ -101,7 +103,7 @@ 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 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 // photo 2 and photo 3, a live photo. const live = await asLivePhoto( @@ -109,20 +111,22 @@ describe("examples/download-albums.ts", () => { 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([ + [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 => ({ - collections: [collection(1, "Trip"), collection(2, "Family")], + collections: [...collections], deleted: [], cursor: 1, }), filesSince: async (args: { collectionID: number; }): Promise => ({ - files: - args.collectionID === 1 - ? [file(1, 1), file(2, 1)] - : [file(2, 2), live.file], + files: filesByAlbum.get(args.collectionID) ?? [], deleted: [], cursor: 1, }), @@ -270,18 +274,44 @@ describe("examples/download-albums.ts", () => { ], }); - // The second run, with a newly opened library, finds every photo - // already local, fetches nothing, and writes, renames or adds no - // file. + // 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: 0, + downloaded: 1, alreadyLocal: 3, }); await second.close(); - expect(calls).toBe(3); - expect(mtimes(dir)).toEqual(before); + 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); + } }); });