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
@@ -37,7 +37,7 @@ class NotificationTrackerCreate(BaseModel):
icon: str = ""
collection_ids: list[str] = []
scan_interval: int = 60
batch_duration: int = 0
adaptive_max_skip: int | None = None
default_tracking_config_id: int | None = None
default_template_config_id: int | None = None
enabled: bool = True
@@ -48,7 +48,11 @@ class NotificationTrackerUpdate(BaseModel):
icon: str | None = None
collection_ids: list[str] | None = None
scan_interval: int | None = None
batch_duration: int | None = None
# int | None is ambiguous for partial updates — we can't distinguish
# "clear the field" from "don't touch". Callers send this via
# model_dump(exclude_unset=True), so an omitted key leaves the value
# alone and an explicit null clears it back to the adaptive-off default.
adaptive_max_skip: int | None = None
default_tracking_config_id: int | None = None
default_template_config_id: int | None = None
enabled: bool | None = None
@@ -125,7 +129,7 @@ def _build_tracker_response(
"provider_id": t.provider_id,
"collection_ids": t.collection_ids,
"scan_interval": t.scan_interval,
"batch_duration": t.batch_duration,
"adaptive_max_skip": t.adaptive_max_skip,
"default_tracking_config_id": t.default_tracking_config_id,
"default_template_config_id": t.default_template_config_id,
"enabled": t.enabled,
@@ -149,7 +153,10 @@ async def create_notification_tracker(
await session.commit()
await session.refresh(tracker)
if tracker.enabled:
await schedule_tracker(tracker.id, tracker.scan_interval)
await schedule_tracker(
tracker.id, tracker.scan_interval,
adaptive_max_skip=tracker.adaptive_max_skip,
)
await reschedule_immich_dispatch_jobs()
return await _tracker_response(session, tracker)
@@ -178,7 +185,10 @@ async def update_notification_tracker(
await session.commit()
await session.refresh(tracker)
if tracker.enabled:
await schedule_tracker(tracker.id, tracker.scan_interval)
await schedule_tracker(
tracker.id, tracker.scan_interval,
adaptive_max_skip=tracker.adaptive_max_skip,
)
else:
await unschedule_tracker(tracker.id)
await reschedule_immich_dispatch_jobs()
@@ -270,7 +280,7 @@ async def _tracker_response(session: AsyncSession, t: NotificationTracker) -> di
"provider_id": t.provider_id,
"collection_ids": t.collection_ids,
"scan_interval": t.scan_interval,
"batch_duration": t.batch_duration,
"adaptive_max_skip": t.adaptive_max_skip,
"default_tracking_config_id": t.default_tracking_config_id,
"default_template_config_id": t.default_template_config_id,
"enabled": t.enabled,
@@ -31,7 +31,7 @@ class TrackingConfigCreate(BaseModel):
notify_favorites_only: bool = False
include_tags: bool = True
include_asset_details: bool = False
max_assets_to_show: int = 5
max_assets_to_show: int = 10
assets_order_by: str = "none"
assets_order: str = "descending"
periodic_enabled: bool = False
@@ -28,6 +28,7 @@ from ..database.models import (
WebhookPayloadLog,
)
from ..services.dispatch_helpers import (
apply_tracking_display_filters,
event_allowed_by_config,
get_app_timezone,
load_link_data,
@@ -207,9 +208,13 @@ async def _dispatch_webhook_event(
# Dispatch to targets
from ..services.http_session import get_http_session
dispatcher = NotificationDispatcher(session=await get_http_session())
target_configs = _build_target_configs(event, link_data, provider_config, app_tz)
if target_configs:
results = await dispatcher.dispatch(event, target_configs)
for tc, target_configs in _build_target_groups(event, link_data, provider_config, app_tz):
if not target_configs:
continue
shaped_event = apply_tracking_display_filters(event, tc)
if shaped_event is None:
continue
results = await dispatcher.dispatch(shaped_event, target_configs)
for r in results:
if r.get("success"):
dispatched += 1
@@ -551,21 +556,27 @@ async def generic_webhook(token: str, request: Request):
return {"ok": True, "dispatched": dispatched}
def _build_target_configs(
def _build_target_groups(
event: ServiceEvent,
link_data: list[dict[str, Any]],
provider_config: dict[str, Any],
app_tz: str = "UTC",
) -> list[TargetConfig]:
"""Build TargetConfig objects for dispatch, applying tracking config filters."""
target_configs: list[TargetConfig] = []
) -> list[tuple[Any, list[TargetConfig]]]:
"""Build TargetConfigs for dispatch, grouped by their TrackingConfig.
Targets sharing a TrackingConfig dispatch together so a single
``apply_tracking_display_filters`` pass can shape one event for the
whole group; targets with different TCs may see differently-shaped
events (e.g. one with favorites_only, one without).
"""
groups: dict[int, tuple[Any, list[TargetConfig]]] = {}
for ld in link_data:
tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc, app_tz):
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"],
@@ -575,5 +586,9 @@ def _build_target_configs(
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("url", ""),
receivers=ld["receivers"],
))
return target_configs
)
key = id(tc) if tc is not None else 0
if key not in groups:
groups[key] = (tc, [])
groups[key][1].append(target_cfg)
return list(groups.values())