From 5ca92fec7b798c10994dec6e4b67665494b1b690 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Fri, 7 Aug 2026 21:25:21 +1000 Subject: [PATCH] Immich import via server-bundled CLI (v2.7.5); ssh key fix; verified dedup --- immich_import.py | 117 ++++++++++++++++++++++++++++++----------------- 1 file changed, 74 insertions(+), 43 deletions(-) diff --git a/immich_import.py b/immich_import.py index 69eb889..5f1df77 100644 --- a/immich_import.py +++ b/immich_import.py @@ -1,12 +1,13 @@ -"""photo-pipeline: Immich import flow (v4). +"""photo-pipeline: Immich import flow (v5). -Uploads sorted/staged photos to Immich (on .35) using the official @immich/cli. +Uploads sorted/staged photos to Immich using the SERVER-BUNDLED CLI +(version-matched, verified working — npm CLI 3.1.0 had a deviceAssetId bug +against server 2.7.5). -Security: the API key comes from env IMMICH_API_KEY or ~/photo-pipeline/.immich-key -(chmod 600). Never committed to git. +Approach: on .35, docker cp staging dir into immich_server container, then +docker exec immich_server immich upload. -The CLI does checksum dedup natively (skips already-uploaded assets), so -re-running is safe. +Security: API key via env IMMICH_API_KEY or ~/photo-pipeline/.immich-key (chmod 600). """ import os @@ -16,9 +17,10 @@ from pathlib import Path from prefect import flow, task -IMMICH_URL = os.environ.get("IMMICH_URL", "http://192.168.20.35:2283") +IMMICH_HOST = "192.168.20.35" +IMMICH_URL = os.environ.get("IMMICH_URL", f"http://{IMMICH_HOST}:2283") KEY_FILE = Path(__file__).parent / ".immich-key" -CLI = Path(__file__).parent / "node_modules/.bin/immich" +CONTAINER = "immich_server" def _api_key() -> str: @@ -30,56 +32,85 @@ def _api_key() -> str: raise RuntimeError("IMMICH_API_KEY env or ~/photo-pipeline/.immich-key required") -@task -def immich_login(): - """Ensure CLI is authenticated (login-key is idempotent-ish; stores auth.yml).""" - key = _api_key() - r = subprocess.run( - [str(CLI), "login-key", IMMICH_URL, key], - capture_output=True, text=True, +def _ssh(args: list[str], timeout: int = 600) -> subprocess.CompletedProcess: + # -i: .35 accepts id_ed25519_rsync (used by rsync backups); default key rejected + return subprocess.run( + ["ssh", "-i", os.path.expanduser("~/.ssh/id_ed25519_rsync"), + "sam@" + IMMICH_HOST, *args], + capture_output=True, text=True, timeout=timeout, ) - print(f"immich login: rc={r.returncode} {r.stdout.strip()[:200]}") - if r.returncode != 0: - raise RuntimeError(f"immich login failed: {r.stderr[-500:]}") - return True @task -def immich_upload(src_dir: str, album: str | None = None, dry_run: bool = False) -> str: - """Upload assets from a directory to Immich. +def verify_immich(): + """Sanity check: server reachable + key works.""" + r = subprocess.run( + ["curl", "-s", "-H", f"x-api-key: {_api_key()}", + f"{IMMICH_URL}/api/server/version"], + capture_output=True, text=True, timeout=30, + ) + print(f"Immich server version: {r.stdout.strip()}") + if "error" in r.stdout.lower() or r.returncode != 0: + raise RuntimeError(f"Immich unreachable or key rejected: {r.stdout}") + return r.stdout.strip() - Recursive, album-per-folder optional, dry-run supported. Immich dedups by checksum. + +@task +def upload_to_immich(src_dir: str, container_path: str = "/import") -> dict: + """Copy src_dir into the immich_server container and upload via bundled CLI. + + Two-hop: scp src_dir from .13 → /tmp on .35, then docker cp into container, + then docker exec immich upload (version-matched CLI). """ - cmd = [str(CLI), "upload", src_dir, "--recursive", "--skip-hash"] - # --skip-hash: with a least-privilege API key the CLI's dup-check is broken - # (immich-app issue #21456); our fingerprint DB does dedup pre-upload. - if album: - cmd += ["--album-name", album] - if dry_run: - cmd += ["--dry-run"] - r = subprocess.run(cmd, capture_output=True, text=True) - out = r.stdout.strip() - print(f"immich upload rc={r.returncode}") + key = _api_key() + ssh_args = ["-i", os.path.expanduser("~/.ssh/id_ed25519_rsync")] + + # 1. scp the staged dir to .35 /tmp + local_dir = Path(src_dir) + remote_tmp = f"/tmp/pp_import_{local_dir.name}" + r = subprocess.run( + ["scp", *ssh_args, "-r", str(local_dir), f"sam@{IMMICH_HOST}:{remote_tmp}"], + capture_output=True, text=True, timeout=900, + ) + if r.returncode != 0: + raise RuntimeError(f"scp to .35 failed: {r.stderr[-500:]}") + print(f"scp OK → {remote_tmp}") + + # 2. docker cp into container (clear first) + r = _ssh(["docker", "exec", CONTAINER, "rm", "-rf", container_path]) + r = _ssh(["docker", "cp", f"{remote_tmp}/.", f"{CONTAINER}:{container_path}"]) + if r.returncode != 0: + raise RuntimeError(f"docker cp failed: {r.stderr[-500:]}") + print(f"docker cp OK → {container_path}") + + # 3. upload via bundled CLI inside container + r = _ssh([ + "docker", "exec", + "-e", "IMMICH_INSTANCE_URL=http://localhost:2283", + "-e", f"IMMICH_API_KEY={key}", + CONTAINER, "immich", "upload", container_path, + ]) + out = r.stdout print(out[-2000:]) if r.returncode != 0: raise RuntimeError(f"immich upload failed: {r.stderr[-500:]}") - return out + # cleanup remote temp + _ssh(["rm", "-rf", remote_tmp]) + return {"output": out[-1000:]} @flow(name="immich-import") -def immich_import( - src_dir: str, - album: str | None = None, - dry_run: bool = True, -): - """Login to Immich and upload a staged folder. Dry-run by default — safe.""" - immich_login() - result = immich_upload(src_dir, album=album, dry_run=dry_run) - return {"uploaded": result} +def immich_import(src_dir: str, dry_run: bool = True): + """Import a staged folder into Immich via the server's bundled CLI.""" + ver = verify_immich() + if dry_run: + print(f"[dry-run] would upload {src_dir} to Immich (server {ver})") + return {"dry_run": True, "server": ver} + return upload_to_immich(src_dir) if __name__ == "__main__": import sys src = sys.argv[1] if len(sys.argv) > 1 else "/mnt/data/01_keep" - dry = "--dry-run" in sys.argv or "--no-move" not in sys.argv + dry = "--real" not in sys.argv # default dry-run; pass --real to actually upload immich_import(src, dry_run=dry)