"""photo-pipeline: Immich import flow (v5). 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). Approach: on .35, docker cp staging dir into immich_server container, then docker exec immich_server immich upload. Security: API key via env IMMICH_API_KEY or ~/photo-pipeline/.immich-key (chmod 600). """ import os import shutil import subprocess from pathlib import Path from prefect import flow, task 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" CONTAINER = "immich_server" def _api_key() -> str: key = os.environ.get("IMMICH_API_KEY") if key: return key if KEY_FILE.exists(): return KEY_FILE.read_text().strip() raise RuntimeError("IMMICH_API_KEY env or ~/photo-pipeline/.immich-key required") 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, ) @task 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() @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). """ 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:]}") # cleanup remote temp _ssh(["rm", "-rf", remote_tmp]) return {"output": out[-1000:]} @flow(name="immich-import") 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 = "--real" not in sys.argv # default dry-run; pass --real to actually upload immich_import(src, dry_run=dry)