Files
playground/upload_images_to_immich.py

330 lines
10 KiB
Python
Executable File

#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["httpx"]
# ///
import dataclasses
import os
import re
import logging
import argparse
import uuid
import hashlib
from pathlib import Path
from typing import List, Dict
from datetime import datetime
import httpx
# Configure logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
logging.getLogger("httpx").setLevel(logging.WARNING)
def must_get_env(var_name: str, default: str | None = None) -> str:
"""Get environment variable or raise error if not set."""
value = os.getenv(var_name, default)
if not value:
raise ValueError(f"Environment variable {var_name} is not set")
return value
class ImmichClient:
"""Client for interacting with Immich API."""
def __init__(self, base_url: str, api_key: str):
self.base_url = base_url.rstrip("/") + "/api"
self.api_key = api_key
self.device_uuid = str(uuid.uuid4())
self.client = httpx.Client(
timeout=60,
headers={
"x-api-key": self.api_key,
},
base_url=self.base_url,
)
self._album_id_by_name = {}
self._tag_id_by_name = {}
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.client.close()
def get_or_create_album(self, name: str) -> str:
try:
return self._album_id_by_name[name]
except KeyError:
pass
try:
res = self.client.get("/albums")
res.raise_for_status()
albums = res.json()
for album in albums:
existing_album = album["albumName"]
self._album_id_by_name[existing_album] = album["id"]
if existing_album == name:
logger.info(f"Found existing album: {name} (ID: {album['id']})")
return album["id"]
except Exception as e:
logger.warning(f"Error searching for album: {e}")
logger.info(f"Creating new album: {name}")
res = self.client.post("/albums", json={"albumName": name})
res.raise_for_status()
album = res.json()
album_id = album["id"]
self._album_id_by_name[name] = album_id
return album_id
def create_tags(self, tags: list[str]) -> dict[str, str]:
if not tags:
return {}
# Check cache for existing tags
cached_tags = {}
missing_tags = []
for tag in tags:
if tag in self._tag_id_by_name:
cached_tags[tag] = self._tag_id_by_name[tag]
else:
missing_tags.append(tag)
if not missing_tags:
logger.debug(f"All tags found in cache: {tags}")
return cached_tags
logger.debug(f"Creating tags: {missing_tags}")
res = self.client.put("/tags", json={"tags": missing_tags})
res.raise_for_status()
# Update cache with new tags
new_tags = {}
for tag in res.json():
tag_name = tag["name"]
tag_id = tag["id"]
self._tag_id_by_name[tag_name] = tag_id
new_tags[tag_name] = tag_id
# Combine cached and new tags
all_tags = {**cached_tags, **new_tags}
return all_tags
def add_asset_to_album(self, album_id: str, asset_id: str) -> None:
"""Add asset to album."""
logger.debug(f"Adding asset {asset_id} to album {album_id}")
res = self.client.put(f"/albums/{album_id}/assets", json={"ids": [asset_id]})
res.raise_for_status()
def link_tag_to_asset(self, tag_id: str, asset_id: str) -> None:
"""Link tag to asset."""
logger.debug(f"Linking tag {tag_id} to asset {asset_id}")
res = self.client.put(f"/tags/{tag_id}/assets", json={"ids": [asset_id]})
res.raise_for_status()
def upload_image(self, image_path: Path) -> Dict:
"""Upload single image to Immich."""
logger.info(f"Uploading image: {image_path}")
stat = image_path.stat()
filename = image_path.name
ext = image_path.suffix.lower()
device_asset_id = f"{filename}-{stat.st_size}"
file_time = datetime.fromtimestamp(stat.st_mtime).isoformat() + "Z"
# Calculate SHA1 checksum for duplicate detection
sha1_hash = hashlib.sha1()
with image_path.open("rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
sha1_hash.update(chunk)
checksum = sha1_hash.hexdigest()
with image_path.open("rb") as f:
response = self.client.post(
"/assets",
headers={
"x-immich-checksum": checksum,
},
files={
"assetData": (filename, f, "image/jpeg"),
},
data={
"deviceAssetId": device_asset_id,
"deviceId": self.device_uuid,
"assetType": "image",
"fileCreatedAt": file_time,
"fileModifiedAt": file_time,
"isFavorite": "false",
"fileExtension": ext,
"duration": "0",
"isReadOnly": "false",
"isArchived": "false",
},
)
response.raise_for_status()
return response.json()
def upload_image(
client: ImmichClient,
image_path: Path,
albums: list[str] | None = None,
tags: list[str] | None = None,
) -> None:
"""
Upload a single image to Immich with optional albums and tags.
Args:
image_path: Path to the image file
albums: List of album names to add the image to
tags: List of tags to apply to the image
immich_client: ImmichClient instance to use (defaults to global instance)
"""
albums = albums or []
tags = tags or []
try:
asset = client.upload_image(image_path)
asset_id = asset["id"]
status = asset.get("status", "uploaded")
logger.info(f"Uploaded: {image_path.name} (status={status})")
# Skip album/tag processing for duplicates
# if status == "duplicate":
# logger.info(f"Skipping album/tag processing for duplicate: {image_path.name}")
# return
album_ids = []
for album_name in albums:
try:
album_id = client.get_or_create_album(album_name)
album_ids.append(album_id)
client.add_asset_to_album(album_id, asset_id)
logger.debug(f"Added to album: {album_name}")
except Exception as e:
logger.error(f"Failed to add to album {album_name}: {e}")
if tags:
try:
tag_id_by_name = client.create_tags(tags)
for tag_name, tag_id in tag_id_by_name.items():
client.link_tag_to_asset(tag_id, asset_id)
logger.debug(f"Applied tag: {tag_name}")
except Exception as e:
logger.error(f"Failed to apply tags: {e}")
except Exception as e:
logger.error(f"Failed to upload {image_path}: {e}")
raise
@dataclasses.dataclass
class ParsedFilename:
actors: list[str]
studio: str | None = None
def parse_filename(image_path: Path) -> ParsedFilename | None:
filename = image_path.stem
if " -- " in filename:
actors_part = filename.split(" -- ")[0]
actors = [actor.strip() for actor in actors_part.split(",")]
studio: str | None = None
if m := re.search(r" -- @(\S+)", filename):
studio = m.group(1)
return ParsedFilename(actors=actors, studio=studio)
return None
def list_images(folder_path: Path) -> List[Path]:
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp"}
images = []
for ext in image_extensions:
images.extend(folder_path.glob(f"*{ext}"))
return images
def parse_args():
parser = argparse.ArgumentParser(description="Upload images to Immich, grouping them by actor name from filename")
parser.add_argument("image_paths", type=Path, nargs="+", help="Path to the images to upload")
parser.add_argument(
"--api-key",
type=str,
default=os.getenv("IMMICH_API_KEY"),
required=True,
help="API key for Immich server",
)
parser.add_argument(
"--server-url",
type=str,
default=os.getenv("IMMICH_SERVER_URL", "https://ph.abdus.dev"),
required=True,
help="URL of the Immich server",
)
return parser.parse_args()
def main():
args = parse_args()
immich = ImmichClient(base_url=args.server_url, api_key=args.api_key)
image_paths: list[Path] = args.image_paths
if not image_paths:
logger.error("No images found to process")
return
logger.info(f"Found {len(image_paths)} images to process")
if not all(image_paths[0].parent == path.parent for path in image_paths):
logger.error("All images must be in the same directory")
return
uploaded_dir = image_paths[0].parent / "_uploaded"
uploaded_dir.mkdir(exist_ok=True)
for image_path in image_paths:
if not image_path.is_file():
logger.warning(f"Skipping non-file path: {image_path}")
continue
try:
parsed = parse_filename(image_path)
if not parsed:
logger.warning(f"Skipping image with unparseable filename: {image_path.name}")
continue
logger.info(f"Processing {image_path.name} with actors: {', '.join(parsed.actors)}")
actor_names = [actor.lower() for actor in parsed.actors]
tags = [*actor_names]
if parsed.studio:
tags.append(parsed.studio.lower())
upload_image(
client=immich,
image_path=image_path,
albums=actor_names,
tags=tags,
)
dest_path = uploaded_dir / image_path.name
logger.info(f"Moving {image_path.name} to {dest_path}")
image_path.rename(dest_path)
except Exception as e:
logger.error(f"Error processing image {image_path.name}: {e}")
if __name__ == "__main__":
main()