Add document type and content_type support for send_telegram_notification
All checks were successful
Validate / Hassfest (push) Successful in 3s

- Add type: document as default media type (instead of photo)
- Add optional content_type field for explicit MIME type specification
- Documents are sent separately (Telegram API limitation for media groups)
- Default content types: image/jpeg (photo), video/mp4 (video), auto-detect (document)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 01:35:57 +03:00
parent fde2d0ae31
commit 6ca3cae5df
5 changed files with 157 additions and 39 deletions

View File

@@ -37,7 +37,7 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
- **Services** - Custom service calls:
- `immich_album_watcher.refresh` - Force immediate data refresh
- `immich_album_watcher.get_assets` - Get assets from an album with filtering and ordering
- `immich_album_watcher.send_telegram_notification` - Send text, photo, video, or media group to Telegram
- `immich_album_watcher.send_telegram_notification` - Send text, photo, video, document, or media group to Telegram
- **Share Link Management** - Button entities to create and delete share links:
- Create/delete public (unprotected) share links
- Create/delete password-protected share links
@@ -335,11 +335,12 @@ data:
Send notifications to Telegram. Supports multiple formats:
- **Text message** - When `urls` is empty or not provided
- **Single photo** - When `urls` contains one photo
- **Single video** - When `urls` contains one video
- **Media group** - When `urls` contains multiple items
- **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)
The service downloads media from Immich and uploads it to Telegram, bypassing any CORS restrictions. Large lists of media are automatically split into multiple media groups based on the `max_group_size` parameter (default: 10 items per group).
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.
@@ -357,6 +358,20 @@ data:
disable_web_page_preview: true
```
Single document (default):
```yaml
service: immich_album_watcher.send_telegram_notification
target:
entity_id: sensor.album_name_asset_limit
data:
chat_id: "-1001234567890"
urls:
- url: "https://immich.example.com/api/assets/xxx/original?key=yyy"
content_type: "image/heic" # Optional: explicit MIME type
caption: "Original file"
```
Single photo:
```yaml
@@ -421,7 +436,7 @@ data:
| Field | Description | Required |
|-------|-------------|----------|
| `chat_id` | Telegram chat ID to send to | Yes |
| `urls` | List of media items with `url` and `type` (photo/video). Empty for text message. | No |
| `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 |
| `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

@@ -348,18 +348,39 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
try:
# Handle single photo
if len(urls) == 1 and urls[0].get("type", "photo") == "photo":
if len(urls) == 1 and urls[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
max_asset_data_size, send_large_photos_as_documents, urls[0].get("content_type")
)
# Handle single video
if len(urls) == 1 and urls[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
session, token, chat_id, urls[0].get("url"), caption, reply_to_message_id, parse_mode,
max_asset_data_size, urls[0].get("content_type")
)
# 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")
try:
download_url = self.coordinator.get_internal_download_url(url)
async with session.get(download_url) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
data = await resp.read()
if max_asset_data_size is not None and len(data) > max_asset_data_size:
return {"success": False, "error": f"Media size ({len(data)} bytes) exceeds max_asset_data_size limit ({max_asset_data_size} bytes)"}
# 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
)
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,
@@ -599,11 +620,16 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
parse_mode: str = "HTML",
max_asset_data_size: int | None = None,
send_large_photos_as_documents: bool = False,
content_type: str | None = None,
) -> ServiceResponse:
"""Send a single photo to Telegram."""
import aiohttp
from aiohttp import FormData
# Use provided content type or default to image/jpeg
if not content_type:
content_type = "image/jpeg"
if not url:
return {"success": False, "error": "Missing 'url' for photo"}
@@ -689,7 +715,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
# Build multipart form
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field("photo", data, filename="photo.jpg", content_type="image/jpeg")
form.add_field("photo", data, filename="photo.jpg", content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
@@ -745,11 +771,16 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
reply_to_message_id: int | None = None,
parse_mode: str = "HTML",
max_asset_data_size: int | None = None,
content_type: str | None = None,
) -> ServiceResponse:
"""Send a single video to Telegram."""
import aiohttp
from aiohttp import FormData
# Use provided content type or default to video/mp4
if not content_type:
content_type = "video/mp4"
if not url:
return {"success": False, "error": "Missing 'url' for video"}
@@ -816,7 +847,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
# Build multipart form
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field("video", data, filename="video.mp4", content_type="video/mp4")
form.add_field("video", data, filename="video.mp4", content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
@@ -867,16 +898,24 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
token: str,
chat_id: str,
data: bytes,
filename: str = "photo.jpg",
filename: str = "file",
caption: str | None = None,
reply_to_message_id: int | None = None,
parse_mode: str = "HTML",
source_url: str | None = None,
content_type: str | None = None,
) -> ServiceResponse:
"""Send a photo as a document to Telegram (for oversized photos)."""
"""Send a file as a document to Telegram."""
import aiohttp
import mimetypes
from aiohttp import FormData
# Use provided content type or detect from filename
if not content_type:
content_type, _ = mimetypes.guess_type(filename)
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:
@@ -915,7 +954,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
# Build multipart form
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field("document", data, filename=filename, content_type="image/jpeg")
form.add_field("document", data, filename=filename, content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
@@ -927,7 +966,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
# Send to Telegram
telegram_url = f"https://api.telegram.org/bot{token}/sendDocument"
_LOGGER.debug("Uploading oversized photo as document to Telegram (%d bytes)", len(data))
_LOGGER.debug("Uploading document to Telegram (%d bytes, %s)", len(data), content_type)
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"))
@@ -1003,8 +1042,9 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
# Optimize: Use single-item APIs for chunks with 1 item
if len(chunk) == 1:
item = chunk[0]
media_type = item.get("type", "photo")
media_type = item.get("type", "document")
url = item.get("url")
item_content_type = item.get("content_type")
# Only apply caption and reply_to to the first chunk
chunk_caption = caption if chunk_idx == 0 else None
@@ -1014,13 +1054,31 @@ 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
max_asset_data_size, send_large_photos_as_documents, item_content_type
)
else: # video
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
session, token, chat_id, url, chunk_caption, chunk_reply_to, parse_mode,
max_asset_data_size, item_content_type
)
else: # document
_LOGGER.debug("Sending chunk %d/%d as single document", chunk_idx + 1, len(chunks))
try:
download_url = self.coordinator.get_internal_download_url(url)
async with session.get(download_url) as resp:
if resp.status != 200:
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}", "failed_at_chunk": chunk_idx + 1}
data = await resp.read()
if max_asset_data_size is not None and len(data) > max_asset_data_size:
_LOGGER.warning("Media size (%d bytes) exceeds max_asset_data_size limit (%d bytes), skipping", len(data), max_asset_data_size)
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
)
except aiohttp.ClientError as err:
return {"success": False, "error": f"Failed to download media: {err}", "failed_at_chunk": chunk_idx + 1}
if not result.get("success"):
result["failed_at_chunk"] = chunk_idx + 1
@@ -1035,15 +1093,17 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
cache = self.coordinator.telegram_cache
# Collect media items - either from cache (file_id) or by downloading
# Each item: (type, media_ref, filename, url, is_cached)
# Each item: (type, media_ref, filename, url, is_cached, content_type)
# media_ref is either file_id (str) or data (bytes)
media_items: list[tuple[str, str | bytes, str, str, bool]] = []
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)
skipped_count = 0
for i, item in enumerate(chunk):
url = item.get("url")
media_type = item.get("type", "photo")
media_type = item.get("type", "document")
item_content_type = item.get("content_type")
if not url:
return {
@@ -1051,19 +1111,48 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
"error": f"Missing 'url' in item {chunk_idx * max_group_size + i}",
}
if media_type not in ("photo", "video"):
if media_type not in ("photo", "video", "document"):
return {
"success": False,
"error": f"Invalid type '{media_type}' in item {chunk_idx * max_group_size + i}. Must be 'photo' or 'video'.",
"error": f"Invalid type '{media_type}' in item {chunk_idx * max_group_size + i}. Must be 'photo', 'video', or 'document'.",
}
# Check cache first
# Documents can't be in media groups - collect them for separate sending
if media_type == "document":
try:
download_url = self.coordinator.get_internal_download_url(url)
async with session.get(download_url) as resp:
if resp.status != 200:
return {
"success": False,
"error": f"Failed to download media {chunk_idx * max_group_size + i}: HTTP {resp.status}",
}
data = await resp.read()
if max_asset_data_size is not None and len(data) > max_asset_data_size:
_LOGGER.warning(
"Media %d size (%d bytes) exceeds max_asset_data_size limit (%d bytes), skipping",
chunk_idx * max_group_size + i, len(data), max_asset_data_size
)
skipped_count += 1
continue
# 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))
except aiohttp.ClientError as err:
return {
"success": False,
"error": f"Failed to download media {chunk_idx * max_group_size + i}: {err}",
}
continue
# Check cache first for photos/videos
cached = cache.get(url) if 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))
media_items.append((media_type, cached["file_id"], filename, url, True, item_content_type))
_LOGGER.debug("Using cached file_id for media %d", chunk_idx * max_group_size + i)
continue
@@ -1108,7 +1197,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))
media_items.append((media_type, data, filename, url, False, item_content_type))
except aiohttp.ClientError as err:
return {
"success": False,
@@ -1124,13 +1213,13 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
# Send media group if we have normal-sized files
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)
all_cached = all(is_cached for _, _, _, _, is_cached, _ in media_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))
media_json = []
for i, (media_type, file_id, _, _, _) in enumerate(media_items):
for i, (media_type, file_id, _, _, _, _) in enumerate(media_items):
media_item_json: dict[str, Any] = {
"type": media_type,
"media": file_id,
@@ -1178,7 +1267,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
upload_idx = 0
urls_to_cache: list[tuple[str, int, str]] = [] # (url, result_idx, type)
for i, (media_type, media_ref, filename, url, is_cached) in enumerate(media_items):
for i, (media_type, media_ref, filename, url, is_cached, item_content_type) in enumerate(media_items):
if is_cached:
# Use file_id directly
media_item_json: dict[str, Any] = {
@@ -1192,7 +1281,8 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
"type": media_type,
"media": f"attach://{attach_name}",
}
content_type = "image/jpeg" if media_type == "photo" else "video/mp4"
# 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))
upload_idx += 1
@@ -1236,7 +1326,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
else:
# Log detailed error for media group with total size info
uploaded_data = [m for m in media_items if not m[4]]
total_size = sum(len(d) for _, d, _, _, _ in uploaded_data if isinstance(d, bytes))
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),
@@ -1246,7 +1336,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
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 media_items:
if media_type == "photo" and not is_cached and isinstance(media_ref, bytes):
self._log_telegram_error(
error_code=result.get("error_code"),
@@ -1282,6 +1372,19 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
_LOGGER.error("Failed to send oversized photo as document: %s", result.get("error"))
# 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):
_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
)
if result.get("success"):
all_message_ids.append(result.get("message_id"))
else:
_LOGGER.error("Failed to send document: %s", result.get("error"))
# Continue with other documents even if one fails
return {
"success": True,
"message_ids": all_message_ids,

View File

@@ -131,7 +131,7 @@ get_assets:
send_telegram_notification:
name: Send Telegram Notification
description: Send a notification to Telegram (text, photo, video, or media group).
description: Send a notification to Telegram (text, photo, video, document, or media group).
target:
entity:
integration: immich_album_watcher
@@ -151,7 +151,7 @@ send_telegram_notification:
text:
urls:
name: URLs
description: List of media URLs to send. Each item should have 'url' and 'type' (photo/video). If empty, sends a text message. Large lists are automatically split into multiple media groups.
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."
required: false
selector:
object:

View File

@@ -195,7 +195,7 @@
},
"send_telegram_notification": {
"name": "Send Telegram Notification",
"description": "Send a notification to Telegram (text, photo, video, or media group).",
"description": "Send a notification to Telegram (text, photo, video, document, or media group).",
"fields": {
"bot_token": {
"name": "Bot Token",
@@ -207,7 +207,7 @@
},
"urls": {
"name": "URLs",
"description": "List of media URLs with type (photo/video). If empty, sends a text message. Large lists are automatically split into multiple media groups."
"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."
},
"caption": {
"name": "Caption",

View File

@@ -195,7 +195,7 @@
},
"send_telegram_notification": {
"name": "Отправить уведомление в Telegram",
"description": "Отправить уведомление в Telegram (текст, фото, видео или медиа-группу).",
"description": "Отправить уведомление в Telegram (текст, фото, видео, документ или медиа-группу).",
"fields": {
"bot_token": {
"name": "Токен бота",
@@ -207,7 +207,7 @@
},
"urls": {
"name": "URL-адреса",
"description": "Список URL медиа-файлов с типом (photo/video). Если пусто, отправляет текстовое сообщение. Большие списки автоматически разделяются на несколько медиа-групп."
"description": "Список URL медиа-файлов с типом (document/photo/video, по умолчанию document) и опциональным content_type (MIME-тип). Если пусто, отправляет текстовое сообщение. Фото и видео группируются; документы отправляются отдельно."
},
"caption": {
"name": "Подпись",