Compare commits

...

2 Commits

Author SHA1 Message Date
alexei.dolgolyov 770c198ac3 chore: release v0.5.2
Release / release (push) Successful in 1m48s
2026-04-24 21:58:40 +03:00
alexei.dolgolyov ab621b6abc 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
2026-04-24 21:12:10 +03:00
23 changed files with 391 additions and 93 deletions
+21 -18
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.5.1",
"version": "0.5.2",
"type": "module",
"scripts": {
"dev": "vite dev",
+3 -2
View File
@@ -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).",
+3 -2
View File
@@ -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).",
+1 -1
View File
@@ -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' },
],
+1 -1
View File
@@ -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; }
+1 -1
View File
@@ -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 = [
+1 -1
View File
@@ -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")