This commit addresses various minor structural improvements across the codebase, primarily focusing on simplifying syntax and enhancing robustness. Several instances of overly verbose string concatenation and unnecessary line breaks have been cleaned up for better adherence to Python best practices. - Simplified dictionary comprehensions in asset retrieval functions - Improved error message formatting using f-strings - Updated .gitignore to exclude agent and codex directories
316 lines
7.6 KiB
Python
316 lines
7.6 KiB
Python
import logging
|
|
import os
|
|
import posixpath
|
|
import sys
|
|
import time
|
|
from pathlib import PurePosixPath
|
|
from typing import Any
|
|
|
|
import requests
|
|
|
|
|
|
IMMICH_URL = os.environ.get("IMMICH_URL", "").rstrip("/")
|
|
IMMICH_API_KEY = os.environ.get("IMMICH_API_KEY", "")
|
|
LIBRARY_ID = os.environ.get("IMMICH_LIBRARY_ID", "")
|
|
|
|
# Path as it appears in Immich's originalPath field.
|
|
EXTERNAL_ROOT = os.environ.get(
|
|
"EXTERNAL_ROOT",
|
|
"/mnt/nextcloud-photos",
|
|
).rstrip("/")
|
|
|
|
# root:
|
|
# /mnt/nextcloud-photos/Korea_2016_07_08/photo.jpg -> Korea_2016_07_08
|
|
#
|
|
# leaf:
|
|
# /mnt/nextcloud-photos/2026/Vacation/photo.jpg -> Vacation
|
|
#
|
|
# relative:
|
|
# /mnt/nextcloud-photos/2026/Vacation/photo.jpg -> 2026 - Vacation
|
|
ALBUM_MODE = os.environ.get("ALBUM_MODE", "root").lower()
|
|
|
|
ALBUM_SEPARATOR = os.environ.get("ALBUM_SEPARATOR", " - ")
|
|
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "1000"))
|
|
DRY_RUN = os.environ.get("DRY_RUN", "false").lower() == "true"
|
|
SYNC_INTERVAL_SECONDS = int(os.environ.get("SYNC_INTERVAL_SECONDS", "1800"))
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
)
|
|
|
|
session = requests.Session()
|
|
session.headers.update(
|
|
{
|
|
"x-api-key": IMMICH_API_KEY,
|
|
"Accept": "application/json",
|
|
"Content-Type": "application/json",
|
|
}
|
|
)
|
|
|
|
|
|
def check_configuration() -> None:
|
|
missing = []
|
|
|
|
if not IMMICH_URL:
|
|
missing.append("IMMICH_URL")
|
|
|
|
if not IMMICH_API_KEY:
|
|
missing.append("IMMICH_API_KEY")
|
|
|
|
if not LIBRARY_ID:
|
|
missing.append("IMMICH_LIBRARY_ID")
|
|
|
|
if missing:
|
|
raise RuntimeError(
|
|
"The following environment variables are missing: " + ", ".join(missing)
|
|
)
|
|
|
|
if ALBUM_MODE not in {"root", "leaf", "relative"}:
|
|
raise RuntimeError("ALBUM_MODE must be root, leaf, or relative")
|
|
|
|
if not EXTERNAL_ROOT.startswith("/"):
|
|
raise RuntimeError("EXTERNAL_ROOT must be an absolute path")
|
|
|
|
if SYNC_INTERVAL_SECONDS < 0:
|
|
raise RuntimeError("SYNC_INTERVAL_SECONDS must be zero or greater")
|
|
|
|
|
|
def api_request(
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
json: dict[str, Any] | None = None,
|
|
) -> Any:
|
|
url = f"{IMMICH_URL}/api{path}"
|
|
|
|
response = session.request(
|
|
method,
|
|
url,
|
|
json=json,
|
|
timeout=120,
|
|
)
|
|
|
|
if not response.ok:
|
|
raise RuntimeError(
|
|
f"{method} {url} failed: {response.status_code} {response.text}"
|
|
)
|
|
|
|
if response.status_code == 204 or not response.content:
|
|
return None
|
|
|
|
return response.json()
|
|
|
|
|
|
def get_albums() -> dict[str, dict[str, Any]]:
|
|
albums = api_request("GET", "/albums?isOwned=true")
|
|
|
|
return {album["albumName"]: album for album in albums}
|
|
|
|
|
|
def create_album(album_name: str) -> dict[str, Any]:
|
|
logging.info("Creating album: %s", album_name)
|
|
|
|
if DRY_RUN:
|
|
return {
|
|
"id": f"dry-run:{album_name}",
|
|
"albumName": album_name,
|
|
"assets": [],
|
|
}
|
|
|
|
return api_request(
|
|
"POST",
|
|
"/albums",
|
|
json={
|
|
"albumName": album_name,
|
|
"assetIds": [],
|
|
},
|
|
)
|
|
|
|
|
|
def search_assets(**filters: Any) -> list[dict[str, Any]]:
|
|
assets: list[dict[str, Any]] = []
|
|
page = 1
|
|
|
|
while True:
|
|
logging.info("Reading asset page %d", page)
|
|
|
|
result = api_request(
|
|
"POST",
|
|
"/search/metadata",
|
|
json={
|
|
**filters,
|
|
"page": page,
|
|
"size": PAGE_SIZE,
|
|
},
|
|
)
|
|
|
|
asset_page = result.get("assets", {})
|
|
items = asset_page.get("items", [])
|
|
assets.extend(items)
|
|
|
|
next_page = asset_page.get("nextPage")
|
|
if not items or next_page is None:
|
|
break
|
|
|
|
page = int(next_page)
|
|
|
|
return assets
|
|
|
|
|
|
def get_album_asset_ids(album_id: str) -> set[str]:
|
|
assets = search_assets(albumIds=[album_id])
|
|
return {asset["id"] for asset in assets if asset.get("id")}
|
|
|
|
|
|
def add_assets_to_album(
|
|
album_id: str,
|
|
asset_ids: list[str],
|
|
) -> None:
|
|
if not asset_ids:
|
|
return
|
|
|
|
logging.info(
|
|
"Adding %d asset(s) to the album",
|
|
len(asset_ids),
|
|
)
|
|
|
|
if DRY_RUN:
|
|
return
|
|
|
|
# Send smaller batches to keep the request body at a reasonable size.
|
|
batch_size = 500
|
|
|
|
for start in range(0, len(asset_ids), batch_size):
|
|
batch = asset_ids[start : start + batch_size]
|
|
|
|
api_request(
|
|
"PUT",
|
|
f"/albums/{album_id}/assets",
|
|
json={
|
|
"ids": batch,
|
|
},
|
|
)
|
|
|
|
|
|
def get_album_name(original_path: str) -> str | None:
|
|
normalized_root = posixpath.normpath(EXTERNAL_ROOT)
|
|
normalized_path = posixpath.normpath(original_path.replace("\\", "/"))
|
|
|
|
prefix = normalized_root + "/"
|
|
|
|
if not normalized_path.startswith(prefix):
|
|
return None
|
|
|
|
relative_path = normalized_path[len(prefix) :]
|
|
relative = PurePosixPath(relative_path)
|
|
|
|
# A file directly in EXTERNAL_ROOT has no album directory.
|
|
if len(relative.parts) < 2:
|
|
return None
|
|
|
|
directory_parts = relative.parts[:-1]
|
|
|
|
if ALBUM_MODE == "root":
|
|
return directory_parts[0]
|
|
|
|
if ALBUM_MODE == "leaf":
|
|
return directory_parts[-1]
|
|
|
|
return ALBUM_SEPARATOR.join(directory_parts)
|
|
|
|
|
|
def get_external_assets() -> list[dict[str, Any]]:
|
|
assets = search_assets(libraryId=LIBRARY_ID)
|
|
logging.info("%d external assets found", len(assets))
|
|
return assets
|
|
|
|
|
|
def synchronize() -> None:
|
|
existing_albums = get_albums()
|
|
assets = get_external_assets()
|
|
|
|
desired_albums: dict[str, list[str]] = {}
|
|
|
|
for asset in assets:
|
|
asset_id = asset.get("id")
|
|
original_path = asset.get("originalPath")
|
|
|
|
if not asset_id or not original_path:
|
|
continue
|
|
|
|
album_name = get_album_name(original_path)
|
|
|
|
if not album_name:
|
|
continue
|
|
|
|
desired_albums.setdefault(album_name, []).append(asset_id)
|
|
|
|
logging.info(
|
|
"%d album directories derived from asset paths",
|
|
len(desired_albums),
|
|
)
|
|
|
|
for album_name, desired_asset_ids in sorted(desired_albums.items()):
|
|
album = existing_albums.get(album_name)
|
|
|
|
if album is None:
|
|
album = create_album(album_name)
|
|
existing_albums[album_name] = album
|
|
|
|
album_id = album["id"]
|
|
|
|
if DRY_RUN:
|
|
existing_asset_ids: set[str] = set()
|
|
else:
|
|
# Immich v3 album responses no longer contain the asset list.
|
|
existing_asset_ids = get_album_asset_ids(album_id)
|
|
|
|
missing_asset_ids = sorted(set(desired_asset_ids) - existing_asset_ids)
|
|
|
|
if missing_asset_ids:
|
|
logging.info(
|
|
"Album '%s': %d new asset(s)",
|
|
album_name,
|
|
len(missing_asset_ids),
|
|
)
|
|
add_assets_to_album(album_id, missing_asset_ids)
|
|
else:
|
|
logging.info(
|
|
"Album '%s' is up to date",
|
|
album_name,
|
|
)
|
|
|
|
logging.info("Synchronization completed")
|
|
|
|
|
|
def main() -> int:
|
|
check_configuration()
|
|
|
|
while True:
|
|
try:
|
|
synchronize()
|
|
except Exception:
|
|
if SYNC_INTERVAL_SECONDS == 0:
|
|
raise
|
|
logging.exception("Synchronization failed; the next run will retry")
|
|
|
|
if SYNC_INTERVAL_SECONDS == 0:
|
|
break
|
|
|
|
logging.info(
|
|
"Next synchronization in %d seconds",
|
|
SYNC_INTERVAL_SECONDS,
|
|
)
|
|
time.sleep(SYNC_INTERVAL_SECONDS)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except Exception:
|
|
logging.exception("Synchronization failed")
|
|
sys.exit(1)
|