Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 770c198ac3 | |||
| ab621b6abc |
+21
-18
@@ -1,33 +1,36 @@
|
||||
# v0.5.1 (2026-04-24)
|
||||
# v0.5.2 (2026-04-24)
|
||||
|
||||
Extends the Immich scheduled/memory dispatch shipped in v0.5.0 with a per-album fan-out mode and rich multi-album templates, adds "Reset to default" tooling and an inline preview modal for notification / command templates, and introduces a `none` listener mode for Telegram bots (safer default for shared-token deployments). Also fixes an infinite-recursion bug in the notification dispatcher that was breaking test dispatch for periodic / scheduled / memory slots.
|
||||
Two related improvements to the notification-tracker stack: the display-filter fields on `TrackingConfig` (favorites-only, sort, max-assets, strip-tags, strip-asset-details) are now actually honored by every dispatch path — they previously existed in the model but were silently ignored on watcher / webhook / scheduled / memory / test fires. And the fixed `batch_duration` knob on `NotificationTracker` is replaced by a per-tracker `adaptive_max_skip`, so quiet trackers can opt into back-off without affecting busy ones.
|
||||
|
||||
## Features
|
||||
|
||||
- **Per-album Immich dispatch for scheduled / memory slots** — honors the new `{kind}_collection_mode` on `TrackingConfig`: `per_collection` fans out one event per album, `combined` pools assets as before. Combined mode now attaches `album_name` / `album_url` / `album_public_url` to each asset so templates can attribute rows to their source album. Default `scheduled_assets` and `memory_mode` templates render a multi-album header with an inline album list and per-row album link. The cron and test-dispatch paths now share a single `build_immich_dispatch_events` helper ([b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f)).
|
||||
- **"Reset to default" for template slots** — new per-slot and whole-template reset buttons on notification and command template configs, backed by `GET /*-template-configs/defaults` endpoints. Confirmations use the app's `ConfirmModal` instead of `window.confirm` ([b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f)).
|
||||
- **Inline template preview + deep-link edit** — tracking-configs "Preview template" now opens an inline preview modal with locale tabs instead of navigating away. The Edit button deep-links with `?edit_slot=<name>` so the destination auto-opens the config and scrolls to the requested slot ([b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f)).
|
||||
- **Telegram bot `none` listener mode** — third option alongside polling and webhook. Disables both long-polling and webhook delivery; useful when another instance owns the listener or the bot is send-only. Switching into `none` unschedules polling and unregisters the active webhook so Telegram stops delivering updates ([be15463](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/be15463)).
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fix `NotificationDispatcher._session_ctx` infinite recursion when no shared `aiohttp.ClientSession` was passed — broke test dispatch for periodic / scheduled / memory slots (cron path was unaffected) ([b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f)).
|
||||
- `telegram-bots /chats/{id}/test` now resolves `chat.language_override` / `language_code` instead of using the raw `?locale` query param, matching the resolution the tracker-target test endpoint already used ([b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f)).
|
||||
- Default `scheduled_assets` template no longer emits a blank line between the header and the first asset when the multi-album branch is taken ([b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f)).
|
||||
- **Tracking-config display filters wired into every dispatch path** — the filter fields on Immich `TrackingConfig` now apply consistently across watcher events, inbound webhooks, scheduled / periodic / memory cron fires, and manual test dispatch ([ab621b6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ab621b6)):
|
||||
- `favorites_only` drops events with no favorited new assets, or filters `added_assets` down 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 raised from 5 → 10)
|
||||
- `include_tags` strips people from event extras and tags from each asset when disabled
|
||||
- `include_asset_details` strips `city` / `country` / `state` / `lat` / `lon` / `is_favorite` / `rating` / `description` when disabled — load-bearing fields (`thumbhash`, `file_size`, `playback_size`, cache keys) are preserved either way
|
||||
- New `apply_tracking_display_filters` helper in `dispatch_helpers` is the single source of truth
|
||||
- Targets sharing a `TrackingConfig` are dispatched together; targets with different configs each see their own shaped event
|
||||
- **Per-tracker adaptive polling** — replaces the global-feeling `NotificationTracker.batch_duration` with `adaptive_max_skip`, an opt-in cap on poll back-off ([ab621b6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ab621b6)):
|
||||
- `NULL` / `0` → disabled, every tick runs (previous default behavior preserved)
|
||||
- Positive `N` → caps the skip factor at `(N-1)-in-N` after a long idle stretch
|
||||
- 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 are all updated to match
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
- **New Telegram bots default to `none`** (safer when multiple bridges share a token). Existing bots upgraded from a pre-`update_mode` schema keep `polling`, so their behavior is unchanged. When creating a new bot, explicitly switch to `polling` or `webhook` if you want it to receive updates.
|
||||
- A new `{kind}_collection_mode` field was added to `TrackingConfig` for Immich scheduled/memory slots. Existing trackers keep the previous `combined` behavior by default; switch to `per_collection` per-tracker to opt in to one-event-per-album fan-out.
|
||||
- **`batch_duration` → `adaptive_max_skip`** on `NotificationTracker`. The migration runs automatically; existing trackers default to disabled (every tick polls), matching previous behavior. Set a positive value per-tracker if you want quiet trackers to back off.
|
||||
- **Default `max_assets_to_show` is now 10** (was 5). Existing tracking configs with a stored value are unaffected; only the default for newly created configs (or unset fields) changes. If you relied on the 5-asset implicit cap, set it explicitly.
|
||||
- **Display filters now actually take effect.** If you had configured `favorites_only`, `include_tags`, `include_asset_details`, etc. previously and expected them to do something — they will now. Review your tracking configs after upgrade if you don't want the filtering applied.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------------------------------------------------------------------------------------------|----------------------------------------------------------------------|------------------|
|
||||
| [b61394f](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b61394f) | feat(immich): per-album scheduled/memory dispatch + template tooling | alexei.dolgolyov |
|
||||
| [be15463](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/be15463) | feat(telegram): add 'none' listener mode for bots | alexei.dolgolyov |
|
||||
| Hash | Message | Author |
|
||||
|------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|------------------|
|
||||
| [ab621b6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/ab621b6) | feat: wire tracking-config display filters + per-tracker adaptive polling | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, any>,
|
||||
});
|
||||
@@ -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) });
|
||||
|
||||
@@ -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<string, any>;
|
||||
@@ -168,19 +168,19 @@
|
||||
class="text-xs text-[var(--color-primary)] hover:underline mt-1">+ {t('notificationTracker.addVariable')}</button>
|
||||
</fieldset>
|
||||
{:else}
|
||||
{#if !isWebhook}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{#if !isWebhook}
|
||||
<div>
|
||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label for="trk-batch" class="block text-sm font-medium mb-1">{t('notificationTracker.batchDuration')}<Hint text={t('hints.batchDuration')} /></label>
|
||||
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<label for="trk-adaptive" class="block text-sm font-medium mb-1">{t('notificationTracker.adaptiveMaxSkip')}<Hint text={t('hints.adaptiveMaxSkip')} /></label>
|
||||
<input id="trk-adaptive" type="number" bind:value={form.adaptive_max_skip} min="0" max="10" placeholder={t('notificationTracker.adaptiveMaxSkipPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Default configs -->
|
||||
{#if trackingConfigItems.length > 0 || templateConfigItems.length > 0}
|
||||
@@ -208,7 +208,10 @@
|
||||
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
|
||||
<div class="flex-1 text-xs">
|
||||
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
|
||||
<a href="/tracking-configs" class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1">
|
||||
<a href={form.default_tracking_config_id
|
||||
? `/tracking-configs?edit=${form.default_tracking_config_id}`
|
||||
: '/tracking-configs'}
|
||||
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1">
|
||||
<MdiIcon name="mdiArrowRight" size={12} />
|
||||
{t('notificationTracker.openTrackingConfig')}
|
||||
</a>
|
||||
|
||||
@@ -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=<id>`` 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; }
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-server"
|
||||
version = "0.5.1"
|
||||
version = "0.5.2"
|
||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user