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:
@@ -111,6 +111,7 @@ async def start_scheduler() -> None:
|
||||
|
||||
await _load_tracker_jobs()
|
||||
await _load_action_jobs()
|
||||
await _load_immich_dispatch_jobs()
|
||||
|
||||
# Start Telegram bot polling for bots with active command listeners
|
||||
from .telegram_poller import start_command_listener_polling
|
||||
@@ -760,6 +761,10 @@ async def reschedule_cron_jobs_for_timezone_change() -> None:
|
||||
"Rescheduled %d cron job(s) for new app timezone %s", rescheduled, tz.key,
|
||||
)
|
||||
|
||||
# Immich scheduled/periodic/memory jobs are also CronTrigger-based and
|
||||
# carry the same frozen-tz problem — rebuild them under the new tz.
|
||||
await reschedule_immich_dispatch_jobs()
|
||||
|
||||
|
||||
async def _run_action(action_id: int) -> None:
|
||||
"""Run an action (called by APScheduler)."""
|
||||
@@ -770,6 +775,155 @@ async def _run_action(action_id: int) -> None:
|
||||
_LOGGER.error("Error running action %d: %s", action_id, e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Immich scheduled / periodic / memory dispatch (cron-fired)
|
||||
#
|
||||
# These three slots fire on wall-clock schedules taken from the tracker's
|
||||
# default ``TrackingConfig`` (``scheduled_times``, ``periodic_times``,
|
||||
# ``memory_times`` — comma-separated ``HH:MM`` strings) interpreted in the
|
||||
# app-level IANA timezone. The dispatch flow lives in
|
||||
# ``services.scheduled_dispatch``; this section just owns scheduling.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IMMICH_DISPATCH_KINDS = ("scheduled", "periodic", "memory")
|
||||
_IMMICH_DISPATCH_PREFIX = "immich_dispatch_"
|
||||
|
||||
|
||||
def _parse_hhmm_list(raw: str) -> list[tuple[int, int]]:
|
||||
"""Parse ``"09:00,18:30"`` → ``[(9, 0), (18, 30)]``, skipping bad entries.
|
||||
|
||||
A typo in one slot must not prevent the others from scheduling — we log
|
||||
and move on rather than raising.
|
||||
"""
|
||||
out: list[tuple[int, int]] = []
|
||||
for part in (raw or "").split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
h_str, m_str = part.split(":", 1)
|
||||
hour, minute = int(h_str), int(m_str)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Skipping invalid time literal %r", part)
|
||||
continue
|
||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||
_LOGGER.warning("Skipping out-of-range time %r", part)
|
||||
continue
|
||||
out.append((hour, minute))
|
||||
return out
|
||||
|
||||
|
||||
async def _run_immich_dispatch(tracker_id: int, kind: str) -> None:
|
||||
"""APScheduler entry point — wraps the dispatch helper to swallow errors."""
|
||||
from .scheduled_dispatch import dispatch_scheduled_for_tracker
|
||||
try:
|
||||
await dispatch_scheduled_for_tracker(tracker_id, kind) # type: ignore[arg-type]
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.error(
|
||||
"Immich %s dispatch for tracker %d failed: %s", kind, tracker_id, err,
|
||||
)
|
||||
|
||||
|
||||
async def _load_immich_dispatch_jobs() -> None:
|
||||
"""Schedule cron jobs for every (tracker, kind, time) where the kind is on.
|
||||
|
||||
Reads each enabled Immich tracker's *default* tracking config — per-link
|
||||
overrides only gate dispatch (handled in ``scheduled_dispatch``), they do
|
||||
not influence the fire schedule.
|
||||
"""
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import (
|
||||
NotificationTracker,
|
||||
ServiceProvider as ServiceProviderModel,
|
||||
TrackingConfig,
|
||||
)
|
||||
|
||||
engine = get_engine()
|
||||
scheduler = get_scheduler()
|
||||
tz = await _load_app_timezone()
|
||||
|
||||
async with AsyncSession(engine) as session:
|
||||
trackers = (await session.exec(
|
||||
select(NotificationTracker).where(NotificationTracker.enabled == True) # noqa: E712
|
||||
)).all()
|
||||
if not trackers:
|
||||
return
|
||||
|
||||
provider_ids = list({t.provider_id for t in trackers})
|
||||
provider_types: dict[int, str] = {}
|
||||
if provider_ids:
|
||||
rows = await session.exec(
|
||||
select(ServiceProviderModel).where(
|
||||
ServiceProviderModel.id.in_(provider_ids)
|
||||
)
|
||||
)
|
||||
provider_types = {p.id: p.type for p in rows.all()}
|
||||
|
||||
tc_ids = list({
|
||||
t.default_tracking_config_id for t in trackers
|
||||
if t.default_tracking_config_id
|
||||
})
|
||||
tc_map: dict[int, TrackingConfig] = {}
|
||||
if tc_ids:
|
||||
rows = await session.exec(
|
||||
select(TrackingConfig).where(TrackingConfig.id.in_(tc_ids))
|
||||
)
|
||||
tc_map = {tc.id: tc for tc in rows.all()}
|
||||
|
||||
scheduled = 0
|
||||
for tracker in trackers:
|
||||
if provider_types.get(tracker.provider_id) != "immich":
|
||||
continue
|
||||
tc = tc_map.get(tracker.default_tracking_config_id) if tracker.default_tracking_config_id else None
|
||||
if tc is None:
|
||||
continue
|
||||
|
||||
for kind in _IMMICH_DISPATCH_KINDS:
|
||||
if not getattr(tc, f"{kind}_enabled", False):
|
||||
continue
|
||||
times_raw = getattr(tc, f"{kind}_times", "") or ""
|
||||
for hour, minute in _parse_hhmm_list(times_raw):
|
||||
job_id = f"{_IMMICH_DISPATCH_PREFIX}{kind}_{tracker.id}_{hour:02d}{minute:02d}"
|
||||
scheduler.add_job(
|
||||
_run_immich_dispatch,
|
||||
CronTrigger(hour=hour, minute=minute, timezone=tz),
|
||||
id=job_id,
|
||||
args=[tracker.id, kind],
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
)
|
||||
scheduled += 1
|
||||
_LOGGER.info(
|
||||
"Scheduled Immich %s for tracker %d at %02d:%02d [tz=%s]",
|
||||
kind, tracker.id, hour, minute, tz.key,
|
||||
)
|
||||
|
||||
if scheduled:
|
||||
_LOGGER.info(
|
||||
"Loaded %d Immich scheduled/periodic/memory job(s) [tz=%s]",
|
||||
scheduled, tz.key,
|
||||
)
|
||||
|
||||
|
||||
async def reschedule_immich_dispatch_jobs() -> None:
|
||||
"""Drop and rebuild all Immich scheduled/periodic/memory jobs.
|
||||
|
||||
Cheap to call on every relevant mutation — a typical install has only a
|
||||
handful of trackers. Called from the tracker, link, and tracking-config
|
||||
CRUD endpoints, and from ``reschedule_cron_jobs_for_timezone_change``.
|
||||
"""
|
||||
scheduler = get_scheduler()
|
||||
for job in list(scheduler.get_jobs()):
|
||||
if job.id.startswith(_IMMICH_DISPATCH_PREFIX):
|
||||
scheduler.remove_job(job.id)
|
||||
await _load_immich_dispatch_jobs()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduled backup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user