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
@@ -74,6 +74,36 @@ async def _get(session: AsyncSession, config_id: int, user_id: int) -> CommandTe
# Routes
# ---------------------------------------------------------------------------
@router.get("/defaults")
async def get_default_command_templates(
provider_type: str,
slot_name: str | None = None,
locale: str | None = None,
user: User = Depends(get_current_user),
):
"""Return the shipped Jinja2 default command templates for a provider type.
Used by the UI's "Reset to default" actions. Filtering works the same way
as the notification-template equivalent: omit ``slot_name`` for the whole
set, omit ``locale`` for every locale.
Response shape: ``{slot_name: {locale: template_text}}``
"""
from notify_bridge_core.templates.command_defaults.loader import (
load_default_command_templates,
)
from notify_bridge_core.templates.defaults.loader import get_available_locales
locales = [locale] if locale else get_available_locales()
result: dict[str, dict[str, str]] = {}
for loc in locales:
defaults = load_default_command_templates(loc, provider_type)
for name, text in defaults.items():
if slot_name and name != slot_name:
continue
result.setdefault(name, {})[loc] = text
return result
@router.get("/variables")
async def get_command_variables(
user: User = Depends(get_current_user),
@@ -84,15 +114,26 @@ async def get_command_variables(
}
asset_fields = {
"id": "Asset ID (UUID)",
"originalFileName": "Original filename",
"filename": "Original filename (preferred; same as originalFileName)",
"originalFileName": "Original filename (alias of filename, kept for backward-compat with older templates)",
"type": "IMAGE or VIDEO",
"createdAt": "Creation date/time (ISO 8601)",
"created_at": "Creation date/time (ISO 8601)",
"createdAt": "Creation date/time (alias of created_at)",
"year": "Year of the memory (memory command only)",
"public_url": "Per-asset public share URL (empty if no album link)",
"city": "City name (empty if unknown)",
"country": "Country name (empty if unknown)",
"is_favorite": "Whether asset is favorited (boolean)",
}
album_fields = {
"name": "Album name",
"asset_count": "Number of assets in the album",
"id": "Album ID (UUID)",
"public_url": "Public share link URL (empty if none)",
"asset_count": "Number of assets in the album",
"photo_count": "Number of photos in the album",
"video_count": "Number of videos in the album",
"shared": "Whether the album is shared (boolean)",
"owner": "Album owner display name",
}
command_fields = {
"name": "Command name (e.g. status, albums)",
@@ -492,10 +533,11 @@ async def preview_raw(
{"name": "latest", "description": "Show latest photos", "usage": "/latest 10"},
{"name": "search", "description": "Smart search (AI)", "usage": "/search sunset at the beach"},
],
# /albums, /summary
# /albums, /summary — provide photo/video split, sharing, owner so the
# enriched summary template previews fully.
"albums": [
{"name": "Family Photos", "asset_count": 142, "id": "abc-123", "public_url": "https://immich.example.com/share/abc123"},
{"name": "Vacation 2025", "asset_count": 87, "id": "def-456", "public_url": ""},
{"name": "Family Photos", "asset_count": 142, "photo_count": 120, "video_count": 22, "shared": True, "owner": "Alice", "id": "abc-123", "public_url": "https://immich.example.com/share/abc123"},
{"name": "Vacation 2025", "asset_count": 87, "photo_count": 80, "video_count": 7, "shared": False, "owner": "Bob", "id": "def-456", "public_url": ""},
],
# /events
"events": [
@@ -505,9 +547,12 @@ async def preview_raw(
# /people
"people": ["Alice", "Bob", "Charlie"],
# /search, /find, /person, /place, /latest, /favorites, /random, /memory
# ``filename`` is the canonical key (matches notification context and
# build_asset_dict output); ``originalFileName`` is kept as an alias
# so templates still using the old key render in preview.
"assets": [
{"id": "a1", "originalFileName": "IMG_001.jpg", "type": "IMAGE", "createdAt": "2026-03-19T14:30:00", "year": 2024, "public_url": "https://immich.example.com/share/abc123/photos/a1", "city": "Paris", "country": "France", "is_favorite": True},
{"id": "a2", "originalFileName": "VID_002.mp4", "type": "VIDEO", "createdAt": "2026-03-19T15:00:00", "year": 2023, "public_url": "", "city": "", "country": "", "is_favorite": False},
{"id": "a1", "filename": "IMG_001.jpg", "originalFileName": "IMG_001.jpg", "type": "IMAGE", "created_at": "2026-03-19T14:30:00", "createdAt": "2026-03-19T14:30:00", "year": 2024, "public_url": "https://immich.example.com/share/abc123/photos/a1", "city": "Paris", "country": "France", "is_favorite": True},
{"id": "a2", "filename": "VID_002.mp4", "originalFileName": "VID_002.mp4", "type": "VIDEO", "created_at": "2026-03-19T15:00:00", "createdAt": "2026-03-19T15:00:00", "year": 2023, "public_url": "", "city": "", "country": "", "is_favorite": False},
],
"query": "sunset",
"command": "search",
@@ -426,19 +426,45 @@ async def get_album_shared_links(
return []
class CreateSharedLinkRequest(BaseModel):
"""Options for POST /shared-links.
``replace=True`` deletes every existing link for the album before creating
the new one, which is the only way to repair an expired or password-
protected link in the Immich API (there is no in-place "reset" endpoint).
Default ``False`` preserves the original additive behaviour used by the
"auto-create missing links" flow.
"""
replace: bool = False
@router.post("/{provider_id}/albums/{album_id}/shared-links")
async def create_album_shared_link(
provider_id: int,
album_id: str,
body: CreateSharedLinkRequest | None = None,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Auto-create a public shared link for an album."""
"""Auto-create a public shared link for an album.
With ``replace=True`` existing links for the album are deleted first, so
expired/password-protected links are effectively recycled into a fresh
public one.
"""
provider = await _get_user_provider(session, provider_id, user.id)
if provider.type == "immich":
http_session = await get_http_session()
immich = make_immich_provider(http_session, provider)
if body and body.replace:
# Best-effort delete; if any delete fails we still try to create —
# the user will see the new link co-exist alongside the old one,
# which is better than a hard failure that leaves them stuck.
existing = await immich.client.get_shared_links(album_id)
for link in existing:
await immich.client.delete_shared_link(link.id)
success = await immich.client.create_shared_link(album_id)
if success:
return {"success": True}
@@ -298,10 +298,30 @@ async def test_chat(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test message to a chat via the bot."""
"""Send a test message to a chat via the bot.
Locale resolution: prefer the chat row's ``language_override`` (explicit
user choice in the UI), fall back to Telegram's ``language_code`` sent
with the chat, and only use the ``?locale=`` query param if neither is
set. Otherwise users who set RU on a chat would still see an EN test.
"""
bot = await _get_user_bot(session, bot_id, user.id)
chat_row = (await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)).first()
effective_locale = locale
if chat_row:
chat_locale = (
getattr(chat_row, 'language_override', '') or
getattr(chat_row, 'language_code', '') or ''
)
if chat_locale:
effective_locale = chat_locale[:2].lower()
from ..services.http_session import get_http_session
message = _get_test_message(locale, "telegram")
message = _get_test_message(effective_locale, "telegram")
http = await get_http_session()
client = TelegramClient(http, bot.token)
return await client.send_message(chat_id, message)
@@ -102,6 +102,37 @@ async def list_configs(
return [await _response(session, c) for c in result.all()]
@router.get("/defaults")
async def get_default_slot_templates(
provider_type: str,
slot_name: str | None = None,
locale: str | None = None,
user: User = Depends(get_current_user),
):
"""Return the shipped Jinja2 default templates for a provider type.
Used by the UI's "Reset to default" actions. Filtering is optional —
omit ``slot_name`` to get every slot, omit ``locale`` to get every locale.
Registered before ``/{config_id}`` so the literal path wins over the
path-parameter route in FastAPI's matcher.
Response shape: ``{slot_name: {locale: template_text}}``
"""
from notify_bridge_core.templates.defaults.loader import (
get_available_locales,
load_default_templates,
)
locales = [locale] if locale else get_available_locales()
result: dict[str, dict[str, str]] = {}
for loc in locales:
defaults = load_default_templates(loc, provider_type)
for name, text in defaults.items():
if slot_name and name != slot_name:
continue
result.setdefault(name, {})[loc] = text
return result
@router.get("/variables")
async def get_template_variables(
user: User = Depends(get_current_user),
@@ -170,13 +201,20 @@ async def get_template_variables(
"download_url": "Direct download URL (if shared)",
"photo_url": "Preview image URL (images only, if shared)",
"playback_url": "Video playback URL (videos only, if shared)",
# Per-asset album attribution (scheduled/memory templates in combined mode).
"album_name": "Source album name (combined-mode scheduled/memory only)",
"album_url": "Source album URL — public share link if available, else internal album URL",
"album_public_url": "Source album public share URL (empty if no public link)",
}
album_fields = {
"name": "Collection/album name",
"url": "Share URL",
"public_url": "Public share link URL",
"asset_count": "Total assets in collection",
"shared": "Whether collection is shared",
"photo_count": "Number of photos in the album",
"video_count": "Number of videos in the album",
"shared": "Whether collection is shared (boolean)",
"owner": "Album owner display name",
}
scheduled_vars = {
"date": "Current date string",
@@ -217,12 +255,26 @@ async def get_template_variables(
},
"scheduled_assets_message": {
"description": "Scheduled asset delivery (daily photo picks)",
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
"variables": {
**scheduled_vars,
"assets": "List of asset dicts (use {% for asset in assets %})",
"album_name": "Source album name",
"public_url": "Public share link URL for the source album (empty if none)",
"asset_count": "Total assets in the source album",
"photo_count": "Photos in the source album",
"video_count": "Videos in the source album",
"owner": "Source album owner",
},
"asset_fields": asset_fields,
},
"memory_mode_message": {
"description": "\"On This Day\" memories from previous years",
"variables": {**scheduled_vars, "assets": "List of asset dicts (use {% for asset in assets %})"},
"variables": {
**scheduled_vars,
"assets": "List of asset dicts (use {% for asset in assets %})",
"album_name": "Source album name (when rendered per-album)",
"public_url": "Public share link URL for the source album (empty if none)",
},
"asset_fields": asset_fields,
},
# --- Generic Webhook slots ---