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. # Start with true to verify the planned album assignments without changes.
DRY_RUN=true 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 ` - ` - `ALBUM_SEPARATOR`: Separator for `relative` mode; defaults to ` - `
- `PAGE_SIZE`: Immich search page size; defaults to `1000` - `PAGE_SIZE`: Immich search page size; defaults to `1000`
- `DRY_RUN`: Set to `true` to log planned work without changing albums - `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: 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 `EXTERNAL_ROOT` is matched against the path recorded in Immich's
`originalPath` field. `originalPath` field.
The Compose service performs one synchronization and then exits. Run it after The Compose service stays running and synchronizes every 30 minutes. Change
an external-library scan, or invoke it regularly with a scheduler such as `SYNC_INTERVAL_SECONDS` in `.env` to use another interval. The delay starts
cron. 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 The script only adds missing assets. It never removes assets from albums and
never deletes albums. never deletes albums.
+2 -1
View File
@@ -4,7 +4,7 @@ services:
context: . context: .
image: immich-extern-to-album:local image: immich-extern-to-album:local
init: true init: true
restart: "no" restart: unless-stopped
environment: environment:
IMMICH_URL: ${IMMICH_URL:?Set IMMICH_URL in .env} IMMICH_URL: ${IMMICH_URL:?Set IMMICH_URL in .env}
IMMICH_API_KEY: ${IMMICH_API_KEY} IMMICH_API_KEY: ${IMMICH_API_KEY}
@@ -14,6 +14,7 @@ services:
ALBUM_SEPARATOR: "${ALBUM_SEPARATOR:- - }" ALBUM_SEPARATOR: "${ALBUM_SEPARATOR:- - }"
PAGE_SIZE: ${PAGE_SIZE:-1000} PAGE_SIZE: ${PAGE_SIZE:-1000}
DRY_RUN: ${DRY_RUN:-false} DRY_RUN: ${DRY_RUN:-false}
SYNC_INTERVAL_SECONDS: ${SYNC_INTERVAL_SECONDS:-1800}
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
read_only: true read_only: true
+34 -3
View File
@@ -2,6 +2,7 @@ import logging
import os import os
import posixpath import posixpath
import sys import sys
import time
from pathlib import PurePosixPath from pathlib import PurePosixPath
from typing import Any from typing import Any
@@ -31,6 +32,9 @@ ALBUM_MODE = os.environ.get("ALBUM_MODE", "root").lower()
ALBUM_SEPARATOR = os.environ.get("ALBUM_SEPARATOR", " - ") ALBUM_SEPARATOR = os.environ.get("ALBUM_SEPARATOR", " - ")
PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "1000")) PAGE_SIZE = int(os.environ.get("PAGE_SIZE", "1000"))
DRY_RUN = os.environ.get("DRY_RUN", "false").lower() == "true" DRY_RUN = os.environ.get("DRY_RUN", "false").lower() == "true"
SYNC_INTERVAL_SECONDS = int(
os.environ.get("SYNC_INTERVAL_SECONDS", "0")
)
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -73,6 +77,11 @@ def check_configuration() -> None:
if not EXTERNAL_ROOT.startswith("/"): if not EXTERNAL_ROOT.startswith("/"):
raise RuntimeError("EXTERNAL_ROOT must be an absolute path") 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( def api_request(
method: str, method: str,
@@ -232,9 +241,7 @@ def get_external_assets() -> list[dict[str, Any]]:
return assets return assets
def main() -> int: def synchronize() -> None:
check_configuration()
existing_albums = get_albums() existing_albums = get_albums()
assets = get_external_assets() assets = get_external_assets()
@@ -292,6 +299,30 @@ def main() -> int:
) )
logging.info("Synchronization completed") 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 return 0
+44
View File
@@ -174,6 +174,50 @@ class SynchronizationTests(unittest.TestCase):
["asset-one", "asset-two"], ["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__": if __name__ == "__main__":
unittest.main() unittest.main()