diff --git a/frontend/src/routes/command-template-configs/+page.svelte b/frontend/src/routes/command-template-configs/+page.svelte index de3de67..d84770e 100644 --- a/frontend/src/routes/command-template-configs/+page.svelte +++ b/frontend/src/routes/command-template-configs/+page.svelte @@ -117,6 +117,14 @@ return form.slots[slotName]?.[activeLocale] || ''; } + /** Resolve variable reference for a slot, preferring provider-specific over shared. */ + function getVarsFor(slotName: string) { + const providerVars = varsRef[form.provider_type]; + return providerVars?.[slotName] ?? varsRef[slotName]; + } + + let modalVars = $derived(showVarsFor ? getVarsFor(showVarsFor) : null); + /** Set slot template for current locale (immutable update). */ function setSlotValue(slotName: string, value: string) { form.slots = { @@ -369,7 +377,7 @@ {t('templateConfig.preview')} {/if} - {#if varsRef[slot.name]} + {#if getVarsFor(slot.name)} {/if} @@ -385,7 +393,7 @@ onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }} rows={3} errorLine={slotErrorLines[slot.name] || null} - variables={varsRef[slot.name] || undefined} + variables={getVarsFor(slot.name) || undefined} /> {/if} @@ -468,11 +476,11 @@ showVarsFor = null}> - {#if showVarsFor && varsRef[showVarsFor]} -

{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 ]