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