Files
notify-bridge/packages/server/src/notify_bridge_server/services/scheduled_dispatch.py
T
alexei.dolgolyov ba199f24bd feat: deferred dispatch, release-check provider, settings polish
- Defer quiet-hours dispatches into new deferred_dispatch table; drain
  job + periodic catch-up scan re-fire at window end with coalescing on
  (link, event_type, collection_id).
- Add ON DELETE SET NULL migration on event_log_id and partial unique
  index on (link_id, collection_id, event_type) WHERE status='pending'.
- Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe
  URL validation, settings UI cassette, and scheduled polling.
- Replace importlib-only version lookup with version.py helper that
  prefers the higher of installed metadata vs source pyproject so stale
  editable dev installs stop misreporting.
- Aurora frontend polish: MetaStrip component, ReleaseCassette,
  EventDetailModal expansion, and i18n additions.
2026-05-12 02:58:07 +03:00

364 lines
14 KiB
Python

"""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,
)