feat: wire tracking-config display filters + per-tracker adaptive polling
Display filters (Immich tracking config): - favorites_only drops events with no favorited new assets, or filters added_assets to favorites only - assets_order_by/assets_order sort the rendered list (date / name / rating / random / none) - max_assets_to_show caps rendered+attached media (default 5 -> 10) - include_tags strips people from event extras and tags from each asset - include_asset_details strips city/country/state/lat/lon/is_favorite/ rating/description; load-bearing fields (thumbhash, file_size, playback_size, cache keys) preserved - New apply_tracking_display_filters helper in dispatch_helpers; wired into watcher, webhooks, scheduled/periodic/memory, and manual test-dispatch - Targets sharing a TrackingConfig dispatch together; targets with different TCs each see their own shaped event Adaptive polling: - Replace NotificationTracker.batch_duration with adaptive_max_skip - Per-tracker opt-in: NULL/0 disables back-off (every tick runs); positive N caps the skip factor at (N-1)-in-N after long idle - Scheduler caches the cap in module state for the tick fast-path - Migration adds the new column; API schemas/responses, frontend types, i18n, and the tracker form updated to match
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user