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
This commit is contained in:
2026-07-23 14:04:13 +02:00
parent 22dcae73ba
commit 7a43db8dd8
5 changed files with 89 additions and 7 deletions
+3
View File
@@ -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
+6 -3
View File
@@ -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.
+2 -1
View File
@@ -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
+34 -3
View File
@@ -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
+44
View File
@@ -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()