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:
@@ -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"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,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."""
|
||||
|
||||
@@ -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