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
@@ -76,16 +76,28 @@ def build_asset_dict(
public_url: str = "",
year: int | None = None,
) -> dict[str, Any]:
"""Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict."""
"""Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict.
Asset-dict contract (shared with notification templates — see
``notify_bridge_core.templates.context``): templates may read either
``filename`` (the canonical field, used by notification defaults) or
``originalFileName`` (the historical command-default field); both are
populated so a custom template authored against either key keeps working.
Same story for ``created_at`` / ``createdAt``.
"""
if isinstance(asset, dict):
# Immich raw search responses nest geo under exifInfo — pull it out so
# templates can use flat asset.city / asset.country.
exif = asset.get("exifInfo") or {}
fname = asset.get("originalFileName") or asset.get("filename") or ""
created = asset.get("createdAt") or asset.get("created_at") or asset.get("fileCreatedAt") or ""
d = {
"id": asset.get("id", ""),
"originalFileName": asset.get("originalFileName", asset.get("filename", "")),
"filename": fname,
"originalFileName": fname,
"type": asset.get("type", "IMAGE"),
"createdAt": asset.get("createdAt", asset.get("created_at", asset.get("fileCreatedAt", ""))),
"created_at": created,
"createdAt": created,
"city": asset.get("city") or exif.get("city") or "",
"country": asset.get("country") or exif.get("country") or "",
"is_favorite": asset.get("is_favorite", asset.get("isFavorite", False)),
@@ -97,8 +109,10 @@ def build_asset_dict(
# ImmichAssetInfo dataclass
return {
"id": asset.id,
"filename": asset.filename,
"originalFileName": asset.filename,
"type": asset.type,
"created_at": asset.created_at,
"createdAt": asset.created_at,
"city": getattr(asset, "city", "") or "",
"country": getattr(asset, "country", "") or "",