Add parse_mode to service call API
All checks were successful
Validate / Hassfest (push) Successful in 3s

This commit is contained in:
2026-01-31 15:32:20 +03:00
parent 2ae706d700
commit 2007b020ba
8 changed files with 196 additions and 22 deletions

View File

@@ -27,6 +27,9 @@ DEFAULT_SHARE_PASSWORD: Final = "immich123"
EVENT_ALBUM_CHANGED: Final = f"{DOMAIN}_album_changed"
EVENT_ASSETS_ADDED: Final = f"{DOMAIN}_assets_added"
EVENT_ASSETS_REMOVED: Final = f"{DOMAIN}_assets_removed"
EVENT_ALBUM_RENAMED: Final = f"{DOMAIN}_album_renamed"
EVENT_ALBUM_DELETED: Final = f"{DOMAIN}_album_deleted"
EVENT_ALBUM_SHARING_CHANGED: Final = f"{DOMAIN}_album_sharing_changed"
# Attributes
ATTR_HUB_NAME: Final = "hub_name"
@@ -50,6 +53,10 @@ ATTR_THUMBNAIL_URL: Final = "thumbnail_url"
ATTR_SHARED: Final = "shared"
ATTR_OWNER: Final = "owner"
ATTR_PEOPLE: Final = "people"
ATTR_OLD_NAME: Final = "old_name"
ATTR_NEW_NAME: Final = "new_name"
ATTR_OLD_SHARED: Final = "old_shared"
ATTR_NEW_SHARED: Final = "new_shared"
ATTR_ASSET_TYPE: Final = "asset_type"
ATTR_ASSET_FILENAME: Final = "asset_filename"
ATTR_ASSET_CREATED: Final = "asset_created"

View File

@@ -38,10 +38,18 @@ from .const import (
ATTR_PEOPLE,
ATTR_REMOVED_ASSETS,
ATTR_REMOVED_COUNT,
ATTR_OLD_NAME,
ATTR_NEW_NAME,
ATTR_OLD_SHARED,
ATTR_NEW_SHARED,
ATTR_SHARED,
DOMAIN,
EVENT_ALBUM_CHANGED,
EVENT_ASSETS_ADDED,
EVENT_ASSETS_REMOVED,
EVENT_ALBUM_RENAMED,
EVENT_ALBUM_DELETED,
EVENT_ALBUM_SHARING_CHANGED,
)
_LOGGER = logging.getLogger(__name__)
@@ -210,6 +218,10 @@ class AlbumChange:
removed_count: int = 0
added_assets: list[AssetInfo] = field(default_factory=list)
removed_asset_ids: list[str] = field(default_factory=list)
old_name: str | None = None
new_name: str | None = None
old_shared: bool | None = None
new_shared: bool | None = None
class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
@@ -510,6 +522,15 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
) as response:
if response.status == 404:
_LOGGER.warning("Album %s not found", self._album_id)
# Fire album_deleted event if we had previous state (album was deleted)
if self._previous_state:
event_data = {
ATTR_HUB_NAME: self._hub_name,
ATTR_ALBUM_ID: self._album_id,
ATTR_ALBUM_NAME: self._previous_state.name,
}
self.hass.bus.async_fire(EVENT_ALBUM_DELETED, event_data)
_LOGGER.info("Album '%s' was deleted", self._previous_state.name)
return None
if response.status != 200:
raise UpdateFailed(
@@ -599,13 +620,23 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
added_ids = new_state.asset_ids - old_state.asset_ids
removed_ids = old_state.asset_ids - new_state.asset_ids
if not added_ids and not removed_ids:
# Detect metadata changes
name_changed = old_state.name != new_state.name
sharing_changed = old_state.shared != new_state.shared
# Return None only if nothing changed at all
if not added_ids and not removed_ids and not name_changed and not sharing_changed:
return None
# Determine primary change type
change_type = "changed"
if added_ids and not removed_ids:
if name_changed and not added_ids and not removed_ids and not sharing_changed:
change_type = "album_renamed"
elif sharing_changed and not added_ids and not removed_ids and not name_changed:
change_type = "album_sharing_changed"
elif added_ids and not removed_ids and not name_changed and not sharing_changed:
change_type = "assets_added"
elif removed_ids and not added_ids:
elif removed_ids and not added_ids and not name_changed and not sharing_changed:
change_type = "assets_removed"
added_assets = [
@@ -620,6 +651,10 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
removed_count=len(removed_ids),
added_assets=added_assets,
removed_asset_ids=list(removed_ids),
old_name=old_state.name if name_changed else None,
new_name=new_state.name if name_changed else None,
old_shared=old_state.shared if sharing_changed else None,
new_shared=new_state.shared if sharing_changed else None,
)
def _fire_events(self, change: AlbumChange, album: AlbumData) -> None:
@@ -658,8 +693,18 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
ATTR_ADDED_ASSETS: added_assets_detail,
ATTR_REMOVED_ASSETS: change.removed_asset_ids,
ATTR_PEOPLE: list(album.people),
ATTR_SHARED: album.shared,
}
# Add metadata change attributes if applicable
if change.old_name is not None:
event_data[ATTR_OLD_NAME] = change.old_name
event_data[ATTR_NEW_NAME] = change.new_name
if change.old_shared is not None:
event_data[ATTR_OLD_SHARED] = change.old_shared
event_data[ATTR_NEW_SHARED] = change.new_shared
album_url = self.get_any_url()
if album_url:
event_data[ATTR_ALBUM_URL] = album_url
@@ -679,6 +724,24 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
if change.removed_count > 0:
self.hass.bus.async_fire(EVENT_ASSETS_REMOVED, event_data)
# Fire specific events for metadata changes
if change.old_name is not None:
self.hass.bus.async_fire(EVENT_ALBUM_RENAMED, event_data)
_LOGGER.info(
"Album renamed: '%s' -> '%s'",
change.old_name,
change.new_name,
)
if change.old_shared is not None:
self.hass.bus.async_fire(EVENT_ALBUM_SHARING_CHANGED, event_data)
_LOGGER.info(
"Album '%s' sharing changed: %s -> %s",
change.album_name,
change.old_shared,
change.new_shared,
)
def get_protected_link_id(self) -> str | None:
"""Get the ID of the first protected link."""
protected_links = self._get_protected_links()

View File

@@ -107,6 +107,7 @@ async def async_setup_entry(
vol.Optional("caption"): str,
vol.Optional("reply_to_message_id"): vol.Coerce(int),
vol.Optional("disable_web_page_preview"): bool,
vol.Optional("parse_mode", default="HTML"): str,
vol.Optional("max_group_size", default=10): vol.All(
vol.Coerce(int), vol.Range(min=2, max=10)
),
@@ -182,6 +183,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
caption: str | None = None,
reply_to_message_id: int | None = None,
disable_web_page_preview: bool | None = None,
parse_mode: str = "HTML",
max_group_size: int = 10,
chunk_delay: int = 0,
) -> ServiceResponse:
@@ -214,24 +216,24 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
# Handle empty URLs - send simple text message
if not urls:
return await self._send_telegram_message(
session, token, chat_id, caption or "", reply_to_message_id, disable_web_page_preview
session, token, chat_id, caption or "", reply_to_message_id, disable_web_page_preview, parse_mode
)
# Handle single photo
if len(urls) == 1 and urls[0].get("type", "photo") == "photo":
return await self._send_telegram_photo(
session, token, chat_id, urls[0].get("url"), caption, reply_to_message_id
session, token, chat_id, urls[0].get("url"), caption, reply_to_message_id, parse_mode
)
# 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
session, token, chat_id, urls[0].get("url"), caption, reply_to_message_id, parse_mode
)
# 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
session, token, chat_id, urls, caption, reply_to_message_id, max_group_size, chunk_delay, parse_mode
)
async def _send_telegram_message(
@@ -242,6 +244,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
text: str,
reply_to_message_id: int | None = None,
disable_web_page_preview: bool | None = None,
parse_mode: str = "HTML",
) -> ServiceResponse:
"""Send a simple text message to Telegram."""
import aiohttp
@@ -251,6 +254,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
payload: dict[str, Any] = {
"chat_id": chat_id,
"text": text or "Notification from Home Assistant",
"parse_mode": parse_mode,
}
if reply_to_message_id:
@@ -288,6 +292,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
url: str | None,
caption: str | None = None,
reply_to_message_id: int | None = None,
parse_mode: str = "HTML",
) -> ServiceResponse:
"""Send a single photo to Telegram."""
import aiohttp
@@ -312,6 +317,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field("photo", data, filename="photo.jpg", content_type="image/jpeg")
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
@@ -350,6 +356,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
url: str | None,
caption: str | None = None,
reply_to_message_id: int | None = None,
parse_mode: str = "HTML",
) -> ServiceResponse:
"""Send a single video to Telegram."""
import aiohttp
@@ -374,6 +381,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field("video", data, filename="video.mp4", content_type="video/mp4")
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
@@ -414,6 +422,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
reply_to_message_id: int | None = None,
max_group_size: int = 10,
chunk_delay: int = 0,
parse_mode: str = "HTML",
) -> ServiceResponse:
"""Send media URLs to Telegram as media group(s).
@@ -454,12 +463,12 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
if media_type == "photo":
_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
session, token, chat_id, url, chunk_caption, chunk_reply_to, parse_mode
)
else: # 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
session, token, chat_id, url, chunk_caption, chunk_reply_to, parse_mode
)
if not result.get("success"):
@@ -527,6 +536,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
# Only add caption to the first item of the first chunk
if chunk_idx == 0 and i == 0 and caption:
media_item["caption"] = caption
media_item["parse_mode"] = parse_mode
media_json.append(media_item)
content_type = "image/jpeg" if media_type == "photo" else "video/mp4"

View File

@@ -71,6 +71,22 @@ send_telegram_notification:
required: false
selector:
boolean:
parse_mode:
name: Parse Mode
description: How to parse the caption/text. Options are "HTML", "Markdown", "MarkdownV2", or empty string for plain text.
required: false
default: "HTML"
selector:
select:
options:
- label: "HTML"
value: "HTML"
- label: "Markdown"
value: "Markdown"
- label: "MarkdownV2"
value: "MarkdownV2"
- label: "Plain Text"
value: ""
max_group_size:
name: Max Group Size
description: Maximum number of media items per media group (2-10). Large lists will be split into multiple groups.

View File

@@ -171,6 +171,10 @@
"name": "Disable Web Page Preview",
"description": "Disable link previews in text messages."
},
"parse_mode": {
"name": "Parse Mode",
"description": "How to parse the caption/text. Options are HTML, Markdown, MarkdownV2, or empty string for plain text."
},
"max_group_size": {
"name": "Max Group Size",
"description": "Maximum number of media items per media group (2-10). Large lists will be split into multiple groups."

View File

@@ -171,6 +171,10 @@
"name": "Отключить предпросмотр ссылок",
"description": "Отключить предпросмотр ссылок в текстовых сообщениях."
},
"parse_mode": {
"name": "Режим парсинга",
"description": "Как парсить подпись/текст. Варианты: HTML, Markdown, MarkdownV2, или пустая строка для обычного текста."
},
"max_group_size": {
"name": "Макс. размер группы",
"description": "Максимальное количество медиа-файлов в одной группе (2-10). Большие списки будут разделены на несколько групп."