"""Cron-fired scheduled / periodic / memory dispatch for Immich trackers. The Immich provider exposes three notification slots that fire on a wall-clock schedule rather than in response to album changes: * ``scheduled_assets_message`` — random asset selection at fixed times of day * ``periodic_summary_message`` — album stats summary at fixed times of day * ``memory_mode_message`` — "On This Day" memories at fixed times of day The fire times live on the tracker's default ``TrackingConfig`` as comma- separated ``HH:MM`` strings (``scheduled_times`` / ``periodic_times`` / ``memory_times``) interpreted in the app-level IANA timezone (``AppSetting.timezone``). The scheduler module wires the cron jobs; this module owns the dispatch flow once a job fires. Note on per-link tracking-config overrides: schedule *times* come from the tracker's default config — a per-link override may disable the slot for that link (via ``{kind}_enabled``) but cannot shift its fire time. Consistent with the test-dispatch path in ``manual_dispatch``. """ from __future__ import annotations import logging from typing import Literal from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession from notify_bridge_core.models.events import EventType from notify_bridge_core.notifications.dispatcher import ( NotificationDispatcher, TargetConfig, ) from ..database.engine import get_engine from ..database.models import ( EventLog, NotificationTracker, ServiceProvider, TemplateSlot, TrackingConfig, ) from .dispatch_helpers import ( GateReason, apply_tracking_display_filters, evaluate_event_gate, get_app_timezone, load_link_data, ) from .manual_dispatch import build_immich_dispatch_events _LOGGER = logging.getLogger(__name__) ScheduledKind = Literal["scheduled", "periodic", "memory"] # Reasons a scheduled cron fire can end up producing no notification. We write # these to EventLog.details.skip_reason so users can see *why* a 09:00 memory # didn't arrive, rather than silently treating the fire as if it never happened. _SKIP_REASON_TRACKER_DISABLED = "tracker_disabled" _SKIP_REASON_NOT_IMMICH = "not_immich_provider" _SKIP_REASON_KIND_DISABLED = "kind_disabled_on_default_config" _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" async def _log_skip( tracker_id: int, kind: ScheduledKind, reason: str, *, tracker_user_id: int | None = None, tracker_name: str = "", provider_id: int | None = None, provider_name: str = "", ) -> None: """Persist an EventLog row for a skipped scheduled fire. Separate from the success-path log (which records targets dispatched) so operators and users can filter "why didn't this fire" from "what was sent". ``event_type`` mirrors the success path's value; the skip is disambiguated by ``details.status == "skipped"``. """ engine = get_engine() async with AsyncSession(engine) as session: session.add(EventLog( user_id=tracker_user_id, tracker_id=tracker_id, tracker_name=tracker_name, provider_id=provider_id, provider_name=provider_name, event_type=EventType.SCHEDULED_MESSAGE.value, collection_id="", collection_name="", assets_count=0, details={ "kind": kind, "trigger": "cron", "status": "skipped", "skip_reason": reason, }, )) await session.commit() # Maps the dispatch kind to the DB slot name that holds its template. # The dispatcher keys templates by ``event.event_type.value`` (always # ``scheduled_message`` here), so we read the right ``TemplateSlot`` row and # inject it under that single event-type key — same pattern as the test path. _SLOT_MAP: dict[ScheduledKind, str] = { "scheduled": "scheduled_assets_message", "periodic": "periodic_summary_message", "memory": "memory_mode_message", } async def dispatch_scheduled_for_tracker( tracker_id: int, kind: ScheduledKind ) -> None: """Build the slot's event for ``tracker_id`` and fan out to its links. Skips silently when the tracker is disabled, the provider is not Immich, the slot is disabled on the tracker's default tracking config, or no link has a ``TemplateConfig`` with the corresponding slot row. """ engine = get_engine() async with AsyncSession(engine) as session: tracker = await session.get(NotificationTracker, tracker_id) if not tracker or not tracker.enabled: # No user context available (tracker missing/disabled); still log so # operators can correlate cron fires that went nowhere. await _log_skip( tracker_id, kind, _SKIP_REASON_TRACKER_DISABLED, tracker_user_id=(tracker.user_id if tracker else None), tracker_name=(tracker.name if tracker else ""), ) return provider = await session.get(ServiceProvider, tracker.provider_id) if not provider or provider.type != "immich": await _log_skip( tracker_id, kind, _SKIP_REASON_NOT_IMMICH, tracker_user_id=tracker.user_id, tracker_name=tracker.name or "", provider_id=(provider.id if provider else None), provider_name=(provider.name if provider else ""), ) return default_tc: TrackingConfig | None = None if tracker.default_tracking_config_id: default_tc = await session.get( TrackingConfig, tracker.default_tracking_config_id ) # If the default config disables this kind, nothing to do — schedule # rebuild only adds jobs when the flag is set, but a stale job from # a previous DB state could still fire one tick before invalidation. if default_tc is None or not getattr(default_tc, f"{kind}_enabled", False): _LOGGER.debug( "Scheduled %s skipped for tracker %d: kind disabled on default config", kind, tracker_id, ) await _log_skip( tracker_id, kind, _SKIP_REASON_KIND_DISABLED, tracker_user_id=tracker.user_id, tracker_name=tracker.name or "", provider_id=provider.id, provider_name=provider.name or provider.type, ) return # Snapshot every field we need outside the session — after the # ``async with`` exits the instances are detached and lazy-load # would fail. Cheaper than re-fetching, safer than touching # attributes through a closed session. provider_id = provider.id provider_config = dict(provider.config) provider_name = provider.name or provider.type tracker_user_id = tracker.user_id tracker_name = tracker.name or "" collection_ids = list(tracker.collection_ids or []) app_tz = await get_app_timezone(session) link_data = await load_link_data(session, tracker_id) if not link_data: _LOGGER.info( "Scheduled %s for tracker %d: no enabled links, skipping", kind, tracker_id, ) await _log_skip( tracker_id, kind, _SKIP_REASON_NO_LINKS, tracker_user_id=tracker_user_id, tracker_name=tracker_name, provider_id=provider_id, provider_name=provider_name, ) return # Resolve mode + build events via the shared helper (same decision logic # the test-dispatch path uses). "per_collection" fans out one event per # album; "combined" pools assets into a single event. ``collection_mode`` # is threaded through to EventLog.details so operators can see *which* # mode a fire used when auditing behaviour. collection_mode = ( "combined" if kind == "periodic" else getattr(default_tc, f"{kind}_collection_mode", "combined") or "combined" ) events = await build_immich_dispatch_events( provider_config=provider_config, provider_name=provider_name, tracker_name=tracker_name, collection_ids=collection_ids, kind=kind, tracking_config=default_tc, ) if not events: # All albums yielded 0 matching assets (per_collection), or the single # combined build produced nothing. Log the same skip reason used for # the legacy single-event path so operators see a consistent signal. reason = ( _SKIP_REASON_NO_EVENT if kind == "periodic" else _SKIP_REASON_EMPTY_PAYLOAD ) _LOGGER.info( "Scheduled %s for tracker %d: no events to dispatch (mode=%s)", kind, tracker_id, collection_mode, ) await _log_skip( tracker_id, kind, reason, tracker_user_id=tracker_user_id, tracker_name=tracker_name, provider_id=provider_id, provider_name=provider_name, ) return slot_name = _SLOT_MAP[kind] # Lazy import to break the watcher↔scheduler↔scheduled_dispatch cycle. from .watcher import _get_telegram_caches from .http_session import get_http_session url_cache, asset_cache = await _get_telegram_caches() http_session = await get_http_session() dispatcher = NotificationDispatcher( url_cache=url_cache, asset_cache=asset_cache, session=http_session, ) any_sent = False for event in events: # Target config assembly depends on the event for quiet-hours / # event_allowed_by_config, which inspects event timestamp. Per-event # rebuilding also lets a per-link override disable one kind while # keeping others live. # Group target configs by TrackingConfig identity so each unique TC # gets its own ``apply_tracking_display_filters`` pass before dispatch. groups: dict[int, tuple[TrackingConfig | None, list[TargetConfig]]] = {} async with AsyncSession(engine) as session: for ld in link_data: tc = ld["tracking_config"] or default_tc tmpl = ld["template_config"] if tc is not None: if not getattr(tc, f"{kind}_enabled", True): continue # Scheduled / periodic / memory dispatches are wall-clock # by nature — a "good morning" delivered at 3 pm is wrong, # so quiet hours = drop (not defer) for these kinds. The # other gate (per-event-type flag) still applies. if not evaluate_event_gate(event, tc, app_tz).allowed: continue if tmpl is None: continue slot_rows = (await session.exec( select(TemplateSlot).where( TemplateSlot.config_id == tmpl.id, TemplateSlot.slot_name == slot_name, ) )).all() if not slot_rows: continue locale_map = {s.locale: s.template for s in slot_rows} template_slots = {EventType.SCHEDULED_MESSAGE.value: locale_map} target_cfg = TargetConfig( type=ld["target_type"], config=ld["target_config"], template_slots=template_slots, date_format=tmpl.date_format, date_only_format=( tmpl.date_only_format or "%d.%m.%Y" ), provider_api_key=provider_config.get("api_key"), provider_internal_url=provider_config.get("url", ""), provider_external_url=provider_config.get("external_domain", ""), receivers=ld["receivers"], ) key = id(tc) if tc is not None else 0 if key not in groups: groups[key] = (tc, []) groups[key][1].append(target_cfg) if not groups: _LOGGER.info( "Scheduled %s for tracker %d (collection=%r): no targets after filtering", kind, tracker_id, event.collection_name, ) continue total_targets = sum(len(tg[1]) for tg in groups.values()) _LOGGER.info( "Dispatching scheduled %s for tracker %d (collection=%r) to %d link(s) across %d group(s)", kind, tracker_id, event.collection_name, total_targets, len(groups), ) results: list = [] dispatched_any = False for tc, target_configs in groups.values(): if not target_configs: continue shaped_event = apply_tracking_display_filters(event, tc) if shaped_event is None: continue results.extend(await dispatcher.dispatch(shaped_event, target_configs)) dispatched_any = True if not dispatched_any: continue any_sent = True successes = sum(1 for r in results if isinstance(r, dict) and r.get("success")) async with AsyncSession(engine) as session: session.add(EventLog( user_id=tracker_user_id, tracker_id=tracker_id, tracker_name=tracker_name, provider_id=provider_id, provider_name=provider_name, event_type=event.event_type.value, collection_id=event.collection_id, collection_name=event.collection_name, assets_count=event.added_count or 0, details={ "kind": kind, "slot": slot_name, "trigger": "cron", "timezone": app_tz, "collection_mode": collection_mode, "status": "sent", "targets_dispatched": total_targets, "targets_succeeded": successes, }, )) await session.commit() if not any_sent: # All events produced zero targets after filtering (quiet hours, etc.). await _log_skip( tracker_id, kind, _SKIP_REASON_NO_TARGETS, tracker_user_id=tracker_user_id, tracker_name=tracker_name, provider_id=provider_id, provider_name=provider_name, )