Skip to content

Instantly share code, notes, and snippets.

@NiklasRosenstein
Last active July 18, 2026 19:28
Show Gist options
  • Select an option

  • Save NiklasRosenstein/91d1bfc22485a4530271959300a69acf to your computer and use it in GitHub Desktop.

Select an option

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
Copy Markdown
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).

@raleik-pl

Copy link
Copy Markdown

Any chance this will get updated for Immich v3? Because right now I'm getting this error:

error: Uncaught (in promise) TypeError: albumInfo.assets is not iterable
  for (const asset of albumInfo.assets) {
                                ^
    at downloadAlbum (file:///mnt/storage/immich-app/image-album-downloader.ts:187:33)
    at async file:///mnt/storage/immich-app/image-album-downloader.ts:317:7

@johannesmols

Copy link
Copy Markdown

I made it work with v3 by replacing the getAlbumInfo with this (well, Claude did this, so it might not be the ideal solution, but it seems to fix it):

async getAlbumInfo(albumId: string): Promise<AlbumInfo> {
    // Immich v3 removed the `assets` array from AlbumResponseDto.
    // Album metadata still comes from GET /api/albums/{id}; assets are now
    // retrieved via the paginated POST /api/search/metadata endpoint.
    const album = await this.httpGet(`${this.host}/api/albums/${albumId}`);
    const assets = await this.searchAlbumAssets(albumId);
    return {
      ...album,
      assetCount: typeof album.assetCount === "number" ? album.assetCount : assets.length,
      assets,
    };
  }
  async searchAlbumAssets(albumId: string): Promise<Asset[]> {
    const url = `${this.host}/api/search/metadata`;
    const assets: Asset[] = [];
    let page: number | null = 1;
    while (page !== null) {
      const resp = await fetch(url, {
        method: "POST",
        headers: {
          "accept": "application/json",
          "x-api-key": this.key,
          "content-type": "application/json",
        },
        body: JSON.stringify({ albumIds: [albumId], page, size: 1000 }),
      });
      if (!resp.ok) {
        const errorBody = await resp.text();
        throw new Error(`HTTP ${resp.status}: ${errorBody}`);
      }
      const data = await resp.json();
      const bucket = (data as { assets?: { items?: Asset[]; nextPage?: string | number | null } }).assets ?? {};
      if (Array.isArray(bucket.items)) assets.push(...bucket.items);
      page = bucket.nextPage != null ? Number(bucket.nextPage) : null;
    }
    return assets;
  }

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