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 from __future__ import annotations
import logging import logging
from datetime import datetime
from typing import Literal from typing import Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlmodel import select from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession 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_NO_EVENT = "provider_returned_no_event"
_SKIP_REASON_EMPTY_PAYLOAD = "zero_assets_matched" _SKIP_REASON_EMPTY_PAYLOAD = "zero_assets_matched"
_SKIP_REASON_NO_TARGETS = "no_targets_after_filtering" _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( async def _log_skip(
@@ -181,6 +220,28 @@ async def dispatch_scheduled_for_tracker(
collection_ids = list(tracker.collection_ids or []) collection_ids = list(tracker.collection_ids or [])
app_tz = await get_app_timezone(session) 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) link_data = await load_link_data(session, tracker_id)
if not link_data: if not link_data: