feat: wire tracking-config display filters + per-tracker adaptive polling

Display filters (Immich tracking config):
- favorites_only drops events with no favorited new assets, or filters
  added_assets to favorites only
- assets_order_by/assets_order sort the rendered list
  (date / name / rating / random / none)
- max_assets_to_show caps rendered+attached media (default 5 -> 10)
- include_tags strips people from event extras and tags from each asset
- include_asset_details strips city/country/state/lat/lon/is_favorite/
  rating/description; load-bearing fields (thumbhash, file_size,
  playback_size, cache keys) preserved
- New apply_tracking_display_filters helper in dispatch_helpers; wired
  into watcher, webhooks, scheduled/periodic/memory, and manual
  test-dispatch
- Targets sharing a TrackingConfig dispatch together; targets with
  different TCs each see their own shaped event

Adaptive polling:
- Replace NotificationTracker.batch_duration with adaptive_max_skip
- Per-tracker opt-in: NULL/0 disables back-off (every tick runs);
  positive N caps the skip factor at (N-1)-in-N after long idle
- Scheduler caches the cap in module state for the tick fast-path
- Migration adds the new column; API schemas/responses, frontend types,
  i18n, and the tracker form updated to match
This commit is contained in:
2026-04-24 21:12:10 +03:00
parent 187b889c45
commit ab621b6abc
19 changed files with 367 additions and 72 deletions
@@ -2,15 +2,18 @@
from __future__ import annotations
import dataclasses
import logging
import random
from datetime import datetime, time, timezone
from typing import Any
from typing import Any, Callable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.models.events import ServiceEvent
from notify_bridge_core.models.media import MediaAsset
from notify_bridge_core.notifications.receiver import Receiver, build_receiver
from ..database.models import (
@@ -137,6 +140,143 @@ def event_allowed_by_config(
return flag_map.get(event_type, True)
# --- Display-time filters driven by TrackingConfig -------------------------
#
# These transform a ServiceEvent so the dispatched notification reflects the
# user's per-tracker "asset display" preferences. Event-tracking flags (which
# events fire at all) live in ``event_allowed_by_config`` above; the filters
# here only reshape an already-allowed event.
# Asset.extra keys stripped when ``include_asset_details=False``. These are
# the enrichment fields the default templates render as prose (city/country,
# ⭐ rating, ❤️ favorite). ``thumbhash``/``file_size``/``playback_size``/
# ``owner_id``/``cache_key`` stay — they are load-bearing for media send and
# caching, not user-facing prose.
_ASSET_DETAIL_KEYS: tuple[str, ...] = (
"city", "country", "state",
"latitude", "longitude",
"is_favorite", "rating",
)
def _sort_key_for(order_by: str) -> Callable[[MediaAsset], Any] | None:
if order_by == "date":
return lambda a: a.created_at
if order_by == "name":
return lambda a: a.filename.lower()
if order_by == "rating":
# None ratings sort last regardless of direction.
return lambda a: (
a.extra.get("rating") is None,
a.extra.get("rating") or 0,
)
return None
def _sort_assets(
assets: list[MediaAsset],
order_by: str,
order: str,
) -> list[MediaAsset]:
"""Sort MediaAssets by the configured key/direction.
``order_by="none"`` preserves the input order (the provider's own
ordering, usually detection order). ``"random"`` shuffles in place
on a copy so repeated renders of the same event aren't identical.
"""
if order_by in ("none", "") or len(assets) < 2:
return list(assets)
if order_by == "random":
shuffled = list(assets)
random.shuffle(shuffled)
return shuffled
key_fn = _sort_key_for(order_by)
if key_fn is None:
return list(assets)
return sorted(assets, key=key_fn, reverse=(order == "descending"))
def _transform_asset(
asset: MediaAsset,
*,
strip_details: bool,
strip_tags: bool,
) -> MediaAsset:
"""Return a copy of ``asset`` with details and/or tags removed."""
new_extra = asset.extra
new_description = asset.description
new_tags = asset.tags
if strip_details:
new_extra = {k: v for k, v in asset.extra.items() if k not in _ASSET_DETAIL_KEYS}
new_description = None
if strip_tags:
new_tags = []
return dataclasses.replace(
asset,
description=new_description,
tags=list(new_tags) if new_tags is not asset.tags else asset.tags,
extra=new_extra,
)
def apply_tracking_display_filters(
event: ServiceEvent,
tc: TrackingConfig | None,
) -> ServiceEvent | None:
"""Apply per-tracker display preferences to an already-allowed event.
Semantics:
* ``notify_favorites_only`` + ``assets_order_by`` + ``max_assets_to_show``
only apply to ``ASSETS_ADDED`` events — the album-change path. Scheduled
/ periodic / memory events have their own limits and ordering
(``scheduled_limit``, ``scheduled_order_by``, etc.), so reapplying the
album-change cap would wrongly truncate them.
* ``include_tags`` and ``include_asset_details`` apply to every event
that carries assets, since they control rendering irrespective of
how the assets were selected.
Returns:
A new ``ServiceEvent`` with filters applied, or ``None`` if the event
should be dropped entirely (``notify_favorites_only=True`` and none of
the added assets are favorites).
"""
if tc is None:
return event
assets = list(event.added_assets)
new_added_count = event.added_count
is_change_event = event.event_type.value == "assets_added"
if is_change_event:
if tc.notify_favorites_only:
assets = [a for a in assets if a.extra.get("is_favorite")]
new_added_count = len(assets)
if not assets:
return None
assets = _sort_assets(assets, tc.assets_order_by, tc.assets_order)
if tc.max_assets_to_show >= 0:
assets = assets[: tc.max_assets_to_show]
strip_details = not tc.include_asset_details
strip_tags = not tc.include_tags
if (strip_details or strip_tags) and assets:
assets = [
_transform_asset(a, strip_details=strip_details, strip_tags=strip_tags)
for a in assets
]
new_extra = event.extra
if strip_tags and "people" in event.extra:
new_extra = {k: v for k, v in event.extra.items() if k != "people"}
return dataclasses.replace(
event,
added_assets=assets,
added_count=new_added_count,
extra=new_extra,
)
async def _resolve_target(
session: AsyncSession,
target: NotificationTarget,