fix(commands): enrich search assets, surface variables for all command slots

- UI: command-template-configs now resolves slot variables against the
  active provider first (varsRef[provider_type][slot]) before falling back
  to shared entries, so provider-specific slots like /search, /status,
  /repos, /issues, /boards show the Variables button and autocomplete.
- Backend: /search, /find, /person, /place now normalize raw Immich API
  responses through build_asset_dict, extracting city/country from
  exifInfo and mapping isFavorite -> is_favorite so templates render
  location and favorite indicators.
- Telegram: extract build_telegram_asset_entry into a shared helper so
  the notification dispatcher and command media groups agree on video
  typing and /video/playback URLs; videos no longer render as still
  thumbnails in /latest /random /favorites media mode.
- Commands: send_media_group now reuses the same Telegram file_id caches
  as the notification dispatcher, avoiding re-upload churn for repeated
  commands.
This commit is contained in:
2026-04-22 16:28:26 +03:00
parent 85311684d9
commit fab6169cf9
6 changed files with 146 additions and 54 deletions
@@ -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,
)
@@ -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}
@@ -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
]