diff --git a/CLAUDE.md b/CLAUDE.md index 5b0674f..41b570b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,3 +14,18 @@ Use semantic versioning: - **MAJOR** (x.0.0): Breaking changes - **MINOR** (0.x.0): New features, backward compatible - **PATCH** (0.0.x): Bug fixes, integration documentation updates + +## Documentation Updates + +**IMPORTANT**: Always keep the README.md synchronized with integration changes. + +When modifying the integration interface, you MUST update the corresponding documentation: + +- **Service parameters**: Update parameter tables and examples in README.md +- **New events**: Add event documentation with examples and field descriptions +- **New entities**: Document entity types, attributes, and usage +- **Configuration options**: Update configuration documentation +- **Translation files**: Add translations for new parameters/entities in `en.json` and `ru.json` +- **services.yaml**: Keep service definitions in sync with implementation + +The README is the primary user-facing documentation and must accurately reflect the current state of the integration. diff --git a/README.md b/README.md index 8a06d73..d58aa33 100644 --- a/README.md +++ b/README.md @@ -171,14 +171,30 @@ data: reply_to_message_id: 123 ``` +HTML formatting: + +```yaml +service: immich_album_watcher.send_telegram_notification +target: + entity_id: sensor.album_name_asset_count +data: + chat_id: "-1001234567890" + caption: | + Album Updated! + New photos by {{ trigger.event.data.added_assets[0].asset_owner }} + View Album + parse_mode: "HTML" # Default, can be omitted +``` + | 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 | | `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. | 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 | | `disable_web_page_preview` | Disable link previews in text messages | No | +| `parse_mode` | How to parse caption/text. Options: `HTML`, `Markdown`, `MarkdownV2`, or empty string for plain text. Default: `HTML` | No | | `max_group_size` | Maximum media items per group (2-10). Large lists split into multiple groups. Default: 10 | No | | `chunk_delay` | Delay in milliseconds between sending multiple groups (0-60000). Useful for rate limiting. Default: 0 | No | @@ -186,7 +202,20 @@ The service returns a response with `success` status and `message_id` (single me ## Events -Use these events in your automations: +The integration fires multiple event types that you can use in your automations: + +### Available Events + +| Event Type | Description | When Fired | +|------------|-------------|------------| +| `immich_album_watcher_album_changed` | General album change event | Fired for any album change | +| `immich_album_watcher_assets_added` | Assets were added to the album | When new photos/videos are added | +| `immich_album_watcher_assets_removed` | Assets were removed from the album | When photos/videos are removed | +| `immich_album_watcher_album_renamed` | Album name was changed | When the album is renamed | +| `immich_album_watcher_album_deleted` | Album was deleted | When the album is deleted from Immich | +| `immich_album_watcher_album_sharing_changed` | Album sharing status changed | When album is shared or unshared | + +### Example Usage ```yaml automation: @@ -199,21 +228,47 @@ automation: data: title: "New Photos" message: "{{ trigger.event.data.added_count }} new photos in {{ trigger.event.data.album_name }}" + + - alias: "Album renamed" + trigger: + - platform: event + event_type: immich_album_watcher_album_renamed + action: + - service: notify.mobile_app + data: + title: "Album Renamed" + message: "Album '{{ trigger.event.data.old_name }}' renamed to '{{ trigger.event.data.new_name }}'" + + - alias: "Album deleted" + trigger: + - platform: event + event_type: immich_album_watcher_album_deleted + action: + - service: notify.mobile_app + data: + title: "Album Deleted" + message: "Album '{{ trigger.event.data.album_name }}' was deleted" ``` ### Event Data -| Field | Description | -|-------|-------------| -| `album_id` | Album ID | -| `album_name` | Album name | -| `album_url` | Public URL to view the album (only present if album has a shared link) | -| `change_type` | Type of change (assets_added, assets_removed, changed) | -| `added_count` | Number of assets added | -| `removed_count` | Number of assets removed | -| `added_assets` | List of added assets with details (see below) | -| `removed_assets` | List of removed asset IDs | -| `people` | List of all people detected in the album | +| Field | Description | Available In | +|-------|-------------|--------------| +| `hub_name` | Hub name configured in integration | All events | +| `album_id` | Album ID | All events | +| `album_name` | Current album name | All events | +| `album_url` | Public URL to view the album (only present if album has a shared link) | All events except `album_deleted` | +| `change_type` | Type of change (assets_added, assets_removed, album_renamed, album_sharing_changed, changed) | All events except `album_deleted` | +| `shared` | Current sharing status of the album | All events except `album_deleted` | +| `added_count` | Number of assets added | `album_changed`, `assets_added` | +| `removed_count` | Number of assets removed | `album_changed`, `assets_removed` | +| `added_assets` | List of added assets with details (see below) | `album_changed`, `assets_added` | +| `removed_assets` | List of removed asset IDs | `album_changed`, `assets_removed` | +| `people` | List of all people detected in the album | All events except `album_deleted` | +| `old_name` | Previous album name | `album_renamed` | +| `new_name` | New album name | `album_renamed` | +| `old_shared` | Previous sharing status | `album_sharing_changed` | +| `new_shared` | New sharing status | `album_sharing_changed` | ### Added Assets Fields diff --git a/custom_components/immich_album_watcher/const.py b/custom_components/immich_album_watcher/const.py index c796824..be19f06 100644 --- a/custom_components/immich_album_watcher/const.py +++ b/custom_components/immich_album_watcher/const.py @@ -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" diff --git a/custom_components/immich_album_watcher/coordinator.py b/custom_components/immich_album_watcher/coordinator.py index f31e15f..8321627 100644 --- a/custom_components/immich_album_watcher/coordinator.py +++ b/custom_components/immich_album_watcher/coordinator.py @@ -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() diff --git a/custom_components/immich_album_watcher/sensor.py b/custom_components/immich_album_watcher/sensor.py index 244efdb..c3cfb17 100644 --- a/custom_components/immich_album_watcher/sensor.py +++ b/custom_components/immich_album_watcher/sensor.py @@ -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" diff --git a/custom_components/immich_album_watcher/services.yaml b/custom_components/immich_album_watcher/services.yaml index b1e7ef1..d0f3690 100644 --- a/custom_components/immich_album_watcher/services.yaml +++ b/custom_components/immich_album_watcher/services.yaml @@ -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. diff --git a/custom_components/immich_album_watcher/translations/en.json b/custom_components/immich_album_watcher/translations/en.json index f8f842e..66df4ae 100644 --- a/custom_components/immich_album_watcher/translations/en.json +++ b/custom_components/immich_album_watcher/translations/en.json @@ -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." diff --git a/custom_components/immich_album_watcher/translations/ru.json b/custom_components/immich_album_watcher/translations/ru.json index 86b1c79..74627f5 100644 --- a/custom_components/immich_album_watcher/translations/ru.json +++ b/custom_components/immich_album_watcher/translations/ru.json @@ -171,6 +171,10 @@ "name": "Отключить предпросмотр ссылок", "description": "Отключить предпросмотр ссылок в текстовых сообщениях." }, + "parse_mode": { + "name": "Режим парсинга", + "description": "Как парсить подпись/текст. Варианты: HTML, Markdown, MarkdownV2, или пустая строка для обычного текста." + }, "max_group_size": { "name": "Макс. размер группы", "description": "Максимальное количество медиа-файлов в одной группе (2-10). Большие списки будут разделены на несколько групп."