feat(immich): wire cron-fired scheduled/periodic/memory dispatch

The scheduled_enabled / scheduled_times (and the periodic / memory
counterparts) on TrackingConfig had been wired into the model, the
API, and the test-dispatch path — but no production scheduler ever
read them, so users saw the slot in the UI and only ever got fires
through "Test". This adds the missing cron jobs and the dispatch
fan-out, both keyed off the app-level IANA timezone.

* services/scheduled_dispatch.py — production fan-out reusing the
  test-path event builders, picking the slot template per kind, and
  writing an EventLog row per fire so the dashboard reflects it.
* services/scheduler.py — _load_immich_dispatch_jobs builds one
  CronTrigger per (tracker, kind, HH:MM) from the tracker's default
  TrackingConfig; reschedule_immich_dispatch_jobs rebuilds them all
  on any relevant CRUD or timezone change.
* tracker / link / tracking-config CRUD endpoints now invalidate.

Also: skip dispatch when scheduled/memory yield zero matching assets
(prevents header-only "On this day:" spam), and update the EN/RU
default scheduled_assets templates to surface that the delivery is
a scheduled random selection.
This commit is contained in:
2026-04-24 12:49:47 +03:00
parent 90def11b8d
commit 309dec2b44
7 changed files with 420 additions and 5 deletions
@@ -23,6 +23,7 @@ from ..database.models import (
)
from ..services.notifier import send_test_notification
from ..services.manual_dispatch import dispatch_test_notification
from ..services.scheduler import reschedule_immich_dispatch_jobs
from .helpers import get_owned_entity
_LOGGER = logging.getLogger(__name__)
@@ -118,6 +119,7 @@ async def create_notification_tracker_target(
session.add(tt)
await session.commit()
await session.refresh(tt)
await reschedule_immich_dispatch_jobs()
return await _tt_response(session, tt)
@@ -164,6 +166,7 @@ async def update_notification_tracker_target(
session.add(tt)
await session.commit()
await session.refresh(tt)
await reschedule_immich_dispatch_jobs()
return await _tt_response(session, tt)
@@ -181,6 +184,7 @@ async def delete_notification_tracker_target(
raise HTTPException(status_code=404, detail="Tracker-target link not found")
await session.delete(tt)
await session.commit()
await reschedule_immich_dispatch_jobs()
@router.post("/{tracker_target_id}/test/{test_type}")
@@ -18,7 +18,11 @@ from ..database.models import (
ServiceProvider,
User,
)
from ..services.scheduler import schedule_tracker, unschedule_tracker
from ..services.scheduler import (
reschedule_immich_dispatch_jobs,
schedule_tracker,
unschedule_tracker,
)
from .helpers import get_owned_entity
from .notification_tracker_targets import _tt_response
@@ -146,6 +150,7 @@ async def create_notification_tracker(
await session.refresh(tracker)
if tracker.enabled:
await schedule_tracker(tracker.id, tracker.scan_interval)
await reschedule_immich_dispatch_jobs()
return await _tracker_response(session, tracker)
@@ -176,6 +181,7 @@ async def update_notification_tracker(
await schedule_tracker(tracker.id, tracker.scan_interval)
else:
await unschedule_tracker(tracker.id)
await reschedule_immich_dispatch_jobs()
return await _tracker_response(session, tracker)
@@ -208,6 +214,7 @@ async def delete_notification_tracker(
await session.delete(tracker)
await session.commit()
await unschedule_tracker(tracker_id)
await reschedule_immich_dispatch_jobs()
@router.post("/{tracker_id}/trigger")
@@ -10,6 +10,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TrackingConfig, User
from ..services.scheduler import reschedule_immich_dispatch_jobs
_LOGGER = logging.getLogger(__name__)
@@ -127,6 +128,8 @@ async def create_config(
session.add(config)
await session.commit()
await session.refresh(config)
if config.provider_type == "immich":
await reschedule_immich_dispatch_jobs()
return _response(config)
@@ -152,6 +155,8 @@ async def update_config(
session.add(config)
await session.commit()
await session.refresh(config)
if config.provider_type == "immich":
await reschedule_immich_dispatch_jobs()
return _response(config)
@@ -164,8 +169,11 @@ async def delete_config(
from .delete_protection import check_tracking_config, raise_if_used
config = await _get(session, config_id, user.id)
raise_if_used(await check_tracking_config(session, config.id), config.name)
provider_type = config.provider_type
await session.delete(config)
await session.commit()
if provider_type == "immich":
await reschedule_immich_dispatch_jobs()
def _response(c: TrackingConfig) -> dict: