|
// 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); |
|
} |
|
} |
Changelog
Revision 5
unzip
as a command because I just couldn't it the extraction to work withzip-js
without getting everything into memory first (which can get very memory hungry with large assets, like videos).