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
@@ -21,7 +21,8 @@ from ..database.models import (
TrackingConfig,
User,
)
from ..services.notifier import send_real_data_notification, send_test_notification
from ..services.notifier import send_test_notification
from ..services.test_dispatch import dispatch_test_notification
_LOGGER = logging.getLogger(__name__)
@@ -242,62 +243,14 @@ async def test_notification_tracker_target(
r = await send_test_notification(target, locale=effective_locale)
return {"target": target.name, **r}
# For periodic/scheduled/memory — fetch real data from provider
template_config = None
template_str = ""
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
if template_config:
slot_map = {
"periodic": "periodic_summary_message",
"scheduled": "scheduled_assets_message",
"memory": "memory_mode_message",
}
slot_name = slot_map[test_type]
slot_result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == template_config.id,
TemplateSlot.slot_name == slot_name,
TemplateSlot.locale == effective_locale,
)
)
slot = slot_result.first()
if not slot:
# Fallback: any locale
slot_result2 = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == template_config.id,
TemplateSlot.slot_name == slot_name,
)
)
slot = slot_result2.first()
template_str = slot.template if slot else ""
# Load provider and tracker data eagerly before aiohttp context
provider = await session.get(ServiceProvider, tracker.provider_id)
if not provider:
raise HTTPException(status_code=404, detail="Provider not found")
provider_config = dict(provider.config)
collection_ids = list(tracker.collection_ids or [])
# Load tracking config to get memory_source
memory_source = "albums"
if tt.tracking_config_id:
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
if tracking_config:
memory_source = tracking_config.memory_source or "albums"
# Fetch real data from provider
r = await send_real_data_notification(
# For periodic/scheduled/memory — dispatch through the real NotificationDispatcher
r = await dispatch_test_notification(
session=session,
tracker=tracker,
tt=tt,
target=target,
template_str=template_str,
test_type=test_type,
provider_type=provider.type,
provider_config=provider_config,
collection_ids=collection_ids,
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",
memory_source=memory_source,
locale=effective_locale,
)
return {"target": target.name, **r}
@@ -54,9 +54,10 @@ async def _load_receivers(target_id: int) -> list[dict]:
async def send_to_target(target: NotificationTarget, message: str) -> dict:
"""Send a message to a target, broadcasting to all receivers.
"""Send a text message to a target, broadcasting to all receivers.
This is the SINGLE send path used by dispatch, test, and real-data notifications.
Used for basic test and template preview sends (text only, no media).
Real notifications with media go through NotificationDispatcher.
"""
try:
receivers = await _load_receivers(target.id)
@@ -356,241 +357,3 @@ async def send_test_template_notification(
return {"success": False, "error": f"Template render error: {e}"}
return await send_to_target(target, message)
async def send_real_data_notification(
target: NotificationTarget,
template_str: str,
test_type: str,
provider_type: str,
provider_config: dict,
collection_ids: list[str],
date_format: str = "%d.%m.%Y, %H:%M UTC",
date_only_format: str = "%d.%m.%Y",
memory_source: str = "albums",
) -> dict:
"""Fetch real data from provider, render template, and send."""
from jinja2.sandbox import SandboxedEnvironment
if not template_str:
return {"success": False, "error": f"No template configured for {test_type}"}
try:
ctx = await _build_real_context(
provider_type, provider_config, collection_ids,
test_type, date_format, date_only_format,
memory_source=memory_source,
)
except Exception as e:
_LOGGER.error("Failed to fetch real data for test: %s", e)
return {"success": False, "error": f"Failed to fetch provider data: {e}"}
ctx["target_type"] = target.type
ctx["date_format"] = date_format
ctx["date_only_format"] = date_only_format
try:
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template_str)
message = tmpl.render(**ctx)
except Exception as e:
return {"success": False, "error": f"Template render error: {e}"}
return await send_to_target(target, message)
async def _build_real_context(
provider_type: str,
provider_config: dict,
collection_ids: list[str],
test_type: str,
date_format: str,
date_only_format: str,
memory_source: str = "albums",
) -> dict:
"""Build template context from real provider data."""
from datetime import datetime, timezone
if provider_type != "immich":
return {"date": datetime.now(timezone.utc).strftime(date_only_format)}
from notify_bridge_core.providers.immich import ImmichServiceProvider
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"),
"Immich",
)
connected = await immich.connect()
if not connected:
raise RuntimeError("Failed to connect to Immich")
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
# --- Native Immich memories ---
if test_type == "memory" and memory_source == "native":
memories = await immich.client.get_memories()
all_assets: list[dict[str, Any]] = []
tracked_ids = set(collection_ids) if collection_ids else None
for mem in memories:
for raw_asset in mem.get("assets", []):
asset_id = raw_asset.get("id", "")
# Optional album filtering: keep only assets in tracked albums
if tracked_ids:
asset_albums = raw_asset.get("albums", [])
if not any(a.get("id") in tracked_ids for a in asset_albums):
continue
exif = raw_asset.get("exifInfo") or {}
people_raw = raw_asset.get("people", [])
all_assets.append({
"id": asset_id,
"filename": raw_asset.get("originalFileName", ""),
"type": (raw_asset.get("type") or "IMAGE").upper(),
"created_at": raw_asset.get("fileCreatedAt", raw_asset.get("createdAt", "")),
"owner": "",
"description": exif.get("description", "") or raw_asset.get("description", "") or "",
"people": [p.get("name", "") for p in people_raw if p.get("name")],
"is_favorite": raw_asset.get("isFavorite", False),
"rating": exif.get("rating"),
"city": exif.get("city", "") or "",
"state": exif.get("state", "") or "",
"country": exif.get("country", "") or "",
"public_url": "",
"url": f"{ext_domain.rstrip('/')}/api/assets/{asset_id}/original",
"photo_url": f"{ext_domain.rstrip('/')}/api/assets/{asset_id}/thumbnail",
"year": mem.get("data", {}).get("year"),
})
now = datetime.now(timezone.utc)
ctx: dict[str, Any] = {
"date": now.strftime(date_only_format),
"timestamp": now.isoformat(),
"service_name": "Immich",
"service_type": "immich",
"collections": [],
"albums": [],
"assets": all_assets,
"common_date": "",
"common_location": "",
"collection_name": "", "album_name": "",
"public_url": "", "album_url": "",
"shared": False, "photo_count": 0, "video_count": 0, "owner": "",
}
people: set[str] = set()
for a in all_assets:
people.update(a.get("people", []))
ctx["people"] = list(people)
ctx["has_videos"] = any(a.get("type") == "VIDEO" for a in all_assets)
ctx["has_photos"] = any(a.get("type") == "IMAGE" for a in all_assets)
ctx["added_count"] = len(all_assets)
ctx["added_assets"] = all_assets
ctx["protected_url"] = ""
return ctx
# --- Album-based asset collection (default path) ---
collections: list[dict[str, Any]] = []
all_assets: list[dict[str, Any]] = []
for album_id in collection_ids:
album = await immich.client.get_album(album_id)
if not album:
continue
shared_links = await immich.client.get_shared_links(album_id)
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
album_public_url = ""
for link in shared_links:
if link.is_accessible and not link.is_expired and not link.has_password:
album_public_url = f"{ext_domain.rstrip('/')}/share/{link.key}"
break
collections.append({
"name": album.name,
"url": album_public_url or f"{ext_domain.rstrip('/')}/albums/{album_id}",
"public_url": album_public_url,
"asset_count": album.asset_count,
"shared": album.shared,
"photo_count": album.photo_count,
"video_count": album.video_count,
"owner": album.owner,
})
for asset_id, asset in list(album.assets.items())[:10]:
asset_public_url = f"{album_public_url}/photos/{asset_id}" if album_public_url else ""
all_assets.append({
"id": asset.id,
"filename": asset.filename,
"type": asset.type.upper(),
"created_at": asset.created_at,
"owner": asset.owner_name or "",
"description": asset.description or "",
"people": list(asset.people),
"is_favorite": asset.is_favorite,
"rating": asset.rating,
"city": asset.city or "",
"state": asset.state or "",
"country": asset.country or "",
"public_url": asset_public_url,
"url": f"{ext_domain.rstrip('/')}/api/assets/{asset.id}/original",
"photo_url": f"{ext_domain.rstrip('/')}/api/assets/{asset.id}/thumbnail",
})
now = datetime.now(timezone.utc)
ctx: dict[str, Any] = {
"date": now.strftime(date_only_format),
"timestamp": now.isoformat(),
"service_name": "Immich",
"service_type": "immich",
"collections": collections,
"albums": collections,
"assets": all_assets,
"common_date": "",
"common_location": "",
}
if len(all_assets) > 1:
dates = {a.get("created_at", "")[:10] for a in all_assets if a.get("created_at")}
if len(dates) == 1:
try:
ctx["common_date"] = datetime.fromisoformat(dates.pop()).strftime(date_only_format)
except (ValueError, TypeError):
pass
locations = set()
for a in all_assets:
city = a.get("city", "")
country = a.get("country", "")
locations.add(f"{city}, {country}" if city and country else city or "")
if len(locations) == 1 and "" not in locations:
ctx["common_location"] = locations.pop()
if collections:
first = collections[0]
ctx.update({
"collection_name": first["name"], "album_name": first["name"],
"public_url": first.get("public_url", ""), "album_url": first.get("url", ""),
"shared": first.get("shared", False),
"photo_count": first.get("photo_count", 0), "video_count": first.get("video_count", 0),
"owner": first.get("owner", ""),
})
else:
ctx.update({
"collection_name": "", "album_name": "",
"public_url": "", "album_url": "",
"shared": False, "photo_count": 0, "video_count": 0, "owner": "",
})
people: set[str] = set()
for a in all_assets:
people.update(a.get("people", []))
ctx["people"] = list(people)
ctx["has_videos"] = any(a.get("type") == "VIDEO" for a in all_assets)
ctx["has_photos"] = any(a.get("type") == "IMAGE" for a in all_assets)
ctx["added_count"] = len(all_assets)
ctx["added_assets"] = all_assets
ctx["protected_url"] = ""
return ctx
@@ -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": [],
},
)