// Download every album's photos to a directory, with each photo's metadata // beside it, using only quak's public API. The README's "Examples" section // describes the files it writes. // // yarn build // QUAK_EMAIL=… QUAK_PASSWORD=… node dist/examples/download-albums.js [dir] import { realpathSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { stdin, stdout } from "node:process"; import { createInterface } from "node:readline/promises"; import { pathToFileURL } from "node:url"; import { Client, Library } from "../src/index.js"; // Write `text` to `path` unless the file already holds exactly that, so a // second run rewrites nothing. async function writeIfChanged(path: string, text: string): Promise { const current = await readFile(path, "utf-8").catch(() => undefined); if (current !== text) await writeFile(path, text); } const pretty = (value: unknown): string => JSON.stringify(value, null, 2) + "\n"; // For every album in `lib`, download each photo to its save path, write the // photo's metadata to `{savePath}.json`, and write the album's photos to // `{dir}/albums/{collectionID}.json`. Returns how many photos it downloaded // and how many were already at their save paths. export async function downloadAlbums( lib: Library, dir: string, ): Promise<{ downloaded: number; alreadyLocal: number }> { let downloaded = 0; let alreadyLocal = 0; // A photo in several albums is handled once. const done = new Set(); for (const album of lib.albums.list()) { const savePaths: string[] = []; for (const photo of album.photos.list()) { if (!done.has(photo.fileID)) { done.add(photo.fileID); if (photo.isLocal) alreadyLocal++; else downloaded++; await photo.download(); // The cache paths say where quak's cache keeps copies, not // anything about the photo. const record = { ...photo.record() }; delete record.thumbnailPath; delete record.originalPath; const exif = await photo.exif(); // savePath is read after download(): a live photo's names its // image only once the image is stored. await writeIfChanged( `${photo.savePath}.json`, pretty({ ...record, exif }), ); } savePaths.push(relative(dir, photo.savePath)); } await mkdir(join(dir, "albums"), { recursive: true }); await writeIfChanged( join(dir, "albums", `${album.collectionID}.json`), pretty({ collectionID: album.collectionID, name: album.name, savePaths, }), ); } return { downloaded, alreadyLocal }; } // Ask for a login code on the terminal. Client.login calls this only when the // account requires a code. async function ask(question: string): Promise { const terminal = createInterface({ input: stdin, output: stdout }); try { return await terminal.question(question); } finally { terminal.close(); } } async function main(): Promise { const email = process.env.QUAK_EMAIL; const password = process.env.QUAK_PASSWORD; if (!email || !password) { console.error( "Set QUAK_EMAIL and QUAK_PASSWORD to the account's email and password.", ); process.exit(1); } const dir = process.argv[2] ?? "photos"; const client = await Client.login({ email, password, totp: () => ask("Two-factor code: "), emailOTP: () => ask("Code sent to your email: "), }); const lib = await Library.open({ client, downloadDirectory: dir, // As in `quak backup`: fetch only the originals this script saves, // not every thumbnail and the recent originals into the cache too. precacheThumbnails: false, precacheOriginals: false, }); try { const { downloaded, alreadyLocal } = await downloadAlbums(lib, dir); console.log( `${downloaded} photos downloaded, ${alreadyLocal} already local, in ${dir}`, ); } finally { await lib.close(); } } // Run main() when node runs this file, not when a test imports it. argv[1] is // the path as given, and import.meta.url has symlinks resolved. const script = process.argv[1]; if (script && pathToFileURL(realpathSync(script)).href === import.meta.url) { await main(); }