Replace TTL with thumbhash-based cache validation and add Telegram video size limits
Some checks failed
Validate / Hassfest (push) Has been cancelled
Some checks failed
Validate / Hassfest (push) Has been cancelled
- Asset cache now validates entries by comparing stored thumbhash with current Immich thumbhash instead of using TTL expiration. This makes cache invalidation precise (only when content actually changes) and eliminates unnecessary re-uploads. URL-based cache retains TTL for non-Immich URLs. - Add TELEGRAM_MAX_VIDEO_SIZE (50 MB) check to skip oversized videos in both single-video and media-group paths, preventing entire groups from failing. - Split media groups into sub-groups by cumulative upload size to ensure each sendMediaGroup request stays under Telegram's 50 MB upload limit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
51
README.md
51
README.md
@@ -334,15 +334,29 @@ data:
|
||||
|
||||
Send notifications to Telegram. Supports multiple formats:
|
||||
|
||||
- **Text message** - When `urls` is empty or not provided
|
||||
- **Single document** - When `urls` contains one document (default type)
|
||||
- **Single photo** - When `urls` contains one photo (`type: photo`)
|
||||
- **Single video** - When `urls` contains one video (`type: video`)
|
||||
- **Media group** - When `urls` contains multiple photos/videos (documents are sent separately)
|
||||
- **Text message** - When `assets` is empty or not provided
|
||||
- **Single document** - When `assets` contains one document (default type)
|
||||
- **Single photo** - When `assets` contains one photo (`type: photo`)
|
||||
- **Single video** - When `assets` contains one video (`type: video`)
|
||||
- **Media group** - When `assets` contains multiple photos/videos (documents are sent separately)
|
||||
|
||||
The service downloads media from Immich and uploads it to Telegram, bypassing any CORS restrictions. Large lists of photos and videos are automatically split into multiple media groups based on the `max_group_size` parameter (default: 10 items per group). Documents cannot be grouped and are sent individually.
|
||||
|
||||
**File ID Caching:** When media is uploaded to Telegram, the service caches the returned `file_id`. Subsequent sends of the same media will use the cached `file_id` instead of re-uploading, significantly improving performance. The cache TTL is configurable in hub options (default: 48 hours, range: 1-168 hours). The cache is persistent across Home Assistant restarts and is stored per album.
|
||||
**File ID Caching:** When media is uploaded to Telegram, the service caches the returned `file_id`. Subsequent sends of the same media will use the cached `file_id` instead of re-uploading, significantly improving performance. The cache TTL is configurable in hub options (default: 48 hours, range: 1-168 hours). The cache is persistent across Home Assistant restarts and is shared across all albums in the hub.
|
||||
|
||||
**Dual Cache System:** The integration maintains two separate caches for optimal performance:
|
||||
|
||||
- **Asset ID Cache** - For Immich assets with extractable asset IDs (UUIDs). The same asset accessed via different URL types (thumbnail, original, video playback, share links) shares the same cache entry.
|
||||
- **URL Cache** - For non-Immich URLs or URLs without extractable asset IDs. Also used when a custom `cache_key` is provided.
|
||||
|
||||
**Smart Cache Keys:** The service automatically extracts asset IDs from Immich URLs. Supported URL patterns:
|
||||
|
||||
- `/api/assets/{asset_id}/original`
|
||||
- `/api/assets/{asset_id}/thumbnail`
|
||||
- `/api/assets/{asset_id}/video/playback`
|
||||
- `/share/{key}/photos/{asset_id}`
|
||||
|
||||
You can provide a custom `cache_key` per asset to override this behavior (stored in URL cache).
|
||||
|
||||
**Examples:**
|
||||
|
||||
@@ -366,7 +380,7 @@ target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
urls:
|
||||
assets:
|
||||
- url: "https://immich.example.com/api/assets/xxx/original?key=yyy"
|
||||
content_type: "image/heic" # Optional: explicit MIME type
|
||||
caption: "Original file"
|
||||
@@ -380,7 +394,7 @@ target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
urls:
|
||||
assets:
|
||||
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||
type: photo
|
||||
caption: "Beautiful sunset!"
|
||||
@@ -394,7 +408,7 @@ target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
urls:
|
||||
assets:
|
||||
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||
type: photo
|
||||
- url: "https://immich.example.com/api/assets/zzz/video/playback?key=yyy"
|
||||
@@ -426,17 +440,32 @@ target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
urls:
|
||||
assets:
|
||||
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||
type: photo
|
||||
caption: "Quick notification"
|
||||
wait_for_response: false # Automation continues immediately
|
||||
```
|
||||
|
||||
Using custom cache_key (useful when same media has different URLs):
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
assets:
|
||||
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||
type: photo
|
||||
cache_key: "asset_xxx" # Custom key for caching instead of URL
|
||||
caption: "Photo with custom cache key"
|
||||
```
|
||||
|
||||
| Field | Description | Required |
|
||||
|-------|-------------|----------|
|
||||
| `chat_id` | Telegram chat ID to send to | Yes |
|
||||
| `urls` | List of media items with `url`, optional `type` (document/photo/video, default: document), and optional `content_type` (MIME type, e.g., `image/jpeg`). Empty for text message. Photos and videos can be grouped; documents are sent separately. | No |
|
||||
| `assets` | List of media items with `url`, optional `type` (document/photo/video, default: document), optional `content_type` (MIME type, e.g., `image/jpeg`), and optional `cache_key` (custom key for caching). Empty for text message. Photos and videos can be grouped; documents are sent separately. | No |
|
||||
| `bot_token` | Telegram bot token (uses configured token if not provided) | No |
|
||||
| `caption` | For media: caption applied to first item. For text: the message text. Supports HTML formatting by default. | No |
|
||||
| `reply_to_message_id` | Message ID to reply to | No |
|
||||
|
||||
@@ -73,11 +73,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo
|
||||
storage = ImmichAlbumStorage(hass, entry.entry_id)
|
||||
await storage.async_load()
|
||||
|
||||
# Create and load Telegram file caches once per hub (shared across all albums)
|
||||
# TTL is in hours from config, convert to seconds
|
||||
cache_ttl_seconds = telegram_cache_ttl * 60 * 60
|
||||
# URL-based cache for non-Immich URLs or URLs without extractable asset IDs
|
||||
telegram_cache = TelegramFileCache(hass, entry.entry_id, ttl_seconds=cache_ttl_seconds)
|
||||
await telegram_cache.async_load()
|
||||
# Asset ID-based cache for Immich URLs — uses thumbhash validation instead of TTL
|
||||
telegram_asset_cache = TelegramFileCache(
|
||||
hass, f"{entry.entry_id}_assets", use_thumbhash=True
|
||||
)
|
||||
await telegram_asset_cache.async_load()
|
||||
|
||||
# Store hub reference
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
"hub": entry.runtime_data,
|
||||
"subentries": {},
|
||||
"storage": storage,
|
||||
"telegram_cache": telegram_cache,
|
||||
"telegram_asset_cache": telegram_asset_cache,
|
||||
}
|
||||
|
||||
# Track loaded subentries to detect changes
|
||||
@@ -109,15 +123,11 @@ async def _async_setup_subentry_coordinator(
|
||||
album_id = subentry.data[CONF_ALBUM_ID]
|
||||
album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||
storage: ImmichAlbumStorage = hass.data[DOMAIN][entry.entry_id]["storage"]
|
||||
telegram_cache: TelegramFileCache = hass.data[DOMAIN][entry.entry_id]["telegram_cache"]
|
||||
telegram_asset_cache: TelegramFileCache = hass.data[DOMAIN][entry.entry_id]["telegram_asset_cache"]
|
||||
|
||||
_LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id)
|
||||
|
||||
# Create and load Telegram file cache for this album
|
||||
# TTL is in hours from config, convert to seconds
|
||||
cache_ttl_seconds = hub_data.telegram_cache_ttl * 60 * 60
|
||||
telegram_cache = TelegramFileCache(hass, album_id, ttl_seconds=cache_ttl_seconds)
|
||||
await telegram_cache.async_load()
|
||||
|
||||
# Create coordinator for this album
|
||||
coordinator = ImmichAlbumWatcherCoordinator(
|
||||
hass,
|
||||
@@ -129,6 +139,7 @@ async def _async_setup_subentry_coordinator(
|
||||
hub_name=hub_data.name,
|
||||
storage=storage,
|
||||
telegram_cache=telegram_cache,
|
||||
telegram_asset_cache=telegram_asset_cache,
|
||||
)
|
||||
|
||||
# Load persisted state before first refresh to detect changes during downtime
|
||||
|
||||
@@ -131,6 +131,7 @@ class AssetInfo:
|
||||
state: str | None = None
|
||||
country: str | None = None
|
||||
is_processed: bool = True # Whether asset is fully processed by Immich
|
||||
thumbhash: str | None = None # Perceptual hash for cache validation
|
||||
|
||||
@classmethod
|
||||
def from_api_response(
|
||||
@@ -169,6 +170,7 @@ class AssetInfo:
|
||||
# Check if asset is fully processed by Immich
|
||||
asset_type = data.get("type", ASSET_TYPE_IMAGE)
|
||||
is_processed = cls._check_processing_status(data, asset_type)
|
||||
thumbhash = data.get("thumbhash")
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
@@ -187,6 +189,7 @@ class AssetInfo:
|
||||
state=state,
|
||||
country=country,
|
||||
is_processed=is_processed,
|
||||
thumbhash=thumbhash,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -336,6 +339,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
hub_name: str = "Immich",
|
||||
storage: ImmichAlbumStorage | None = None,
|
||||
telegram_cache: TelegramFileCache | None = None,
|
||||
telegram_asset_cache: TelegramFileCache | None = None,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
@@ -356,6 +360,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
self._shared_links: list[SharedLinkInfo] = []
|
||||
self._storage = storage
|
||||
self._telegram_cache = telegram_cache
|
||||
self._telegram_asset_cache = telegram_asset_cache
|
||||
self._persisted_asset_ids: set[str] | None = None
|
||||
self._external_domain: str | None = None # Fetched from server config
|
||||
self._pending_asset_ids: set[str] = set() # Assets detected but not yet processed
|
||||
@@ -411,9 +416,20 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
|
||||
@property
|
||||
def telegram_cache(self) -> TelegramFileCache | None:
|
||||
"""Return the Telegram file cache."""
|
||||
"""Return the Telegram file cache (URL-based)."""
|
||||
return self._telegram_cache
|
||||
|
||||
@property
|
||||
def telegram_asset_cache(self) -> TelegramFileCache | None:
|
||||
"""Return the Telegram asset cache (asset ID-based)."""
|
||||
return self._telegram_asset_cache
|
||||
|
||||
def get_asset_thumbhash(self, asset_id: str) -> str | None:
|
||||
"""Get the current thumbhash for an asset from coordinator data."""
|
||||
if self.data and asset_id in self.data.assets:
|
||||
return self.data.assets[asset_id].thumbhash
|
||||
return None
|
||||
|
||||
def update_scan_interval(self, scan_interval: int) -> None:
|
||||
"""Update the scan interval."""
|
||||
self.update_interval = timedelta(seconds=scan_interval)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
|
||||
"requirements": [],
|
||||
"version": "2.7.1"
|
||||
"version": "2.8.0"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -47,14 +48,108 @@ from .const import (
|
||||
SERVICE_SEND_TELEGRAM_NOTIFICATION,
|
||||
)
|
||||
from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
|
||||
from .storage import TelegramFileCache
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Telegram constants
|
||||
TELEGRAM_API_BASE_URL = "https://api.telegram.org/bot"
|
||||
TELEGRAM_MAX_PHOTO_SIZE = 10 * 1024 * 1024 # 10 MB - Telegram's max photo size
|
||||
TELEGRAM_MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50 MB - Telegram's max video/document upload size
|
||||
TELEGRAM_MAX_DIMENSION_SUM = 10000 # Maximum sum of width + height in pixels
|
||||
|
||||
# Regex pattern for Immich asset ID (UUID format)
|
||||
_ASSET_ID_PATTERN = re.compile(r"^[a-f0-9-]{36}$")
|
||||
|
||||
# Regex patterns to extract asset ID from Immich URLs
|
||||
# Matches patterns like:
|
||||
# - /api/assets/{asset_id}/original
|
||||
# - /api/assets/{asset_id}/thumbnail
|
||||
# - /api/assets/{asset_id}/video/playback
|
||||
# - /share/{key}/photos/{asset_id}
|
||||
_IMMICH_ASSET_ID_PATTERNS = [
|
||||
re.compile(r"/api/assets/([a-f0-9-]{36})/(?:original|thumbnail|video)"),
|
||||
re.compile(r"/share/[^/]+/photos/([a-f0-9-]{36})"),
|
||||
]
|
||||
|
||||
|
||||
def _is_asset_id(value: str) -> bool:
|
||||
"""Check if a string is a valid Immich asset ID (UUID format).
|
||||
|
||||
Args:
|
||||
value: The string to check
|
||||
|
||||
Returns:
|
||||
True if the string matches the UUID format, False otherwise
|
||||
"""
|
||||
return bool(_ASSET_ID_PATTERN.match(value))
|
||||
|
||||
|
||||
def _extract_asset_id_from_url(url: str) -> str | None:
|
||||
"""Extract asset ID from Immich URL if possible.
|
||||
|
||||
Supports the following URL patterns:
|
||||
- /api/assets/{asset_id}/original?...
|
||||
- /api/assets/{asset_id}/thumbnail?...
|
||||
- /api/assets/{asset_id}/video/playback?...
|
||||
- /share/{key}/photos/{asset_id}
|
||||
|
||||
Args:
|
||||
url: The URL to extract asset ID from
|
||||
|
||||
Returns:
|
||||
The asset ID if found, None otherwise
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
|
||||
for pattern in _IMMICH_ASSET_ID_PATTERNS:
|
||||
match = pattern.search(url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _split_media_by_upload_size(
|
||||
media_items: list[tuple], max_upload_size: int
|
||||
) -> list[list[tuple]]:
|
||||
"""Split media items into sub-groups respecting upload size limit.
|
||||
|
||||
Cached items (file_id references) don't count toward upload size since
|
||||
they are sent as lightweight JSON references, not uploaded data.
|
||||
|
||||
Args:
|
||||
media_items: List of (media_type, media_ref, filename, cache_key, is_cached, content_type)
|
||||
max_upload_size: Maximum total upload bytes per sub-group
|
||||
|
||||
Returns:
|
||||
List of sub-groups, each a list of media_items tuples
|
||||
"""
|
||||
if not media_items:
|
||||
return []
|
||||
|
||||
groups: list[list[tuple]] = []
|
||||
current_group: list[tuple] = []
|
||||
current_upload_size = 0
|
||||
|
||||
for item in media_items:
|
||||
_, media_ref, _, _, is_cached, _ = item
|
||||
item_upload_size = 0 if is_cached else len(media_ref)
|
||||
|
||||
if current_group and current_upload_size + item_upload_size > max_upload_size:
|
||||
groups.append(current_group)
|
||||
current_group = [item]
|
||||
current_upload_size = item_upload_size
|
||||
else:
|
||||
current_group.append(item)
|
||||
current_upload_size += item_upload_size
|
||||
|
||||
if current_group:
|
||||
groups.append(current_group)
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -130,7 +225,7 @@ async def async_setup_entry(
|
||||
{
|
||||
vol.Optional("bot_token"): str,
|
||||
vol.Required("chat_id"): vol.Coerce(str),
|
||||
vol.Optional("urls"): list,
|
||||
vol.Optional("assets"): list,
|
||||
vol.Optional("caption"): str,
|
||||
vol.Optional("reply_to_message_id"): vol.Coerce(int),
|
||||
vol.Optional("disable_web_page_preview"): bool,
|
||||
@@ -242,7 +337,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
async def async_send_telegram_notification(
|
||||
self,
|
||||
chat_id: str,
|
||||
urls: list[dict[str, str]] | None = None,
|
||||
assets: list[dict[str, str]] | None = None,
|
||||
bot_token: str | None = None,
|
||||
caption: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
@@ -258,12 +353,13 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
"""Send notification to Telegram.
|
||||
|
||||
Supports:
|
||||
- Empty URLs: sends a simple text message
|
||||
- Empty assets: sends a simple text message
|
||||
- Single photo: uses sendPhoto API
|
||||
- Single video: uses sendVideo API
|
||||
- Multiple items: uses sendMediaGroup API (splits into multiple groups if needed)
|
||||
|
||||
Each item in urls should be a dict with 'url' and 'type' (photo/video).
|
||||
Each item in assets should be a dict with 'url', optional 'type' (photo/video/document),
|
||||
and optional 'cache_key' (custom key for caching instead of URL).
|
||||
Downloads media and uploads to Telegram to bypass CORS restrictions.
|
||||
|
||||
If wait_for_response is False, the task will be executed in the background
|
||||
@@ -274,7 +370,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
self.hass.async_create_task(
|
||||
self._execute_telegram_notification(
|
||||
chat_id=chat_id,
|
||||
urls=urls,
|
||||
assets=assets,
|
||||
bot_token=bot_token,
|
||||
caption=caption,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
@@ -292,7 +388,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Blocking mode - execute and return result
|
||||
return await self._execute_telegram_notification(
|
||||
chat_id=chat_id,
|
||||
urls=urls,
|
||||
assets=assets,
|
||||
bot_token=bot_token,
|
||||
caption=caption,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
@@ -308,7 +404,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
async def _execute_telegram_notification(
|
||||
self,
|
||||
chat_id: str,
|
||||
urls: list[dict[str, str]] | None = None,
|
||||
assets: list[dict[str, str]] | None = None,
|
||||
bot_token: str | None = None,
|
||||
caption: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
@@ -336,8 +432,8 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
# Handle empty URLs - send simple text message (no typing indicator needed)
|
||||
if not urls:
|
||||
# Handle empty assets - send simple text message (no typing indicator needed)
|
||||
if not assets:
|
||||
return await self._send_telegram_message(
|
||||
session, token, chat_id, caption or "", reply_to_message_id, disable_web_page_preview, parse_mode
|
||||
)
|
||||
@@ -349,23 +445,27 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
|
||||
try:
|
||||
# Handle single photo
|
||||
if len(urls) == 1 and urls[0].get("type") == "photo":
|
||||
if len(assets) == 1 and assets[0].get("type") == "photo":
|
||||
return await self._send_telegram_photo(
|
||||
session, token, chat_id, urls[0].get("url"), caption, reply_to_message_id, parse_mode,
|
||||
max_asset_data_size, send_large_photos_as_documents, urls[0].get("content_type")
|
||||
session, token, chat_id, assets[0].get("url"), caption, reply_to_message_id, parse_mode,
|
||||
max_asset_data_size, send_large_photos_as_documents, assets[0].get("content_type"),
|
||||
assets[0].get("cache_key")
|
||||
)
|
||||
|
||||
# Handle single video
|
||||
if len(urls) == 1 and urls[0].get("type") == "video":
|
||||
if len(assets) == 1 and assets[0].get("type") == "video":
|
||||
return await self._send_telegram_video(
|
||||
session, token, chat_id, urls[0].get("url"), caption, reply_to_message_id, parse_mode,
|
||||
max_asset_data_size, urls[0].get("content_type")
|
||||
session, token, chat_id, assets[0].get("url"), caption, reply_to_message_id, parse_mode,
|
||||
max_asset_data_size, assets[0].get("content_type"), assets[0].get("cache_key")
|
||||
)
|
||||
|
||||
# Handle single document (default type)
|
||||
if len(urls) == 1 and urls[0].get("type", "document") == "document":
|
||||
url = urls[0].get("url")
|
||||
item_content_type = urls[0].get("content_type")
|
||||
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"}
|
||||
item_content_type = assets[0].get("content_type")
|
||||
item_cache_key = assets[0].get("cache_key")
|
||||
try:
|
||||
download_url = self.coordinator.get_internal_download_url(url)
|
||||
async with session.get(download_url) as resp:
|
||||
@@ -377,14 +477,15 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Detect filename from URL or use generic name
|
||||
filename = url.split("/")[-1].split("?")[0] or "file"
|
||||
return await self._send_telegram_document(
|
||||
session, token, chat_id, data, filename, caption, reply_to_message_id, parse_mode, url, item_content_type
|
||||
session, token, chat_id, data, filename, caption, reply_to_message_id, parse_mode,
|
||||
url, item_content_type, item_cache_key
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": f"Failed to download media: {err}"}
|
||||
|
||||
# Handle multiple items - send as media group(s)
|
||||
return await self._send_telegram_media_group(
|
||||
session, token, chat_id, urls, caption, reply_to_message_id, max_group_size, chunk_delay, parse_mode,
|
||||
session, token, chat_id, assets, caption, reply_to_message_id, max_group_size, chunk_delay, parse_mode,
|
||||
max_asset_data_size, send_large_photos_as_documents
|
||||
)
|
||||
finally:
|
||||
@@ -552,6 +653,10 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check size limit for videos
|
||||
if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE:
|
||||
error_msg += f" | EXCEEDS Telegram upload limit ({TELEGRAM_MAX_VIDEO_SIZE / (1024 * 1024):.0f} MB)"
|
||||
|
||||
# Provide suggestions based on error description
|
||||
suggestions = []
|
||||
if "dimension" in description.lower() or "PHOTO_INVALID_DIMENSIONS" in description:
|
||||
@@ -609,6 +714,42 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
_LOGGER.debug("Failed to check photo dimensions: %s", e)
|
||||
return False, None, None, None
|
||||
|
||||
def _get_telegram_cache_and_key(
|
||||
self,
|
||||
url: str | None,
|
||||
cache_key: str | None = None,
|
||||
) -> tuple[TelegramFileCache | None, str | None, str | None]:
|
||||
"""Determine which Telegram cache, key, and thumbhash to use.
|
||||
|
||||
Priority: custom cache_key -> direct asset ID -> extracted asset ID from URL -> URL
|
||||
|
||||
Args:
|
||||
url: The URL of the media (or asset ID directly)
|
||||
cache_key: Optional custom cache key provided by user
|
||||
|
||||
Returns:
|
||||
Tuple of (cache instance, cache key, thumbhash) to use.
|
||||
thumbhash is only populated when using the asset cache.
|
||||
"""
|
||||
if cache_key:
|
||||
# Custom cache_key uses URL cache (no thumbhash)
|
||||
return self.coordinator.telegram_cache, cache_key, None
|
||||
|
||||
if url:
|
||||
# Check if url is already an asset ID (UUID format)
|
||||
if _is_asset_id(url):
|
||||
thumbhash = self.coordinator.get_asset_thumbhash(url)
|
||||
return self.coordinator.telegram_asset_cache, url, thumbhash
|
||||
# Try to extract asset ID from URL
|
||||
asset_id = _extract_asset_id_from_url(url)
|
||||
if asset_id:
|
||||
# Extracted asset ID uses asset cache
|
||||
thumbhash = self.coordinator.get_asset_thumbhash(asset_id)
|
||||
return self.coordinator.telegram_asset_cache, asset_id, thumbhash
|
||||
# Fallback to URL cache with URL as key (no thumbhash)
|
||||
return self.coordinator.telegram_cache, url, None
|
||||
|
||||
return None, None, None
|
||||
|
||||
async def _send_telegram_photo(
|
||||
self,
|
||||
@@ -622,6 +763,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
max_asset_data_size: int | None = None,
|
||||
send_large_photos_as_documents: bool = False,
|
||||
content_type: str | None = None,
|
||||
cache_key: str | None = None,
|
||||
) -> ServiceResponse:
|
||||
"""Send a single photo to Telegram."""
|
||||
import aiohttp
|
||||
@@ -634,14 +776,16 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' for photo"}
|
||||
|
||||
# Check cache for file_id
|
||||
cache = self.coordinator.telegram_cache
|
||||
cached = cache.get(url) if cache else None
|
||||
# Determine which cache to use and the cache key
|
||||
effective_cache, effective_cache_key, effective_thumbhash = self._get_telegram_cache_and_key(url, cache_key)
|
||||
|
||||
if cached and cached.get("file_id"):
|
||||
# Check cache for file_id
|
||||
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
|
||||
|
||||
if cached and cached.get("file_id") and effective_cache_key:
|
||||
# Use cached file_id - no download needed
|
||||
file_id = cached["file_id"]
|
||||
_LOGGER.debug("Using cached Telegram file_id for photo")
|
||||
_LOGGER.debug("Using cached Telegram file_id for photo (key: %s)", effective_cache_key[:36] if len(effective_cache_key) > 36 else effective_cache_key)
|
||||
|
||||
payload = {
|
||||
"chat_id": chat_id,
|
||||
@@ -702,7 +846,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
_LOGGER.info("Photo %s, sending as document", reason)
|
||||
return await self._send_telegram_document(
|
||||
session, token, chat_id, data, "photo.jpg",
|
||||
caption, reply_to_message_id, parse_mode, url
|
||||
caption, reply_to_message_id, parse_mode, url, None, cache_key
|
||||
)
|
||||
else:
|
||||
# Skip oversized photo
|
||||
@@ -735,11 +879,11 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
if response.status == 200 and result.get("ok"):
|
||||
# Extract and cache file_id
|
||||
photos = result.get("result", {}).get("photo", [])
|
||||
if photos and cache:
|
||||
if photos and effective_cache and effective_cache_key:
|
||||
# Use the largest photo's file_id
|
||||
file_id = photos[-1].get("file_id")
|
||||
if file_id:
|
||||
await cache.async_set(url, file_id, "photo")
|
||||
await effective_cache.async_set(effective_cache_key, file_id, "photo", thumbhash=effective_thumbhash)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -773,6 +917,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
parse_mode: str = "HTML",
|
||||
max_asset_data_size: int | None = None,
|
||||
content_type: str | None = None,
|
||||
cache_key: str | None = None,
|
||||
) -> ServiceResponse:
|
||||
"""Send a single video to Telegram."""
|
||||
import aiohttp
|
||||
@@ -785,14 +930,16 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' for video"}
|
||||
|
||||
# Check cache for file_id
|
||||
cache = self.coordinator.telegram_cache
|
||||
cached = cache.get(url) if cache else None
|
||||
# Determine which cache to use and the cache key
|
||||
effective_cache, effective_cache_key, effective_thumbhash = self._get_telegram_cache_and_key(url, cache_key)
|
||||
|
||||
if cached and cached.get("file_id"):
|
||||
# Check cache for file_id
|
||||
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
|
||||
|
||||
if cached and cached.get("file_id") and effective_cache_key:
|
||||
# Use cached file_id - no download needed
|
||||
file_id = cached["file_id"]
|
||||
_LOGGER.debug("Using cached Telegram file_id for video")
|
||||
_LOGGER.debug("Using cached Telegram file_id for video (key: %s)", effective_cache_key[:36] if len(effective_cache_key) > 36 else effective_cache_key)
|
||||
|
||||
payload = {
|
||||
"chat_id": chat_id,
|
||||
@@ -833,7 +980,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
data = await resp.read()
|
||||
_LOGGER.debug("Downloaded video: %d bytes", len(data))
|
||||
|
||||
# Check if video exceeds max size limit
|
||||
# Check if video exceeds max size limit (user-defined limit)
|
||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||
_LOGGER.warning(
|
||||
"Video size (%d bytes) exceeds max_asset_data_size limit (%d bytes), skipping",
|
||||
@@ -845,6 +992,19 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
"skipped": True,
|
||||
}
|
||||
|
||||
# Check if video exceeds Telegram's upload limit (50 MB)
|
||||
if len(data) > TELEGRAM_MAX_VIDEO_SIZE:
|
||||
_LOGGER.warning(
|
||||
"Video size (%d bytes, %.1f MB) exceeds Telegram's %d bytes (%.0f MB) upload limit, skipping",
|
||||
len(data), len(data) / (1024 * 1024),
|
||||
TELEGRAM_MAX_VIDEO_SIZE, TELEGRAM_MAX_VIDEO_SIZE / (1024 * 1024),
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Video size ({len(data) / (1024 * 1024):.1f} MB) exceeds Telegram's {TELEGRAM_MAX_VIDEO_SIZE / (1024 * 1024):.0f} MB upload limit",
|
||||
"skipped": True,
|
||||
}
|
||||
|
||||
# Build multipart form
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
@@ -867,10 +1027,10 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
if response.status == 200 and result.get("ok"):
|
||||
# Extract and cache file_id
|
||||
video = result.get("result", {}).get("video", {})
|
||||
if video and cache:
|
||||
if video and effective_cache and effective_cache_key:
|
||||
file_id = video.get("file_id")
|
||||
if file_id:
|
||||
await cache.async_set(url, file_id, "video")
|
||||
await effective_cache.async_set(effective_cache_key, file_id, "video", thumbhash=effective_thumbhash)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -905,6 +1065,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
parse_mode: str = "HTML",
|
||||
source_url: str | None = None,
|
||||
content_type: str | None = None,
|
||||
cache_key: str | None = None,
|
||||
) -> ServiceResponse:
|
||||
"""Send a file as a document to Telegram."""
|
||||
import aiohttp
|
||||
@@ -917,14 +1078,16 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
if not content_type:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
# Check cache for file_id if source_url is provided
|
||||
cache = self.coordinator.telegram_cache
|
||||
if source_url:
|
||||
cached = cache.get(source_url) if cache else None
|
||||
# Determine which cache and key to use
|
||||
effective_cache, effective_cache_key, effective_thumbhash = self._get_telegram_cache_and_key(source_url, cache_key)
|
||||
|
||||
# Check cache for file_id
|
||||
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":
|
||||
# Use cached file_id
|
||||
file_id = cached["file_id"]
|
||||
_LOGGER.debug("Using cached Telegram file_id for document")
|
||||
_LOGGER.debug("Using cached Telegram file_id for document (key: %s)", effective_cache_key[:36] if len(effective_cache_key) > 36 else effective_cache_key)
|
||||
|
||||
payload = {
|
||||
"chat_id": chat_id,
|
||||
@@ -973,11 +1136,11 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
_LOGGER.debug("Telegram API response: status=%d, ok=%s", response.status, result.get("ok"))
|
||||
if response.status == 200 and result.get("ok"):
|
||||
# Extract and cache file_id
|
||||
if source_url and cache:
|
||||
if effective_cache_key and effective_cache:
|
||||
document = result.get("result", {}).get("document", {})
|
||||
file_id = document.get("file_id")
|
||||
if file_id:
|
||||
await cache.async_set(source_url, file_id, "document")
|
||||
await effective_cache.async_set(effective_cache_key, file_id, "document", thumbhash=effective_thumbhash)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -1005,7 +1168,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
session: Any,
|
||||
token: str,
|
||||
chat_id: str,
|
||||
urls: list[dict[str, str]],
|
||||
assets: list[dict[str, str]],
|
||||
caption: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
max_group_size: int = 10,
|
||||
@@ -1014,9 +1177,9 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
max_asset_data_size: int | None = None,
|
||||
send_large_photos_as_documents: bool = False,
|
||||
) -> ServiceResponse:
|
||||
"""Send media URLs to Telegram as media group(s).
|
||||
"""Send media assets to Telegram as media group(s).
|
||||
|
||||
If urls list exceeds max_group_size, splits into multiple media groups.
|
||||
If assets list exceeds max_group_size, splits into multiple media groups.
|
||||
For chunks with single items, uses sendPhoto/sendVideo APIs.
|
||||
Applies chunk_delay (in milliseconds) between groups if specified.
|
||||
"""
|
||||
@@ -1025,12 +1188,12 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
import aiohttp
|
||||
from aiohttp import FormData
|
||||
|
||||
# Split URLs into chunks based on max_group_size
|
||||
chunks = [urls[i:i + max_group_size] for i in range(0, len(urls), max_group_size)]
|
||||
# Split assets into chunks based on max_group_size
|
||||
chunks = [assets[i:i + max_group_size] for i in range(0, len(assets), max_group_size)]
|
||||
all_message_ids = []
|
||||
|
||||
_LOGGER.debug("Sending %d media items in %d chunk(s) of max %d items (delay: %dms)",
|
||||
len(urls), len(chunks), max_group_size, chunk_delay)
|
||||
len(assets), len(chunks), max_group_size, chunk_delay)
|
||||
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
# Add delay before sending subsequent chunks
|
||||
@@ -1046,6 +1209,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
media_type = item.get("type", "document")
|
||||
url = item.get("url")
|
||||
item_content_type = item.get("content_type")
|
||||
item_cache_key = item.get("cache_key")
|
||||
|
||||
# Only apply caption and reply_to to the first chunk
|
||||
chunk_caption = caption if chunk_idx == 0 else None
|
||||
@@ -1055,16 +1219,18 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
_LOGGER.debug("Sending chunk %d/%d as single photo", chunk_idx + 1, len(chunks))
|
||||
result = await self._send_telegram_photo(
|
||||
session, token, chat_id, url, chunk_caption, chunk_reply_to, parse_mode,
|
||||
max_asset_data_size, send_large_photos_as_documents, item_content_type
|
||||
max_asset_data_size, send_large_photos_as_documents, item_content_type, item_cache_key
|
||||
)
|
||||
elif media_type == "video":
|
||||
_LOGGER.debug("Sending chunk %d/%d as single video", chunk_idx + 1, len(chunks))
|
||||
result = await self._send_telegram_video(
|
||||
session, token, chat_id, url, chunk_caption, chunk_reply_to, parse_mode,
|
||||
max_asset_data_size, item_content_type
|
||||
max_asset_data_size, item_content_type, item_cache_key
|
||||
)
|
||||
else: # document
|
||||
_LOGGER.debug("Sending chunk %d/%d as single document", chunk_idx + 1, len(chunks))
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' for document", "failed_at_chunk": chunk_idx + 1}
|
||||
try:
|
||||
download_url = self.coordinator.get_internal_download_url(url)
|
||||
async with session.get(download_url) as resp:
|
||||
@@ -1076,7 +1242,8 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
continue
|
||||
filename = url.split("/")[-1].split("?")[0] or "file"
|
||||
result = await self._send_telegram_document(
|
||||
session, token, chat_id, data, filename, chunk_caption, chunk_reply_to, parse_mode, url, item_content_type
|
||||
session, token, chat_id, data, filename, chunk_caption, chunk_reply_to, parse_mode,
|
||||
url, item_content_type, item_cache_key
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": f"Failed to download media: {err}", "failed_at_chunk": chunk_idx + 1}
|
||||
@@ -1090,28 +1257,35 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Multi-item chunk: use sendMediaGroup
|
||||
_LOGGER.debug("Sending chunk %d/%d as media group (%d items)", chunk_idx + 1, len(chunks), len(chunk))
|
||||
|
||||
# Get cache reference
|
||||
cache = self.coordinator.telegram_cache
|
||||
# Helper to get the appropriate cache for a cache key
|
||||
def get_cache_for_key(key: str) -> TelegramFileCache | None:
|
||||
"""Return asset cache if key is a UUID, otherwise URL cache."""
|
||||
is_asset_id = _is_asset_id(key)
|
||||
return self.coordinator.telegram_asset_cache if is_asset_id else self.coordinator.telegram_cache
|
||||
|
||||
# Collect media items - either from cache (file_id) or by downloading
|
||||
# Each item: (type, media_ref, filename, url, is_cached, content_type)
|
||||
# Each item: (type, media_ref, filename, cache_key, is_cached, content_type)
|
||||
# media_ref is either file_id (str) or data (bytes)
|
||||
media_items: list[tuple[str, str | bytes, str, str, bool, str | None]] = []
|
||||
oversized_photos: list[tuple[bytes, str | None, str]] = [] # (data, caption, url)
|
||||
documents_to_send: list[tuple[bytes, str | None, str, str, str | None]] = [] # (data, caption, url, filename, content_type)
|
||||
oversized_photos: list[tuple[bytes, str | None, str, str | None]] = [] # (data, caption, url, cache_key)
|
||||
documents_to_send: list[tuple[bytes, str | None, str, str | None, str, str | None]] = [] # (data, caption, url, cache_key, filename, content_type)
|
||||
skipped_count = 0
|
||||
|
||||
for i, item in enumerate(chunk):
|
||||
url = item.get("url")
|
||||
media_type = item.get("type", "document")
|
||||
item_content_type = item.get("content_type")
|
||||
|
||||
if not url:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Missing 'url' in item {chunk_idx * max_group_size + i}",
|
||||
}
|
||||
|
||||
media_type = item.get("type", "document")
|
||||
item_content_type = item.get("content_type")
|
||||
# Determine cache key: custom cache_key -> extracted asset ID -> URL
|
||||
custom_cache_key = item.get("cache_key")
|
||||
extracted_asset_id = _extract_asset_id_from_url(url) if not custom_cache_key else None
|
||||
item_cache_key = custom_cache_key or extracted_asset_id or url
|
||||
|
||||
if media_type not in ("photo", "video", "document"):
|
||||
return {
|
||||
"success": False,
|
||||
@@ -1139,7 +1313,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Caption only on first item of first chunk if no media items yet
|
||||
doc_caption = caption if chunk_idx == 0 and i == 0 and len(media_items) == 0 and len(documents_to_send) == 0 else None
|
||||
filename = url.split("/")[-1].split("?")[0] or f"file_{i}"
|
||||
documents_to_send.append((data, doc_caption, url, filename, item_content_type))
|
||||
documents_to_send.append((data, doc_caption, url, custom_cache_key, filename, item_content_type))
|
||||
except aiohttp.ClientError as err:
|
||||
return {
|
||||
"success": False,
|
||||
@@ -1148,12 +1322,14 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
continue
|
||||
|
||||
# Check cache first for photos/videos
|
||||
cached = cache.get(url) if cache else None
|
||||
item_cache = get_cache_for_key(item_cache_key)
|
||||
item_thumbhash = self.coordinator.get_asset_thumbhash(item_cache_key) if _is_asset_id(item_cache_key) else None
|
||||
cached = item_cache.get(item_cache_key, thumbhash=item_thumbhash) if item_cache else None
|
||||
if cached and cached.get("file_id"):
|
||||
# Use cached file_id
|
||||
ext = "jpg" if media_type == "photo" else "mp4"
|
||||
filename = f"media_{chunk_idx * max_group_size + i}.{ext}"
|
||||
media_items.append((media_type, cached["file_id"], filename, url, True, item_content_type))
|
||||
media_items.append((media_type, cached["file_id"], filename, item_cache_key, True, item_content_type))
|
||||
_LOGGER.debug("Using cached file_id for media %d", chunk_idx * max_group_size + i)
|
||||
continue
|
||||
|
||||
@@ -1179,6 +1355,17 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# For videos, check Telegram upload limit (50 MB)
|
||||
if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE:
|
||||
_LOGGER.warning(
|
||||
"Video %d size (%d bytes, %.1f MB) exceeds Telegram's %.0f MB upload limit, skipping",
|
||||
chunk_idx * max_group_size + i, len(data),
|
||||
len(data) / (1024 * 1024),
|
||||
TELEGRAM_MAX_VIDEO_SIZE / (1024 * 1024),
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# For photos, check Telegram limits
|
||||
if media_type == "photo":
|
||||
exceeds_limits, reason, width, height = self._check_telegram_photo_limits(data)
|
||||
@@ -1187,7 +1374,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Separate this photo to send as document later
|
||||
# Caption only on first item of first chunk
|
||||
photo_caption = caption if chunk_idx == 0 and i == 0 and len(media_items) == 0 else None
|
||||
oversized_photos.append((data, photo_caption, url))
|
||||
oversized_photos.append((data, photo_caption, url, custom_cache_key))
|
||||
_LOGGER.info("Photo %d %s, will send as document", i, reason)
|
||||
continue
|
||||
else:
|
||||
@@ -1198,7 +1385,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
|
||||
ext = "jpg" if media_type == "photo" else "mp4"
|
||||
filename = f"media_{chunk_idx * max_group_size + i}.{ext}"
|
||||
media_items.append((media_type, data, filename, url, False, item_content_type))
|
||||
media_items.append((media_type, data, filename, item_cache_key, False, item_content_type))
|
||||
except aiohttp.ClientError as err:
|
||||
return {
|
||||
"success": False,
|
||||
@@ -1211,31 +1398,128 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
chunk_idx + 1, len(chunks), len(chunk))
|
||||
continue
|
||||
|
||||
# Send media group if we have normal-sized files
|
||||
# Split media items into sub-groups respecting Telegram's upload size limit
|
||||
# This ensures the total upload data per sendMediaGroup call stays under 50 MB
|
||||
if media_items:
|
||||
# Check if all items are cached (can use simple JSON payload)
|
||||
all_cached = all(is_cached for _, _, _, _, is_cached, _ in media_items)
|
||||
media_sub_groups = _split_media_by_upload_size(media_items, TELEGRAM_MAX_VIDEO_SIZE)
|
||||
if len(media_sub_groups) > 1:
|
||||
_LOGGER.debug(
|
||||
"Chunk %d/%d: split %d media items into %d sub-groups by upload size",
|
||||
chunk_idx + 1, len(chunks), len(media_items), len(media_sub_groups),
|
||||
)
|
||||
|
||||
first_caption_used = False
|
||||
for sub_idx, sub_group_items in enumerate(media_sub_groups):
|
||||
is_first = chunk_idx == 0 and sub_idx == 0
|
||||
sub_caption = caption if is_first and not first_caption_used and not oversized_photos else None
|
||||
sub_reply_to = reply_to_message_id if is_first else None
|
||||
|
||||
# Add delay between sub-groups (not before the first one)
|
||||
if sub_idx > 0 and chunk_delay > 0:
|
||||
await asyncio.sleep(chunk_delay / 1000)
|
||||
|
||||
# Single item - use sendPhoto/sendVideo (sendMediaGroup requires 2+ items)
|
||||
if len(sub_group_items) == 1:
|
||||
sg_type, sg_ref, sg_fname, sg_ck, sg_cached, sg_ct = sub_group_items[0]
|
||||
if sg_type == "photo":
|
||||
api_method = "sendPhoto"
|
||||
media_field = "photo"
|
||||
else:
|
||||
api_method = "sendVideo"
|
||||
media_field = "video"
|
||||
|
||||
try:
|
||||
if sg_cached:
|
||||
sg_payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
media_field: sg_ref,
|
||||
"parse_mode": parse_mode,
|
||||
}
|
||||
if sub_caption:
|
||||
sg_payload["caption"] = sub_caption
|
||||
if sub_reply_to:
|
||||
sg_payload["reply_to_message_id"] = sub_reply_to
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{token}/{api_method}"
|
||||
async with session.post(telegram_url, json=sg_payload) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
all_message_ids.append(result["result"].get("message_id"))
|
||||
if sub_caption:
|
||||
first_caption_used = True
|
||||
else:
|
||||
_LOGGER.debug("Cached file_id failed in sub-group, will re-upload: %s", result.get("description"))
|
||||
sg_cached = False # Fall through to upload
|
||||
if not sg_cached:
|
||||
sg_form = FormData()
|
||||
sg_form.add_field("chat_id", chat_id)
|
||||
sg_content_type = sg_ct or ("image/jpeg" if sg_type == "photo" else "video/mp4")
|
||||
sg_form.add_field(media_field, sg_ref, filename=sg_fname, content_type=sg_content_type)
|
||||
sg_form.add_field("parse_mode", parse_mode)
|
||||
if sub_caption:
|
||||
sg_form.add_field("caption", sub_caption)
|
||||
if sub_reply_to:
|
||||
sg_form.add_field("reply_to_message_id", str(sub_reply_to))
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{token}/{api_method}"
|
||||
async with session.post(telegram_url, data=sg_form) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
all_message_ids.append(result["result"].get("message_id"))
|
||||
if sub_caption:
|
||||
first_caption_used = True
|
||||
# Cache the uploaded file_id
|
||||
sg_cache = get_cache_for_key(sg_ck)
|
||||
if sg_cache:
|
||||
sg_thumbhash = self.coordinator.get_asset_thumbhash(sg_ck) if _is_asset_id(sg_ck) else None
|
||||
result_data = result.get("result", {})
|
||||
if sg_type == "photo":
|
||||
photos = result_data.get("photo", [])
|
||||
if photos:
|
||||
await sg_cache.async_set(sg_ck, photos[-1].get("file_id"), "photo", thumbhash=sg_thumbhash)
|
||||
elif sg_type == "video":
|
||||
video = result_data.get("video", {})
|
||||
if video.get("file_id"):
|
||||
await sg_cache.async_set(sg_ck, video["file_id"], "video", thumbhash=sg_thumbhash)
|
||||
else:
|
||||
self._log_telegram_error(
|
||||
error_code=result.get("error_code"),
|
||||
description=result.get("description", "Unknown Telegram error"),
|
||||
data=sg_ref if isinstance(sg_ref, bytes) else None,
|
||||
media_type=sg_type,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.get("description", "Unknown Telegram error"),
|
||||
"error_code": result.get("error_code"),
|
||||
"failed_at_chunk": chunk_idx + 1,
|
||||
}
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Telegram upload failed for sub-group %d: %s", sub_idx + 1, err)
|
||||
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
|
||||
continue
|
||||
|
||||
# Multiple items - use sendMediaGroup
|
||||
all_cached = all(is_cached for _, _, _, _, is_cached, _ in sub_group_items)
|
||||
|
||||
if all_cached:
|
||||
# All items cached - use simple JSON payload with file_ids
|
||||
_LOGGER.debug("All %d items cached, using file_ids", len(media_items))
|
||||
_LOGGER.debug("Sub-group %d/%d: all %d items cached, using file_ids",
|
||||
sub_idx + 1, len(media_sub_groups), len(sub_group_items))
|
||||
media_json = []
|
||||
for i, (media_type, file_id, _, _, _, _) in enumerate(media_items):
|
||||
for i, (media_type, file_id, _, _, _, _) in enumerate(sub_group_items):
|
||||
media_item_json: dict[str, Any] = {
|
||||
"type": media_type,
|
||||
"media": file_id,
|
||||
}
|
||||
if chunk_idx == 0 and i == 0 and caption and not oversized_photos:
|
||||
media_item_json["caption"] = caption
|
||||
if i == 0 and sub_caption and not first_caption_used:
|
||||
media_item_json["caption"] = sub_caption
|
||||
media_item_json["parse_mode"] = parse_mode
|
||||
media_json.append(media_item_json)
|
||||
|
||||
payload = {
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"media": media_json,
|
||||
}
|
||||
if chunk_idx == 0 and reply_to_message_id:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
if sub_reply_to:
|
||||
payload["reply_to_message_id"] = sub_reply_to
|
||||
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{token}/sendMediaGroup"
|
||||
try:
|
||||
@@ -1246,6 +1530,8 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
msg.get("message_id") for msg in result.get("result", [])
|
||||
]
|
||||
all_message_ids.extend(chunk_message_ids)
|
||||
if sub_caption:
|
||||
first_caption_used = True
|
||||
else:
|
||||
# Cache might be stale - fall through to upload path
|
||||
_LOGGER.debug("Cached file_ids failed, will re-upload: %s", result.get("description"))
|
||||
@@ -1259,16 +1545,15 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
|
||||
# Only use reply_to_message_id for the first chunk
|
||||
if chunk_idx == 0 and reply_to_message_id:
|
||||
form.add_field("reply_to_message_id", str(reply_to_message_id))
|
||||
if sub_reply_to:
|
||||
form.add_field("reply_to_message_id", str(sub_reply_to))
|
||||
|
||||
# Build media JSON - use file_id for cached, attach:// for uploaded
|
||||
media_json = []
|
||||
upload_idx = 0
|
||||
urls_to_cache: list[tuple[str, int, str]] = [] # (url, result_idx, type)
|
||||
keys_to_cache: list[tuple[str, int, str]] = [] # (cache_key, result_idx, type)
|
||||
|
||||
for i, (media_type, media_ref, filename, url, is_cached, item_content_type) in enumerate(media_items):
|
||||
for i, (media_type, media_ref, filename, item_cache_key, is_cached, item_content_type) in enumerate(sub_group_items):
|
||||
if is_cached:
|
||||
# Use file_id directly
|
||||
media_item_json: dict[str, Any] = {
|
||||
@@ -1285,11 +1570,11 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Use provided content_type or default based on media type
|
||||
content_type = item_content_type or ("image/jpeg" if media_type == "photo" else "video/mp4")
|
||||
form.add_field(attach_name, media_ref, filename=filename, content_type=content_type)
|
||||
urls_to_cache.append((url, i, media_type))
|
||||
keys_to_cache.append((item_cache_key, i, media_type))
|
||||
upload_idx += 1
|
||||
|
||||
if chunk_idx == 0 and i == 0 and caption and not oversized_photos:
|
||||
media_item_json["caption"] = caption
|
||||
if i == 0 and sub_caption and not first_caption_used:
|
||||
media_item_json["caption"] = sub_caption
|
||||
media_item_json["parse_mode"] = parse_mode
|
||||
media_json.append(media_item_json)
|
||||
|
||||
@@ -1299,8 +1584,8 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{token}/sendMediaGroup"
|
||||
|
||||
try:
|
||||
_LOGGER.debug("Uploading media group chunk %d/%d (%d files, %d cached) to Telegram",
|
||||
chunk_idx + 1, len(chunks), len(media_items), len(media_items) - upload_idx)
|
||||
_LOGGER.debug("Uploading media group sub-group %d/%d (%d files, %d cached) to Telegram",
|
||||
sub_idx + 1, len(media_sub_groups), len(sub_group_items), len(sub_group_items) - upload_idx)
|
||||
async with session.post(telegram_url, data=form) as response:
|
||||
result = await response.json()
|
||||
_LOGGER.debug("Telegram API response: status=%d, ok=%s", response.status, result.get("ok"))
|
||||
@@ -1309,35 +1594,39 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
msg.get("message_id") for msg in result.get("result", [])
|
||||
]
|
||||
all_message_ids.extend(chunk_message_ids)
|
||||
if sub_caption:
|
||||
first_caption_used = True
|
||||
|
||||
# Cache the newly uploaded file_ids
|
||||
if cache and urls_to_cache:
|
||||
if keys_to_cache:
|
||||
result_messages = result.get("result", [])
|
||||
for url, result_idx, m_type in urls_to_cache:
|
||||
if result_idx < len(result_messages):
|
||||
for ck, result_idx, m_type in keys_to_cache:
|
||||
ck_cache = get_cache_for_key(ck)
|
||||
ck_thumbhash = self.coordinator.get_asset_thumbhash(ck) if _is_asset_id(ck) else None
|
||||
if result_idx < len(result_messages) and ck_cache:
|
||||
msg = result_messages[result_idx]
|
||||
if m_type == "photo":
|
||||
photos = msg.get("photo", [])
|
||||
if photos:
|
||||
await cache.async_set(url, photos[-1].get("file_id"), "photo")
|
||||
await ck_cache.async_set(ck, photos[-1].get("file_id"), "photo", thumbhash=ck_thumbhash)
|
||||
elif m_type == "video":
|
||||
video = msg.get("video", {})
|
||||
if video.get("file_id"):
|
||||
await cache.async_set(url, video["file_id"], "video")
|
||||
await ck_cache.async_set(ck, video["file_id"], "video", thumbhash=ck_thumbhash)
|
||||
else:
|
||||
# Log detailed error for media group with total size info
|
||||
uploaded_data = [m for m in media_items if not m[4]]
|
||||
uploaded_data = [m for m in sub_group_items if not m[4]]
|
||||
total_size = sum(len(d) for _, d, _, _, _, _ in uploaded_data if isinstance(d, bytes))
|
||||
_LOGGER.error(
|
||||
"Telegram API error for chunk %d/%d: %s | Media count: %d | Uploaded size: %d bytes (%.2f MB)",
|
||||
chunk_idx + 1, len(chunks),
|
||||
"Telegram API error for sub-group %d/%d: %s | Media count: %d | Uploaded size: %d bytes (%.2f MB)",
|
||||
sub_idx + 1, len(media_sub_groups),
|
||||
result.get("description", "Unknown Telegram error"),
|
||||
len(media_items),
|
||||
len(sub_group_items),
|
||||
total_size,
|
||||
total_size / (1024 * 1024) if total_size else 0
|
||||
)
|
||||
# Log detailed diagnostics for the first photo in the group
|
||||
for media_type, media_ref, _, _, is_cached, _ in media_items:
|
||||
for media_type, media_ref, _, _, is_cached, _ in sub_group_items:
|
||||
if media_type == "photo" and not is_cached and isinstance(media_ref, bytes):
|
||||
self._log_telegram_error(
|
||||
error_code=result.get("error_code"),
|
||||
@@ -1353,7 +1642,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
"failed_at_chunk": chunk_idx + 1,
|
||||
}
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Telegram upload failed for chunk %d: %s", chunk_idx + 1, err)
|
||||
_LOGGER.error("Telegram upload failed for sub-group %d: %s", sub_idx + 1, err)
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(err),
|
||||
@@ -1361,11 +1650,11 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
}
|
||||
|
||||
# Send oversized photos as documents
|
||||
for i, (data, photo_caption, photo_url) in enumerate(oversized_photos):
|
||||
for i, (data, photo_caption, photo_url, photo_cache_key) in enumerate(oversized_photos):
|
||||
_LOGGER.debug("Sending oversized photo %d/%d as document", i + 1, len(oversized_photos))
|
||||
result = await self._send_telegram_document(
|
||||
session, token, chat_id, data, f"photo_{i}.jpg",
|
||||
photo_caption, None, parse_mode, photo_url
|
||||
photo_caption, None, parse_mode, photo_url, None, photo_cache_key
|
||||
)
|
||||
if result.get("success"):
|
||||
all_message_ids.append(result.get("message_id"))
|
||||
@@ -1374,11 +1663,11 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
# Continue with other photos even if one fails
|
||||
|
||||
# Send documents (can't be in media groups)
|
||||
for i, (data, doc_caption, doc_url, filename, doc_content_type) in enumerate(documents_to_send):
|
||||
for i, (data, doc_caption, doc_url, doc_cache_key, filename, doc_content_type) in enumerate(documents_to_send):
|
||||
_LOGGER.debug("Sending document %d/%d", i + 1, len(documents_to_send))
|
||||
result = await self._send_telegram_document(
|
||||
session, token, chat_id, data, filename,
|
||||
doc_caption, None, parse_mode, doc_url, doc_content_type
|
||||
doc_caption, None, parse_mode, doc_url, doc_content_type, doc_cache_key
|
||||
)
|
||||
if result.get("success"):
|
||||
all_message_ids.append(result.get("message_id"))
|
||||
|
||||
@@ -149,9 +149,9 @@ send_telegram_notification:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
urls:
|
||||
name: URLs
|
||||
description: "List of media URLs to send. Each item should have 'url', optional 'type' (document/photo/video, default: document), and optional 'content_type' (MIME type, e.g., 'image/jpeg'). If empty, sends a text message. Photos and videos can be grouped; documents are sent separately."
|
||||
assets:
|
||||
name: Assets
|
||||
description: "List of media assets to send. Each item should have 'url', optional 'type' (document/photo/video, default: document), optional 'content_type' (MIME type, e.g., 'image/jpeg'), and optional 'cache_key' (custom key for caching instead of URL). If empty, sends a text message. Photos and videos can be grouped; documents are sent separately."
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
|
||||
@@ -73,40 +73,53 @@ class TelegramFileCache:
|
||||
|
||||
When a file is uploaded to Telegram, it returns a file_id that can be reused
|
||||
to send the same file without re-uploading. This cache stores these file_ids
|
||||
keyed by the source URL.
|
||||
keyed by the source URL or asset ID.
|
||||
|
||||
Supports two validation modes:
|
||||
- TTL mode (default): entries expire after a configured time-to-live
|
||||
- Thumbhash mode: entries are validated by comparing stored thumbhash with
|
||||
the current asset thumbhash from Immich
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
album_id: str,
|
||||
entry_id: str,
|
||||
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
|
||||
use_thumbhash: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the Telegram file cache.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
album_id: Album ID for scoping the cache
|
||||
ttl_seconds: Time-to-live for cache entries in seconds (default: 48 hours)
|
||||
entry_id: Config entry ID for scoping the cache (per hub)
|
||||
ttl_seconds: Time-to-live for cache entries in seconds (TTL mode only)
|
||||
use_thumbhash: Use thumbhash-based validation instead of TTL
|
||||
"""
|
||||
self._store: Store[dict[str, Any]] = Store(
|
||||
hass, STORAGE_VERSION, f"{STORAGE_KEY_PREFIX}.telegram_cache.{album_id}"
|
||||
hass, STORAGE_VERSION, f"{STORAGE_KEY_PREFIX}.telegram_cache.{entry_id}"
|
||||
)
|
||||
self._data: dict[str, Any] | None = None
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._use_thumbhash = use_thumbhash
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load cache data from storage."""
|
||||
self._data = await self._store.async_load() or {"files": {}}
|
||||
# Clean up expired entries on load
|
||||
# Clean up expired entries on load (TTL mode only)
|
||||
await self._cleanup_expired()
|
||||
mode = "thumbhash" if self._use_thumbhash else "TTL"
|
||||
_LOGGER.debug(
|
||||
"Loaded Telegram file cache with %d entries",
|
||||
"Loaded Telegram file cache with %d entries (mode: %s)",
|
||||
len(self._data.get("files", {})),
|
||||
mode,
|
||||
)
|
||||
|
||||
async def _cleanup_expired(self) -> None:
|
||||
"""Remove expired cache entries."""
|
||||
"""Remove expired cache entries (TTL mode only)."""
|
||||
if self._use_thumbhash:
|
||||
return
|
||||
|
||||
if not self._data or "files" not in self._data:
|
||||
return
|
||||
|
||||
@@ -127,23 +140,38 @@ class TelegramFileCache:
|
||||
await self._store.async_save(self._data)
|
||||
_LOGGER.debug("Cleaned up %d expired Telegram cache entries", len(expired_keys))
|
||||
|
||||
def get(self, url: str) -> dict[str, Any] | None:
|
||||
"""Get cached file_id for a URL.
|
||||
def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
|
||||
"""Get cached file_id for a key.
|
||||
|
||||
Args:
|
||||
url: The source URL of the media
|
||||
key: The cache key (URL or asset ID)
|
||||
thumbhash: Current thumbhash for validation (thumbhash mode only).
|
||||
If provided, compares with stored thumbhash. Mismatch = cache miss.
|
||||
|
||||
Returns:
|
||||
Dict with 'file_id' and 'type' if cached and not expired, None otherwise
|
||||
Dict with 'file_id' and 'type' if cached and valid, None otherwise
|
||||
"""
|
||||
if not self._data or "files" not in self._data:
|
||||
return None
|
||||
|
||||
entry = self._data["files"].get(url)
|
||||
entry = self._data["files"].get(key)
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
# Check if expired
|
||||
if self._use_thumbhash:
|
||||
# Thumbhash-based validation
|
||||
if thumbhash is not None:
|
||||
stored_thumbhash = entry.get("thumbhash")
|
||||
if stored_thumbhash and stored_thumbhash != thumbhash:
|
||||
_LOGGER.debug(
|
||||
"Cache miss for %s: thumbhash changed",
|
||||
key[:36],
|
||||
)
|
||||
return None
|
||||
# If no thumbhash provided (asset not in monitored album),
|
||||
# return cached entry anyway — self-heals on Telegram rejection
|
||||
else:
|
||||
# TTL-based validation
|
||||
cached_at_str = entry.get("cached_at")
|
||||
if cached_at_str:
|
||||
cached_at = datetime.fromisoformat(cached_at_str)
|
||||
@@ -156,24 +184,31 @@ class TelegramFileCache:
|
||||
"type": entry.get("type"),
|
||||
}
|
||||
|
||||
async def async_set(self, url: str, file_id: str, media_type: str) -> None:
|
||||
"""Store a file_id for a URL.
|
||||
async def async_set(
|
||||
self, key: str, file_id: str, media_type: str, thumbhash: str | None = None
|
||||
) -> None:
|
||||
"""Store a file_id for a key.
|
||||
|
||||
Args:
|
||||
url: The source URL of the media
|
||||
key: The cache key (URL or asset ID)
|
||||
file_id: The Telegram file_id
|
||||
media_type: The type of media ('photo', 'video', 'document')
|
||||
thumbhash: Current thumbhash to store alongside file_id (thumbhash mode only)
|
||||
"""
|
||||
if self._data is None:
|
||||
self._data = {"files": {}}
|
||||
|
||||
self._data["files"][url] = {
|
||||
entry_data: dict[str, Any] = {
|
||||
"file_id": file_id,
|
||||
"type": media_type,
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if thumbhash is not None:
|
||||
entry_data["thumbhash"] = thumbhash
|
||||
|
||||
self._data["files"][key] = entry_data
|
||||
await self._store.async_save(self._data)
|
||||
_LOGGER.debug("Cached Telegram file_id for URL (type: %s)", media_type)
|
||||
_LOGGER.debug("Cached Telegram file_id for key (type: %s)", media_type)
|
||||
|
||||
async def async_remove(self) -> None:
|
||||
"""Remove all cache data."""
|
||||
|
||||
@@ -205,9 +205,9 @@
|
||||
"name": "Chat ID",
|
||||
"description": "Telegram chat ID to send to."
|
||||
},
|
||||
"urls": {
|
||||
"name": "URLs",
|
||||
"description": "List of media URLs with optional type (document/photo/video, default: document) and optional content_type (MIME type). If empty, sends a text message. Photos and videos can be grouped; documents are sent separately."
|
||||
"assets": {
|
||||
"name": "Assets",
|
||||
"description": "List of media assets with 'url', optional 'type' (document/photo/video, default: document), optional 'content_type' (MIME type), and optional 'cache_key' (custom key for caching instead of URL). If empty, sends a text message. Photos and videos can be grouped; documents are sent separately."
|
||||
},
|
||||
"caption": {
|
||||
"name": "Caption",
|
||||
|
||||
@@ -205,9 +205,9 @@
|
||||
"name": "ID чата",
|
||||
"description": "ID чата Telegram для отправки."
|
||||
},
|
||||
"urls": {
|
||||
"name": "URL-адреса",
|
||||
"description": "Список URL медиа-файлов с типом (document/photo/video, по умолчанию document) и опциональным content_type (MIME-тип). Если пусто, отправляет текстовое сообщение. Фото и видео группируются; документы отправляются отдельно."
|
||||
"assets": {
|
||||
"name": "Ресурсы",
|
||||
"description": "Список медиа-ресурсов с 'url', опциональным 'type' (document/photo/video, по умолчанию document), опциональным 'content_type' (MIME-тип) и опциональным 'cache_key' (свой ключ кэширования вместо URL). Если пусто, отправляет текстовое сообщение. Фото и видео группируются; документы отправляются отдельно."
|
||||
},
|
||||
"caption": {
|
||||
"name": "Подпись",
|
||||
|
||||
Reference in New Issue
Block a user