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
@@ -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)
@@ -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]]: