refactor: unify test dispatch with real NotificationDispatcher

- Route scheduled/memory test sends through the same NotificationDispatcher
  the watcher uses — identical template rendering, media handling, caching
- Add preview_url field to MediaAsset (transcoded mid-size), separate from
  thumbnail_url (small) and full_url (original). Dispatcher prefers preview_url
- Fix sendMediaGroup cache: extract file_ids from Telegram response and store
  via async_set_many so repeat sends use cached file_ids
- Parallelize asset downloads in _send_media_group with asyncio.gather
- Filter unprocessed assets (archived/trashed/offline/no-thumbhash) at album
  parse time in ImmichAlbumData.from_api_response
- Extract shared asset_to_media + collect_scheduled_assets into asset_utils.py
  (single source for test dispatch and future real scheduler)
- Respect tracking config filters: limit, asset_type, favorite_only, min_rating
- Random asset sampling for scheduled sends
- Memory mode: "On This Day" date filter (same month+day, previous year)
- Skip dispatch when no matching assets found
- Remove ~250 lines of duplicated send logic from notifier.py
- Fix restart-backend.sh: proper env var export, Python path resolution, error log
This commit is contained in:
2026-03-24 19:32:40 +03:00
parent 1a8c95e942
commit d4cb388c74
12 changed files with 746 additions and 370 deletions
@@ -0,0 +1,314 @@
"""Test dispatch — manual trigger through the real NotificationDispatcher.
No separate logic — just builds a ServiceEvent + TargetConfig from DB
objects and dispatches through the same path the watcher uses.
"""
import logging
from typing import Any
import aiohttp
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.models.media import MediaAsset
from notify_bridge_core.notifications.dispatcher import NotificationDispatcher, TargetConfig
from notify_bridge_core.providers.base import ServiceProviderType
from ..database.models import (
NotificationTarget,
NotificationTracker,
NotificationTrackerTarget,
ServiceProvider,
TemplateConfig,
TemplateSlot,
TrackingConfig,
)
from .dispatch_helpers import _resolve_target
from .watcher import _get_telegram_caches
_LOGGER = logging.getLogger(__name__)
# Maps test_type → DB template slot name
_TEST_TYPE_SLOT_MAP = {
"periodic": "periodic_summary_message",
"scheduled": "scheduled_assets_message",
"memory": "memory_mode_message",
}
async def dispatch_test_notification(
*,
session: AsyncSession,
tracker: NotificationTracker,
tt: NotificationTrackerTarget,
target: NotificationTarget,
test_type: str,
locale: str = "en",
) -> dict[str, Any]:
"""Dispatch a test notification through the real NotificationDispatcher."""
# Load provider
provider = await session.get(ServiceProvider, tracker.provider_id)
if not provider:
return {"success": False, "error": "Provider not found"}
provider_config = dict(provider.config)
collection_ids = list(tracker.collection_ids or [])
# Load tracking config
tracking_config = None
if tt.tracking_config_id:
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
# Load template slots keyed by EventType.SCHEDULED_MESSAGE.value
template_config = None
template_slots: dict[str, dict[str, str]] | None = None
slot_name = _TEST_TYPE_SLOT_MAP.get(test_type, test_type)
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
if template_config:
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == template_config.id,
TemplateSlot.slot_name == slot_name,
)
)
locale_map: dict[str, str] = {}
for s in slot_result.all():
locale_map[s.locale] = s.template
if locale_map:
template_slots = {EventType.SCHEDULED_MESSAGE.value: locale_map}
# Resolve target config + receivers (same as watcher)
resolved = await _resolve_target(session, target)
target_cfg = TargetConfig(
type=resolved["target_type"],
config=resolved["target_config"],
template_slots=template_slots,
locale=locale,
date_format=template_config.date_format if template_config else "%d.%m.%Y, %H:%M UTC",
date_only_format=template_config.date_only_format if template_config and template_config.date_only_format else "%d.%m.%Y",
provider_api_key=provider_config.get("api_key"),
provider_internal_url=provider_config.get("url", ""),
provider_external_url=provider_config.get("external_domain", ""),
receivers=resolved["receivers"],
)
# Fetch assets and build event
event = await _build_event(
provider_type=provider.type,
provider_config=provider_config,
provider_name=provider.name or provider.type,
tracker_name=tracker.name or "",
tracker_filters=dict(tracker.filters) if tracker.filters else {},
collection_ids=collection_ids,
test_type=test_type,
tracking_config=tracking_config,
)
if event is None:
return {"success": False, "error": "No data returned from provider"}
if not event.added_assets and test_type in ("scheduled", "memory"):
return {"success": False, "error": "No matching assets found" + (" for today" if test_type == "memory" else "")}
# Dispatch through the real NotificationDispatcher
url_cache, asset_cache = await _get_telegram_caches()
dispatcher = NotificationDispatcher(url_cache=url_cache, asset_cache=asset_cache)
results = await dispatcher.dispatch(event, [target_cfg])
if not results:
return {"success": False, "error": "No dispatch results"}
return results[0]
async def _build_event(
*,
provider_type: str,
provider_config: dict,
provider_name: str,
tracker_name: str,
tracker_filters: dict,
collection_ids: list[str],
test_type: str,
tracking_config: TrackingConfig | None = None,
) -> ServiceEvent | None:
"""Build a ServiceEvent with real provider data."""
from datetime import datetime, timezone
if provider_type == "immich":
return await _build_immich_event(
provider_config=provider_config,
provider_name=provider_name,
tracker_name=tracker_name,
collection_ids=collection_ids,
test_type=test_type,
tracking_config=tracking_config,
)
elif provider_type == "scheduler":
from notify_bridge_core.providers.scheduler import SchedulerServiceProvider
custom_vars = tracker_filters.get("custom_variables", {})
sched = SchedulerServiceProvider(
name=provider_name,
tracker_name=tracker_name,
custom_variables=custom_vars,
)
events, _ = await sched.poll(collection_ids, {})
return events[0] if events else None
return None
async def _build_immich_event(
*,
provider_config: dict,
provider_name: str,
tracker_name: str,
collection_ids: list[str],
test_type: str,
tracking_config: TrackingConfig | None = None,
) -> ServiceEvent | None:
"""Build an Immich scheduled/memory event using shared core utilities."""
from datetime import datetime, timezone
from notify_bridge_core.providers.immich import ImmichServiceProvider
from notify_bridge_core.providers.immich.asset_utils import collect_scheduled_assets
from notify_bridge_core.providers.immich.models import ImmichAlbumData, SharedLinkInfo
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
prefix = "memory" if test_type == "memory" else "scheduled"
limit = getattr(tracking_config, f"{prefix}_limit", 10) if tracking_config else 10
asset_type = getattr(tracking_config, f"{prefix}_asset_type", "all") if tracking_config else "all"
favorite_only = getattr(tracking_config, f"{prefix}_favorite_only", False) if tracking_config else False
min_rating = getattr(tracking_config, f"{prefix}_min_rating", 0) if tracking_config else 0
memory_source = getattr(tracking_config, "memory_source", "albums") if tracking_config else "albums"
is_memory = test_type == "memory"
async with aiohttp.ClientSession() as http_session:
immich = ImmichServiceProvider(
http_session,
provider_config.get("url", ""),
provider_config.get("api_key", ""),
provider_config.get("external_domain"),
provider_name,
)
if not await immich.connect():
return None
# Native Immich memories API path
if is_memory and memory_source == "native":
return await _build_native_memory_event(
immich, ext_domain, provider_name, tracker_name,
collection_ids, limit, asset_type, favorite_only, min_rating,
)
# Album-based path: use shared collect_scheduled_assets
albums: dict[str, ImmichAlbumData] = {}
shared_links: dict[str, list[SharedLinkInfo]] = {}
for album_id in collection_ids:
album = await immich.client.get_album(album_id)
if album:
albums[album_id] = album
shared_links[album_id] = await immich.client.get_shared_links(album_id)
assets, collections_extra = collect_scheduled_assets(
albums, shared_links, ext_domain,
limit=limit,
asset_type=asset_type,
favorite_only=favorite_only,
min_rating=min_rating,
is_memory=is_memory,
)
first_col = collections_extra[0] if collections_extra else {}
return ServiceEvent(
event_type=EventType.SCHEDULED_MESSAGE,
provider_type=ServiceProviderType.IMMICH,
provider_name=provider_name,
collection_id=collection_ids[0] if collection_ids else "",
collection_name=first_col.get("name", tracker_name),
timestamp=datetime.now(timezone.utc),
added_assets=assets,
added_count=len(assets),
extra={
"collections": collections_extra,
"albums": collections_extra,
**(first_col if first_col else {}),
},
)
async def _build_native_memory_event(
immich,
ext_domain: str,
provider_name: str,
tracker_name: str,
collection_ids: list[str],
limit: int,
asset_type: str,
favorite_only: bool,
min_rating: int,
) -> ServiceEvent | None:
"""Build event from Immich native memories API."""
import random
from datetime import datetime, timezone
from notify_bridge_core.models.media import MediaAsset, MediaType
from notify_bridge_core.providers.immich.asset_utils import filter_assets
from notify_bridge_core.providers.immich.models import ImmichAssetInfo
memories = await immich.client.get_memories()
tracked_ids = set(collection_ids) if collection_ids else None
# Collect raw assets, convert to ImmichAssetInfo for unified filtering
raw_assets: list[ImmichAssetInfo] = []
year_map: dict[str, int | None] = {} # asset_id → memory year
for mem in memories:
mem_year = mem.get("data", {}).get("year")
for raw in mem.get("assets", []):
asset_id = raw.get("id", "")
if tracked_ids:
asset_albums = raw.get("albums", [])
if not any(a.get("id") in tracked_ids for a in asset_albums):
continue
asset = ImmichAssetInfo.from_api_response(raw)
if not asset.is_processed:
continue
raw_assets.append(asset)
year_map[asset_id] = mem_year
# Apply standard filters (no memory_date — native API already filters by date)
filtered = filter_assets(
raw_assets,
favorite_only=favorite_only,
min_rating=min_rating,
asset_type=asset_type,
)
# Random sample
if len(filtered) > limit:
selected = random.sample(filtered, limit)
else:
random.shuffle(filtered)
selected = filtered
from notify_bridge_core.providers.immich.asset_utils import asset_to_media
all_assets = []
for asset in selected:
media = asset_to_media(asset, ext_domain)
media.extra["year"] = year_map.get(asset.id)
all_assets.append(media)
return ServiceEvent(
event_type=EventType.SCHEDULED_MESSAGE,
provider_type=ServiceProviderType.IMMICH,
provider_name=provider_name,
collection_id=collection_ids[0] if collection_ids else "",
collection_name=tracker_name,
timestamp=datetime.now(timezone.utc),
added_assets=all_assets,
added_count=len(all_assets),
extra={
"collections": [],
"albums": [],
},
)