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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user