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
@@ -22,6 +22,7 @@ from ..database.models import (
ServiceProvider,
)
from .dispatch_helpers import (
apply_tracking_display_filters,
event_allowed_by_config,
get_app_timezone,
load_link_data,
@@ -382,16 +383,18 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
event.event_type.value, event.collection_name,
event.added_count, event.removed_count,
)
target_configs = []
# Group targets by tracking-config identity so each unique TC
# gets one event-transform pass; targets sharing a TC dispatch
# together (preserves the gather-fan-out inside the dispatcher).
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
for ld in link_data:
# Apply per-link event filtering from tracking config
tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc, app_tz):
_LOGGER.info(" Skipped by tracking config filter")
continue
tmpl = ld["template_config"]
target_configs.append(TargetConfig(
target_cfg = TargetConfig(
type=ld["target_type"],
config=ld["target_config"],
template_slots=ld["template_slots"],
@@ -401,10 +404,22 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
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 target_configs:
results = await dispatcher.dispatch(event, target_configs)
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:
_LOGGER.info(
" Event suppressed by display filters (favorites_only)",
)
continue
results = await dispatcher.dispatch(shaped_event, target_configs)
for r in results:
if r.get("success"):
_LOGGER.info(" Notification sent successfully")