fix(server): honor periodic_interval_days for Immich periodic summary

The APScheduler cron job fires daily at every entry in `periodic_times`,
and nothing in the dispatch path consulted `periodic_interval_days` or
`periodic_start_date` — so a configured 3-day interval still produced a
daily summary.

Gate the dispatch in `dispatch_scheduled_for_tracker` for kind=periodic
via `(today_in_app_tz - start_date).days % interval == 0`. Log a skip
with reason `interval_not_due` on non-firing days so operators can tell
suppressed-by-interval apart from other skip causes.
This commit is contained in:
2026-05-13 12:14:18 +03:00
parent dec0839853
commit 90f958bdc6
@@ -22,7 +22,9 @@ the test-dispatch path in ``manual_dispatch``.
from __future__ import annotations
import logging
from datetime import datetime
from typing import Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -64,6 +66,43 @@ _SKIP_REASON_NO_LINKS = "no_enabled_links"
_SKIP_REASON_NO_EVENT = "provider_returned_no_event"
_SKIP_REASON_EMPTY_PAYLOAD = "zero_assets_matched"
_SKIP_REASON_NO_TARGETS = "no_targets_after_filtering"
_SKIP_REASON_INTERVAL_NOT_DUE = "interval_not_due"
def _periodic_due_today(default_tc: TrackingConfig, app_tz: str) -> bool:
"""Return True iff today (in app tz) falls on the periodic schedule.
The schedule is ``start_date + N * interval_days`` for non-negative N.
The cron job fires every day at the configured ``periodic_times`` — this
gate skips the dispatch on days that aren't a multiple of the interval
from the start date, so a user-configured ``interval_days=3`` fires every
third day rather than every day.
Defensive: an interval ≤ 1 is treated as "fire daily"; an unparseable
``periodic_start_date`` is treated as "fire" so a bad config doesn't
silently stop all summaries (operators see notifications and can fix it).
"""
interval = max(1, int(getattr(default_tc, "periodic_interval_days", 1) or 1))
if interval <= 1:
return True
raw_start = getattr(default_tc, "periodic_start_date", "") or ""
try:
start = datetime.strptime(raw_start, "%Y-%m-%d").date()
except (ValueError, TypeError):
_LOGGER.warning(
"Invalid periodic_start_date %r; treating periodic as due today",
raw_start,
)
return True
try:
tz = ZoneInfo(app_tz)
except (ZoneInfoNotFoundError, ValueError):
tz = ZoneInfo("UTC")
today = datetime.now(tz).date()
delta_days = (today - start).days
if delta_days < 0:
return False
return delta_days % interval == 0
async def _log_skip(
@@ -181,6 +220,28 @@ async def dispatch_scheduled_for_tracker(
collection_ids = list(tracker.collection_ids or [])
app_tz = await get_app_timezone(session)
# Periodic summary respects an N-day interval starting from
# ``periodic_start_date`` — the cron job fires every day at
# ``periodic_times``, so without this gate a configured
# ``interval_days=3`` would still dispatch daily. Other kinds
# (scheduled/memory) don't have an interval setting.
if kind == "periodic" and not _periodic_due_today(default_tc, app_tz):
_LOGGER.info(
"Periodic for tracker %d: not due today (interval=%s, start=%s)",
tracker_id,
getattr(default_tc, "periodic_interval_days", None),
getattr(default_tc, "periodic_start_date", None),
)
await _log_skip(
tracker_id, kind, _SKIP_REASON_INTERVAL_NOT_DUE,
tracker_user_id=tracker.user_id,
tracker_name=tracker.name or "",
provider_id=provider.id,
provider_name=provider.name or provider.type,
)
return
link_data = await load_link_data(session, tracker_id)
if not link_data: