Replace TTL with thumbhash-based cache validation and add Telegram video size limits
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:
2026-02-16 12:28:33 +03:00
parent 65ca81a3f3
commit dd7032b411
9 changed files with 637 additions and 257 deletions

View File

@@ -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 |

View File

@@ -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

View File

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

View File

@@ -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"
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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,53 +140,75 @@ 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
cached_at_str = entry.get("cached_at")
if cached_at_str:
cached_at = datetime.fromisoformat(cached_at_str)
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
if age_seconds > self._ttl_seconds:
return None
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)
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
if age_seconds > self._ttl_seconds:
return None
return {
"file_id": entry.get("file_id"),
"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."""

View File

@@ -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",

View File

@@ -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": "Подпись",