From 5d2f4c7edf332834fd1ca1667050a4aa9b641ad9 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 30 Jan 2026 03:45:06 +0300 Subject: [PATCH] Add link generation buttons --- immich_album_watcher/button.py | 437 ++++++++++++++++++++++ immich_album_watcher/const.py | 3 +- immich_album_watcher/coordinator.py | 89 +++++ immich_album_watcher/sensor.py | 36 -- immich_album_watcher/translations/en.json | 17 +- immich_album_watcher/translations/ru.json | 17 +- 6 files changed, 556 insertions(+), 43 deletions(-) create mode 100644 immich_album_watcher/button.py diff --git a/immich_album_watcher/button.py b/immich_album_watcher/button.py new file mode 100644 index 0000000..dd64822 --- /dev/null +++ b/immich_album_watcher/button.py @@ -0,0 +1,437 @@ +"""Button platform for Immich Album Watcher.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify + +from .const import ( + ATTR_ALBUM_ID, + ATTR_ALBUM_PROTECTED_URL, + CONF_ALBUM_ID, + CONF_ALBUM_NAME, + CONF_HUB_NAME, + DEFAULT_SHARE_PASSWORD, + DOMAIN, +) +from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Immich Album Watcher button entities from a config entry.""" + # Iterate through all album subentries + for subentry_id, subentry in entry.subentries.items(): + subentry_data = hass.data[DOMAIN][entry.entry_id]["subentries"].get(subentry_id) + if not subentry_data: + _LOGGER.error("Subentry data not found for %s", subentry_id) + continue + + coordinator = subentry_data.coordinator + + async_add_entities( + [ + ImmichCreateShareLinkButton(coordinator, entry, subentry), + ImmichDeleteShareLinkButton(coordinator, entry, subentry), + ImmichCreateProtectedLinkButton(coordinator, entry, subentry), + ImmichDeleteProtectedLinkButton(coordinator, entry, subentry), + ], + config_subentry_id=subentry_id, + ) + + +class ImmichCreateShareLinkButton( + CoordinatorEntity[ImmichAlbumWatcherCoordinator], ButtonEntity +): + """Button entity for creating an unprotected share link.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:link-plus" + _attr_translation_key = "create_share_link" + + def __init__( + self, + coordinator: ImmichAlbumWatcherCoordinator, + entry: ConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the button entity.""" + super().__init__(coordinator) + self._entry = entry + self._subentry = subentry + self._album_id = subentry.data[CONF_ALBUM_ID] + self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") + self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") + unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}") + self._attr_unique_id = f"{unique_id_prefix}_create_share_link" + + @property + def _album_data(self) -> AlbumData | None: + """Get the album data from coordinator.""" + return self.coordinator.data + + @property + def translation_placeholders(self) -> dict[str, str]: + """Return translation placeholders.""" + if self._album_data: + return {"album_name": self._album_data.name} + return {"album_name": self._album_name} + + @property + def available(self) -> bool: + """Return if entity is available. + + Only available when there is no unprotected link. + """ + if not self.coordinator.last_update_success or self._album_data is None: + return False + # Only available if there's no unprotected link yet + return not self.coordinator.has_unprotected_link() + + @property + def device_info(self) -> DeviceInfo: + """Return device info - one device per album.""" + return DeviceInfo( + identifiers={(DOMAIN, self._subentry.subentry_id)}, + name=self._album_name, + manufacturer="Immich", + entry_type=DeviceEntryType.SERVICE, + via_device=(DOMAIN, self._entry.entry_id), + ) + + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return extra state attributes.""" + if not self._album_data: + return {} + + return { + ATTR_ALBUM_ID: self._album_data.id, + } + + async def async_press(self) -> None: + """Handle button press - create share link.""" + if self.coordinator.has_unprotected_link(): + _LOGGER.warning( + "Album %s already has an unprotected share link", + self._album_name, + ) + return + + success = await self.coordinator.async_create_shared_link() + + if success: + await self.coordinator.async_refresh() + else: + _LOGGER.error("Failed to create share link for album %s", self._album_id) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() + + +class ImmichDeleteShareLinkButton( + CoordinatorEntity[ImmichAlbumWatcherCoordinator], ButtonEntity +): + """Button entity for deleting an unprotected share link.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:link-off" + _attr_translation_key = "delete_share_link" + + def __init__( + self, + coordinator: ImmichAlbumWatcherCoordinator, + entry: ConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the button entity.""" + super().__init__(coordinator) + self._entry = entry + self._subentry = subentry + self._album_id = subentry.data[CONF_ALBUM_ID] + self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") + self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") + unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}") + self._attr_unique_id = f"{unique_id_prefix}_delete_share_link" + + @property + def _album_data(self) -> AlbumData | None: + """Get the album data from coordinator.""" + return self.coordinator.data + + @property + def translation_placeholders(self) -> dict[str, str]: + """Return translation placeholders.""" + if self._album_data: + return {"album_name": self._album_data.name} + return {"album_name": self._album_name} + + @property + def available(self) -> bool: + """Return if entity is available. + + Only available when there is an unprotected link. + """ + if not self.coordinator.last_update_success or self._album_data is None: + return False + # Only available if there's an unprotected link to delete + return self.coordinator.has_unprotected_link() + + @property + def device_info(self) -> DeviceInfo: + """Return device info - one device per album.""" + return DeviceInfo( + identifiers={(DOMAIN, self._subentry.subentry_id)}, + name=self._album_name, + manufacturer="Immich", + entry_type=DeviceEntryType.SERVICE, + via_device=(DOMAIN, self._entry.entry_id), + ) + + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return extra state attributes.""" + if not self._album_data: + return {} + + attrs = { + ATTR_ALBUM_ID: self._album_data.id, + } + + public_url = self.coordinator.get_public_url() + if public_url: + attrs[ATTR_ALBUM_PROTECTED_URL] = public_url + + return attrs + + async def async_press(self) -> None: + """Handle button press - delete share link.""" + link_id = self.coordinator.get_unprotected_link_id() + if not link_id: + _LOGGER.warning( + "No unprotected share link found for album %s", + self._album_name, + ) + return + + success = await self.coordinator.async_delete_shared_link(link_id) + + if success: + await self.coordinator.async_refresh() + else: + _LOGGER.error("Failed to delete share link for album %s", self._album_id) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() + + +class ImmichCreateProtectedLinkButton( + CoordinatorEntity[ImmichAlbumWatcherCoordinator], ButtonEntity +): + """Button entity for creating a protected (password) share link.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:link-lock" + _attr_translation_key = "create_protected_link" + + def __init__( + self, + coordinator: ImmichAlbumWatcherCoordinator, + entry: ConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the button entity.""" + super().__init__(coordinator) + self._entry = entry + self._subentry = subentry + self._album_id = subentry.data[CONF_ALBUM_ID] + self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") + self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") + unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}") + self._attr_unique_id = f"{unique_id_prefix}_create_protected_link" + + @property + def _album_data(self) -> AlbumData | None: + """Get the album data from coordinator.""" + return self.coordinator.data + + @property + def translation_placeholders(self) -> dict[str, str]: + """Return translation placeholders.""" + if self._album_data: + return {"album_name": self._album_data.name} + return {"album_name": self._album_name} + + @property + def available(self) -> bool: + """Return if entity is available. + + Only available when there is no protected link. + """ + if not self.coordinator.last_update_success or self._album_data is None: + return False + # Only available if there's no protected link yet + return not self.coordinator.has_protected_link() + + @property + def device_info(self) -> DeviceInfo: + """Return device info - one device per album.""" + return DeviceInfo( + identifiers={(DOMAIN, self._subentry.subentry_id)}, + name=self._album_name, + manufacturer="Immich", + entry_type=DeviceEntryType.SERVICE, + via_device=(DOMAIN, self._entry.entry_id), + ) + + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return extra state attributes.""" + if not self._album_data: + return {} + + return { + ATTR_ALBUM_ID: self._album_data.id, + } + + async def async_press(self) -> None: + """Handle button press - create protected share link.""" + if self.coordinator.has_protected_link(): + _LOGGER.warning( + "Album %s already has a protected share link", + self._album_name, + ) + return + + success = await self.coordinator.async_create_shared_link( + password=DEFAULT_SHARE_PASSWORD + ) + + if success: + await self.coordinator.async_refresh() + else: + _LOGGER.error( + "Failed to create protected share link for album %s", self._album_id + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() + + +class ImmichDeleteProtectedLinkButton( + CoordinatorEntity[ImmichAlbumWatcherCoordinator], ButtonEntity +): + """Button entity for deleting a protected (password) share link.""" + + _attr_has_entity_name = True + _attr_icon = "mdi:link-off" + _attr_translation_key = "delete_protected_link" + + def __init__( + self, + coordinator: ImmichAlbumWatcherCoordinator, + entry: ConfigEntry, + subentry: ConfigSubentry, + ) -> None: + """Initialize the button entity.""" + super().__init__(coordinator) + self._entry = entry + self._subentry = subentry + self._album_id = subentry.data[CONF_ALBUM_ID] + self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") + self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") + unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}") + self._attr_unique_id = f"{unique_id_prefix}_delete_protected_link" + + @property + def _album_data(self) -> AlbumData | None: + """Get the album data from coordinator.""" + return self.coordinator.data + + @property + def translation_placeholders(self) -> dict[str, str]: + """Return translation placeholders.""" + if self._album_data: + return {"album_name": self._album_data.name} + return {"album_name": self._album_name} + + @property + def available(self) -> bool: + """Return if entity is available. + + Only available when there is a protected link. + """ + if not self.coordinator.last_update_success or self._album_data is None: + return False + # Only available if there's a protected link to delete + return self.coordinator.has_protected_link() + + @property + def device_info(self) -> DeviceInfo: + """Return device info - one device per album.""" + return DeviceInfo( + identifiers={(DOMAIN, self._subentry.subentry_id)}, + name=self._album_name, + manufacturer="Immich", + entry_type=DeviceEntryType.SERVICE, + via_device=(DOMAIN, self._entry.entry_id), + ) + + @property + def extra_state_attributes(self) -> dict[str, str]: + """Return extra state attributes.""" + if not self._album_data: + return {} + + attrs = { + ATTR_ALBUM_ID: self._album_data.id, + } + + protected_url = self.coordinator.get_protected_url() + if protected_url: + attrs[ATTR_ALBUM_PROTECTED_URL] = protected_url + + return attrs + + async def async_press(self) -> None: + """Handle button press - delete protected share link.""" + link_id = self.coordinator.get_protected_link_id() + if not link_id: + _LOGGER.warning( + "No protected share link found for album %s", + self._album_name, + ) + return + + success = await self.coordinator.async_delete_shared_link(link_id) + + if success: + await self.coordinator.async_refresh() + else: + _LOGGER.error( + "Failed to delete protected share link for album %s", self._album_id + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() diff --git a/immich_album_watcher/const.py b/immich_album_watcher/const.py index b9e9222..80784e9 100644 --- a/immich_album_watcher/const.py +++ b/immich_album_watcher/const.py @@ -20,6 +20,7 @@ SUBENTRY_TYPE_ALBUM: Final = "album" # Defaults DEFAULT_SCAN_INTERVAL: Final = 60 # seconds NEW_ASSETS_RESET_DELAY: Final = 300 # 5 minutes +DEFAULT_SHARE_PASSWORD: Final = "immich123" # Events EVENT_ALBUM_CHANGED: Final = f"{DOMAIN}_album_changed" @@ -61,7 +62,7 @@ ASSET_TYPE_IMAGE: Final = "IMAGE" ASSET_TYPE_VIDEO: Final = "VIDEO" # Platforms -PLATFORMS: Final = ["sensor", "binary_sensor", "camera", "text"] +PLATFORMS: Final = ["sensor", "binary_sensor", "camera", "text", "button"] # Services SERVICE_REFRESH: Final = "refresh" diff --git a/immich_album_watcher/coordinator.py b/immich_album_watcher/coordinator.py index ab5d927..c2b9698 100644 --- a/immich_album_watcher/coordinator.py +++ b/immich_album_watcher/coordinator.py @@ -621,3 +621,92 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): if self.data: self.data.has_new_assets = False self.data.last_change_time = None + + def has_unprotected_link(self) -> bool: + """Check if album has an unprotected (accessible) shared link.""" + return len(self._get_accessible_links()) > 0 + + def has_protected_link(self) -> bool: + """Check if album has a protected (password) shared link.""" + return len(self._get_protected_links()) > 0 + + def get_unprotected_link_id(self) -> str | None: + """Get the ID of the first unprotected link.""" + accessible_links = self._get_accessible_links() + if accessible_links: + return accessible_links[0].id + return None + + async def async_create_shared_link(self, password: str | None = None) -> bool: + """Create a new shared link for the album via Immich API.""" + if self._session is None: + self._session = async_get_clientsession(self.hass) + + headers = { + "x-api-key": self._api_key, + "Content-Type": "application/json", + } + + payload: dict[str, Any] = { + "albumId": self._album_id, + "type": "ALBUM", + "allowDownload": True, + "allowUpload": False, + "showMetadata": True, + } + + if password: + payload["password"] = password + + try: + async with self._session.post( + f"{self._url}/api/shared-links", + headers=headers, + json=payload, + ) as response: + if response.status == 201: + _LOGGER.info( + "Successfully created shared link for album %s", + self._album_name, + ) + await self._async_fetch_shared_links() + return True + else: + error_text = await response.text() + _LOGGER.error( + "Failed to create shared link: HTTP %s - %s", + response.status, + error_text, + ) + return False + except aiohttp.ClientError as err: + _LOGGER.error("Error creating shared link: %s", err) + return False + + async def async_delete_shared_link(self, link_id: str) -> bool: + """Delete a shared link via Immich API.""" + if self._session is None: + self._session = async_get_clientsession(self.hass) + + headers = {"x-api-key": self._api_key} + + try: + async with self._session.delete( + f"{self._url}/api/shared-links/{link_id}", + headers=headers, + ) as response: + if response.status == 200: + _LOGGER.info("Successfully deleted shared link") + await self._async_fetch_shared_links() + return True + else: + error_text = await response.text() + _LOGGER.error( + "Failed to delete shared link: HTTP %s - %s", + response.status, + error_text, + ) + return False + except aiohttp.ClientError as err: + _LOGGER.error("Error deleting shared link: %s", err) + return False diff --git a/immich_album_watcher/sensor.py b/immich_album_watcher/sensor.py index 06354a4..cfd3484 100644 --- a/immich_album_watcher/sensor.py +++ b/immich_album_watcher/sensor.py @@ -68,7 +68,6 @@ async def async_setup_entry( ImmichAlbumVideoCountSensor(coordinator, entry, subentry), ImmichAlbumLastUpdatedSensor(coordinator, entry, subentry), ImmichAlbumCreatedSensor(coordinator, entry, subentry), - ImmichAlbumPeopleSensor(coordinator, entry, subentry), ImmichAlbumPublicUrlSensor(coordinator, entry, subentry), ImmichAlbumProtectedUrlSensor(coordinator, entry, subentry), ImmichAlbumProtectedPasswordSensor(coordinator, entry, subentry), @@ -322,41 +321,6 @@ class ImmichAlbumCreatedSensor(ImmichAlbumBaseSensor): return None -class ImmichAlbumPeopleSensor(ImmichAlbumBaseSensor): - """Sensor representing people detected in an Immich album.""" - - _attr_state_class = SensorStateClass.MEASUREMENT - _attr_icon = "mdi:account-group" - _attr_translation_key = "album_people_count" - - def __init__( - self, - coordinator: ImmichAlbumWatcherCoordinator, - entry: ConfigEntry, - subentry: ConfigSubentry, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator, entry, subentry) - self._attr_unique_id = f"{self._unique_id_prefix}_people_count" - - @property - def native_value(self) -> int | None: - """Return the state of the sensor (number of unique people).""" - if self._album_data: - return len(self._album_data.people) - return None - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return extra state attributes.""" - if not self._album_data: - return {} - - return { - ATTR_PEOPLE: list(self._album_data.people), - } - - class ImmichAlbumPublicUrlSensor(ImmichAlbumBaseSensor): """Sensor representing an Immich album public URL.""" diff --git a/immich_album_watcher/translations/en.json b/immich_album_watcher/translations/en.json index 3188154..cf7f402 100644 --- a/immich_album_watcher/translations/en.json +++ b/immich_album_watcher/translations/en.json @@ -16,9 +16,6 @@ "album_created": { "name": "{album_name}: Created" }, - "album_people_count": { - "name": "{album_name}: People Count" - }, "album_public_url": { "name": "{album_name}: Public URL" }, @@ -43,6 +40,20 @@ "album_protected_password_edit": { "name": "{album_name}: Share Password" } + }, + "button": { + "create_share_link": { + "name": "{album_name}: Create Share Link" + }, + "delete_share_link": { + "name": "{album_name}: Delete Share Link" + }, + "create_protected_link": { + "name": "{album_name}: Create Protected Link" + }, + "delete_protected_link": { + "name": "{album_name}: Delete Protected Link" + } } }, "config": { diff --git a/immich_album_watcher/translations/ru.json b/immich_album_watcher/translations/ru.json index 7d76ab2..e304f41 100644 --- a/immich_album_watcher/translations/ru.json +++ b/immich_album_watcher/translations/ru.json @@ -16,9 +16,6 @@ "album_created": { "name": "{album_name}: Дата создания" }, - "album_people_count": { - "name": "{album_name}: Число людей" - }, "album_public_url": { "name": "{album_name}: Публичная ссылка" }, @@ -43,6 +40,20 @@ "album_protected_password_edit": { "name": "{album_name}: Пароль ссылки" } + }, + "button": { + "create_share_link": { + "name": "{album_name}: Создать ссылку" + }, + "delete_share_link": { + "name": "{album_name}: Удалить ссылку" + }, + "create_protected_link": { + "name": "{album_name}: Создать защищённую ссылку" + }, + "delete_protected_link": { + "name": "{album_name}: Удалить защищённую ссылку" + } } }, "config": {