check / check (push) Successful in 1m32s
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
130 lines
4.8 KiB
TypeScript
130 lines
4.8 KiB
TypeScript
// 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<void> {
|
|
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<number>();
|
|
// 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)) {
|
|
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<string> {
|
|
const terminal = createInterface({ input: stdin, output: stdout });
|
|
try {
|
|
return await terminal.question(question);
|
|
} finally {
|
|
terminal.close();
|
|
}
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
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();
|
|
}
|