Add link generation buttons

This commit is contained in:
2026-01-30 03:45:06 +03:00
parent 82f293d0df
commit 5d2f4c7edf
6 changed files with 556 additions and 43 deletions

View File

@@ -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()

View File

@@ -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"

View File

@@ -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

View File

@@ -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."""

View File

@@ -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": {

View File

@@ -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": {