From 7a43db8dd8e94b8af23b2b27550202dbe70c1edc Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 23 Jul 2026 14:04:13 +0200 Subject: [PATCH] feat(sync): enable scheduled asset synchronization mode This update introduces a persistent background synchronization mode, allowing the external assets container to run continuously and periodically sync albums instead of executing once and exiting. The system now checks a configurable interval, retries on temporary failures, and manages its lifecycle using `time.sleep`. - Added SYNC_INTERVAL_SECONDS configuration for scheduling - Updated compose service to restart automatically and loop indefinitely - Implemented continuous run logic in main function and added corresponding unit tests --- .env.example | 3 +++ README.md | 9 ++++++--- compose.yaml | 3 ++- src/main.py | 37 ++++++++++++++++++++++++++++++++++--- tests/test_main.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 3270ce1..ce6afb9 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,6 @@ PAGE_SIZE=1000 # Start with true to verify the planned album assignments without changes. DRY_RUN=true + +# Keep the container running and synchronize every 30 minutes. +SYNC_INTERVAL_SECONDS=1800 diff --git a/README.md b/README.md index 228f5ab..3eda618 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ Optional environment variables: - `ALBUM_SEPARATOR`: Separator for `relative` mode; defaults to ` - ` - `PAGE_SIZE`: Immich search page size; defaults to `1000` - `DRY_RUN`: Set to `true` to log planned work without changing albums +- `SYNC_INTERVAL_SECONDS`: Delay between runs; `0` runs once and exits. + Docker Compose defaults to `1800` seconds (30 minutes) Run the script after Immich has scanned the external library: @@ -68,9 +70,10 @@ The photo directory does not need to be mounted into this container: `EXTERNAL_ROOT` is matched against the path recorded in Immich's `originalPath` field. -The Compose service performs one synchronization and then exits. Run it after -an external-library scan, or invoke it regularly with a scheduler such as -cron. +The Compose service stays running and synchronizes every 30 minutes. Change +`SYNC_INTERVAL_SECONDS` in `.env` to use another interval. The delay starts +after a synchronization finishes. Temporary API errors are logged and retried +on the next scheduled run. The script only adds missing assets. It never removes assets from albums and never deletes albums. diff --git a/compose.yaml b/compose.yaml index 42a6e9f..e617ce5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,7 +4,7 @@ services: context: . image: immich-extern-to-album:local init: true - restart: "no" + restart: unless-stopped environment: IMMICH_URL: ${IMMICH_URL:?Set IMMICH_URL in .env} IMMICH_API_KEY: ${IMMICH_API_KEY} @@ -14,6 +14,7 @@ services: ALBUM_SEPARATOR: "${ALBUM_SEPARATOR:- - }" PAGE_SIZE: ${PAGE_SIZE:-1000} DRY_RUN: ${DRY_RUN:-false} + SYNC_INTERVAL_SECONDS: ${SYNC_INTERVAL_SECONDS:-1800} extra_hosts: - "host.docker.internal:host-gateway" read_only: true diff --git a/src/main.py b/src/main.py index 11e95a2..b3945b7 100644 --- a/src/main.py +++ b/src/main.py @@ -2,6 +2,7 @@ import logging import os import posixpath import sys +import time from pathlib import PurePosixPath from typing import Any @@ -31,6 +32,9 @@ 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", "0") +) logging.basicConfig( level=logging.INFO, @@ -73,6 +77,11 @@ def check_configuration() -> None: 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, @@ -232,9 +241,7 @@ def get_external_assets() -> list[dict[str, Any]]: return assets -def main() -> int: - check_configuration() - +def synchronize() -> None: existing_albums = get_albums() assets = get_external_assets() @@ -292,6 +299,30 @@ def main() -> int: ) 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 diff --git a/tests/test_main.py b/tests/test_main.py index 225c41f..7f24871 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -174,6 +174,50 @@ class SynchronizationTests(unittest.TestCase): ["asset-one", "asset-two"], ) + def test_continuous_mode_waits_and_runs_again(self) -> None: + with patch.object( + album_sync, + "SYNC_INTERVAL_SECONDS", + 1800, + ), patch.object( + album_sync, + "check_configuration", + ), patch.object( + album_sync, + "synchronize", + side_effect=[None, KeyboardInterrupt], + ) as synchronize, patch.object( + album_sync.time, + "sleep", + ) as sleep: + with self.assertRaises(KeyboardInterrupt): + album_sync.main() + + self.assertEqual(synchronize.call_count, 2) + sleep.assert_called_once_with(1800) + + def test_continuous_mode_retries_after_sync_error(self) -> None: + with patch.object( + album_sync, + "SYNC_INTERVAL_SECONDS", + 1800, + ), patch.object( + album_sync, + "check_configuration", + ), patch.object( + album_sync, + "synchronize", + side_effect=[RuntimeError("temporary"), KeyboardInterrupt], + ) as synchronize, patch.object( + album_sync.time, + "sleep", + ) as sleep: + with self.assertRaises(KeyboardInterrupt): + album_sync.main() + + self.assertEqual(synchronize.call_count, 2) + sleep.assert_called_once_with(1800) + if __name__ == "__main__": unittest.main()