feat: large polish pass — UX fixes, per-chat scope, restore/backup, action events

Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
  allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
  through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
  / search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
  to POST /api/search/metadata with personIds (fixes /person command and
  auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
  image when missing (falls back to any asset type); failures do not fail the
  rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
  + backfill. Status query filters by user_id directly; Immich/webhook paths
  emit user_id explicitly. action_runner writes an action_success/partial/
  failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
  tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
  (ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
  pending_restore.json; lifespan hook applies on next startup and archives
  under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
  shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
  Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
  (limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
  TelegramChat.language_override per chat instead of applying the first
  receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
  and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
  save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
  deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
  track_assets_removed default False.

Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
  labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
  create forms (trackers, command-trackers, targets, template/command
  configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
  multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
  restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
  inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.

Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
  notification_tracker).
- command_tracker_listener: + allowed_album_ids.
This commit is contained in:
2026-04-22 01:13:11 +03:00
parent b5ffab7ece
commit a7a2b4efa4
57 changed files with 2452 additions and 335 deletions
@@ -16,6 +16,13 @@ from .ssrf import UnsafeURLError, validate_outbound_url
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=30)
# Cap on how many asset downloads run concurrently inside
# ``_preload_asset_data``. Peak memory during a send is bounded to roughly
# ``_PRELOAD_CONCURRENCY * max_asset_size`` instead of ``max_media_to_send *
# max_asset_size``, which matters on small-RAM Docker hosts when a batch
# contains many large videos.
_PRELOAD_CONCURRENCY = 6
def _new_session() -> aiohttp.ClientSession:
"""Per-dispatch aiohttp session with a sane default timeout.
@@ -38,6 +45,11 @@ from .receiver import (
)
from .telegram.cache import TelegramFileCache
from .telegram.client import TelegramClient
from .telegram.media import (
extract_asset_id_from_url,
is_asset_cache_key,
is_asset_id,
)
from .webhook.client import WebhookClient
_LOGGER = logging.getLogger(__name__)
@@ -146,6 +158,90 @@ class NotificationDispatcher:
return await send_method(target, default_message, event)
return {"success": False, "error": f"Unknown target type: {target.type}"}
async def _preload_asset_data(
self,
assets: list[dict[str, Any]],
media_assets: list[Any],
session: aiohttp.ClientSession,
max_size: int | None,
) -> None:
"""Download each non-cached asset's bytes once and attach to the entry.
Three benefits:
* ``TelegramClient`` sees ``entry["data"]`` and skips its own download,
so we don't fetch each URL twice.
* We know the exact upload size, which lets the oversize warning in
the rendered text compare against real bytes (for Immich videos,
the transcoded ``/video/playback``), not the original ``file_size``.
* Assets already in the Telegram file_id cache are skipped, and their
stored size (if any) is used to populate ``playback_size`` — so
templates see consistent sizes for repeat sends without re-download.
Entries whose download fails or exceeds ``max_size`` are left without
``data``; ``TelegramClient`` will then fall back to its own download
path and apply the same checks — no regression, just no preload win.
Concurrency is bounded by ``_PRELOAD_CONCURRENCY`` so peak memory
stays predictable: at most N assets worth of bytes held in RAM at
once, regardless of ``max_media_to_send``. Total wall-clock is
unchanged for small batches and only marginally slower for large
ones (most assets fit in a single RTT and SSL negotiation cost
dominates, so 6-way parallelism is sufficient).
"""
if not assets:
return
sem = asyncio.Semaphore(_PRELOAD_CONCURRENCY)
async def _fetch(entry: dict[str, Any], media: Any) -> None:
# Cache hit → skip download; populate playback_size from stored size.
cache, key = self._cache_for_entry(entry)
if cache and key:
cached = cache.get(key)
if cached and cached.get("file_id"):
stored_size = cached.get("size")
if stored_size is not None:
media.extra["playback_size"] = stored_size
return
url = entry["url"]
headers = entry.get("headers") or {}
async with sem:
try:
async with session.get(url, headers=headers) as resp:
if resp.status != 200:
return
data = await resp.read()
except aiohttp.ClientError:
return
if max_size is not None and len(data) > max_size:
return
entry["data"] = data
media.extra["playback_size"] = len(data)
await asyncio.gather(*(_fetch(e, m) for e, m in zip(assets, media_assets)))
def _cache_for_entry(
self, entry: dict[str, Any],
) -> tuple[TelegramFileCache | None, str | None]:
"""Resolve (cache, key) for an asset entry — mirrors TelegramClient logic.
Returns (None, None) if no cache is configured or no key can be derived.
"""
cache_key = entry.get("cache_key")
if cache_key:
cache = self._asset_cache if is_asset_cache_key(cache_key) else self._url_cache
return cache, cache_key
url = entry.get("url")
if url:
if is_asset_id(url):
return self._asset_cache, url
extracted = extract_asset_id_from_url(url)
if extracted:
return self._asset_cache, extracted
return self._url_cache, url
return None, None
async def _send_telegram(
self, target: TargetConfig, default_message: str, event: ServiceEvent
) -> dict[str, Any]:
@@ -172,6 +268,7 @@ class NotificationDispatcher:
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:
@@ -187,9 +284,16 @@ class NotificationDispatcher:
if asset.extra.get("cache_key"):
asset_entry["cache_key"] = asset.extra["cache_key"]
assets.append(asset_entry)
media_assets.append(asset)
results: list[dict[str, Any]] = []
async with _new_session() as session:
# Preload all asset bytes once so (a) TelegramClient can skip its
# own download and (b) we know exact upload sizes in time for the
# oversize warning in the rendered text.
await self._preload_asset_data(assets, media_assets, session, max_size)
default_message = self._render_message(event, target, target.locale)
client = TelegramClient(
session, bot_token,
url_cache=self._url_cache,
@@ -84,10 +84,19 @@ class TelegramFileCache:
if age > self._ttl_seconds:
return None
return {"file_id": entry.get("file_id"), "type": entry.get("type")}
return {
"file_id": entry.get("file_id"),
"type": entry.get("type"),
"size": entry.get("size"), # bytes of what was uploaded; None for legacy entries
}
async def async_set(
self, key: str, file_id: str, media_type: str, thumbhash: str | None = None
self,
key: str,
file_id: str,
media_type: str,
thumbhash: str | None = None,
size: int | None = None,
) -> None:
if self._data is None:
self._data = {"files": {}}
@@ -99,20 +108,34 @@ class TelegramFileCache:
}
if thumbhash is not None:
entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry
await self._backend.save(self._data)
async def async_set_many(
self, entries: list[tuple[str, str, str, str | None]]
self,
entries: list[tuple[str, str, str, str | None] | tuple[str, str, str, str | None, int | None]],
) -> None:
"""Bulk-store file_id cache entries.
Each entry is a tuple ``(key, file_id, media_type, thumbhash[, size])``.
The size element is optional for backward compatibility with callers
that don't yet track upload sizes.
"""
if not entries:
return
if self._data is None:
self._data = {"files": {}}
now_iso = datetime.now(timezone.utc).isoformat()
for key, file_id, media_type, thumbhash in entries:
for item in entries:
if len(item) == 5:
key, file_id, media_type, thumbhash, size = item
else:
key, file_id, media_type, thumbhash = item
size = None
entry: dict[str, Any] = {
"file_id": file_id,
"type": media_type,
@@ -120,6 +143,8 @@ class TelegramFileCache:
}
if thumbhash is not None:
entry["thumbhash"] = thumbhash
if size is not None:
entry["size"] = size
self._data["files"][key] = entry
await self._backend.save(self._data)
@@ -6,6 +6,7 @@ import asyncio
import json
import logging
import mimetypes
from dataclasses import dataclass
from typing import Any, Callable
import aiohttp
@@ -29,6 +30,36 @@ _LOGGER = logging.getLogger(__name__)
NotificationResult = dict[str, Any]
@dataclass(frozen=True)
class _MediaKind:
"""Describes one Telegram media kind (photo / video / document).
Used by the generic _send_from_cache / _upload_media helpers so the three
send paths don't have to duplicate endpoint, field-name, or response-shape
boilerplate.
"""
api_method: str # "sendPhoto" / "sendVideo" / "sendDocument"
form_field: str # "photo" / "video" / "document"
cache_type: str # same string stored in cache entries
default_filename: str # "photo.jpg" / "video.mp4" / "file"
default_content_type: str
def file_id_from_result(self, result: dict[str, Any]) -> str | None:
obj = result.get(self.form_field)
if isinstance(obj, list) and obj:
# sendPhoto returns a list of resolutions; the largest is last.
last = obj[-1]
return last.get("file_id") if isinstance(last, dict) else None
if isinstance(obj, dict):
return obj.get("file_id")
return None
_PHOTO_KIND = _MediaKind("sendPhoto", "photo", "photo", "photo.jpg", "image/jpeg")
_VIDEO_KIND = _MediaKind("sendVideo", "video", "video", "video.mp4", "video/mp4")
_DOCUMENT_KIND = _MediaKind("sendDocument", "document", "document", "file", "application/octet-stream")
class TelegramClient:
"""Async Telegram Bot API client for sending notifications with media."""
@@ -76,6 +107,94 @@ class TelegramClient:
is_asset = is_asset_cache_key(key)
return self._asset_cache if is_asset else self._url_cache
async def _fetch_bytes(
self,
url: str,
headers: dict[str, str] | None,
preloaded: bytes | None,
) -> tuple[bytes | None, str | None]:
"""Return ``(data, error_msg)``. Uses ``preloaded`` bytes if provided."""
if preloaded is not None:
return preloaded, None
try:
async with self._session.get(self._resolve_url(url), headers=headers or {}) as resp:
if resp.status != 200:
return None, f"HTTP {resp.status}"
return await resp.read(), None
except aiohttp.ClientError as err:
return None, str(err)
async def _send_from_cache(
self,
kind: _MediaKind,
chat_id: str,
file_id: str,
caption: str | None,
reply_to_message_id: int | None,
parse_mode: str,
) -> NotificationResult | None:
"""POST a file_id reference. Return None on transient error so the
caller can fall through to a fresh upload."""
payload: dict[str, Any] = {"chat_id": chat_id, kind.form_field: file_id, "parse_mode": parse_mode}
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
try:
async with self._session.post(telegram_url, json=payload) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {
"success": True,
"message_id": result.get("result", {}).get("message_id"),
"cached": True,
}
except aiohttp.ClientError:
pass
return None
async def _upload_media(
self,
kind: _MediaKind,
chat_id: str,
data: bytes,
filename: str,
content_type: str,
caption: str | None,
reply_to_message_id: int | None,
parse_mode: str,
cache: TelegramFileCache | None,
cache_key: str | None,
thumbhash: str | None,
) -> NotificationResult:
"""Multipart-upload ``data`` to Telegram and cache the returned file_id."""
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field(kind.form_field, data, filename=filename, content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
try:
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
res = result.get("result", {})
file_id = kind.file_id_from_result(res)
if file_id and cache and cache_key:
await cache.async_set(
cache_key, file_id, kind.cache_type,
thumbhash=thumbhash, size=len(data),
)
return {"success": True, "message_id": res.get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def send_notification(
self,
chat_id: str,
@@ -107,6 +226,7 @@ class TelegramClient:
parse_mode, max_asset_data_size, send_large_photos_as_documents,
assets[0].get("content_type"), assets[0].get("cache_key"),
download_headers=assets[0].get("headers"),
preloaded_data=assets[0].get("data"),
)
if len(assets) == 1 and assets[0].get("type") == "video":
return await self._send_video(
@@ -114,28 +234,31 @@ class TelegramClient:
parse_mode, max_asset_data_size,
assets[0].get("content_type"), assets[0].get("cache_key"),
download_headers=assets[0].get("headers"),
preloaded_data=assets[0].get("data"),
)
if len(assets) == 1 and assets[0].get("type", "document") == "document":
url = assets[0].get("url")
if not url:
return {"success": False, "error": "Missing 'url' for document"}
try:
download_url = self._resolve_url(url)
dl_headers = assets[0].get("headers") or {}
async with self._session.get(download_url, headers=dl_headers) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
data = await resp.read()
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": f"Media size exceeds limit"}
filename = url.split("/")[-1].split("?")[0] or "file"
return await self._send_document(
chat_id, data, filename, caption, reply_to_message_id,
parse_mode, url, assets[0].get("content_type"),
assets[0].get("cache_key"),
)
except aiohttp.ClientError as err:
return {"success": False, "error": f"Failed to download media: {err}"}
data = assets[0].get("data")
if data is None:
try:
download_url = self._resolve_url(url)
dl_headers = assets[0].get("headers") or {}
async with self._session.get(download_url, headers=dl_headers) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
data = await resp.read()
except aiohttp.ClientError as err:
return {"success": False, "error": f"Failed to download media: {err}"}
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": f"Media size exceeds limit"}
filename = url.split("/")[-1].split("?")[0] or "file"
return await self._send_document(
chat_id, data, filename, caption, reply_to_message_id,
parse_mode, url, assets[0].get("content_type"),
assets[0].get("cache_key"),
)
return await self._send_media_group(
chat_id, assets, caption, reply_to_message_id, max_group_size,
@@ -211,133 +334,85 @@ class TelegramClient:
max_asset_data_size: int | None = None, send_large_photos_as_documents: bool = False,
content_type: str | None = None, cache_key: str | None = None,
download_headers: dict[str, str] | None = None,
preloaded_data: bytes | None = None,
) -> NotificationResult:
if not content_type:
content_type = "image/jpeg"
if not url:
return {"success": False, "error": "Missing 'url' for photo"}
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key)
# Check cache
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
cache, key, thumbhash = self._get_cache_and_key(url, cache_key)
cached = cache.get(key, thumbhash=thumbhash) if cache and key else None
if cached and cached.get("file_id"):
payload = {"chat_id": chat_id, "photo": cached["file_id"], "parse_mode": parse_mode}
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
try:
async with self._session.post(telegram_url, json=payload) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
except aiohttp.ClientError:
pass
cached_result = await self._send_from_cache(
_PHOTO_KIND, chat_id, cached["file_id"],
caption, reply_to_message_id, parse_mode,
)
if cached_result is not None:
return cached_result
try:
download_url = self._resolve_url(url)
async with self._session.get(download_url, headers=download_headers or {}) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download photo: HTTP {resp.status}"}
data = await resp.read()
data, err = await self._fetch_bytes(url, download_headers, preloaded_data)
if data is None:
return {"success": False, "error": f"Failed to download photo: {err}"}
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": "Photo exceeds size limit", "skipped": True}
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": "Photo exceeds size limit", "skipped": True}
exceeds_limits, reason, _, _ = check_photo_limits(data)
if exceeds_limits:
if send_large_photos_as_documents:
return await self._send_document(chat_id, data, "photo.jpg", caption, reply_to_message_id, parse_mode, url, None, cache_key)
return {"success": False, "error": f"Photo {reason}", "skipped": True}
exceeds_limits, reason, _, _ = check_photo_limits(data)
if exceeds_limits:
if send_large_photos_as_documents:
return await self._send_document(
chat_id, data, "photo.jpg", caption, reply_to_message_id,
parse_mode, url, None, cache_key,
)
return {"success": False, "error": f"Photo {reason}", "skipped": True}
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field("photo", data, filename="photo.jpg", content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
photos = result.get("result", {}).get("photo", [])
if photos and effective_cache and effective_cache_key:
file_id = photos[-1].get("file_id")
if file_id:
await effective_cache.async_set(effective_cache_key, file_id, "photo", thumbhash=effective_thumbhash)
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
return await self._upload_media(
_PHOTO_KIND, chat_id, data,
_PHOTO_KIND.default_filename,
content_type or _PHOTO_KIND.default_content_type,
caption, reply_to_message_id, parse_mode,
cache, key, thumbhash,
)
async def _send_video(
self, chat_id: str, url: str | None, caption: str | None = None,
reply_to_message_id: int | None = None, parse_mode: str = "HTML",
max_asset_data_size: int | None = None, content_type: str | None = None,
cache_key: str | None = None, download_headers: dict[str, str] | None = None,
preloaded_data: bytes | None = None,
) -> NotificationResult:
if not content_type:
content_type = "video/mp4"
if not url:
return {"success": False, "error": "Missing 'url' for video"}
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key)
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
cache, key, thumbhash = self._get_cache_and_key(url, cache_key)
cached = cache.get(key, thumbhash=thumbhash) if cache and key else None
if cached and cached.get("file_id"):
payload = {"chat_id": chat_id, "video": cached["file_id"], "parse_mode": parse_mode}
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
try:
async with self._session.post(telegram_url, json=payload) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
except aiohttp.ClientError:
pass
cached_result = await self._send_from_cache(
_VIDEO_KIND, chat_id, cached["file_id"],
caption, reply_to_message_id, parse_mode,
)
if cached_result is not None:
return cached_result
try:
download_url = self._resolve_url(url)
async with self._session.get(download_url, headers=download_headers or {}) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download video: HTTP {resp.status}"}
data = await resp.read()
data, err = await self._fetch_bytes(url, download_headers, preloaded_data)
if data is None:
return {"success": False, "error": f"Failed to download video: {err}"}
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": "Video exceeds size limit", "skipped": True}
if len(data) > TELEGRAM_MAX_VIDEO_SIZE:
return {"success": False, "error": f"Video exceeds Telegram's {TELEGRAM_MAX_VIDEO_SIZE // (1024*1024)} MB limit", "skipped": True}
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": "Video exceeds size limit", "skipped": True}
if len(data) > TELEGRAM_MAX_VIDEO_SIZE:
return {
"success": False,
"error": f"Video exceeds Telegram's {TELEGRAM_MAX_VIDEO_SIZE // (1024*1024)} MB limit",
"skipped": True,
}
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field("video", data, filename="video.mp4", content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
video = result.get("result", {}).get("video", {})
if video and effective_cache and effective_cache_key:
file_id = video.get("file_id")
if file_id:
await effective_cache.async_set(effective_cache_key, file_id, "video", thumbhash=effective_thumbhash)
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
return await self._upload_media(
_VIDEO_KIND, chat_id, data,
_VIDEO_KIND.default_filename,
content_type or _VIDEO_KIND.default_content_type,
caption, reply_to_message_id, parse_mode,
cache, key, thumbhash,
)
async def _send_document(
self, chat_id: str, data: bytes, filename: str = "file",
@@ -348,50 +423,24 @@ class TelegramClient:
if not content_type:
content_type, _ = mimetypes.guess_type(filename)
if not content_type:
content_type = "application/octet-stream"
content_type = _DOCUMENT_KIND.default_content_type
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(source_url, cache_key)
cache, key, thumbhash = self._get_cache_and_key(source_url, cache_key)
if cache and key:
cached = cache.get(key, thumbhash=thumbhash)
if cached and cached.get("file_id") and cached.get("type") == _DOCUMENT_KIND.cache_type:
cached_result = await self._send_from_cache(
_DOCUMENT_KIND, chat_id, cached["file_id"],
caption, reply_to_message_id, parse_mode,
)
if cached_result is not None:
return cached_result
if effective_cache and effective_cache_key:
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash)
if cached and cached.get("file_id") and cached.get("type") == "document":
payload = {"chat_id": chat_id, "document": cached["file_id"], "parse_mode": parse_mode}
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
try:
async with self._session.post(telegram_url, json=payload) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
except aiohttp.ClientError:
pass
try:
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field("document", data, filename=filename, content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
if effective_cache_key and effective_cache:
document = result.get("result", {}).get("document", {})
file_id = document.get("file_id")
if file_id:
await effective_cache.async_set(effective_cache_key, file_id, "document", thumbhash=effective_thumbhash)
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
return await self._upload_media(
_DOCUMENT_KIND, chat_id, data, filename, content_type,
caption, reply_to_message_id, parse_mode,
cache, key, thumbhash,
)
async def _send_media_group(
self, chat_id: str, assets: list[dict[str, str]],
@@ -411,9 +460,9 @@ class TelegramClient:
chunk_caption = caption if chunk_idx == 0 else None
chunk_reply = reply_to_message_id if chunk_idx == 0 else None
if item.get("type") == "photo":
result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"))
result = await self._send_photo(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, send_large_photos_as_documents, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"), preloaded_data=item.get("data"))
elif item.get("type") == "video":
result = await self._send_video(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"))
result = await self._send_video(chat_id, item.get("url"), chunk_caption, chunk_reply, parse_mode, max_asset_data_size, item.get("content_type"), item.get("cache_key"), download_headers=item.get("headers"), preloaded_data=item.get("data"))
else:
continue
if not result.get("success"):
@@ -433,7 +482,8 @@ class TelegramClient:
# Track cache info per media_json entry (in order) so we can map
# Telegram response items back to cache keys for newly uploaded items.
# None = already cached (no need to store), tuple = needs caching.
media_cache_info: list[tuple[str, str, str | None] | None] = []
# Tuple is (cache_key, media_type, thumbhash, uploaded_size).
media_cache_info: list[tuple[str, str, str | None, int] | None] = []
# Resolve cache hits and collect download tasks in parallel
async def _fetch_asset(idx: int, item: dict) -> tuple[int, dict | None, bytes | None]:
@@ -454,6 +504,20 @@ class TelegramClient:
if cached and cached.get("file_id"):
return idx, cached, None
# Use preloaded bytes if the dispatcher already fetched them
preloaded = item.get("data")
if preloaded is not None:
data = preloaded
if max_asset_data_size and len(data) > max_asset_data_size:
return idx, None, None
if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE:
return idx, None, None
if media_type == "photo":
exceeds, _, _, _ = check_photo_limits(data)
if exceeds:
return idx, None, None
return idx, None, data
try:
download_url = self._resolve_url(url)
dl_headers = item.get("headers") or {}
@@ -500,7 +564,7 @@ class TelegramClient:
ck_is_asset = is_asset_cache_key(ck)
bare_ck = asset_id_from_cache_key(ck) if ck_is_asset else ck
th = self._thumbhash_resolver(bare_ck) if ck_is_asset and self._thumbhash_resolver else None
media_cache_info.append((ck, media_type, th))
media_cache_info.append((ck, media_type, th, len(data)))
else:
continue
@@ -523,14 +587,14 @@ class TelegramClient:
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
# Cache file_ids from response — map by position
cache_entries: list[tuple[str, str, str, str | None]] = []
cache_entries: list[tuple[str, str, str, str | None, int | None]] = []
for i, msg in enumerate(result_msgs):
if i >= len(media_cache_info):
break
info = media_cache_info[i]
if info is None:
continue # was a cache hit, skip
ck, mt, th = info
ck, mt, th, sz = info
file_id = None
if msg.get("photo"):
file_id = msg["photo"][-1].get("file_id")
@@ -539,7 +603,7 @@ class TelegramClient:
elif msg.get("document"):
file_id = msg["document"].get("file_id")
if file_id:
cache_entries.append((ck, file_id, mt, th))
cache_entries.append((ck, file_id, mt, th, sz))
if cache_entries:
# All entries in a chunk share the same cache backend
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
@@ -568,6 +632,18 @@ class TelegramClient:
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def get_chat(self, chat_id: str) -> dict[str, Any]:
"""Call getChat to fetch up-to-date chat metadata (title, username, type, etc.)."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getChat"
try:
async with self._session.post(url, json={"chat_id": chat_id}) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True, "result": data.get("result", {})}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def get_webhook_info(self) -> dict[str, Any]:
"""Call getWebhookInfo to check current webhook status."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getWebhookInfo"
@@ -132,8 +132,10 @@ class ImmichActionExecutor(ActionExecutor):
target_album_ids = [single]
try:
# Step 1: Gather candidate assets from criteria
candidate_ids = await self._gather_candidates(criteria)
# Step 1: Gather candidate assets from criteria. Asset type is
# kept alongside the id so we can pick the first *photo* (not a
# video) as an album thumbnail when one is missing.
candidate_ids, types_by_id = await self._gather_candidates(criteria)
if not candidate_ids:
return RuleResult(
@@ -146,6 +148,7 @@ class ImmichActionExecutor(ActionExecutor):
)
# If no target albums and create_if_missing, create one
album_created_now: set[str] = set()
if not target_album_ids and create_if_missing and create_album_name:
if dry_run:
_LOGGER.info("[DRY RUN] Would create album '%s'", create_album_name)
@@ -153,6 +156,8 @@ class ImmichActionExecutor(ActionExecutor):
else:
created = await self._client.create_album(create_album_name)
target_album_ids = [created.get("id", "")]
if target_album_ids[0]:
album_created_now.add(target_album_ids[0])
_LOGGER.info("Created album '%s' with id %s", create_album_name, target_album_ids[0])
if not target_album_ids:
@@ -169,6 +174,7 @@ class ImmichActionExecutor(ActionExecutor):
for album_id in target_album_ids:
album_asset_ids: set[str] = set()
needs_thumbnail = album_id in album_created_now
if album_id and album_id != "__dry_run_new__":
album = await self._client.get_album(album_id)
@@ -176,27 +182,56 @@ class ImmichActionExecutor(ActionExecutor):
if not dry_run:
created = await self._client.create_album(create_album_name)
album_id = created.get("id", album_id)
album_created_now.add(album_id)
needs_thumbnail = True
_LOGGER.info("Created album '%s' with id %s", create_album_name, album_id)
elif album is None:
album_details.append({"album_id": album_id, "error": "not found"})
continue
elif album is not None:
album_asset_ids = set(album.asset_ids)
if not album.thumbnail_asset_id:
needs_thumbnail = True
new_asset_ids = [aid for aid in candidate_ids if aid not in album_asset_ids]
skipped = len(candidate_ids) - len(new_asset_ids)
thumbnail_set_id: str | None = None
if new_asset_ids and not dry_run and album_id:
for i in range(0, len(new_asset_ids), 500):
batch = new_asset_ids[i : i + 500]
await self._client.add_assets_to_album(album_id, batch)
_LOGGER.info("Added %d assets to album %s", len(new_asset_ids), album_id)
# Best-effort: give newly-created/empty-thumbnail albums a
# cover. Prefer the first image; fall back to the first
# added asset of any type if none are images (Immich renders
# a video poster, which still looks fine). Failures here
# must not fail the rule — the add already succeeded.
if needs_thumbnail:
pick = next(
(aid for aid in new_asset_ids if (types_by_id.get(aid) or "").lower() == "image"),
None,
) or new_asset_ids[0]
try:
await self._client.set_album_thumbnail(album_id, pick)
thumbnail_set_id = pick
_LOGGER.info("Set thumbnail of album %s to %s", album_id, pick)
except ImmichApiError as err:
_LOGGER.warning(
"Could not set thumbnail for album %s: %s", album_id, err
)
elif dry_run and new_asset_ids:
_LOGGER.info("[DRY RUN] Would add %d assets to album %s", len(new_asset_ids), album_id)
if needs_thumbnail:
_LOGGER.info("[DRY RUN] Would set album %s thumbnail to first added asset", album_id)
total_affected += len(new_asset_ids)
total_skipped += skipped
album_details.append({"album_id": album_id, "added": len(new_asset_ids), "skipped": skipped})
detail = {"album_id": album_id, "added": len(new_asset_ids), "skipped": skipped}
if thumbnail_set_id:
detail["thumbnail_set_to"] = thumbnail_set_id
album_details.append(detail)
return RuleResult(
rule_name=rule_name,
@@ -228,10 +263,16 @@ class ImmichActionExecutor(ActionExecutor):
async def _gather_candidates(
self, criteria: dict[str, Any]
) -> list[str]:
"""Gather asset IDs matching the criteria (union of all sources)."""
) -> tuple[list[str], dict[str, str]]:
"""Gather asset IDs matching the criteria (union of all sources).
Returns ``(ordered_ids, types_by_id)`` so callers that need asset
type — e.g. picking a photo for an album thumbnail — don't have to
re-fetch each asset.
"""
seen: set[str] = set()
result: list[str] = []
types_by_id: dict[str, str] = {}
# Source 1: Person assets
person_ids = criteria.get("person_ids", [])
@@ -243,6 +284,7 @@ class ImmichActionExecutor(ActionExecutor):
if self._matches_filters(asset, criteria):
seen.add(aid)
result.append(aid)
types_by_id[aid] = asset.get("type", "") or ""
# Source 2: Smart search
query = criteria.get("query", "")
@@ -254,6 +296,7 @@ class ImmichActionExecutor(ActionExecutor):
if self._matches_filters(asset, criteria):
seen.add(aid)
result.append(aid)
types_by_id[aid] = asset.get("type", "") or ""
# Exclude assets belonging to excluded persons
exclude_person_ids = criteria.get("exclude_person_ids", [])
@@ -266,8 +309,12 @@ class ImmichActionExecutor(ActionExecutor):
if aid:
excluded_asset_ids.add(aid)
result = [aid for aid in result if aid not in excluded_asset_ids]
for aid in list(types_by_id):
if aid not in excluded_asset_ids:
continue
types_by_id.pop(aid, None)
return result
return result, types_by_id
def _matches_filters(
self, asset: dict[str, Any], criteria: dict[str, Any]
@@ -243,6 +243,16 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
except (ValueError, AttributeError):
created_at = datetime.now(timezone.utc)
# preview_url is what the notification dispatcher feeds to Telegram as the
# actual media bytes — for videos it must be the transcoded playback (mp4),
# not the JPEG thumbnail, or Telegram receives a JPEG labeled as video/mp4.
if asset.type == ASSET_TYPE_VIDEO:
preview_url = f"{external_url}/api/assets/{asset.id}/video/playback"
full_url = f"{external_url}/api/assets/{asset.id}/original"
else:
preview_url = f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview"
full_url = f"{external_url}/api/assets/{asset.id}/original"
return MediaAsset(
id=asset.id,
type=media_type,
@@ -252,8 +262,8 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
description=asset.description or None,
tags=list(asset.people),
thumbnail_url=f"{external_url}/api/assets/{asset.id}/thumbnail",
preview_url=f"{external_url}/api/assets/{asset.id}/thumbnail?size=preview",
full_url=f"{external_url}/api/assets/{asset.id}/original",
preview_url=preview_url,
full_url=full_url,
extra={
"owner_id": asset.owner_id,
"is_favorite": asset.is_favorite,
@@ -264,7 +274,11 @@ def asset_to_media(asset: ImmichAssetInfo, external_url: str) -> MediaAsset:
"state": asset.state,
"country": asset.country,
"thumbhash": asset.thumbhash,
# file_size = original asset bytes (from exifInfo.fileSizeInByte).
# playback_size = bytes we will actually upload (videos: transcoded
# /video/playback). Populated lazily at dispatch time via HEAD.
"file_size": asset.file_size,
"playback_size": None,
},
)
@@ -235,8 +235,9 @@ class ImmichClient:
query: str,
album_ids: list[str] | None = None,
limit: int = 10,
page: int = 1,
) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"query": query, "page": 1, "size": limit}
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": limit}
if album_ids:
payload["albumIds"] = album_ids
try:
@@ -258,8 +259,9 @@ class ImmichClient:
query: str,
album_ids: list[str] | None = None,
limit: int = 10,
page: int = 1,
) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"originalFileName": query, "page": 1, "size": limit}
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": limit}
if album_ids:
payload["albumIds"] = album_ids
try:
@@ -279,14 +281,28 @@ class ImmichClient:
async def search_by_person(
self, person_id: str, limit: int = 10
) -> list[dict[str, Any]]:
"""Fetch up to ``limit`` assets tagged with ``person_id``.
Uses ``POST /api/search/metadata`` with ``personIds`` — the public
``GET /api/people/{id}/assets`` endpoint was removed from Immich
around v1.106 and now silently 404s, which is why this method used
to return an empty list on current servers.
"""
payload: dict[str, Any] = {
"personIds": [person_id],
"page": 1,
"size": max(1, min(limit, 100)),
}
try:
async with self._session.get(
f"{self._url}/api/people/{person_id}/assets",
headers=self._headers,
async with self._session.post(
f"{self._url}/api/search/metadata",
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
data = await response.json()
return data[:limit] if isinstance(data, list) else []
items = data.get("assets", {}).get("items", [])
return items[:limit]
except aiohttp.ClientError:
pass
return []
@@ -329,7 +345,15 @@ class ImmichClient:
async def add_assets_to_album(
self, album_id: str, asset_ids: list[str]
) -> dict[str, Any]:
"""Add assets to an album. Returns API response with success/error arrays."""
"""Add assets to an album. Returns API response with success/error arrays.
Immich returns 200 with a per-asset array even when some IDs fail
individually (already in album, not found, etc). Partial failures
are data, not errors — surface them as the normal return value.
Non-2xx responses include Immich's error body in the raised message
so callers and logs see the real reason (bad UUIDs, stale album,
permission, etc.) instead of just the HTTP status code.
"""
payload = {"ids": asset_ids}
try:
async with self._session.put(
@@ -337,14 +361,51 @@ class ImmichClient:
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
return await response.json()
body_text = await response.text()
if response.status in (200, 201):
try:
parsed = await response.json(content_type=None)
except Exception: # noqa: BLE001 — malformed body, still 200
return {"raw": body_text}
# Per-asset array is the typical shape; wrap for consistency.
if isinstance(parsed, list):
return {"results": parsed}
if isinstance(parsed, dict):
return parsed
return {"raw": body_text}
raise ImmichApiError(
f"Failed to add assets to album {album_id}: HTTP {response.status}"
f"Failed to add assets to album {album_id}: "
f"HTTP {response.status} body={body_text[:512]}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error adding assets to album: {err}") from err
async def set_album_thumbnail(
self, album_id: str, asset_id: str
) -> None:
"""Set an album's cover/thumbnail to the given asset.
Uses ``PATCH /api/albums/{id}`` with ``albumThumbnailAssetId``.
Raises ``ImmichApiError`` on non-2xx so callers can treat it as
best-effort and log.
"""
payload = {"albumThumbnailAssetId": asset_id}
try:
async with self._session.patch(
f"{self._url}/api/albums/{album_id}",
headers=self._json_headers,
json=payload,
) as response:
if response.status in (200, 201, 204):
return
body_text = await response.text()
raise ImmichApiError(
f"Failed to set album thumbnail for {album_id}: "
f"HTTP {response.status} body={body_text[:512]}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error setting album thumbnail: {err}") from err
async def remove_assets_from_album(
self, album_id: str, asset_ids: list[str]
) -> dict[str, Any]:
@@ -386,22 +447,49 @@ class ImmichClient:
raise ImmichApiError(f"Error creating album: {err}") from err
async def get_person_assets_all(self, person_id: str) -> list[dict[str, Any]]:
"""Fetch ALL assets for a person (no limit)."""
try:
async with self._session.get(
f"{self._url}/api/people/{person_id}/assets",
headers=self._headers,
) as response:
if response.status == 200:
data = await response.json()
return data if isinstance(data, list) else []
if response.status == 404:
return []
raise ImmichApiError(
f"Failed to fetch person {person_id} assets: HTTP {response.status}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error fetching person assets: {err}") from err
"""Fetch ALL assets tagged with a person (paginated, no soft cap).
Uses ``POST /api/search/metadata`` with ``personIds``. The legacy
``GET /api/people/{id}/assets`` endpoint was removed from Immich
around v1.106 and returns 404 on current servers — switching to
the search endpoint is the only way to get person-filtered assets
from modern Immich.
"""
all_items: list[dict[str, Any]] = []
page = 1
page_size = 100
max_pages = 1000 # hard cap to avoid runaway loops if server misbehaves
while page <= max_pages:
payload: dict[str, Any] = {
"personIds": [person_id],
"page": page,
"size": page_size,
}
try:
async with self._session.post(
f"{self._url}/api/search/metadata",
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
if not items:
break
all_items.extend(items)
if len(items) < page_size:
break
page += 1
continue
if response.status == 404:
# Person doesn't exist — return empty rather than raising
return all_items
raise ImmichApiError(
f"Failed to fetch person {person_id} assets: HTTP {response.status}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error fetching person assets: {err}") from err
return all_items
async def search_smart_all(
self, query: str, limit: int = 1000
@@ -64,6 +64,8 @@ def build_template_context(
# Flatten extras into asset dict for template access
asset_dict.update(asset.extra)
asset_dict.setdefault("oversized", False)
asset_dict.setdefault("file_size", None)
asset_dict.setdefault("playback_size", None)
assets.append(asset_dict)
# Enrich assets with per-asset public URLs if album has a public share link
@@ -87,12 +89,16 @@ def build_template_context(
ctx["max_video_size"] = max_video_bytes # bytes or None
ctx["max_video_size_mb"] = max_video_bytes // (1024 * 1024) if max_video_bytes else None
# Oversize check uses playback_size (bytes we actually upload). file_size
# (original asset size) is informational only — for providers that transcode
# before sending (e.g. Immich /video/playback), original can be much larger
# than what reaches Telegram, so it would false-positive the warning.
has_oversized = False
if max_video_bytes:
for a in assets:
if a.get("type") == "VIDEO":
fs = a.get("file_size")
oversized = fs is not None and fs > max_video_bytes
size = a.get("playback_size")
oversized = size is not None and size > max_video_bytes
a["oversized"] = oversized
if oversized:
has_oversized = True