Skip to content

Instantly share code, notes, and snippets.

@NiklasRosenstein
Last active June 12, 2025 15:02
Show Gist options
  • Save NiklasRosenstein/91d1bfc22485a4530271959300a69acf to your computer and use it in GitHub Desktop.
Save NiklasRosenstein/91d1bfc22485a4530271959300a69acf to your computer and use it in GitHub Desktop.
Immich Album Downloader

Immich Album Downloader

An album downloader for Immich.

Usage

$ deno run https://gist.githubusercontent.com/NiklasRosenstein/91d1bfc22485a4530271959300a69acf/raw/main.ts \
  --server https://myimmich.example.com --apiKey $API_KEY --out albums/ --albumGlob '*'

Add the --allow-all option after deno run if you trust this script to not get prompted for permissions.

Don't have Deno?

You can install Deno with

$ curl -fsSL https://deno.land/install.sh | sh

or use Mise

$ curl https://mise.run | sh
$ mise exec deno -- deno run ...
// Immich Album Downloader in Deno TypeScript
// deno run --allow-net --allow-read --allow-write main.ts --album "Album Name"
import { stringify as stringifyYaml } from "jsr:@std/yaml";
import { ensureDir } from "jsr:@std/fs/ensure-dir";
import { join } from "jsr:@std/path";
import { parseArgs } from "jsr:@std/cli/parse-args";
import * as zipjs from "jsr:@zip-js/zip-js";
// Utility for glob matching
function globMatch(str: string, pattern: string): boolean {
// Simple glob: * matches any sequence, ? matches one char
const regex = new RegExp(
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
);
return regex.test(str);
}
interface Album {
id: string;
albumName: string;
assetCount: number;
statusCode?: number;
}
interface DownloadInfo {
statusCode?: number;
totalSize: number;
archives: { assetIds: string[] }[];
}
interface Asset {
id: string;
exifInfo?: { dateTimeOriginal?: string };
livePhotoVideoId?: string;
originalFileName: string;
}
interface AlbumInfo {
assetCount: number;
assets: Asset[];
}
class DownloadedAssetTracker {
private filePath: string;
private assetIds: Set<string>;
constructor(filePath: string) {
this.filePath = filePath;
this.assetIds = new Set();
}
async load() {
try {
const text = await Deno.readTextFile(this.filePath);
const arr = JSON.parse(text);
if (Array.isArray(arr)) this.assetIds = new Set(arr);
}
catch (_e) {
// Ignore missing or invalid file
this.assetIds = new Set();
}
}
has(id: string) {
return this.assetIds.has(id);
}
add(id: string) {
this.assetIds.add(id);
}
addMany(ids: string[]) {
for (const id of ids) this.assetIds.add(id);
}
toArray() {
return [...this.assetIds];
}
async save() {
await Deno.writeTextFile(this.filePath, JSON.stringify(this.toArray(), null, 2));
}
size() {
return this.assetIds.size;
}
}
class ImmichApi {
key: string;
host: string;
constructor(host: string, key: string) {
this.key = key;
this.host = host;
}
async downloadInfo(albumId: string): Promise<DownloadInfo> {
const url = `${this.host}/api/download/info`;
const resp = await fetch(url, {
method: "POST",
headers: {
"accept": "application/json",
"x-api-key": this.key,
"content-type": "application/json",
},
body: JSON.stringify({ albumId }),
});
if (!resp.ok) {
const errorBody = await resp.text();
throw new Error(`HTTP ${resp.status}: ${errorBody}`);
}
return await resp.json();
}
async downloadArchive(assetIds: string[]): Promise<Response> {
const url = `${this.host}/api/download/archive`;
const resp = await fetch(url, {
method: "POST",
headers: {
"accept": "application/octet-stream",
"x-api-key": this.key,
"content-type": "application/json",
},
body: JSON.stringify({ assetIds }),
});
if (!resp.ok) {
const errorBody = await resp.text();
throw new Error(`HTTP ${resp.status}: ${errorBody}`);
}
return resp;
}
async getAlbumInfo(albumId: string): Promise<AlbumInfo> {
const url = `${this.host}/api/albums/${albumId}`;
return await this.httpGet(url);
}
async albums(): Promise<Album[]> {
const url = `${this.host}/api/albums`;
return await this.httpGet(url);
}
async httpGet(url: string) {
const resp = await fetch(url, {
headers: {
"accept": "application/json",
"x-api-key": this.key,
},
});
if (!resp.ok) {
const errorBody = await resp.text();
throw new Error(`HTTP ${resp.status}: ${errorBody}`);
}
return await resp.json();
}
}
async function exists(path: string) {
try {
await Deno.stat(path);
return true;
}
catch {
return false;
}
}
async function downloadAlbum(
api: ImmichApi,
album: Album,
outDir: string,
) {
await ensureDir(outDir);
const tempDir = join(outDir, ".tmp");
await ensureDir(tempDir);
const downloadInfo = await api.downloadInfo(album.id);
const albumInfo = await api.getAlbumInfo(album.id);
await Deno.writeTextFile(
join(outDir, ".album-info.yaml"),
stringifyYaml(albumInfo),
);
if (downloadInfo.statusCode !== undefined) {
console.log(downloadInfo);
return;
}
// Output total size before proceeding
console.log(`[${album.albumName}] Total album size: ${Math.floor(downloadInfo.totalSize / 1024 / 1024)} MB`);
const allAssetIds: string[] = downloadInfo.archives.flatMap((archive) => archive.assetIds);
const assetIdToSize = new Map<string, number>();
// Map assetId to size using albumInfo.assets (if available)
for (const asset of albumInfo.assets) {
const origSize = (asset as unknown as Record<string, unknown>)["originalSize"];
if (asset.id && typeof origSize === "number") {
assetIdToSize.set(asset.id, origSize);
}
}
// If downloadInfo provides per-asset size, prefer that
const downloadInfoAssets = (downloadInfo as unknown as { assets?: { id: string; size: number }[] }).assets;
if (Array.isArray(downloadInfoAssets)) {
for (const asset of downloadInfoAssets) {
if (asset.id && asset.size) assetIdToSize.set(asset.id, asset.size);
}
}
const downloadedJson = join(outDir, ".downloaded-assets.json");
const tracker = new DownloadedAssetTracker(downloadedJson);
await tracker.load();
const alreadyDownloaded = tracker.size();
const toDownload = allAssetIds.filter((id) => !tracker.has(id)).length;
console.log(`[${album.albumName}] ${alreadyDownloaded} assets already downloaded, ${toDownload} assets remaining.`);
// Only download assets that are not already downloaded
const assetsToDownload = allAssetIds.filter((id) => !tracker.has(id));
// Download in the batches suggested by DownloadInfo.archives
let archiveBatchIndex = 0;
let currentArchiveFile: string | null = null;
// Setup SIGINT handler for cleanup
async function cleanupOnAbort() {
if (currentArchiveFile) {
try {
await Deno.remove(currentArchiveFile);
}
catch (_e) { /* ignore */ }
}
try {
await Deno.remove(tempDir, { recursive: true });
}
catch (_e) { /* ignore */ }
}
const abortHandler = () => {
cleanupOnAbort().then(() => Deno.exit(130));
};
Deno.addSignalListener("SIGINT", abortHandler);
try {
for (const archive of downloadInfo.archives) {
// Filter out already-downloaded assets in this batch
const batch = archive.assetIds.filter((id) => assetsToDownload.includes(id));
if (batch.length === 0) continue;
const out = join(tempDir, `batch-${String(archiveBatchIndex + 1).padStart(3, "0")}.zip`);
currentArchiveFile = out;
if (!await exists(out)) {
console.log(`[${album.albumName}] Downloading batch #${archiveBatchIndex + 1}: ${batch.length} assets`);
const resp = await api.downloadArchive(batch);
const file = await Deno.open(out, { write: true, create: true, truncate: true });
if (resp.body) {
await resp.body.pipeTo(file.writable);
}
else {
file.close();
throw new Error("No response body to stream");
}
}
// Extract using system unzip for streaming and efficiency
const unzipProc = new Deno.Command("unzip", {
args: [out, "-d", outDir],
stdout: "inherit",
stderr: "inherit",
}).spawn();
const { code } = await unzipProc.status;
if (code !== 0) {
throw new Error(`unzip failed with exit code ${code}`);
}
await Deno.remove(out);
currentArchiveFile = null;
for (const asset of batch) {
const assetObj = albumInfo.assets.find((a) => a.id === asset);
if (assetObj) {
tracker.add(assetObj.id);
console.log(
`[${album.albumName}] ${tracker.size()} / ${albumInfo.assetCount} - ${assetObj.originalFileName}`,
);
}
else {
console.log(`[${album.albumName}] ${tracker.size()} / ${albumInfo.assetCount} - ${asset}`);
}
}
await tracker.save();
archiveBatchIndex++;
}
}
finally {
await cleanupOnAbort();
Deno.removeSignalListener("SIGINT", abortHandler);
}
await Deno.remove(tempDir, { recursive: true }).catch(() => { });
}
if (import.meta.main) {
const args = parseArgs(Deno.args, {
string: ["album", "server", "apiKey", "out", "albumGlob"],
alias: { s: "server", k: "apiKey", a: "album", o: "out", g: "albumGlob" },
default: {},
});
if (args.help || args.h) {
console.log(
"Usage: deno run --allow-all main.ts --server <url> --apiKey <key> --out <output-dir> " +
"[--album <name>] [--albumGlob <pattern>]",
);
Deno.exit(0);
}
const server = args.server;
const apiKey = args.apiKey;
const outDir = args.out || "downloads";
const albumName = args.album;
const albumGlob = args.albumGlob;
if (!server || !apiKey) {
console.error("--server and --apiKey are required. Use --help for usage.");
Deno.exit(1);
}
const api = new ImmichApi(server, apiKey);
const albums = await api.albums();
if (albumGlob) {
// Download all albums matching glob
const matching = albums.filter((a) => a.statusCode === undefined && globMatch(a.albumName, albumGlob));
console.log(`Found ${matching.length} albums matching pattern '${albumGlob}'.`);
if (matching.length === 0) {
console.error(`No albums matching pattern '${albumGlob}'.`);
Deno.exit(3);
}
console.log("Going through albuns.")
for (const album of matching) {
const albumOutDir = join(outDir, album.albumName);
await downloadAlbum(api, album, albumOutDir);
}
console.log(`All matching albums downloaded to: ${outDir}`);
}
else if (albumName) {
const album = albums.find((a) => a.statusCode === undefined && a.albumName === albumName);
if (!album) {
console.error(`Album '${albumName}' not found.`);
Deno.exit(3);
}
await downloadAlbum(api, album, outDir);
}
else {
console.error("Either --album or --albumGlob is required.");
Deno.exit(2);
}
}
@NiklasRosenstein
Copy link
Author

Changelog

Revision 5

  • Switched to running unzip as a command because I just couldn't it the extraction to work with zip-js without getting everything into memory first (which can get very memory hungry with large assets, like videos).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment