ba199f24bd
- 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.
364 lines
14 KiB
Python
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,
|
|
)
|