{varsRef[showVarsFor].description}
+ {#if showVarsFor && modalVars} +{modalVars.description}
{t('templateConfig.variables')}:
- {#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]} + {#each Object.entries(modalVars.variables || {}) as [name, desc]}{'{{ ' + name + ' }}'}
{desc}
@@ -484,11 +492,19 @@
['album_fields', 'album', 'Album fields'],
['command_fields', 'cmd', 'Command fields'],
['event_fields', 'event', 'Event fields'],
+ ['repo_fields', 'repo', 'Repository fields'],
+ ['issue_fields', 'issue', 'Issue fields'],
+ ['pr_fields', 'pr', 'Pull request fields'],
+ ['commit_fields', 'c', 'Commit fields'],
+ ['board_fields', 'board', 'Board fields'],
+ ['card_fields', 'card', 'Card fields'],
+ ['list_fields', 'lst', 'List fields'],
+ ['device_fields', 'd', 'Device fields'],
] as [fieldKey, prefix, title]}
- {#if varsRef[showVarsFor][fieldKey]}
+ {#if modalVars[fieldKey]}
{title} (use {prefix}.field):
- {#each Object.entries(varsRef[showVarsFor][fieldKey]) as [name, desc]} + {#each Object.entries(modalVars[fieldKey]) as [name, desc]}{'{{ ' + prefix + '.' + name + ' }}'}
{desc}
diff --git a/packages/core/src/notify_bridge_core/notifications/dispatcher.py b/packages/core/src/notify_bridge_core/notifications/dispatcher.py
index b380eb5..5395135 100644
--- a/packages/core/src/notify_bridge_core/notifications/dispatcher.py
+++ b/packages/core/src/notify_bridge_core/notifications/dispatcher.py
@@ -46,6 +46,7 @@ from .receiver import (
from .telegram.cache import TelegramFileCache
from .telegram.client import TelegramClient
from .telegram.media import (
+ build_telegram_asset_entry,
extract_asset_id_from_url,
is_asset_cache_key,
is_asset_id,
@@ -266,23 +267,19 @@ class NotificationDispatcher:
# Prefer internal URL for fetching (LAN speed vs public internet)
internal_url = (target.provider_internal_url or "").rstrip("/")
external_url = (target.provider_external_url or "").rstrip("/")
- provider_urls = [u for u in (internal_url, external_url) if u]
assets = []
media_assets: list[Any] = [] # aligned with `assets` for preload
for asset in event.added_assets[:max_media]:
url = asset.preview_url or asset.thumbnail_url or asset.full_url
- if url:
- # Rewrite external URL to internal for faster LAN fetching
- if internal_url and external_url and url.startswith(external_url):
- url = internal_url + url[len(external_url):]
- asset_type = "video" if asset.type.value == "video" else "photo"
- asset_headers = {}
- if target.provider_api_key and any(url.startswith(u) for u in provider_urls):
- asset_headers["x-api-key"] = target.provider_api_key
- asset_entry: dict[str, Any] = {"url": url, "type": asset_type, "headers": asset_headers}
- # Pass explicit cache_key if set by provider (e.g. Google Photos)
- if asset.extra.get("cache_key"):
- asset_entry["cache_key"] = asset.extra["cache_key"]
+ asset_entry = build_telegram_asset_entry(
+ url=url or "",
+ media_type=asset.type.value,
+ api_key=target.provider_api_key,
+ internal_url=internal_url,
+ external_url=external_url,
+ cache_key=asset.extra.get("cache_key"),
+ )
+ if asset_entry is not None:
assets.append(asset_entry)
media_assets.append(asset)
diff --git a/packages/core/src/notify_bridge_core/notifications/telegram/media.py b/packages/core/src/notify_bridge_core/notifications/telegram/media.py
index 4a1179a..d5c630c 100644
--- a/packages/core/src/notify_bridge_core/notifications/telegram/media.py
+++ b/packages/core/src/notify_bridge_core/notifications/telegram/media.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import re
-from typing import Final
+from typing import Any, Final
from urllib.parse import urlparse
# Telegram constants
@@ -52,6 +52,65 @@ def extract_asset_id_from_url(url: str) -> str | None:
return None
+def build_telegram_asset_entry(
+ *,
+ url: str,
+ media_type: str,
+ api_key: str | None = None,
+ internal_url: str = "",
+ external_url: str = "",
+ cache_key: str | None = None,
+) -> dict[str, Any] | None:
+ """Build a ``TelegramClient.send_notification`` asset dict from raw fields.
+
+ Shared by the notification dispatcher and provider command handlers so
+ both paths agree on media typing, URL rewriting, and auth headers. In
+ particular: video assets MUST be typed ``"video"`` and point at a real
+ video endpoint (e.g. Immich ``/video/playback``) — if they are sent as
+ ``"photo"`` pointing at a thumbnail URL, Telegram delivers a still image
+ for every video in a media group and the user sees a dead poster frame
+ instead of a playable clip.
+
+ Args:
+ url: Source URL for the asset bytes. Prefer a transcoded/preview
+ URL for videos (``/video/playback``) and a preview-sized
+ thumbnail for photos.
+ media_type: Case-insensitive type token. Accepts ``"video"``/
+ ``"VIDEO"``/``MediaType.VIDEO`` or any photo-like string.
+ api_key: Optional API key. Attached as ``x-api-key`` iff the URL is
+ served by one of the provider hosts in ``internal_url`` /
+ ``external_url`` (prevents leaking the key to unrelated hosts).
+ internal_url: LAN-facing provider URL. Used to rewrite
+ ``external_url`` prefixes so Docker-host downloads stay on the
+ LAN instead of egressing to the public domain.
+ external_url: Public provider URL the notification URL was built
+ from. Only used for the LAN rewrite and the api-key scope check.
+ cache_key: Optional explicit cache key. Providers whose URLs don't
+ embed a stable asset id (Google Photos) pass one through so the
+ file_id cache still works.
+
+ Returns ``None`` iff ``url`` is empty.
+ """
+ if not url:
+ return None
+
+ if internal_url and external_url and url.startswith(external_url):
+ url = internal_url + url[len(external_url):]
+
+ normalized_type = str(media_type or "").lower()
+ entry_type = "video" if normalized_type == "video" else "photo"
+
+ headers: dict[str, str] = {}
+ provider_urls = [u for u in (internal_url, external_url) if u]
+ if api_key and (not provider_urls or any(url.startswith(u) for u in provider_urls)):
+ headers["x-api-key"] = api_key
+
+ entry: dict[str, Any] = {"url": url, "type": entry_type, "headers": headers}
+ if cache_key:
+ entry["cache_key"] = cache_key
+ return entry
+
+
def split_media_by_upload_size(
media_items: list[tuple], max_upload_size: int
) -> list[list[tuple]]:
diff --git a/packages/server/src/notify_bridge_server/commands/handler.py b/packages/server/src/notify_bridge_server/commands/handler.py
index c0b7949..21cc574 100644
--- a/packages/server/src/notify_bridge_server/commands/handler.py
+++ b/packages/server/src/notify_bridge_server/commands/handler.py
@@ -393,30 +393,38 @@ async def send_media_group(
reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None,
) -> None:
- """Send media items via TelegramClient.send_notification."""
+ """Send media items via TelegramClient.send_notification.
+
+ ``media_items`` must already be in TelegramClient asset format — each
+ entry contains ``type`` (``"photo"``/``"video"``/``"document"``),
+ ``url``, optional ``cache_key``, and optional ``headers``. Provider
+ command handlers build this format directly (via
+ ``build_telegram_asset_entry``) so videos keep their ``"video"`` type
+ and point at a real video URL instead of a still thumbnail.
+
+ Reuses the same Telegram file_id caches as the notification dispatcher
+ so repeated ``/latest`` / ``/random`` commands don't re-upload bytes
+ for assets Telegram has already seen. If the cache hasn't been
+ initialized (no data dir configured) we fall through to a plain
+ upload — identical behavior to the notification path.
+ """
if not media_items:
return
- # Convert command handler media format to TelegramClient asset format
- assets = []
- for item in media_items:
- assets.append({
- "type": "photo",
- "url": item.get("thumbnail_url", ""),
- "cache_key": item.get("asset_id", ""),
- "headers": {"x-api-key": item.get("api_key", "")},
- })
-
- # Build caption from first item
- captions = [item.get("caption", "") for item in media_items if item.get("caption")]
- caption = "\n".join(captions) if captions else None
-
if session is None:
from ..services.http_session import get_http_session
session = await get_http_session()
- client = TelegramClient(session, bot_token)
+
+ from ..services.watcher import _get_telegram_caches
+ url_cache, asset_cache = await _get_telegram_caches()
+
+ client = TelegramClient(
+ session, bot_token,
+ url_cache=url_cache,
+ asset_cache=asset_cache,
+ )
result = await client.send_notification(
- chat_id, assets=assets, caption=caption,
+ chat_id, assets=media_items,
reply_to_message_id=reply_to_message_id,
chat_action=None,
)
diff --git a/packages/server/src/notify_bridge_server/commands/immich/common.py b/packages/server/src/notify_bridge_server/commands/immich/common.py
index c637f6a..cd5d98a 100644
--- a/packages/server/src/notify_bridge_server/commands/immich/common.py
+++ b/packages/server/src/notify_bridge_server/commands/immich/common.py
@@ -6,6 +6,7 @@ import asyncio
import logging
from typing import Any
+from notify_bridge_core.notifications.telegram.media import build_telegram_asset_entry
from notify_bridge_core.providers.immich.asset_utils import get_public_url
from ..handler import _render_cmd_template
@@ -74,13 +75,16 @@ def build_asset_dict(
) -> dict[str, Any]:
"""Build a rich asset dict for command templates from an ImmichAssetInfo or raw dict."""
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 {}
d = {
"id": asset.get("id", ""),
"originalFileName": asset.get("originalFileName", asset.get("filename", "")),
"type": asset.get("type", "IMAGE"),
"createdAt": asset.get("createdAt", asset.get("created_at", asset.get("fileCreatedAt", ""))),
- "city": asset.get("city", ""),
- "country": asset.get("country", ""),
+ "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)),
"public_url": asset.get("public_url", public_url),
}
@@ -123,16 +127,27 @@ def _format_assets(
})
if response_mode == "media":
+ # Reuse the same entry-building helper as the notification dispatcher
+ # so videos keep their "video" type and point at /video/playback —
+ # typing them as "photo" made Telegram render the still poster
+ # thumbnail in media groups instead of the real clip.
media_items: list[dict[str, Any]] = []
for asset in assets:
asset_id = asset.get("id", "")
- media_items.append({
- "type": "photo",
- "asset_id": asset_id,
- "caption": "",
- "thumbnail_url": f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview",
- "api_key": client.api_key,
- })
+ is_video = (asset.get("type") or "").upper() == "VIDEO"
+ if is_video:
+ url = f"{client.url}/api/assets/{asset_id}/video/playback"
+ else:
+ url = f"{client.url}/api/assets/{asset_id}/thumbnail?size=preview"
+ entry = build_telegram_asset_entry(
+ url=url,
+ media_type="video" if is_video else "image",
+ api_key=client.api_key,
+ internal_url=client.url,
+ cache_key=asset_id,
+ )
+ if entry is not None:
+ media_items.append(entry)
# Return text message + media items — text is sent first, media as reply
return {"text": text, "media": media_items}
diff --git a/packages/server/src/notify_bridge_server/commands/immich/search.py b/packages/server/src/notify_bridge_server/commands/immich/search.py
index 9023126..6356016 100644
--- a/packages/server/src/notify_bridge_server/commands/immich/search.py
+++ b/packages/server/src/notify_bridge_server/commands/immich/search.py
@@ -5,17 +5,14 @@ from __future__ import annotations
from typing import Any
from ..handler import _render_cmd_template
-from .common import _format_assets
+from .common import _format_assets, build_asset_dict
def _enrich_assets(assets: list[dict[str, Any]], asset_public_urls: dict[str, str]) -> list[dict[str, Any]]:
- """Add public_url to assets from the pre-built map. Returns new list without mutating inputs."""
- if not asset_public_urls:
- return assets
+ """Normalize raw Immich assets and attach public_url from the pre-built map."""
+ pub = asset_public_urls or {}
return [
- {**asset, "public_url": asset_public_urls.get(asset.get("id", ""), "")}
- if asset.get("id", "") in asset_public_urls and not asset.get("public_url")
- else asset
+ build_asset_dict(asset, public_url=pub.get(asset.get("id", ""), ""))
for asset in assets
]