diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index 6288269..346722d 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -250,7 +250,8 @@ "descending": "Descending", "quietHoursStart": "Quiet hours start", "quietHoursEnd": "Quiet hours end", - "batchDuration": "Batch duration (seconds)", + "adaptiveMaxSkip": "Adaptive polling cap", + "adaptiveMaxSkipPlaceholder": "Off (blank or 0)", "defaultTrackingConfig": "Default tracking config", "defaultTemplateConfig": "Default template config", "linkedTargets": "targets", @@ -755,7 +756,7 @@ "trackingConfig": "Controls which events trigger notifications and how assets are filtered.", "templateConfig": "Controls the message format. Uses default templates if not set.", "scanInterval": "How often to poll the provider for changes, in seconds. Lower = faster detection but more API calls.", - "batchDuration": "Time to accumulate changes before dispatching notifications. 0 = send immediately.", + "adaptiveMaxSkip": "Reduces polling when the tracker is idle, to save load on the upstream server. Leave blank or set to 0 for snappy notifications — every tick runs at the configured interval. Set to 2 to allow up to 2× slower polling after ~5 min of silence, or 4 for up to 4× slower polling after ~15 min. Activity resets back to the base rate immediately.", "defaultTrackingConfig": "Applied to all linked targets unless overridden per target.", "defaultTemplateConfig": "Applied to all linked targets unless overridden per target.", "defaultCount": "How many results to return when the user doesn't specify a count (1-20).", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 59fdcc8..b5b8b67 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -250,7 +250,8 @@ "descending": "По убыванию", "quietHoursStart": "Тихие часы начало", "quietHoursEnd": "Тихие часы конец", - "batchDuration": "Длительность пакета (секунды)", + "adaptiveMaxSkip": "Предел адаптивного опроса", + "adaptiveMaxSkipPlaceholder": "Выкл. (пусто или 0)", "defaultTrackingConfig": "Конфигурация отслеживания по умолчанию", "defaultTemplateConfig": "Шаблон уведомлений по умолчанию", "linkedTargets": "получатели", @@ -755,7 +756,7 @@ "trackingConfig": "Управляет тем, какие события вызывают уведомления и как фильтруются ассеты.", "templateConfig": "Управляет форматом сообщений. Используются шаблоны по умолчанию, если не задано.", "scanInterval": "Как часто опрашивать провайдер на предмет изменений (в секундах). Меньше = быстрее обнаружение, но больше запросов к API.", - "batchDuration": "Время накопления изменений перед отправкой уведомлений. 0 = отправлять сразу.", + "adaptiveMaxSkip": "Снижает частоту опроса, когда отслеживание простаивает — уменьшает нагрузку на сервер-источник. Оставьте пустым или 0, чтобы уведомления приходили без задержки: каждый тик выполняется с заданным интервалом. Значение 2 позволит замедлять опрос до 2× после ~5 мин простоя, а 4 — до 4× после ~15 мин. Любая активность сразу возвращает базовую частоту.", "defaultTrackingConfig": "Применяется ко всем привязанным получателям, если не переопределено.", "defaultTemplateConfig": "Применяется ко всем привязанным получателям, если не переопределено.", "defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).", diff --git a/frontend/src/lib/providers/immich.ts b/frontend/src/lib/providers/immich.ts index a6ec7ed..ef2086f 100644 --- a/frontend/src/lib/providers/immich.ts +++ b/frontend/src/lib/providers/immich.ts @@ -55,7 +55,7 @@ export const immichDescriptor: ProviderDescriptor = { ], extraTrackingFields: [ - { key: 'max_assets_to_show', label: 'trackingConfig.maxAssets', type: 'number', min: 0, max: 50, defaultValue: 5, hint: 'hints.maxAssets' }, + { key: 'max_assets_to_show', label: 'trackingConfig.maxAssets', type: 'number', min: 0, max: 50, defaultValue: 10, hint: 'hints.maxAssets' }, { key: 'assets_order_by', label: 'trackingConfig.sortBy', type: 'grid-select', gridItems: 'sortByItems', gridColumns: 2, defaultValue: 'none' }, { key: 'assets_order', label: 'trackingConfig.sortOrder', type: 'grid-select', gridItems: 'sortOrderItems', gridColumns: 2, defaultValue: 'descending' }, ], diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b04c314..382b9a6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -80,7 +80,7 @@ export interface Tracker { provider_id: number; collection_ids: string[]; scan_interval: number; - batch_duration: number; + adaptive_max_skip: number | null; default_tracking_config_id: number | null; default_template_config_id: number | null; enabled: boolean; diff --git a/frontend/src/routes/notification-trackers/+page.svelte b/frontend/src/routes/notification-trackers/+page.svelte index b0c70bc..39ac88a 100644 --- a/frontend/src/routes/notification-trackers/+page.svelte +++ b/frontend/src/routes/notification-trackers/+page.svelte @@ -62,7 +62,8 @@ // Tracker form const defaultForm = () => ({ name: '', icon: '', provider_id: 0, collection_ids: [] as string[], - scan_interval: 60, batch_duration: 0, + scan_interval: 60, + adaptive_max_skip: null as number | null, default_tracking_config_id: 0, default_template_config_id: 0, filters: {} as Record, }); @@ -180,7 +181,8 @@ form = { name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id, collection_ids: [...(trk.collection_ids || [])], - scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0, + scan_interval: trk.scan_interval, + adaptive_max_skip: trk.adaptive_max_skip ?? null, default_tracking_config_id: trk.default_tracking_config_id ?? 0, default_template_config_id: trk.default_template_config_id ?? 0, filters: trk.filters || {}, @@ -223,6 +225,12 @@ ...form, default_tracking_config_id: form.default_tracking_config_id || null, default_template_config_id: form.default_template_config_id || null, + // Empty string, 0, or null all mean "disable adaptive polling". + // Coerce to null so the DB column stays NULL rather than 0. + adaptive_max_skip: + form.adaptive_max_skip && form.adaptive_max_skip > 1 + ? form.adaptive_max_skip + : null, }; if (editing) { await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(payload) }); diff --git a/frontend/src/routes/notification-trackers/TrackerForm.svelte b/frontend/src/routes/notification-trackers/TrackerForm.svelte index 07458f3..28dd788 100644 --- a/frontend/src/routes/notification-trackers/TrackerForm.svelte +++ b/frontend/src/routes/notification-trackers/TrackerForm.svelte @@ -16,7 +16,7 @@ provider_id: number; collection_ids: string[]; scan_interval: number; - batch_duration: number; + adaptive_max_skip: number | null; default_tracking_config_id: number; default_template_config_id: number; filters: Record; @@ -168,19 +168,19 @@ class="text-xs text-[var(--color-primary)] hover:underline mt-1">+ {t('notificationTracker.addVariable')} {:else} + {#if !isWebhook}
- {#if !isWebhook}
- {/if}
- - + +
{/if} + {/if} {#if trackingConfigItems.length > 0 || templateConfigItems.length > 0} @@ -208,7 +208,10 @@

{t('notificationTracker.featureDiscovery')}

- + {t('notificationTracker.openTrackingConfig')} diff --git a/frontend/src/routes/tracking-configs/+page.svelte b/frontend/src/routes/tracking-configs/+page.svelte index ce73808..079b81c 100644 --- a/frontend/src/routes/tracking-configs/+page.svelte +++ b/frontend/src/routes/tracking-configs/+page.svelte @@ -194,7 +194,25 @@ async function load() { try { await trackingConfigsCache.fetch(true); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } - finally { loaded = true; highlightFromUrl(); } + finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); } + } + + // Cross-page deep-link: ``/tracking-configs?edit=`` auto-opens that + // config in edit mode. Used by the Notification Tracker form's "Open + // Tracking Config" link so users land directly on the right editor + // instead of the generic list. Strips the param afterwards so a browser + // refresh doesn't re-open the modal. + function _openEditFromUrl() { + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + const editId = params.get('edit'); + if (!editId) return; + const match = allConfigs.find(c => String(c.id) === editId); + if (match) edit(match); + params.delete('edit'); + const qs = params.toString(); + const cleanUrl = window.location.pathname + (qs ? '?' + qs : ''); + window.history.replaceState(null, '', cleanUrl); } function openNew() { form = defaultForm(); editing = null; showForm = true; } diff --git a/packages/server/src/notify_bridge_server/api/notification_trackers.py b/packages/server/src/notify_bridge_server/api/notification_trackers.py index 96bc46d..d9d9e32 100644 --- a/packages/server/src/notify_bridge_server/api/notification_trackers.py +++ b/packages/server/src/notify_bridge_server/api/notification_trackers.py @@ -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, diff --git a/packages/server/src/notify_bridge_server/api/tracking_configs.py b/packages/server/src/notify_bridge_server/api/tracking_configs.py index abc7822..0bdf70b 100644 --- a/packages/server/src/notify_bridge_server/api/tracking_configs.py +++ b/packages/server/src/notify_bridge_server/api/tracking_configs.py @@ -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 diff --git a/packages/server/src/notify_bridge_server/api/webhooks.py b/packages/server/src/notify_bridge_server/api/webhooks.py index 973691f..26d236b 100644 --- a/packages/server/src/notify_bridge_server/api/webhooks.py +++ b/packages/server/src/notify_bridge_server/api/webhooks.py @@ -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()) diff --git a/packages/server/src/notify_bridge_server/database/migrations.py b/packages/server/src/notify_bridge_server/database/migrations.py index 884270c..d4de69e 100644 --- a/packages/server/src/notify_bridge_server/database/migrations.py +++ b/packages/server/src/notify_bridge_server/database/migrations.py @@ -71,11 +71,14 @@ async def migrate_schema(engine: AsyncEngine) -> None: tracker_table = "notification_tracker" if await _has_table(conn, "notification_tracker") else "tracker" if await _has_table(conn, tracker_table): - if not await _has_column(conn, tracker_table, "batch_duration"): + # NULL default = adaptive polling disabled for existing trackers. + # Operators who want the old back-off behavior can set a positive + # value per tracker from the UI. + if not await _has_column(conn, tracker_table, "adaptive_max_skip"): await conn.execute( - text(f"ALTER TABLE {tracker_table} ADD COLUMN batch_duration INTEGER DEFAULT 0") + text(f"ALTER TABLE {tracker_table} ADD COLUMN adaptive_max_skip INTEGER") ) - logger.info("Added batch_duration column to %s table", tracker_table) + logger.info("Added adaptive_max_skip column to %s table", tracker_table) # Add enriched fields to event_log if missing if await _has_table(conn, "event_log"): diff --git a/packages/server/src/notify_bridge_server/database/models.py b/packages/server/src/notify_bridge_server/database/models.py index 071733f..05def8c 100644 --- a/packages/server/src/notify_bridge_server/database/models.py +++ b/packages/server/src/notify_bridge_server/database/models.py @@ -173,7 +173,7 @@ class TrackingConfig(SQLModel, table=True): # Asset display include_tags: bool = Field(default=True) include_asset_details: bool = Field(default=False) - max_assets_to_show: int = Field(default=5) + max_assets_to_show: int = Field(default=10) assets_order_by: str = Field(default="none") assets_order: str = Field(default="descending") @@ -320,7 +320,12 @@ class NotificationTracker(SQLModel, table=True): collection_ids: list[str] = Field(default_factory=list, sa_column=Column(JSON)) filters: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) scan_interval: int = Field(default=60) - batch_duration: int = Field(default=0) # seconds to accumulate events before dispatch (0=immediate) + # Cap on the adaptive-polling skip factor (see services/scheduler.py). + # None or 0 disables adaptive back-off entirely — every scheduled tick + # runs. Positive values (2..N) enable skipping up to (N-1) out of N ticks + # once the tracker has been idle long enough. Per-tracker so an operator + # can opt a latency-sensitive tracker out of the global heuristic. + adaptive_max_skip: int | None = Field(default=None) default_tracking_config_id: int | None = Field(default=None, foreign_key="tracking_config.id") default_template_config_id: int | None = Field(default=None, foreign_key="template_config.id") enabled: bool = Field(default=True) diff --git a/packages/server/src/notify_bridge_server/services/backup_schema.py b/packages/server/src/notify_bridge_server/services/backup_schema.py index 01ded51..bf63ddf 100644 --- a/packages/server/src/notify_bridge_server/services/backup_schema.py +++ b/packages/server/src/notify_bridge_server/services/backup_schema.py @@ -116,7 +116,6 @@ class NotificationTrackerData(BaseModel): collection_ids: list[str] = [] filters: dict[str, Any] = {} scan_interval: int = 60 - batch_duration: int = 0 default_tracking_config_id: int | None = None default_template_config_id: int | None = None enabled: bool = True diff --git a/packages/server/src/notify_bridge_server/services/backup_service.py b/packages/server/src/notify_bridge_server/services/backup_service.py index 0292b57..fc27280 100644 --- a/packages/server/src/notify_bridge_server/services/backup_service.py +++ b/packages/server/src/notify_bridge_server/services/backup_service.py @@ -294,7 +294,6 @@ async def export_backup( id=nt.id, provider_id=nt.provider_id, name=nt.name, icon=nt.icon, collection_ids=nt.collection_ids, filters=nt.filters, scan_interval=nt.scan_interval, - batch_duration=nt.batch_duration, default_tracking_config_id=nt.default_tracking_config_id, default_template_config_id=nt.default_template_config_id, enabled=nt.enabled, targets=targets, @@ -733,7 +732,6 @@ async def import_backup( user_id=user_id, provider_id=provider_id, name=name, icon=nt.icon, collection_ids=nt.collection_ids, filters=nt.filters, scan_interval=nt.scan_interval, - batch_duration=nt.batch_duration, default_tracking_config_id=_map_id(id_map, "tracking_configs", nt.default_tracking_config_id), default_template_config_id=_map_id(id_map, "template_configs", nt.default_template_config_id), enabled=nt.enabled, diff --git a/packages/server/src/notify_bridge_server/services/dispatch_helpers.py b/packages/server/src/notify_bridge_server/services/dispatch_helpers.py index 482e11b..169d7d2 100644 --- a/packages/server/src/notify_bridge_server/services/dispatch_helpers.py +++ b/packages/server/src/notify_bridge_server/services/dispatch_helpers.py @@ -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, diff --git a/packages/server/src/notify_bridge_server/services/manual_dispatch.py b/packages/server/src/notify_bridge_server/services/manual_dispatch.py index 95c8b2f..362218f 100644 --- a/packages/server/src/notify_bridge_server/services/manual_dispatch.py +++ b/packages/server/src/notify_bridge_server/services/manual_dispatch.py @@ -166,11 +166,25 @@ async def dispatch_test_notification( } # Dispatch each event to the same target (per-album fan-out sends N messages). + # Apply display filters so the test notification matches production behavior + # for ``favorites_only``, ``include_tags``, ``include_asset_details``, etc. + from .dispatch_helpers import apply_tracking_display_filters + url_cache, asset_cache = await _get_telegram_caches() dispatcher = NotificationDispatcher(url_cache=url_cache, asset_cache=asset_cache) all_results: list[dict[str, Any]] = [] for event in events: - results = await dispatcher.dispatch(event, [target_cfg]) + shaped_event = apply_tracking_display_filters(event, tracking_config) + if shaped_event is None: + all_results.append({ + "success": False, + "error": ( + "Event suppressed by tracking config (favorites_only is on " + "but no added assets are favorites)." + ), + }) + continue + results = await dispatcher.dispatch(shaped_event, [target_cfg]) if results: all_results.append(results[0]) diff --git a/packages/server/src/notify_bridge_server/services/scheduled_dispatch.py b/packages/server/src/notify_bridge_server/services/scheduled_dispatch.py index 5ed82d9..b0ea569 100644 --- a/packages/server/src/notify_bridge_server/services/scheduled_dispatch.py +++ b/packages/server/src/notify_bridge_server/services/scheduled_dispatch.py @@ -42,6 +42,7 @@ from ..database.models import ( TrackingConfig, ) from .dispatch_helpers import ( + apply_tracking_display_filters, event_allowed_by_config, get_app_timezone, load_link_data, @@ -251,7 +252,9 @@ async def dispatch_scheduled_for_tracker( # event_allowed_by_config, which inspects event timestamp. Per-event # rebuilding also lets a per-link override disable one kind while # keeping others live. - target_configs: list[TargetConfig] = [] + # 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 @@ -275,7 +278,7 @@ async def dispatch_scheduled_for_tracker( locale_map = {s.locale: s.template for s in slot_rows} template_slots = {EventType.SCHEDULED_MESSAGE.value: locale_map} - target_configs.append(TargetConfig( + target_cfg = TargetConfig( type=ld["target_type"], config=ld["target_config"], template_slots=template_slots, @@ -287,20 +290,36 @@ async def dispatch_scheduled_for_tracker( 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 target_configs: + 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)", - kind, tracker_id, event.collection_name, len(target_configs), + "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 = await dispatcher.dispatch(event, target_configs) + 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")) @@ -322,7 +341,7 @@ async def dispatch_scheduled_for_tracker( "timezone": app_tz, "collection_mode": collection_mode, "status": "sent", - "targets_dispatched": len(target_configs), + "targets_dispatched": total_targets, "targets_succeeded": successes, }, )) diff --git a/packages/server/src/notify_bridge_server/services/scheduler.py b/packages/server/src/notify_bridge_server/services/scheduler.py index 3784f48..16b4111 100644 --- a/packages/server/src/notify_bridge_server/services/scheduler.py +++ b/packages/server/src/notify_bridge_server/services/scheduler.py @@ -49,21 +49,44 @@ _scheduler: AsyncIOScheduler | None = None # than one tick — but the steady-state HTTP cost for a fleet of idle # trackers drops by ~75%. # +# Opt-in per tracker via the ``adaptive_max_skip`` column: +# * NULL or 0 → adaptive polling disabled, every tick runs (default) +# * 2 → skip at most 1-in-2 ticks after long idle +# * 3, 4, ... → up to (N-1)-in-N skipping # Thresholds are intentionally conservative: a tracker polling every 30 s # needs 5 min of silence before we halve its effective rate, and 15 min -# before we quarter it. Any caller can disable adaptive behavior by passing -# ``adaptive=False`` in the tracker filters dict (checked in ``_poll_tracker``). +# before we quarter it. # --------------------------------------------------------------------------- _ADAPTIVE_HALVE_THRESHOLD = 10 # consecutive empty ticks → 1-in-2 _ADAPTIVE_QUARTER_THRESHOLD = 30 # consecutive empty ticks → 1-in-4 -_ADAPTIVE_MAX_SKIP = 4 # hard cap on skip factor # Per-tracker adaptive state, keyed by tracker_id. Rebuilt on process # restart — a short warmup period is fine and avoids persisting what is # effectively a performance heuristic. _adaptive_state: dict[int, dict[str, int]] = {} +# Per-tracker cap on the skip factor, mirrored from the DB column at +# schedule time. Absence of an entry (or 0) means adaptive polling is off +# for that tracker — ``_adaptive_should_skip`` returns False immediately. +_adaptive_max_skip: dict[int, int] = {} + + +def set_adaptive_max_skip(tracker_id: int, max_skip: int | None) -> None: + """Register/clear the adaptive cap for a tracker. + + Called by the scheduling helpers so the tick-fast-path in + ``_adaptive_should_skip`` doesn't need to re-query the DB. Values ≤ 1 + disable back-off for the tracker — every scheduled tick runs. + """ + if max_skip and max_skip > 1: + _adaptive_max_skip[tracker_id] = int(max_skip) + else: + _adaptive_max_skip.pop(tracker_id, None) + # Opting in/out mid-session should drop any prior counters so the + # new behavior applies from the next tick, not N ticks later. + _adaptive_state.pop(tracker_id, None) + def _compute_jitter(interval_seconds: int) -> int: """Return a jitter bound (in seconds) suitable for an IntervalTrigger. @@ -387,9 +410,11 @@ async def _load_tracker_jobs() -> None: replace_existing=True, max_instances=1, ) + set_adaptive_max_skip(tracker.id, tracker.adaptive_max_skip) _LOGGER.info( - "Scheduled tracker %d (%s) every %ds (jitter ±%ds)", + "Scheduled tracker %d (%s) every %ds (jitter ±%ds, adaptive_max_skip=%s)", tracker.id, tracker.name, tracker.scan_interval, jitter, + tracker.adaptive_max_skip, ) @@ -429,14 +454,21 @@ async def schedule_tracker( tracker_id: int, interval: int, cron_expression: str | None = None, + adaptive_max_skip: int | None = None, ) -> None: - """Add or update a scheduler job for a tracker.""" + """Add or update a scheduler job for a tracker. + + ``adaptive_max_skip`` mirrors the DB column and is registered with the + adaptive module-state so tick-time skip decisions don't re-query the DB. + Pass ``None`` or ``0`` to disable back-off for the tracker. + """ scheduler = get_scheduler() job_id = f"tracker_{tracker_id}" # A reschedule typically follows a config edit or enable/disable flip — # drop adaptive back-off so the first tick after the change runs promptly. reset_adaptive_state(tracker_id) + set_adaptive_max_skip(tracker_id, adaptive_max_skip) # Remove existing job first to allow trigger type changes if scheduler.get_job(job_id): @@ -461,7 +493,8 @@ async def schedule_tracker( replace_existing=True, ) _LOGGER.info( - "Scheduled tracker %d every %ds (jitter ±%ds)", tracker_id, interval, jitter, + "Scheduled tracker %d every %ds (jitter ±%ds, adaptive_max_skip=%s)", + tracker_id, interval, jitter, adaptive_max_skip, ) @@ -470,6 +503,7 @@ async def unschedule_tracker(tracker_id: int) -> None: scheduler = get_scheduler() job_id = f"tracker_{tracker_id}" reset_adaptive_state(tracker_id) + _adaptive_max_skip.pop(tracker_id, None) if scheduler.get_job(job_id): scheduler.remove_job(job_id) _LOGGER.info("Unscheduled tracker %d", tracker_id) @@ -478,10 +512,12 @@ async def unschedule_tracker(tracker_id: int) -> None: def _adaptive_should_skip(tracker_id: int) -> bool: """Return True when the adaptive heuristic says to skip this tick. - Run-length skip: if we're in 1-in-K mode, skip (K-1) ticks between each - real poll. Stateless about the *current* tick counter except for the - ``tick_counter`` we bump here. + Short-circuits to False for trackers without a registered cap (adaptive + off). Otherwise: if we're in 1-in-K mode, skip (K-1) ticks between each + real poll. """ + if tracker_id not in _adaptive_max_skip: + return False state = _adaptive_state.get(tracker_id) if not state: return False @@ -494,7 +530,14 @@ def _adaptive_should_skip(tracker_id: int) -> bool: def _adaptive_update(tracker_id: int, events_detected: int) -> None: - """Update the adaptive counter after a real tick ran.""" + """Update the adaptive counter after a real tick ran. + + No-op when the tracker has adaptive polling disabled — otherwise we'd + build up empty counters for trackers that will never use them. + """ + cap = _adaptive_max_skip.get(tracker_id) + if not cap or cap <= 1: + return state = _adaptive_state.setdefault( tracker_id, {"empty_count": 0, "skip_every": 1, "tick_counter": 0} ) @@ -510,20 +553,22 @@ def _adaptive_update(tracker_id: int, events_detected: int) -> None: return state["empty_count"] = state.get("empty_count", 0) + 1 + target_quarter = min(cap, 4) if ( state["empty_count"] >= _ADAPTIVE_QUARTER_THRESHOLD - and state["skip_every"] < _ADAPTIVE_MAX_SKIP + and state["skip_every"] < target_quarter ): - state["skip_every"] = _ADAPTIVE_MAX_SKIP + state["skip_every"] = target_quarter _LOGGER.info( - "Adaptive polling: tracker %d idle for %d ticks, skipping 3 of 4", + "Adaptive polling: tracker %d idle for %d ticks, skipping %d of %d", tracker_id, state["empty_count"], + target_quarter - 1, target_quarter, ) elif ( state["empty_count"] >= _ADAPTIVE_HALVE_THRESHOLD - and state["skip_every"] < 2 + and state["skip_every"] < min(cap, 2) ): - state["skip_every"] = 2 + state["skip_every"] = min(cap, 2) _LOGGER.info( "Adaptive polling: tracker %d idle for %d ticks, skipping every other", tracker_id, state["empty_count"], @@ -535,7 +580,8 @@ def reset_adaptive_state(tracker_id: int) -> None: Used by API callers that make changes requiring the tracker to run promptly on the next scheduled tick (enable/disable, config edits, - manual "check now" actions). + manual "check now" actions). Does NOT clear the configured cap — use + ``set_adaptive_max_skip(..., None)`` for that. """ _adaptive_state.pop(tracker_id, None) diff --git a/packages/server/src/notify_bridge_server/services/watcher.py b/packages/server/src/notify_bridge_server/services/watcher.py index f00dcc7..994bec5 100644 --- a/packages/server/src/notify_bridge_server/services/watcher.py +++ b/packages/server/src/notify_bridge_server/services/watcher.py @@ -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")