Immich import via server-bundled CLI (v2.7.5); ssh key fix; verified dedup

This commit is contained in:
2026-08-07 21:25:21 +10:00
parent 5f8d6ce9ad
commit 5ca92fec7b

View File

@@ -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 Approach: on .35, docker cp staging dir into immich_server container, then
(chmod 600). Never committed to git. docker exec immich_server immich upload.
The CLI does checksum dedup natively (skips already-uploaded assets), so Security: API key via env IMMICH_API_KEY or ~/photo-pipeline/.immich-key (chmod 600).
re-running is safe.
""" """
import os import os
@@ -16,9 +17,10 @@ from pathlib import Path
from prefect import flow, task 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" KEY_FILE = Path(__file__).parent / ".immich-key"
CLI = Path(__file__).parent / "node_modules/.bin/immich" CONTAINER = "immich_server"
def _api_key() -> str: def _api_key() -> str:
@@ -30,56 +32,85 @@ def _api_key() -> str:
raise RuntimeError("IMMICH_API_KEY env or ~/photo-pipeline/.immich-key required") raise RuntimeError("IMMICH_API_KEY env or ~/photo-pipeline/.immich-key required")
@task def _ssh(args: list[str], timeout: int = 600) -> subprocess.CompletedProcess:
def immich_login(): # -i: .35 accepts id_ed25519_rsync (used by rsync backups); default key rejected
"""Ensure CLI is authenticated (login-key is idempotent-ish; stores auth.yml).""" return subprocess.run(
key = _api_key() ["ssh", "-i", os.path.expanduser("~/.ssh/id_ed25519_rsync"),
r = subprocess.run( "sam@" + IMMICH_HOST, *args],
[str(CLI), "login-key", IMMICH_URL, key], capture_output=True, text=True, timeout=timeout,
capture_output=True, text=True,
) )
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 @task
def immich_upload(src_dir: str, album: str | None = None, dry_run: bool = False) -> str: def verify_immich():
"""Upload assets from a directory to 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"] key = _api_key()
# --skip-hash: with a least-privilege API key the CLI's dup-check is broken ssh_args = ["-i", os.path.expanduser("~/.ssh/id_ed25519_rsync")]
# (immich-app issue #21456); our fingerprint DB does dedup pre-upload.
if album: # 1. scp the staged dir to .35 /tmp
cmd += ["--album-name", album] local_dir = Path(src_dir)
if dry_run: remote_tmp = f"/tmp/pp_import_{local_dir.name}"
cmd += ["--dry-run"] r = subprocess.run(
r = subprocess.run(cmd, capture_output=True, text=True) ["scp", *ssh_args, "-r", str(local_dir), f"sam@{IMMICH_HOST}:{remote_tmp}"],
out = r.stdout.strip() capture_output=True, text=True, timeout=900,
print(f"immich upload rc={r.returncode}") )
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:]) print(out[-2000:])
if r.returncode != 0: if r.returncode != 0:
raise RuntimeError(f"immich upload failed: {r.stderr[-500:]}") 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") @flow(name="immich-import")
def immich_import( def immich_import(src_dir: str, dry_run: bool = True):
src_dir: str, """Import a staged folder into Immich via the server's bundled CLI."""
album: str | None = None, ver = verify_immich()
dry_run: bool = True, if dry_run:
): print(f"[dry-run] would upload {src_dir} to Immich (server {ver})")
"""Login to Immich and upload a staged folder. Dry-run by default — safe.""" return {"dry_run": True, "server": ver}
immich_login() return upload_to_immich(src_dir)
result = immich_upload(src_dir, album=album, dry_run=dry_run)
return {"uploaded": result}
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
src = sys.argv[1] if len(sys.argv) > 1 else "/mnt/data/01_keep" 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) immich_import(src, dry_run=dry)