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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user