From 60573374a4aa5ca73fa7ba19d5297c7e05723fd8 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 30 Jan 2026 02:39:59 +0300 Subject: [PATCH] Implement hub and subenty approach based on telegram bot integration implementation --- immich_album_watcher/__init__.py | 137 ++++++++-- immich_album_watcher/binary_sensor.py | 54 ++-- immich_album_watcher/camera.py | 46 ++-- immich_album_watcher/config_flow.py | 167 ++++++++----- immich_album_watcher/const.py | 5 + immich_album_watcher/coordinator.py | 288 +++++++++------------- immich_album_watcher/manifest.json | 3 +- immich_album_watcher/sensor.py | 141 +++++------ immich_album_watcher/text.py | 54 ++-- immich_album_watcher/translations/en.json | 40 ++- immich_album_watcher/translations/ru.json | 40 ++- 11 files changed, 549 insertions(+), 426 deletions(-) diff --git a/immich_album_watcher/__init__.py b/immich_album_watcher/__init__.py index e96ae60..e069c41 100644 --- a/immich_album_watcher/__init__.py +++ b/immich_album_watcher/__init__.py @@ -3,12 +3,14 @@ from __future__ import annotations import logging +from dataclasses import dataclass -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import HomeAssistant from .const import ( - CONF_ALBUMS, + CONF_ALBUM_ID, + CONF_ALBUM_NAME, CONF_API_KEY, CONF_IMMICH_URL, CONF_SCAN_INTERVAL, @@ -21,60 +23,143 @@ from .coordinator import ImmichAlbumWatcherCoordinator _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Immich Album Watcher from a config entry.""" +@dataclass +class ImmichHubData: + """Data for the Immich hub.""" + + url: str + api_key: str + scan_interval: int + + +@dataclass +class ImmichAlbumRuntimeData: + """Runtime data for an album subentry.""" + + coordinator: ImmichAlbumWatcherCoordinator + album_id: str + album_name: str + + +type ImmichConfigEntry = ConfigEntry[ImmichHubData] + + +async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool: + """Set up Immich Album Watcher hub from a config entry.""" hass.data.setdefault(DOMAIN, {}) url = entry.data[CONF_IMMICH_URL] api_key = entry.data[CONF_API_KEY] - album_ids = entry.options.get(CONF_ALBUMS, []) scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - coordinator = ImmichAlbumWatcherCoordinator( - hass, + # Store hub data + entry.runtime_data = ImmichHubData( url=url, api_key=api_key, - album_ids=album_ids, scan_interval=scan_interval, ) - # Fetch initial data - await coordinator.async_config_entry_first_refresh() + # Store hub reference + hass.data[DOMAIN][entry.entry_id] = { + "hub": entry.runtime_data, + "subentries": {}, + } - hass.data[DOMAIN][entry.entry_id] = coordinator + # Track loaded subentries to detect changes + hass.data[DOMAIN][entry.entry_id]["loaded_subentries"] = set(entry.subentries.keys()) - # Set up platforms + # Set up coordinators for all subentries (albums) + for subentry_id, subentry in entry.subentries.items(): + await _async_setup_subentry_coordinator(hass, entry, subentry) + + # Forward platform setup once - platforms will iterate through subentries await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Register update listener for options changes - entry.async_on_unload(entry.add_update_listener(async_update_options)) + # Register update listener for options and subentry changes + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) _LOGGER.info( - "Immich Album Watcher set up successfully, watching %d albums", - len(album_ids), + "Immich Album Watcher hub set up successfully with %d albums", + len(entry.subentries), ) return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] +async def _async_setup_subentry_coordinator( + hass: HomeAssistant, entry: ImmichConfigEntry, subentry: ConfigSubentry +) -> None: + """Set up coordinator for an album subentry.""" + hub_data: ImmichHubData = entry.runtime_data + album_id = subentry.data[CONF_ALBUM_ID] + album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") - album_ids = entry.options.get(CONF_ALBUMS, []) - scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + _LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id) - coordinator.update_config(album_ids, scan_interval) + # Create coordinator for this album + coordinator = ImmichAlbumWatcherCoordinator( + hass, + url=hub_data.url, + api_key=hub_data.api_key, + album_id=album_id, + album_name=album_name, + scan_interval=hub_data.scan_interval, + ) - # Reload the entry to update sensors - await hass.config_entries.async_reload(entry.entry_id) + # Fetch initial data + await coordinator.async_config_entry_first_refresh() + + # Store subentry runtime data + subentry_data = ImmichAlbumRuntimeData( + coordinator=coordinator, + album_id=album_id, + album_name=album_name, + ) + hass.data[DOMAIN][entry.entry_id]["subentries"][subentry.subentry_id] = subentry_data + + _LOGGER.info("Coordinator for album '%s' set up successfully", album_name) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def _async_update_listener( + hass: HomeAssistant, entry: ImmichConfigEntry +) -> None: + """Handle config entry updates (options or subentry changes).""" + entry_data = hass.data[DOMAIN][entry.entry_id] + loaded_subentries = entry_data.get("loaded_subentries", set()) + current_subentries = set(entry.subentries.keys()) + + # Check if subentries changed + if loaded_subentries != current_subentries: + _LOGGER.info( + "Subentries changed (loaded: %d, current: %d), reloading entry", + len(loaded_subentries), + len(current_subentries), + ) + await hass.config_entries.async_reload(entry.entry_id) + return + + # Handle options-only update (scan interval change) + new_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + # Update hub data + entry.runtime_data.scan_interval = new_interval + + # Update all subentry coordinators + subentries_data = entry_data["subentries"] + for subentry_data in subentries_data.values(): + subentry_data.coordinator.update_scan_interval(new_interval) + + _LOGGER.info("Updated scan interval to %d seconds", new_interval) + + +async def async_unload_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool: """Unload a config entry.""" + # Unload all platforms unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + # Clean up hub data + hass.data[DOMAIN].pop(entry.entry_id, None) + _LOGGER.info("Immich Album Watcher hub unloaded") return unload_ok diff --git a/immich_album_watcher/binary_sensor.py b/immich_album_watcher/binary_sensor.py index 30e28d1..20cf146 100644 --- a/immich_album_watcher/binary_sensor.py +++ b/immich_album_watcher/binary_sensor.py @@ -9,18 +9,18 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry +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.event import async_track_time_interval from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - ATTR_ADDED_COUNT, ATTR_ALBUM_ID, ATTR_ALBUM_NAME, - CONF_ALBUMS, + CONF_ALBUM_ID, + CONF_ALBUM_NAME, DOMAIN, NEW_ASSETS_RESET_DELAY, ) @@ -35,15 +35,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Immich Album Watcher binary sensors from a config entry.""" - coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] - album_ids = entry.options.get(CONF_ALBUMS, []) + # 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 - entities = [ - ImmichAlbumNewAssetsSensor(coordinator, entry, album_id) - for album_id in album_ids - ] + coordinator = subentry_data.coordinator - async_add_entities(entities) + async_add_entities( + [ImmichAlbumNewAssetsSensor(coordinator, entry, subentry)], + config_subentry_id=subentry_id, + ) class ImmichAlbumNewAssetsSensor( @@ -59,28 +63,27 @@ class ImmichAlbumNewAssetsSensor( self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the binary sensor.""" super().__init__(coordinator) - self._album_id = album_id self._entry = entry - self._attr_unique_id = f"{entry.entry_id}_{album_id}_new_assets" - self._reset_unsub = None + self._subentry = subentry + self._album_id = subentry.data[CONF_ALBUM_ID] + self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") + self._attr_unique_id = f"{subentry.subentry_id}_new_assets" @property def _album_data(self) -> AlbumData | None: """Get the album data from coordinator.""" - if self.coordinator.data is None: - return None - return self.coordinator.data.get(self._album_id) + 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": f"Album {self._album_id[:8]}"} + return {"album_name": self._album_name} @property def is_on(self) -> bool | None: @@ -96,7 +99,7 @@ class ImmichAlbumNewAssetsSensor( elapsed = datetime.now() - self._album_data.last_change_time if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY): # Auto-reset the flag - self.coordinator.clear_new_assets_flag(self._album_id) + self.coordinator.clear_new_assets_flag() return False return True @@ -124,12 +127,13 @@ class ImmichAlbumNewAssetsSensor( @property def device_info(self) -> DeviceInfo: - """Return device info.""" + """Return device info - one device per album.""" return DeviceInfo( - identifiers={(DOMAIN, self._entry.entry_id)}, - name="Immich Album Watcher", + identifiers={(DOMAIN, self._subentry.subentry_id)}, + name=self._album_name, manufacturer="Immich", - entry_type="service", + entry_type=DeviceEntryType.SERVICE, + via_device=(DOMAIN, self._entry.entry_id), ) @callback @@ -139,5 +143,5 @@ class ImmichAlbumNewAssetsSensor( async def async_turn_off(self, **kwargs) -> None: """Turn off the sensor (clear new assets flag).""" - self.coordinator.clear_new_assets_flag(self._album_id) + self.coordinator.clear_new_assets_flag() self.async_write_ha_state() diff --git a/immich_album_watcher/camera.py b/immich_album_watcher/camera.py index 02ac711..2fffea7 100644 --- a/immich_album_watcher/camera.py +++ b/immich_album_watcher/camera.py @@ -8,14 +8,15 @@ from datetime import timedelta import aiohttp from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +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 .const import CONF_ALBUMS, DOMAIN +from .const import CONF_ALBUM_ID, CONF_ALBUM_NAME, DOMAIN from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator _LOGGER = logging.getLogger(__name__) @@ -29,15 +30,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Immich Album Watcher cameras from a config entry.""" - coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] - album_ids = entry.options.get(CONF_ALBUMS, []) + # 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 - entities = [ - ImmichAlbumThumbnailCamera(coordinator, entry, album_id) - for album_id in album_ids - ] + coordinator = subentry_data.coordinator - async_add_entities(entities) + async_add_entities( + [ImmichAlbumThumbnailCamera(coordinator, entry, subentry)], + config_subentry_id=subentry_id, + ) class ImmichAlbumThumbnailCamera( @@ -52,30 +57,30 @@ class ImmichAlbumThumbnailCamera( self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the camera.""" CoordinatorEntity.__init__(self, coordinator) Camera.__init__(self) - self._album_id = album_id self._entry = entry - self._attr_unique_id = f"{entry.entry_id}_{album_id}_thumbnail" + self._subentry = subentry + self._album_id = subentry.data[CONF_ALBUM_ID] + self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") + self._attr_unique_id = f"{subentry.subentry_id}_thumbnail" self._cached_image: bytes | None = None self._last_thumbnail_id: str | None = None @property def _album_data(self) -> AlbumData | None: """Get the album data from coordinator.""" - if self.coordinator.data is None: - return None - return self.coordinator.data.get(self._album_id) + 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": f"Album {self._album_id[:8]}"} + return {"album_name": self._album_name} @property def available(self) -> bool: @@ -88,12 +93,13 @@ class ImmichAlbumThumbnailCamera( @property def device_info(self) -> DeviceInfo: - """Return device info.""" + """Return device info - one device per album.""" return DeviceInfo( - identifiers={(DOMAIN, self._entry.entry_id)}, - name="Immich Album Watcher", + identifiers={(DOMAIN, self._subentry.subentry_id)}, + name=self._album_name, manufacturer="Immich", - entry_type="service", + entry_type=DeviceEntryType.SERVICE, + via_device=(DOMAIN, self._entry.entry_id), ) @property diff --git a/immich_album_watcher/config_flow.py b/immich_album_watcher/config_flow.py index 70c588b..745415c 100644 --- a/immich_album_watcher/config_flow.py +++ b/immich_album_watcher/config_flow.py @@ -8,19 +8,26 @@ from typing import Any import aiohttp import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + OptionsFlow, + SubentryFlowResult, +) from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from .const import ( - CONF_ALBUMS, + CONF_ALBUM_ID, + CONF_ALBUM_NAME, CONF_API_KEY, CONF_IMMICH_URL, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, DOMAIN, + SUBENTRY_TYPE_ALBUM, ) _LOGGER = logging.getLogger(__name__) @@ -57,13 +64,12 @@ async def fetch_albums( class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Immich Album Watcher.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" self._url: str | None = None self._api_key: str | None = None - self._albums: list[dict[str, Any]] = [] @staticmethod @callback @@ -71,9 +77,17 @@ class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return ImmichAlbumWatcherOptionsFlow(config_entry) + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return supported subentry types.""" + return {SUBENTRY_TYPE_ALBUM: ImmichAlbumSubentryFlowHandler} + async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Handle the initial step - connection details.""" errors: dict[str, str] = {} @@ -85,12 +99,21 @@ class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN): try: await validate_connection(session, self._url, self._api_key) - self._albums = await fetch_albums(session, self._url, self._api_key) - if not self._albums: - errors["base"] = "no_albums" - else: - return await self.async_step_albums() + # Set unique ID based on URL + await self.async_set_unique_id(self._url) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="Immich Album Watcher", + data={ + CONF_IMMICH_URL: self._url, + CONF_API_KEY: self._api_key, + }, + options={ + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + }, + ) except InvalidAuth: errors["base"] = "invalid_auth" @@ -116,45 +139,87 @@ class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_albums( + +class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow for adding albums.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + super().__init__() + self._albums: list[dict[str, Any]] = [] + + async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle album selection step.""" + ) -> SubentryFlowResult: + """Handle album selection.""" errors: dict[str, str] = {} + # Get parent config entry data + config_entry = self._get_entry() + + url = config_entry.data[CONF_IMMICH_URL] + api_key = config_entry.data[CONF_API_KEY] + + # Fetch available albums + session = async_get_clientsession(self.hass) + try: + self._albums = await fetch_albums(session, url, api_key) + except Exception: + _LOGGER.exception("Failed to fetch albums") + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({}), + errors=errors, + ) + + if not self._albums: + return self.async_abort(reason="no_albums") + if user_input is not None: - selected_albums = user_input.get(CONF_ALBUMS, []) + album_id = user_input[CONF_ALBUM_ID] - if not selected_albums: - errors["base"] = "no_albums_selected" - else: - # Create unique ID based on URL - await self.async_set_unique_id(self._url) - self._abort_if_unique_id_configured() + # Check if album is already configured + for subentry in config_entry.subentries.values(): + if subentry.data.get(CONF_ALBUM_ID) == album_id: + return self.async_abort(reason="album_already_configured") - return self.async_create_entry( - title="Immich Album Watcher", - data={ - CONF_IMMICH_URL: self._url, - CONF_API_KEY: self._api_key, - }, - options={ - CONF_ALBUMS: selected_albums, - CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, - }, - ) + # Find album name + album_name = "Unknown Album" + for album in self._albums: + if album["id"] == album_id: + album_name = album.get("albumName", "Unnamed") + break - # Build album selection list + return self.async_create_entry( + title=album_name, + data={ + CONF_ALBUM_ID: album_id, + CONF_ALBUM_NAME: album_name, + }, + ) + + # Get already configured album IDs + configured_albums = set() + for subentry in config_entry.subentries.values(): + if aid := subentry.data.get(CONF_ALBUM_ID): + configured_albums.add(aid) + + # Build album selection list (excluding already configured) album_options = { album["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)" for album in self._albums + if album["id"] not in configured_albums } + if not album_options: + return self.async_abort(reason="all_albums_configured") + return self.async_show_form( - step_id="albums", + step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_ALBUMS): cv.multi_select(album_options), + vol.Required(CONF_ALBUM_ID): vol.In(album_options), } ), errors=errors, @@ -167,43 +232,21 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self._config_entry = config_entry - self._albums: list[dict[str, Any]] = [] async def async_step_init( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Manage the options.""" - errors: dict[str, str] = {} - - # Fetch current albums from Immich - session = async_get_clientsession(self.hass) - url = self._config_entry.data[CONF_IMMICH_URL] - api_key = self._config_entry.data[CONF_API_KEY] - - try: - self._albums = await fetch_albums(session, url, api_key) - except Exception: - _LOGGER.exception("Failed to fetch albums") - errors["base"] = "cannot_connect" - - if user_input is not None and not errors: + if user_input is not None: return self.async_create_entry( title="", data={ - CONF_ALBUMS: user_input.get(CONF_ALBUMS, []), CONF_SCAN_INTERVAL: user_input.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ), }, ) - # Build album selection list - album_options = { - album["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)" - for album in self._albums - } - - current_albums = self._config_entry.options.get(CONF_ALBUMS, []) current_interval = self._config_entry.options.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ) @@ -212,15 +255,11 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow): step_id="init", data_schema=vol.Schema( { - vol.Required(CONF_ALBUMS, default=current_albums): cv.multi_select( - album_options - ), vol.Required( CONF_SCAN_INTERVAL, default=current_interval ): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), } ), - errors=errors, ) diff --git a/immich_album_watcher/const.py b/immich_album_watcher/const.py index 989fede..5d1d6ad 100644 --- a/immich_album_watcher/const.py +++ b/immich_album_watcher/const.py @@ -9,8 +9,13 @@ DOMAIN: Final = "immich_album_watcher" CONF_IMMICH_URL: Final = "immich_url" CONF_API_KEY: Final = "api_key" CONF_ALBUMS: Final = "albums" +CONF_ALBUM_ID: Final = "album_id" +CONF_ALBUM_NAME: Final = "album_name" CONF_SCAN_INTERVAL: Final = "scan_interval" +# Subentry type +SUBENTRY_TYPE_ALBUM: Final = "album" + # Defaults DEFAULT_SCAN_INTERVAL: Final = 60 # seconds NEW_ASSETS_RESET_DELAY: Final = 300 # 5 minutes diff --git a/immich_album_watcher/coordinator.py b/immich_album_watcher/coordinator.py index f467941..c4506e8 100644 --- a/immich_album_watcher/coordinator.py +++ b/immich_album_watcher/coordinator.py @@ -206,7 +206,7 @@ class AlbumChange: removed_asset_ids: list[str] = field(default_factory=list) -class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]): +class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): """Coordinator for fetching Immich album data.""" def __init__( @@ -214,24 +214,26 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) hass: HomeAssistant, url: str, api_key: str, - album_ids: list[str], + album_id: str, + album_name: str, scan_interval: int, ) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, - name=DOMAIN, + name=f"{DOMAIN}_{album_id}", update_interval=timedelta(seconds=scan_interval), ) self._url = url.rstrip("/") self._api_key = api_key - self._album_ids = album_ids - self._previous_states: dict[str, AlbumData] = {} + self._album_id = album_id + self._album_name = album_name + self._previous_state: AlbumData | None = None self._session: aiohttp.ClientSession | None = None self._people_cache: dict[str, str] = {} # person_id -> name self._users_cache: dict[str, str] = {} # user_id -> name - self._shared_links_cache: dict[str, list[SharedLinkInfo]] = {} # album_id -> list of SharedLinkInfo + self._shared_links: list[SharedLinkInfo] = [] @property def immich_url(self) -> str: @@ -243,35 +245,32 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) """Return the API key.""" return self._api_key - def update_config(self, album_ids: list[str], scan_interval: int) -> None: - """Update configuration.""" - self._album_ids = album_ids + @property + def album_id(self) -> str: + """Return the album ID.""" + return self._album_id + + @property + def album_name(self) -> str: + """Return the album name.""" + return self._album_name + + def update_scan_interval(self, scan_interval: int) -> None: + """Update the scan interval.""" self.update_interval = timedelta(seconds=scan_interval) async def async_refresh_now(self) -> None: """Force an immediate refresh.""" await self.async_request_refresh() - async def async_refresh_album(self, album_id: str) -> None: - """Force an immediate refresh of a specific album. - - Currently refreshes all albums as they share the same coordinator, - but the method signature allows for future optimization. - """ - if album_id in self._album_ids: - await self.async_request_refresh() - - async def async_get_recent_assets( - self, album_id: str, count: int = 10 - ) -> list[dict[str, Any]]: - """Get recent assets from an album.""" - if self.data is None or album_id not in self.data: + async def async_get_recent_assets(self, count: int = 10) -> list[dict[str, Any]]: + """Get recent assets from the album.""" + if self.data is None: return [] - album = self.data[album_id] # Sort assets by created_at descending sorted_assets = sorted( - album.assets.values(), + self.data.assets.values(), key=lambda a: a.created_at, reverse=True, )[:count] @@ -336,12 +335,14 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) return self._users_cache - async def _async_fetch_shared_links(self) -> dict[str, list[SharedLinkInfo]]: - """Fetch shared links from Immich and cache album_id -> SharedLinkInfo mapping.""" + async def _async_fetch_shared_links(self) -> list[SharedLinkInfo]: + """Fetch shared links for this album from Immich.""" if self._session is None: self._session = async_get_clientsession(self.hass) headers = {"x-api-key": self._api_key} + self._shared_links = [] + try: async with self._session.get( f"{self._url}/api/shared-links", @@ -349,100 +350,71 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) ) as response: if response.status == 200: data = await response.json() - _LOGGER.debug("Fetched %d shared links from Immich", len(data)) - self._shared_links_cache.clear() for link in data: album = link.get("album") key = link.get("key") - if album and key: - album_id = album.get("id") - if album_id: - link_info = SharedLinkInfo.from_api_response(link) - _LOGGER.debug( - "Shared link: key=%s, album_id=%s, " - "has_password=%s, expired=%s, accessible=%s", - key[:8], - album_id[:8], - link_info.has_password, - link_info.is_expired, - link_info.is_accessible, - ) - if album_id not in self._shared_links_cache: - self._shared_links_cache[album_id] = [] - self._shared_links_cache[album_id].append(link_info) - _LOGGER.debug( - "Cached shared links for %d albums", len(self._shared_links_cache) - ) - else: - _LOGGER.warning( - "Failed to fetch shared links: HTTP %s", response.status - ) + if album and key and album.get("id") == self._album_id: + link_info = SharedLinkInfo.from_api_response(link) + self._shared_links.append(link_info) + _LOGGER.debug( + "Found shared link for album: key=%s, has_password=%s", + key[:8], + link_info.has_password, + ) except aiohttp.ClientError as err: _LOGGER.warning("Failed to fetch shared links: %s", err) - return self._shared_links_cache + return self._shared_links - def _get_accessible_links(self, album_id: str) -> list[SharedLinkInfo]: - """Get all accessible (no password, not expired) shared links for an album.""" - all_links = self._shared_links_cache.get(album_id, []) - return [link for link in all_links if link.is_accessible] + def _get_accessible_links(self) -> list[SharedLinkInfo]: + """Get all accessible (no password, not expired) shared links.""" + return [link for link in self._shared_links if link.is_accessible] - def _get_non_expired_links(self, album_id: str) -> list[SharedLinkInfo]: - """Get all non-expired shared links for an album (including password-protected).""" - all_links = self._shared_links_cache.get(album_id, []) - return [link for link in all_links if not link.is_expired] + def _get_protected_links(self) -> list[SharedLinkInfo]: + """Get password-protected but not expired shared links.""" + return [link for link in self._shared_links if link.has_password and not link.is_expired] - def _get_protected_only_links(self, album_id: str) -> list[SharedLinkInfo]: - """Get password-protected but not expired shared links for an album.""" - all_links = self._shared_links_cache.get(album_id, []) - return [link for link in all_links if link.has_password and not link.is_expired] - - def get_album_public_url(self, album_id: str) -> str | None: - """Get the public URL for an album if it has an accessible shared link.""" - accessible_links = self._get_accessible_links(album_id) + def get_public_url(self) -> str | None: + """Get the public URL if album has an accessible shared link.""" + accessible_links = self._get_accessible_links() if accessible_links: return f"{self._url}/share/{accessible_links[0].key}" return None - def get_album_any_url(self, album_id: str) -> str | None: - """Get any non-expired URL for an album (prefers accessible, falls back to protected).""" - # First try accessible links - accessible_links = self._get_accessible_links(album_id) + def get_any_url(self) -> str | None: + """Get any non-expired URL (prefers accessible, falls back to protected).""" + accessible_links = self._get_accessible_links() if accessible_links: return f"{self._url}/share/{accessible_links[0].key}" - # Fall back to any non-expired link (including password-protected) - non_expired = self._get_non_expired_links(album_id) + non_expired = [link for link in self._shared_links if not link.is_expired] if non_expired: return f"{self._url}/share/{non_expired[0].key}" return None - def get_album_protected_url(self, album_id: str) -> str | None: - """Get a protected URL for an album if any password-protected link exists.""" - protected_links = self._get_protected_only_links(album_id) + def get_protected_url(self) -> str | None: + """Get a protected URL if any password-protected link exists.""" + protected_links = self._get_protected_links() if protected_links: return f"{self._url}/share/{protected_links[0].key}" return None - def get_album_protected_urls(self, album_id: str) -> list[str]: - """Get all password-protected (but not expired) URLs for an album.""" - protected_links = self._get_protected_only_links(album_id) - return [f"{self._url}/share/{link.key}" for link in protected_links] + def get_protected_urls(self) -> list[str]: + """Get all password-protected URLs.""" + return [f"{self._url}/share/{link.key}" for link in self._get_protected_links()] - def get_album_protected_password(self, album_id: str) -> str | None: - """Get the password for the first protected link (matches get_album_protected_url).""" - protected_links = self._get_protected_only_links(album_id) + def get_protected_password(self) -> str | None: + """Get the password for the first protected link.""" + protected_links = self._get_protected_links() if protected_links and protected_links[0].password: return protected_links[0].password return None - def get_album_public_urls(self, album_id: str) -> list[str]: - """Get all accessible public URLs for an album.""" - accessible_links = self._get_accessible_links(album_id) - return [f"{self._url}/share/{link.key}" for link in accessible_links] + def get_public_urls(self) -> list[str]: + """Get all accessible public URLs.""" + return [f"{self._url}/share/{link.key}" for link in self._get_accessible_links()] - def get_album_shared_links_info(self, album_id: str) -> list[dict[str, Any]]: - """Get detailed info about all shared links for an album.""" - all_links = self._shared_links_cache.get(album_id, []) + def get_shared_links_info(self) -> list[dict[str, Any]]: + """Get detailed info about all shared links.""" return [ { "url": f"{self._url}/share/{link.key}", @@ -451,22 +423,20 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) "expires_at": link.expires_at.isoformat() if link.expires_at else None, "is_accessible": link.is_accessible, } - for link in all_links + for link in self._shared_links ] - def _get_asset_public_url(self, album_id: str, asset_id: str) -> str | None: - """Get the public URL for an asset (prefers accessible, falls back to protected).""" - # First try accessible links - accessible_links = self._get_accessible_links(album_id) + def _get_asset_public_url(self, asset_id: str) -> str | None: + """Get the public URL for an asset.""" + accessible_links = self._get_accessible_links() if accessible_links: return f"{self._url}/share/{accessible_links[0].key}/photos/{asset_id}" - # Fall back to any non-expired link - non_expired = self._get_non_expired_links(album_id) + non_expired = [link for link in self._shared_links if not link.is_expired] if non_expired: return f"{self._url}/share/{non_expired[0].key}/photos/{asset_id}" return None - async def _async_update_data(self) -> dict[str, AlbumData]: + async def _async_update_data(self) -> AlbumData | None: """Fetch data from Immich API.""" if self._session is None: self._session = async_get_clientsession(self.hass) @@ -475,60 +445,52 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) if not self._users_cache: await self._async_fetch_users() - # Fetch shared links to resolve public URLs (refresh each time as links can change) + # Fetch shared links (refresh each time as links can change) await self._async_fetch_shared_links() headers = {"x-api-key": self._api_key} - albums_data: dict[str, AlbumData] = {} - for album_id in self._album_ids: - try: - async with self._session.get( - f"{self._url}/api/albums/{album_id}", - headers=headers, - ) as response: - if response.status == 404: - _LOGGER.warning("Album %s not found, skipping", album_id) - continue - if response.status != 200: - raise UpdateFailed( - f"Error fetching album {album_id}: HTTP {response.status}" - ) + try: + async with self._session.get( + f"{self._url}/api/albums/{self._album_id}", + headers=headers, + ) as response: + if response.status == 404: + _LOGGER.warning("Album %s not found", self._album_id) + return None + if response.status != 200: + raise UpdateFailed( + f"Error fetching album {self._album_id}: HTTP {response.status}" + ) - data = await response.json() - album = AlbumData.from_api_response(data, self._users_cache) + data = await response.json() + album = AlbumData.from_api_response(data, self._users_cache) - # Detect changes and update flags - if album_id in self._previous_states: - change = self._detect_change( - self._previous_states[album_id], album - ) - if change: - album.has_new_assets = change.added_count > 0 - album.last_change_time = datetime.now() - self._fire_events(change, album) - else: - # First run, no changes - album.has_new_assets = False + # Detect changes + if self._previous_state: + change = self._detect_change(self._previous_state, album) + if change: + album.has_new_assets = change.added_count > 0 + album.last_change_time = datetime.now() + self._fire_events(change, album) + else: + album.has_new_assets = False - # Preserve has_new_assets from previous state if still within window - if album_id in self._previous_states: - prev = self._previous_states[album_id] - if prev.has_new_assets and prev.last_change_time: - # Keep the flag if change was recent - album.last_change_time = prev.last_change_time - if not album.has_new_assets: - album.has_new_assets = prev.has_new_assets + # Preserve has_new_assets from previous state if still within window + if self._previous_state: + prev = self._previous_state + if prev.has_new_assets and prev.last_change_time: + album.last_change_time = prev.last_change_time + if not album.has_new_assets: + album.has_new_assets = prev.has_new_assets - albums_data[album_id] = album + # Update previous state + self._previous_state = album - except aiohttp.ClientError as err: - raise UpdateFailed(f"Error communicating with Immich: {err}") from err + return album - # Update previous states - self._previous_states = albums_data.copy() - - return albums_data + except aiohttp.ClientError as err: + raise UpdateFailed(f"Error communicating with Immich: {err}") from err def _detect_change( self, old_state: AlbumData, new_state: AlbumData @@ -546,7 +508,6 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) elif removed_ids and not added_ids: change_type = "assets_removed" - # Get full asset info for added assets added_assets = [ new_state.assets[aid] for aid in added_ids if aid in new_state.assets ] @@ -563,7 +524,6 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) def _fire_events(self, change: AlbumChange, album: AlbumData) -> None: """Fire Home Assistant events for album changes.""" - # Build detailed asset info for events added_assets_detail = [] for asset in change.added_assets: asset_detail = { @@ -576,8 +536,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) ATTR_ASSET_DESCRIPTION: asset.description, ATTR_PEOPLE: asset.people, } - # Add public URL if album has a shared link - asset_url = self._get_asset_public_url(change.album_id, asset.id) + asset_url = self._get_asset_public_url(asset.id) if asset_url: asset_detail[ATTR_ASSET_URL] = asset_url added_assets_detail.append(asset_detail) @@ -593,12 +552,10 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) ATTR_PEOPLE: list(album.people), } - # Add album URL if it has a shared link (prefers accessible, falls back to protected) - album_url = self.get_album_any_url(change.album_id) + album_url = self.get_any_url() if album_url: event_data[ATTR_ALBUM_URL] = album_url - # Fire general change event self.hass.bus.async_fire(EVENT_ALBUM_CHANGED, event_data) _LOGGER.info( @@ -608,16 +565,15 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) change.removed_count, ) - # Fire specific events if change.added_count > 0: self.hass.bus.async_fire(EVENT_ASSETS_ADDED, event_data) if change.removed_count > 0: self.hass.bus.async_fire(EVENT_ASSETS_REMOVED, event_data) - def get_album_protected_link_id(self, album_id: str) -> str | None: - """Get the ID of the first protected link (matches get_album_protected_url).""" - protected_links = self._get_protected_only_links(album_id) + def get_protected_link_id(self) -> str | None: + """Get the ID of the first protected link.""" + protected_links = self._get_protected_links() if protected_links: return protected_links[0].id return None @@ -625,15 +581,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) async def async_set_shared_link_password( self, link_id: str, password: str | None ) -> bool: - """Update the password for a shared link via Immich API. - - Args: - link_id: The ID of the shared link to update. - password: The new password, or None/empty string to remove the password. - - Returns: - True if successful, False otherwise. - """ + """Update the password for a shared link via Immich API.""" if self._session is None: self._session = async_get_clientsession(self.hass) @@ -642,7 +590,6 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) "Content-Type": "application/json", } - # Immich API expects null to remove password, or a string to set it payload = {"password": password if password else None} try: @@ -653,7 +600,6 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) ) as response: if response.status == 200: _LOGGER.info("Successfully updated shared link password") - # Refresh shared links cache to reflect the change await self._async_fetch_shared_links() return True else: @@ -666,8 +612,8 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) _LOGGER.error("Error updating shared link password: %s", err) return False - def clear_new_assets_flag(self, album_id: str) -> None: - """Clear the new assets flag for an album.""" - if self.data and album_id in self.data: - self.data[album_id].has_new_assets = False - self.data[album_id].last_change_time = None + def clear_new_assets_flag(self) -> None: + """Clear the new assets flag.""" + if self.data: + self.data.has_new_assets = False + self.data.last_change_time = None diff --git a/immich_album_watcher/manifest.json b/immich_album_watcher/manifest.json index 3c9b1da..e40cb2b 100644 --- a/immich_album_watcher/manifest.json +++ b/immich_album_watcher/manifest.json @@ -8,5 +8,6 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/your-repo/immich-album-watcher/issues", "requirements": [], - "version": "1.0.0" + "single_config_entry": true, + "version": "1.1.0" } diff --git a/immich_album_watcher/sensor.py b/immich_album_watcher/sensor.py index 7df7364..363e044 100644 --- a/immich_album_watcher/sensor.py +++ b/immich_album_watcher/sensor.py @@ -13,18 +13,17 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse, callback from homeassistant.helpers import entity_platform +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 .const import ( ATTR_ALBUM_ID, - ATTR_ALBUM_PROTECTED_PASSWORD, ATTR_ALBUM_PROTECTED_URL, - ATTR_ALBUM_URL, ATTR_ALBUM_URLS, ATTR_ASSET_COUNT, ATTR_CREATED_AT, @@ -35,7 +34,8 @@ from .const import ( ATTR_SHARED, ATTR_THUMBNAIL_URL, ATTR_VIDEO_COUNT, - CONF_ALBUMS, + CONF_ALBUM_ID, + CONF_ALBUM_NAME, DOMAIN, SERVICE_GET_RECENT_ASSETS, SERVICE_REFRESH, @@ -51,22 +51,28 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Immich Album Watcher sensors from a config entry.""" - coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] - album_ids = entry.options.get(CONF_ALBUMS, []) + # 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 - entities: list[SensorEntity] = [] - for album_id in album_ids: - entities.append(ImmichAlbumAssetCountSensor(coordinator, entry, album_id)) - entities.append(ImmichAlbumPhotoCountSensor(coordinator, entry, album_id)) - entities.append(ImmichAlbumVideoCountSensor(coordinator, entry, album_id)) - entities.append(ImmichAlbumLastUpdatedSensor(coordinator, entry, album_id)) - entities.append(ImmichAlbumCreatedSensor(coordinator, entry, album_id)) - entities.append(ImmichAlbumPeopleSensor(coordinator, entry, album_id)) - entities.append(ImmichAlbumPublicUrlSensor(coordinator, entry, album_id)) - entities.append(ImmichAlbumProtectedUrlSensor(coordinator, entry, album_id)) - entities.append(ImmichAlbumProtectedPasswordSensor(coordinator, entry, album_id)) + coordinator = subentry_data.coordinator - async_add_entities(entities) + entities: list[SensorEntity] = [ + ImmichAlbumAssetCountSensor(coordinator, entry, subentry), + ImmichAlbumPhotoCountSensor(coordinator, entry, subentry), + 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), + ] + + async_add_entities(entities, config_subentry_id=subentry_id) # Register entity services platform = entity_platform.async_get_current_platform() @@ -98,26 +104,26 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._album_id = album_id 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") @property def _album_data(self) -> AlbumData | None: """Get the album data from coordinator.""" - if self.coordinator.data is None: - return None - return self.coordinator.data.get(self._album_id) + 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": f"Album {self._album_id[:8]}"} + return {"album_name": self._album_name} @property def available(self) -> bool: @@ -126,12 +132,13 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se @property def device_info(self) -> DeviceInfo: - """Return device info.""" + """Return device info - one device per album.""" return DeviceInfo( - identifiers={(DOMAIN, self._entry.entry_id)}, - name="Immich Album Watcher", + identifiers={(DOMAIN, self._subentry.subentry_id)}, + name=self._album_name, manufacturer="Immich", - entry_type="service", + entry_type=DeviceEntryType.SERVICE, + via_device=(DOMAIN, self._entry.entry_id), ) @callback @@ -141,11 +148,11 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se async def async_refresh_album(self) -> None: """Refresh data for this album.""" - await self.coordinator.async_refresh_album(self._album_id) + await self.coordinator.async_refresh_now() async def async_get_recent_assets(self, count: int = 10) -> ServiceResponse: """Get recent assets for this album.""" - assets = await self.coordinator.async_get_recent_assets(self._album_id, count) + assets = await self.coordinator.async_get_recent_assets(count) return {"assets": assets} @@ -160,11 +167,11 @@ class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor): self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, entry, album_id) - self._attr_unique_id = f"{entry.entry_id}_{album_id}_asset_count" + super().__init__(coordinator, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_asset_count" @property def native_value(self) -> int | None: @@ -191,7 +198,6 @@ class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor): ATTR_PEOPLE: list(self._album_data.people), } - # Add thumbnail URL if available if self._album_data.thumbnail_asset_id: attrs[ATTR_THUMBNAIL_URL] = ( f"{self.coordinator.immich_url}/api/assets/" @@ -212,11 +218,11 @@ class ImmichAlbumPhotoCountSensor(ImmichAlbumBaseSensor): self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, entry, album_id) - self._attr_unique_id = f"{entry.entry_id}_{album_id}_photo_count" + super().__init__(coordinator, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_photo_count" @property def native_value(self) -> int | None: @@ -237,11 +243,11 @@ class ImmichAlbumVideoCountSensor(ImmichAlbumBaseSensor): self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, entry, album_id) - self._attr_unique_id = f"{entry.entry_id}_{album_id}_video_count" + super().__init__(coordinator, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_video_count" @property def native_value(self) -> int | None: @@ -262,11 +268,11 @@ class ImmichAlbumLastUpdatedSensor(ImmichAlbumBaseSensor): self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, entry, album_id) - self._attr_unique_id = f"{entry.entry_id}_{album_id}_last_updated" + super().__init__(coordinator, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_last_updated" @property def native_value(self) -> datetime | None: @@ -292,11 +298,11 @@ class ImmichAlbumCreatedSensor(ImmichAlbumBaseSensor): self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, entry, album_id) - self._attr_unique_id = f"{entry.entry_id}_{album_id}_created" + super().__init__(coordinator, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_created" @property def native_value(self) -> datetime | None: @@ -322,11 +328,11 @@ class ImmichAlbumPeopleSensor(ImmichAlbumBaseSensor): self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, entry, album_id) - self._attr_unique_id = f"{entry.entry_id}_{album_id}_people_count" + super().__init__(coordinator, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_people_count" @property def native_value(self) -> int | None: @@ -356,17 +362,17 @@ class ImmichAlbumPublicUrlSensor(ImmichAlbumBaseSensor): self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, entry, album_id) - self._attr_unique_id = f"{entry.entry_id}_{album_id}_public_url" + super().__init__(coordinator, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_public_url" @property def native_value(self) -> str | None: """Return the state of the sensor (public URL).""" if self._album_data: - return self.coordinator.get_album_public_url(self._album_id) + return self.coordinator.get_public_url() return None @property @@ -380,13 +386,11 @@ class ImmichAlbumPublicUrlSensor(ImmichAlbumBaseSensor): ATTR_SHARED: self._album_data.shared, } - # Include all accessible URLs if there are multiple - all_urls = self.coordinator.get_album_public_urls(self._album_id) + all_urls = self.coordinator.get_public_urls() if len(all_urls) > 1: attrs[ATTR_ALBUM_URLS] = all_urls - # Include detailed info about all shared links (including protected/expired) - links_info = self.coordinator.get_album_shared_links_info(self._album_id) + links_info = self.coordinator.get_shared_links_info() if links_info: attrs["shared_links"] = links_info @@ -403,17 +407,17 @@ class ImmichAlbumProtectedUrlSensor(ImmichAlbumBaseSensor): self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, entry, album_id) - self._attr_unique_id = f"{entry.entry_id}_{album_id}_protected_url" + super().__init__(coordinator, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_protected_url" @property def native_value(self) -> str | None: """Return the state of the sensor (protected URL).""" if self._album_data: - return self.coordinator.get_album_protected_url(self._album_id) + return self.coordinator.get_protected_url() return None @property @@ -426,8 +430,7 @@ class ImmichAlbumProtectedUrlSensor(ImmichAlbumBaseSensor): ATTR_ALBUM_ID: self._album_data.id, } - # Include all protected URLs if there are multiple - all_urls = self.coordinator.get_album_protected_urls(self._album_id) + all_urls = self.coordinator.get_protected_urls() if len(all_urls) > 1: attrs["protected_urls"] = all_urls @@ -444,17 +447,17 @@ class ImmichAlbumProtectedPasswordSensor(ImmichAlbumBaseSensor): self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, entry, album_id) - self._attr_unique_id = f"{entry.entry_id}_{album_id}_protected_password" + super().__init__(coordinator, entry, subentry) + self._attr_unique_id = f"{subentry.subentry_id}_protected_password" @property def native_value(self) -> str | None: """Return the state of the sensor (protected link password).""" if self._album_data: - return self.coordinator.get_album_protected_password(self._album_id) + return self.coordinator.get_protected_password() return None @property @@ -465,7 +468,5 @@ class ImmichAlbumProtectedPasswordSensor(ImmichAlbumBaseSensor): return { ATTR_ALBUM_ID: self._album_data.id, - ATTR_ALBUM_PROTECTED_URL: self.coordinator.get_album_protected_url( - self._album_id - ), + ATTR_ALBUM_PROTECTED_URL: self.coordinator.get_protected_url(), } diff --git a/immich_album_watcher/text.py b/immich_album_watcher/text.py index 9e3d726..4e51f54 100644 --- a/immich_album_watcher/text.py +++ b/immich_album_watcher/text.py @@ -5,8 +5,9 @@ from __future__ import annotations import logging from homeassistant.components.text import TextEntity, TextMode -from homeassistant.config_entries import ConfigEntry +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 @@ -14,7 +15,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_ALBUM_ID, ATTR_ALBUM_PROTECTED_URL, - CONF_ALBUMS, + CONF_ALBUM_ID, + CONF_ALBUM_NAME, DOMAIN, ) from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator @@ -28,14 +30,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Immich Album Watcher text entities from a config entry.""" - coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] - album_ids = entry.options.get(CONF_ALBUMS, []) + # 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 - entities: list[TextEntity] = [] - for album_id in album_ids: - entities.append(ImmichAlbumProtectedPasswordText(coordinator, entry, album_id)) + coordinator = subentry_data.coordinator - async_add_entities(entities) + async_add_entities( + [ImmichAlbumProtectedPasswordText(coordinator, entry, subentry)], + config_subentry_id=subentry_id, + ) class ImmichAlbumProtectedPasswordText( @@ -53,27 +60,27 @@ class ImmichAlbumProtectedPasswordText( self, coordinator: ImmichAlbumWatcherCoordinator, entry: ConfigEntry, - album_id: str, + subentry: ConfigSubentry, ) -> None: """Initialize the text entity.""" super().__init__(coordinator) - self._album_id = album_id self._entry = entry - self._attr_unique_id = f"{entry.entry_id}_{album_id}_protected_password_edit" + self._subentry = subentry + self._album_id = subentry.data[CONF_ALBUM_ID] + self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") + self._attr_unique_id = f"{subentry.subentry_id}_protected_password_edit" @property def _album_data(self) -> AlbumData | None: """Get the album data from coordinator.""" - if self.coordinator.data is None: - return None - return self.coordinator.data.get(self._album_id) + 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": f"Album {self._album_id[:8]}"} + return {"album_name": self._album_name} @property def available(self) -> bool: @@ -84,23 +91,24 @@ class ImmichAlbumProtectedPasswordText( if not self.coordinator.last_update_success or self._album_data is None: return False # Only available if there's a protected link to edit - return self.coordinator.get_album_protected_link_id(self._album_id) is not None + return self.coordinator.get_protected_link_id() is not None @property def device_info(self) -> DeviceInfo: - """Return device info.""" + """Return device info - one device per album.""" return DeviceInfo( - identifiers={(DOMAIN, self._entry.entry_id)}, - name="Immich Album Watcher", + identifiers={(DOMAIN, self._subentry.subentry_id)}, + name=self._album_name, manufacturer="Immich", - entry_type="service", + entry_type=DeviceEntryType.SERVICE, + via_device=(DOMAIN, self._entry.entry_id), ) @property def native_value(self) -> str | None: """Return the current password value.""" if self._album_data: - return self.coordinator.get_album_protected_password(self._album_id) + return self.coordinator.get_protected_password() return None @property @@ -113,7 +121,7 @@ class ImmichAlbumProtectedPasswordText( ATTR_ALBUM_ID: self._album_data.id, } - protected_url = self.coordinator.get_album_protected_url(self._album_id) + protected_url = self.coordinator.get_protected_url() if protected_url: attrs[ATTR_ALBUM_PROTECTED_URL] = protected_url @@ -121,7 +129,7 @@ class ImmichAlbumProtectedPasswordText( async def async_set_value(self, value: str) -> None: """Set the password for the protected shared link.""" - link_id = self.coordinator.get_album_protected_link_id(self._album_id) + link_id = self.coordinator.get_protected_link_id() if not link_id: _LOGGER.error( "Cannot set password: no protected link found for album %s", diff --git a/immich_album_watcher/translations/en.json b/immich_album_watcher/translations/en.json index b3a0082..443c4ef 100644 --- a/immich_album_watcher/translations/en.json +++ b/immich_album_watcher/translations/en.json @@ -58,42 +58,56 @@ "immich_url": "The URL of your Immich server (e.g., http://192.168.1.100:2283)", "api_key": "Your Immich API key" } - }, - "albums": { - "title": "Select Albums", - "description": "Choose which albums to monitor for changes.", - "data": { - "albums": "Albums to watch" - } } }, "error": { "cannot_connect": "Failed to connect to Immich server", "invalid_auth": "Invalid API key", "no_albums": "No albums found on the server", - "no_albums_selected": "Please select at least one album", "unknown": "Unexpected error occurred" }, "abort": { "already_configured": "This Immich server is already configured" } }, + "config_subentries": { + "album": { + "initiate_flow": { + "user": "Add Album" + }, + "entry_type": "Album", + "step": { + "user": { + "title": "Add Album to Watch", + "description": "Select an album from your Immich server to monitor for changes.", + "data": { + "album_id": "Album" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to Immich server" + }, + "abort": { + "parent_not_found": "Hub configuration not found", + "no_albums": "No albums found on the server", + "all_albums_configured": "All albums are already configured", + "album_already_configured": "This album is already being watched" + } + } + }, "options": { "step": { "init": { "title": "Immich Album Watcher Options", - "description": "Configure which albums to monitor and how often to check for changes.", + "description": "Configure the polling interval for all albums.", "data": { - "albums": "Albums to watch", "scan_interval": "Scan interval (seconds)" }, "data_description": { "scan_interval": "How often to check for album changes (10-3600 seconds)" } } - }, - "error": { - "cannot_connect": "Failed to connect to Immich server" } }, "services": { diff --git a/immich_album_watcher/translations/ru.json b/immich_album_watcher/translations/ru.json index b7bad69..7621cca 100644 --- a/immich_album_watcher/translations/ru.json +++ b/immich_album_watcher/translations/ru.json @@ -58,42 +58,56 @@ "immich_url": "URL вашего сервера Immich (например, http://192.168.1.100:2283)", "api_key": "Ваш API-ключ Immich" } - }, - "albums": { - "title": "Выбор альбомов", - "description": "Выберите альбомы для отслеживания изменений.", - "data": { - "albums": "Альбомы для отслеживания" - } } }, "error": { "cannot_connect": "Не удалось подключиться к серверу Immich", "invalid_auth": "Неверный API-ключ", "no_albums": "На сервере не найдено альбомов", - "no_albums_selected": "Пожалуйста, выберите хотя бы один альбом", "unknown": "Произошла непредвиденная ошибка" }, "abort": { "already_configured": "Этот сервер Immich уже настроен" } }, + "config_subentries": { + "album": { + "initiate_flow": { + "user": "Добавить альбом" + }, + "entry_type": "Альбом", + "step": { + "user": { + "title": "Добавить альбом для отслеживания", + "description": "Выберите альбом с вашего сервера Immich для отслеживания изменений.", + "data": { + "album_id": "Альбом" + } + } + }, + "error": { + "cannot_connect": "Не удалось подключиться к серверу Immich" + }, + "abort": { + "parent_not_found": "Конфигурация хаба не найдена", + "no_albums": "На сервере не найдено альбомов", + "all_albums_configured": "Все альбомы уже настроены", + "album_already_configured": "Этот альбом уже отслеживается" + } + } + }, "options": { "step": { "init": { "title": "Настройки Immich Album Watcher", - "description": "Настройте отслеживаемые альбомы и частоту проверки изменений.", + "description": "Настройте интервал опроса для всех альбомов.", "data": { - "albums": "Альбомы для отслеживания", "scan_interval": "Интервал сканирования (секунды)" }, "data_description": { "scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)" } } - }, - "error": { - "cannot_connect": "Не удалось подключиться к серверу Immich" } }, "services": {