feat(immich): per-album scheduled/memory dispatch + template tooling

Dispatch: honor {kind}_collection_mode on TrackingConfig — "per_collection"
fans out one event per album; "combined" pools assets as before. Extract
build_immich_dispatch_events shared by cron and test paths.

Assets: collect_scheduled_assets attaches album_name/album_url/album_public_url
to each asset so combined-mode templates can attribute rows to their source
album. Default scheduled_assets templates render a multi-album header with
inline album list and per-row album link; memory_mode follows the same pattern.

UI: "Reset to default" buttons on notification and command template slots
(per-slot and whole-template), backed by new GET /*-template-configs/defaults
endpoints. tracking-configs "Preview template" now opens an inline preview
modal with locale tabs instead of navigating away; Edit button deep-links
with ?edit_slot=<name> so the destination auto-opens the config and scrolls
to the slot. Reset confirmations use ConfirmModal instead of window.confirm.

Fixes:
* NotificationDispatcher._session_ctx infinite recursion when no shared
  aiohttp.ClientSession was passed — broke test dispatch for periodic/
  scheduled/memory (cron path was unaffected).
* 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.
* scheduled_assets default template no longer emits a blank line between
  header and the first asset when the multi-album branch is taken.
This commit is contained in:
2026-04-24 19:15:54 +03:00
parent be15463fd2
commit b61394f057
40 changed files with 1235 additions and 224 deletions
@@ -120,22 +120,43 @@ async def dispatch_test_notification(
),
}
# Fetch assets and build event
# Build events (single or per-album) via the shared helper so test and
# cron dispatch stay in lockstep on the mode decision.
try:
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 provider.type == "immich" and test_type in ("periodic", "scheduled", "memory"):
events = await build_immich_dispatch_events(
provider_config=provider_config,
provider_name=provider.name or provider.type,
tracker_name=tracker.name or "",
collection_ids=collection_ids,
kind=test_type,
tracking_config=tracking_config,
)
else:
ev = 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,
)
events = [ev] if ev is not None else []
except Exception as err: # noqa: BLE001
_LOGGER.exception("Test dispatch event build failed")
return {"success": False, "error": f"Provider connection failed: {err}"}
if event is None:
if not events:
if test_type in ("scheduled", "memory"):
return {
"success": False,
"error": (
"No matching assets found. Verify the tracker's albums contain assets "
"that pass the tracking config filters (favorites only, rating, asset type)."
) + (" for today" if test_type == "memory" else ""),
}
return {
"success": False,
"error": (
@@ -143,24 +164,92 @@ async def dispatch_test_notification(
"credentials are valid, and the tracker has collections configured."
),
}
# Periodic summary only needs album stats (extra.albums), not assets — skip the asset check.
if not event.added_assets and test_type in ("scheduled", "memory"):
return {
"success": False,
"error": (
"No matching assets found. Verify the tracker's albums contain assets "
"that pass the tracking config filters (favorites only, rating, asset type)."
) + (" for today" if test_type == "memory" else ""),
}
# Dispatch through the real NotificationDispatcher
# Dispatch each event to the same target (per-album fan-out sends N messages).
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])
all_results: list[dict[str, Any]] = []
for event in events:
results = await dispatcher.dispatch(event, [target_cfg])
if results:
all_results.append(results[0])
if not results:
if not all_results:
return {"success": False, "error": "No dispatch results"}
return results[0]
all_ok = all(r.get("success") for r in all_results)
if all_ok:
return {"success": True, "dispatched": len(all_results)}
first_err = next(
(r.get("error") for r in all_results if not r.get("success")),
"Unknown error",
)
return {
"success": False,
"error": first_err,
"dispatched": sum(1 for r in all_results if r.get("success")),
"failed": sum(1 for r in all_results if not r.get("success")),
}
async def build_immich_dispatch_events(
*,
provider_config: dict,
provider_name: str,
tracker_name: str,
collection_ids: list[str],
kind: str,
tracking_config: TrackingConfig | None,
) -> list[ServiceEvent]:
"""Build the list of ServiceEvents to dispatch for an Immich scheduled kind.
Single source of truth for the mode decision: ``periodic`` is always one
summary event; ``scheduled``/``memory`` honour the ``{kind}_collection_mode``
on the tracking config and fan out one event per album in ``per_collection``
mode, or one combined event in ``combined`` mode.
Empty-payload filtering (no assets matched) is applied here so callers get
back only events that should actually dispatch. ``periodic`` is exempt —
a zero-asset summary is still meaningful (shows album stats only).
"""
if kind == "periodic":
ev = await _build_immich_periodic_event(
provider_config=provider_config,
provider_name=provider_name,
tracker_name=tracker_name,
collection_ids=collection_ids,
)
return [ev] if ev is not None else []
mode = getattr(
tracking_config, f"{kind}_collection_mode", "combined"
) or "combined"
if mode == "per_collection" and len(collection_ids) > 1:
events: list[ServiceEvent] = []
for aid in collection_ids:
ev = await _build_immich_event(
provider_config=provider_config,
provider_name=provider_name,
tracker_name=tracker_name,
collection_ids=[aid],
test_type=kind,
tracking_config=tracking_config,
)
if ev is not None and ev.added_assets:
events.append(ev)
return events
ev = await _build_immich_event(
provider_config=provider_config,
provider_name=provider_name,
tracker_name=tracker_name,
collection_ids=collection_ids,
test_type=kind,
tracking_config=tracking_config,
)
if ev is None or not ev.added_assets:
return []
return [ev]
async def _build_event(