Implement hub and subenty approach based on telegram bot integration implementation

This commit is contained in:
2026-01-30 02:39:59 +03:00
parent c8bd475a52
commit 60573374a4
11 changed files with 549 additions and 426 deletions

View File

@@ -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
# 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_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: ConfigEntry) -> bool:
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

View File

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

View File

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

View File

@@ -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] = {}
if user_input is not None:
selected_albums = user_input.get(CONF_ALBUMS, [])
# Get parent config entry data
config_entry = self._get_entry()
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()
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:
album_id = user_input[CONF_ALBUM_ID]
# 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")
# Find album name
album_name = "Unknown Album"
for album in self._albums:
if album["id"] == album_id:
album_name = album.get("albumName", "Unnamed")
break
return self.async_create_entry(
title="Immich Album Watcher",
title=album_name,
data={
CONF_IMMICH_URL: self._url,
CONF_API_KEY: self._api_key,
},
options={
CONF_ALBUMS: selected_albums,
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
CONF_ALBUM_ID: album_id,
CONF_ALBUM_NAME: album_name,
},
)
# Build album selection list
# 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,
)

View File

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

View File

@@ -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:
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(
"Shared link: key=%s, album_id=%s, "
"has_password=%s, expired=%s, accessible=%s",
"Found shared link for album: key=%s, has_password=%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
)
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,61 +445,53 @@ 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}",
f"{self._url}/api/albums/{self._album_id}",
headers=headers,
) as response:
if response.status == 404:
_LOGGER.warning("Album %s not found, skipping", album_id)
continue
_LOGGER.warning("Album %s not found", self._album_id)
return None
if response.status != 200:
raise UpdateFailed(
f"Error fetching album {album_id}: HTTP {response.status}"
f"Error fetching album {self._album_id}: HTTP {response.status}"
)
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
)
# 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:
# First run, no changes
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 self._previous_state:
prev = self._previous_state
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
albums_data[album_id] = album
# Update previous state
self._previous_state = album
return album
except aiohttp.ClientError as err:
raise UpdateFailed(f"Error communicating with Immich: {err}") from err
# Update previous states
self._previous_states = albums_data.copy()
return albums_data
def _detect_change(
self, old_state: AlbumData, new_state: AlbumData
) -> AlbumChange | None:
@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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