feat: Add immich uploader

This commit is contained in:
abdus committed 2026-09-30 12:15:43 +02:00
1 parent eb6b73e392
commit 4e61ace0df
1 file changed
+294
Executable
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["httpx>=0.27,<1"]
# requires-python = ">=3.10"
# ///
"""Upload images to Immich and derive albums and tags from their filenames."""
from __future__ import annotations
import argparse
import hashlib
import logging
import os
import re
import shutil
import struct
import sys
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from typing import Any, Sequence
import httpx
LOG = logging.getLogger("immichup")
TAG_PORTRAIT = "aspect:portrait"
TAG_LANDSCAPE = "aspect:landscape"
STUDIO_RE = re.compile(r"\b@(\S+)\b")
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Upload JPEG images to Immich; filename prefixes become albums and tags."
)
parser.add_argument(
"--api-key", default=os.getenv("IMMICH_API_KEY", ""),
help="Immich API key (defaults to IMMICH_API_KEY)",
)
parser.add_argument(
"--server-url", default=os.getenv("IMMICH_SERVER_URL", ""),
help="Immich server URL (defaults to IMMICH_SERVER_URL)",
)
parser.add_argument(
"--move-uploaded-to", metavar="DIR",
help="move successfully uploaded images into this directory",
)
parser.add_argument("--debug", action="store_true", help="enable debug logging")
parser.add_argument(
"--dry-run", action="store_true", help="show planned uploads without uploading",
)
parser.add_argument("images", nargs="+", type=Path, help="image paths to process")
args = parser.parse_args(argv)
if not args.dry_run and not args.server_url:
parser.error("--server-url or IMMICH_SERVER_URL is required unless --dry-run is used")
if not args.dry_run and not args.api_key:
parser.error("--api-key or IMMICH_API_KEY is required unless --dry-run is used")
return args
def parse_filename(image_path: Path) -> tuple[list[str], str | None] | None:
filename = image_path.stem
if " -- " not in filename:
return None
people = [name.strip() for name in filename.split(" -- ", 1)[0].split(",")]
if not people:
return None
match = STUDIO_RE.search(filename)
return people, match.group(1) if match else None
def read_jpeg_dimensions(path: Path) -> tuple[int, int]:
"""Read width and height from a JPEG frame header without loading the image."""
with path.open("rb") as image:
if image.read(2) != b"\xff\xd8":
raise ValueError("not a valid JPEG file")
while True:
byte = image.read(1)
if not byte:
raise ValueError("JPEG has no frame header")
if byte != b"\xff":
raise ValueError("invalid JPEG format")
marker_byte = image.read(1)
while marker_byte == b"\xff":
marker_byte = image.read(1)
if not marker_byte:
raise ValueError("truncated JPEG marker")
marker = marker_byte[0]
if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7 or marker == 0x01:
continue
length_data = image.read(2)
if len(length_data) != 2:
raise ValueError("truncated JPEG segment")
length = struct.unpack(">H", length_data)[0]
if length < 2:
raise ValueError("invalid JPEG segment length")
if 0xC0 <= marker <= 0xC3:
frame = image.read(length - 2)
if len(frame) < 5:
raise ValueError("truncated JPEG frame header")
height, width = struct.unpack(">HH", frame[1:5])
return width, height
image.seek(length - 2, os.SEEK_CUR)
class ImmichClient:
def __init__(self, server_url: str, api_key: str) -> None:
self.http = httpx.Client(
base_url=server_url.rstrip("/") + "/api/",
headers={"x-api-key": api_key},
timeout=60.0,
)
self.device_id = str(uuid.uuid4())
self.album_ids: dict[str, str] = {}
self.tag_ids: dict[str, str] = {}
self.metadata_lock = threading.RLock()
def close(self) -> None:
self.http.close()
def _check(self, response: httpx.Response, action: str) -> None:
if not response.is_success:
raise RuntimeError(f"{action}: HTTP {response.status_code} {response.reason_phrase}")
def upload_asset(self, path: Path) -> dict[str, Any]:
stat = path.stat()
with path.open("rb") as image:
digest = hashlib.sha1()
for chunk in iter(lambda: image.read(1024 * 1024), b""):
digest.update(chunk)
checksum = digest.hexdigest()
modified = datetime.fromtimestamp(stat.st_mtime).astimezone().isoformat()
form = {
"deviceAssetId": f"{path.name}-{stat.st_size}",
"deviceId": self.device_id,
"assetType": "image",
"fileCreatedAt": modified,
"fileModifiedAt": modified,
"isFavorite": "false",
"fileExtension": path.suffix.lower(),
"duration": "0",
"isReadOnly": "false",
"isArchived": "false",
}
with path.open("rb") as image:
response = self.http.post(
"assets",
headers={"x-immich-checksum": checksum},
data=form,
files={"assetData": (path.name, image)},
)
self._check(response, "failed to upload asset")
return response.json()
def get_or_create_album(self, name: str) -> str:
with self.metadata_lock:
if name in self.album_ids:
return self.album_ids[name]
response = self.http.get("albums")
self._check(response, "failed to get albums")
for album in response.json():
self.album_ids[album["albumName"]] = album["id"]
if name not in self.album_ids:
response = self.http.post("albums", json={"albumName": name})
self._check(response, "failed to create album")
album = response.json()
self.album_ids[name] = album["id"]
return self.album_ids[name]
def create_tags(self, names: list[str]) -> dict[str, str]:
with self.metadata_lock:
missing = list(dict.fromkeys(name for name in names if name not in self.tag_ids))
if missing:
response = self.http.put("tags", json={"tags": missing})
self._check(response, "failed to create tags")
self.tag_ids.update({tag["name"]: tag["id"] for tag in response.json()})
return {name: self.tag_ids[name] for name in names if name in self.tag_ids}
def add_asset_to_album(self, album_id: str, asset_id: str) -> None:
response = self.http.put(f"albums/{album_id}/assets", json={"ids": [asset_id]})
self._check(response, "failed to add asset to album")
def link_tag_to_asset(self, tag_id: str, asset_id: str) -> None:
response = self.http.put(f"tags/{tag_id}/assets", json={"ids": [asset_id]})
self._check(response, "failed to link tag to asset")
def upload_image(self, path: Path, albums: list[str], tags: list[str]) -> str:
asset = self.upload_asset(path)
asset_id = asset["id"]
for album in albums:
try:
self.add_asset_to_album(self.get_or_create_album(album), asset_id)
except (httpx.HTTPError, RuntimeError, KeyError, ValueError) as error:
LOG.error("Could not add %s to album %r: %s", path, album, error)
try:
tag_ids = self.create_tags(tags)
for tag, tag_id in tag_ids.items():
try:
self.link_tag_to_asset(tag_id, asset_id)
except (httpx.HTTPError, RuntimeError) as error:
LOG.error("Could not apply tag %r to %s: %s", tag, path, error)
except (httpx.HTTPError, RuntimeError, KeyError, ValueError) as error:
LOG.error("Could not create tags for %s: %s", path, error)
return asset.get("status") or "uploaded"
def process_image(path: Path, client: ImmichClient | None, dry_run: bool) -> bool:
try:
parsed = parse_filename(path)
if parsed is None:
raise ValueError(f"failed to parse filename: {path}")
people, studio = parsed
width, height = read_jpeg_dimensions(path)
albums = [person.lower() for person in people]
tags = list(albums)
if studio:
tags.append(studio.lower())
tags.append(TAG_PORTRAIT if width <= height else TAG_LANDSCAPE)
if dry_run:
LOG.info("[DRY RUN] Would upload %s (albums=%s, tags=%s)", path, albums, tags)
return True
assert client is not None
status = client.upload_image(path, albums, tags)
LOG.info("Processed %s (status=%s)", path, status)
return True
except (OSError, ValueError, RuntimeError, httpx.HTTPError, KeyError) as error:
LOG.error("Failed to process %s: %s", path, error)
return False
def run(args: argparse.Namespace) -> int:
logging.basicConfig(
level=logging.DEBUG if args.debug else logging.INFO,
format="%(levelname)s: %(message)s",
)
paths: list[Path] = []
failures = 0
for path in args.images:
if not path.exists():
LOG.error("Image does not exist, skipping: %s", path)
failures += 1
elif path.is_dir():
LOG.warning("Skipping directory: %s", path)
else:
paths.append(path)
LOG.info("Found %d images to process", len(paths))
if args.move_uploaded_to:
try:
Path(args.move_uploaded_to).mkdir(parents=True, exist_ok=True)
except OSError as error:
LOG.error("Failed to create destination directory: %s", error)
return 1
client = None if args.dry_run else ImmichClient(args.server_url, args.api_key)
try:
with ThreadPoolExecutor(max_workers=5) as pool:
futures = {pool.submit(process_image, path, client, args.dry_run): path for path in paths}
for future in as_completed(futures):
path = futures[future]
try:
succeeded = future.result()
except Exception:
LOG.exception("Unexpected error processing %s", path)
succeeded = False
if not succeeded:
failures += 1
continue
if args.move_uploaded_to:
destination = Path(args.move_uploaded_to) / path.name
if args.dry_run:
LOG.info("[DRY RUN] Would move %s to %s", path, destination.parent)
else:
try:
shutil.move(str(path), str(destination))
LOG.info("Moved %s to %s", path, destination)
except OSError as error:
LOG.error("Failed to move %s: %s", path, error)
failures += 1
finally:
if client is not None:
client.close()
LOG.info("Completed processing all images")
return 1 if failures else 0
def main() -> int:
return run(parse_args())
if __name__ == "__main__":
sys.exit(main())