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 from __future__ import annotations
import logging 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 homeassistant.core import HomeAssistant
from .const import ( from .const import (
CONF_ALBUMS, CONF_ALBUM_ID,
CONF_ALBUM_NAME,
CONF_API_KEY, CONF_API_KEY,
CONF_IMMICH_URL, CONF_IMMICH_URL,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
@@ -21,60 +23,143 @@ from .coordinator import ImmichAlbumWatcherCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @dataclass
"""Set up Immich Album Watcher from a config entry.""" 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, {}) hass.data.setdefault(DOMAIN, {})
url = entry.data[CONF_IMMICH_URL] url = entry.data[CONF_IMMICH_URL]
api_key = entry.data[CONF_API_KEY] 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) scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
coordinator = ImmichAlbumWatcherCoordinator( # Store hub data
hass, entry.runtime_data = ImmichHubData(
url=url, url=url,
api_key=api_key, api_key=api_key,
album_ids=album_ids,
scan_interval=scan_interval, scan_interval=scan_interval,
) )
# Fetch initial data # Store hub reference
await coordinator.async_config_entry_first_refresh() 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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register update listener for options changes # Register update listener for options and subentry changes
entry.async_on_unload(entry.add_update_listener(async_update_options)) entry.async_on_unload(entry.add_update_listener(_async_update_listener))
_LOGGER.info( _LOGGER.info(
"Immich Album Watcher set up successfully, watching %d albums", "Immich Album Watcher hub set up successfully with %d albums",
len(album_ids), len(entry.subentries),
) )
return True return True
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: async def _async_setup_subentry_coordinator(
"""Handle options update.""" hass: HomeAssistant, entry: ImmichConfigEntry, subentry: ConfigSubentry
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] ) -> 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, []) _LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id)
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
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 hass.config_entries.async_reload(entry.entry_id) 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 a config entry."""
# Unload all platforms
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: 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 return unload_ok

View File

@@ -9,18 +9,18 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ( from .const import (
ATTR_ADDED_COUNT,
ATTR_ALBUM_ID, ATTR_ALBUM_ID,
ATTR_ALBUM_NAME, ATTR_ALBUM_NAME,
CONF_ALBUMS, CONF_ALBUM_ID,
CONF_ALBUM_NAME,
DOMAIN, DOMAIN,
NEW_ASSETS_RESET_DELAY, NEW_ASSETS_RESET_DELAY,
) )
@@ -35,15 +35,19 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Immich Album Watcher binary sensors from a config entry.""" """Set up Immich Album Watcher binary sensors from a config entry."""
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] # Iterate through all album subentries
album_ids = entry.options.get(CONF_ALBUMS, []) 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 = [ coordinator = subentry_data.coordinator
ImmichAlbumNewAssetsSensor(coordinator, entry, album_id)
for album_id in album_ids
]
async_add_entities(entities) async_add_entities(
[ImmichAlbumNewAssetsSensor(coordinator, entry, subentry)],
config_subentry_id=subentry_id,
)
class ImmichAlbumNewAssetsSensor( class ImmichAlbumNewAssetsSensor(
@@ -59,28 +63,27 @@ class ImmichAlbumNewAssetsSensor(
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the binary sensor.""" """Initialize the binary sensor."""
super().__init__(coordinator) super().__init__(coordinator)
self._album_id = album_id
self._entry = entry self._entry = entry
self._attr_unique_id = f"{entry.entry_id}_{album_id}_new_assets" self._subentry = subentry
self._reset_unsub = None 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 @property
def _album_data(self) -> AlbumData | None: def _album_data(self) -> AlbumData | None:
"""Get the album data from coordinator.""" """Get the album data from coordinator."""
if self.coordinator.data is None: return self.coordinator.data
return None
return self.coordinator.data.get(self._album_id)
@property @property
def translation_placeholders(self) -> dict[str, str]: def translation_placeholders(self) -> dict[str, str]:
"""Return translation placeholders.""" """Return translation placeholders."""
if self._album_data: if self._album_data:
return {"album_name": self._album_data.name} return {"album_name": self._album_data.name}
return {"album_name": f"Album {self._album_id[:8]}"} return {"album_name": self._album_name}
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
@@ -96,7 +99,7 @@ class ImmichAlbumNewAssetsSensor(
elapsed = datetime.now() - self._album_data.last_change_time elapsed = datetime.now() - self._album_data.last_change_time
if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY): if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY):
# Auto-reset the flag # Auto-reset the flag
self.coordinator.clear_new_assets_flag(self._album_id) self.coordinator.clear_new_assets_flag()
return False return False
return True return True
@@ -124,12 +127,13 @@ class ImmichAlbumNewAssetsSensor(
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return device info.""" """Return device info - one device per album."""
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)}, identifiers={(DOMAIN, self._subentry.subentry_id)},
name="Immich Album Watcher", name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type="service", entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
) )
@callback @callback
@@ -139,5 +143,5 @@ class ImmichAlbumNewAssetsSensor(
async def async_turn_off(self, **kwargs) -> None: async def async_turn_off(self, **kwargs) -> None:
"""Turn off the sensor (clear new assets flag).""" """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() self.async_write_ha_state()

View File

@@ -8,14 +8,15 @@ from datetime import timedelta
import aiohttp import aiohttp
from homeassistant.components.camera import Camera 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.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession 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 import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity 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 from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -29,15 +30,19 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Immich Album Watcher cameras from a config entry.""" """Set up Immich Album Watcher cameras from a config entry."""
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] # Iterate through all album subentries
album_ids = entry.options.get(CONF_ALBUMS, []) 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 = [ coordinator = subentry_data.coordinator
ImmichAlbumThumbnailCamera(coordinator, entry, album_id)
for album_id in album_ids
]
async_add_entities(entities) async_add_entities(
[ImmichAlbumThumbnailCamera(coordinator, entry, subentry)],
config_subentry_id=subentry_id,
)
class ImmichAlbumThumbnailCamera( class ImmichAlbumThumbnailCamera(
@@ -52,30 +57,30 @@ class ImmichAlbumThumbnailCamera(
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the camera.""" """Initialize the camera."""
CoordinatorEntity.__init__(self, coordinator) CoordinatorEntity.__init__(self, coordinator)
Camera.__init__(self) Camera.__init__(self)
self._album_id = album_id
self._entry = entry 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._cached_image: bytes | None = None
self._last_thumbnail_id: str | None = None self._last_thumbnail_id: str | None = None
@property @property
def _album_data(self) -> AlbumData | None: def _album_data(self) -> AlbumData | None:
"""Get the album data from coordinator.""" """Get the album data from coordinator."""
if self.coordinator.data is None: return self.coordinator.data
return None
return self.coordinator.data.get(self._album_id)
@property @property
def translation_placeholders(self) -> dict[str, str]: def translation_placeholders(self) -> dict[str, str]:
"""Return translation placeholders.""" """Return translation placeholders."""
if self._album_data: if self._album_data:
return {"album_name": self._album_data.name} return {"album_name": self._album_data.name}
return {"album_name": f"Album {self._album_id[:8]}"} return {"album_name": self._album_name}
@property @property
def available(self) -> bool: def available(self) -> bool:
@@ -88,12 +93,13 @@ class ImmichAlbumThumbnailCamera(
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return device info.""" """Return device info - one device per album."""
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)}, identifiers={(DOMAIN, self._subentry.subentry_id)},
name="Immich Album Watcher", name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type="service", entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
) )
@property @property

View File

@@ -8,19 +8,26 @@ from typing import Any
import aiohttp import aiohttp
import voluptuous as vol 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.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from .const import ( from .const import (
CONF_ALBUMS, CONF_ALBUM_ID,
CONF_ALBUM_NAME,
CONF_API_KEY, CONF_API_KEY,
CONF_IMMICH_URL, CONF_IMMICH_URL,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL,
DOMAIN, DOMAIN,
SUBENTRY_TYPE_ALBUM,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -57,13 +64,12 @@ async def fetch_albums(
class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN): class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Immich Album Watcher.""" """Handle a config flow for Immich Album Watcher."""
VERSION = 1 VERSION = 2
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the config flow.""" """Initialize the config flow."""
self._url: str | None = None self._url: str | None = None
self._api_key: str | None = None self._api_key: str | None = None
self._albums: list[dict[str, Any]] = []
@staticmethod @staticmethod
@callback @callback
@@ -71,9 +77,17 @@ class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return ImmichAlbumWatcherOptionsFlow(config_entry) 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( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> ConfigFlowResult:
"""Handle the initial step - connection details.""" """Handle the initial step - connection details."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
@@ -85,12 +99,21 @@ class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN):
try: try:
await validate_connection(session, self._url, self._api_key) await validate_connection(session, self._url, self._api_key)
self._albums = await fetch_albums(session, self._url, self._api_key)
if not self._albums: # Set unique ID based on URL
errors["base"] = "no_albums" await self.async_set_unique_id(self._url)
else: self._abort_if_unique_id_configured()
return await self.async_step_albums()
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: except InvalidAuth:
errors["base"] = "invalid_auth" 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 self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> SubentryFlowResult:
"""Handle album selection step.""" """Handle album selection."""
errors: dict[str, str] = {} 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: if user_input is not None:
selected_albums = user_input.get(CONF_ALBUMS, []) album_id = user_input[CONF_ALBUM_ID]
if not selected_albums: # Check if album is already configured
errors["base"] = "no_albums_selected" for subentry in config_entry.subentries.values():
else: if subentry.data.get(CONF_ALBUM_ID) == album_id:
# Create unique ID based on URL return self.async_abort(reason="album_already_configured")
await self.async_set_unique_id(self._url)
self._abort_if_unique_id_configured()
return self.async_create_entry( # Find album name
title="Immich Album Watcher", album_name = "Unknown Album"
data={ for album in self._albums:
CONF_IMMICH_URL: self._url, if album["id"] == album_id:
CONF_API_KEY: self._api_key, album_name = album.get("albumName", "Unnamed")
}, break
options={
CONF_ALBUMS: selected_albums,
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
},
)
# 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_options = {
album["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)" album["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)"
for album in self._albums 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( return self.async_show_form(
step_id="albums", step_id="user",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_ALBUMS): cv.multi_select(album_options), vol.Required(CONF_ALBUM_ID): vol.In(album_options),
} }
), ),
errors=errors, errors=errors,
@@ -167,43 +232,21 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self._config_entry = config_entry self._config_entry = config_entry
self._albums: list[dict[str, Any]] = []
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> ConfigFlowResult:
"""Manage the options.""" """Manage the options."""
errors: dict[str, str] = {} if user_input is not None:
# 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:
return self.async_create_entry( return self.async_create_entry(
title="", title="",
data={ data={
CONF_ALBUMS: user_input.get(CONF_ALBUMS, []),
CONF_SCAN_INTERVAL: user_input.get( CONF_SCAN_INTERVAL: user_input.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL 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( current_interval = self._config_entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
) )
@@ -212,15 +255,11 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
step_id="init", step_id="init",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_ALBUMS, default=current_albums): cv.multi_select(
album_options
),
vol.Required( vol.Required(
CONF_SCAN_INTERVAL, default=current_interval CONF_SCAN_INTERVAL, default=current_interval
): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), ): 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_IMMICH_URL: Final = "immich_url"
CONF_API_KEY: Final = "api_key" CONF_API_KEY: Final = "api_key"
CONF_ALBUMS: Final = "albums" CONF_ALBUMS: Final = "albums"
CONF_ALBUM_ID: Final = "album_id"
CONF_ALBUM_NAME: Final = "album_name"
CONF_SCAN_INTERVAL: Final = "scan_interval" CONF_SCAN_INTERVAL: Final = "scan_interval"
# Subentry type
SUBENTRY_TYPE_ALBUM: Final = "album"
# Defaults # Defaults
DEFAULT_SCAN_INTERVAL: Final = 60 # seconds DEFAULT_SCAN_INTERVAL: Final = 60 # seconds
NEW_ASSETS_RESET_DELAY: Final = 300 # 5 minutes 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) 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.""" """Coordinator for fetching Immich album data."""
def __init__( def __init__(
@@ -214,24 +214,26 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
hass: HomeAssistant, hass: HomeAssistant,
url: str, url: str,
api_key: str, api_key: str,
album_ids: list[str], album_id: str,
album_name: str,
scan_interval: int, scan_interval: int,
) -> None: ) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
name=DOMAIN, name=f"{DOMAIN}_{album_id}",
update_interval=timedelta(seconds=scan_interval), update_interval=timedelta(seconds=scan_interval),
) )
self._url = url.rstrip("/") self._url = url.rstrip("/")
self._api_key = api_key self._api_key = api_key
self._album_ids = album_ids self._album_id = album_id
self._previous_states: dict[str, AlbumData] = {} self._album_name = album_name
self._previous_state: AlbumData | None = None
self._session: aiohttp.ClientSession | None = None self._session: aiohttp.ClientSession | None = None
self._people_cache: dict[str, str] = {} # person_id -> name self._people_cache: dict[str, str] = {} # person_id -> name
self._users_cache: dict[str, str] = {} # user_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 @property
def immich_url(self) -> str: def immich_url(self) -> str:
@@ -243,35 +245,32 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
"""Return the API key.""" """Return the API key."""
return self._api_key return self._api_key
def update_config(self, album_ids: list[str], scan_interval: int) -> None: @property
"""Update configuration.""" def album_id(self) -> str:
self._album_ids = album_ids """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) self.update_interval = timedelta(seconds=scan_interval)
async def async_refresh_now(self) -> None: async def async_refresh_now(self) -> None:
"""Force an immediate refresh.""" """Force an immediate refresh."""
await self.async_request_refresh() await self.async_request_refresh()
async def async_refresh_album(self, album_id: str) -> None: async def async_get_recent_assets(self, count: int = 10) -> list[dict[str, Any]]:
"""Force an immediate refresh of a specific album. """Get recent assets from the album."""
if self.data is None:
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:
return [] return []
album = self.data[album_id]
# Sort assets by created_at descending # Sort assets by created_at descending
sorted_assets = sorted( sorted_assets = sorted(
album.assets.values(), self.data.assets.values(),
key=lambda a: a.created_at, key=lambda a: a.created_at,
reverse=True, reverse=True,
)[:count] )[:count]
@@ -336,12 +335,14 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
return self._users_cache return self._users_cache
async def _async_fetch_shared_links(self) -> dict[str, list[SharedLinkInfo]]: async def _async_fetch_shared_links(self) -> list[SharedLinkInfo]:
"""Fetch shared links from Immich and cache album_id -> SharedLinkInfo mapping.""" """Fetch shared links for this album from Immich."""
if self._session is None: if self._session is None:
self._session = async_get_clientsession(self.hass) self._session = async_get_clientsession(self.hass)
headers = {"x-api-key": self._api_key} headers = {"x-api-key": self._api_key}
self._shared_links = []
try: try:
async with self._session.get( async with self._session.get(
f"{self._url}/api/shared-links", f"{self._url}/api/shared-links",
@@ -349,100 +350,71 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
) as response: ) as response:
if response.status == 200: if response.status == 200:
data = await response.json() data = await response.json()
_LOGGER.debug("Fetched %d shared links from Immich", len(data))
self._shared_links_cache.clear()
for link in data: for link in data:
album = link.get("album") album = link.get("album")
key = link.get("key") key = link.get("key")
if album and key: if album and key and album.get("id") == self._album_id:
album_id = album.get("id") link_info = SharedLinkInfo.from_api_response(link)
if album_id: self._shared_links.append(link_info)
link_info = SharedLinkInfo.from_api_response(link) _LOGGER.debug(
_LOGGER.debug( "Found shared link for album: key=%s, has_password=%s",
"Shared link: key=%s, album_id=%s, " key[:8],
"has_password=%s, expired=%s, accessible=%s", link_info.has_password,
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: except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch shared links: %s", 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]: def _get_accessible_links(self) -> list[SharedLinkInfo]:
"""Get all accessible (no password, not expired) shared links for an album.""" """Get all accessible (no password, not expired) shared links."""
all_links = self._shared_links_cache.get(album_id, []) return [link for link in self._shared_links if link.is_accessible]
return [link for link in all_links if link.is_accessible]
def _get_non_expired_links(self, album_id: str) -> list[SharedLinkInfo]: def _get_protected_links(self) -> list[SharedLinkInfo]:
"""Get all non-expired shared links for an album (including password-protected).""" """Get password-protected but not expired shared links."""
all_links = self._shared_links_cache.get(album_id, []) return [link for link in self._shared_links if link.has_password and not link.is_expired]
return [link for link in all_links if not link.is_expired]
def _get_protected_only_links(self, album_id: str) -> list[SharedLinkInfo]: def get_public_url(self) -> str | None:
"""Get password-protected but not expired shared links for an album.""" """Get the public URL if album has an accessible shared link."""
all_links = self._shared_links_cache.get(album_id, []) accessible_links = self._get_accessible_links()
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)
if accessible_links: if accessible_links:
return f"{self._url}/share/{accessible_links[0].key}" return f"{self._url}/share/{accessible_links[0].key}"
return None return None
def get_album_any_url(self, album_id: str) -> str | None: def get_any_url(self) -> str | None:
"""Get any non-expired URL for an album (prefers accessible, falls back to protected).""" """Get any non-expired URL (prefers accessible, falls back to protected)."""
# First try accessible links accessible_links = self._get_accessible_links()
accessible_links = self._get_accessible_links(album_id)
if accessible_links: if accessible_links:
return f"{self._url}/share/{accessible_links[0].key}" return f"{self._url}/share/{accessible_links[0].key}"
# Fall back to any non-expired link (including password-protected) non_expired = [link for link in self._shared_links if not link.is_expired]
non_expired = self._get_non_expired_links(album_id)
if non_expired: if non_expired:
return f"{self._url}/share/{non_expired[0].key}" return f"{self._url}/share/{non_expired[0].key}"
return None return None
def get_album_protected_url(self, album_id: str) -> str | None: def get_protected_url(self) -> str | None:
"""Get a protected URL for an album if any password-protected link exists.""" """Get a protected URL if any password-protected link exists."""
protected_links = self._get_protected_only_links(album_id) protected_links = self._get_protected_links()
if protected_links: if protected_links:
return f"{self._url}/share/{protected_links[0].key}" return f"{self._url}/share/{protected_links[0].key}"
return None return None
def get_album_protected_urls(self, album_id: str) -> list[str]: def get_protected_urls(self) -> list[str]:
"""Get all password-protected (but not expired) URLs for an album.""" """Get all password-protected URLs."""
protected_links = self._get_protected_only_links(album_id) return [f"{self._url}/share/{link.key}" for link in self._get_protected_links()]
return [f"{self._url}/share/{link.key}" for link in protected_links]
def get_album_protected_password(self, album_id: str) -> str | None: def get_protected_password(self) -> str | None:
"""Get the password for the first protected link (matches get_album_protected_url).""" """Get the password for the first protected link."""
protected_links = self._get_protected_only_links(album_id) protected_links = self._get_protected_links()
if protected_links and protected_links[0].password: if protected_links and protected_links[0].password:
return protected_links[0].password return protected_links[0].password
return None return None
def get_album_public_urls(self, album_id: str) -> list[str]: def get_public_urls(self) -> list[str]:
"""Get all accessible public URLs for an album.""" """Get all accessible public URLs."""
accessible_links = self._get_accessible_links(album_id) return [f"{self._url}/share/{link.key}" for link in self._get_accessible_links()]
return [f"{self._url}/share/{link.key}" for link in accessible_links]
def get_album_shared_links_info(self, album_id: str) -> list[dict[str, Any]]: def get_shared_links_info(self) -> list[dict[str, Any]]:
"""Get detailed info about all shared links for an album.""" """Get detailed info about all shared links."""
all_links = self._shared_links_cache.get(album_id, [])
return [ return [
{ {
"url": f"{self._url}/share/{link.key}", "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, "expires_at": link.expires_at.isoformat() if link.expires_at else None,
"is_accessible": link.is_accessible, "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: def _get_asset_public_url(self, asset_id: str) -> str | None:
"""Get the public URL for an asset (prefers accessible, falls back to protected).""" """Get the public URL for an asset."""
# First try accessible links accessible_links = self._get_accessible_links()
accessible_links = self._get_accessible_links(album_id)
if accessible_links: if accessible_links:
return f"{self._url}/share/{accessible_links[0].key}/photos/{asset_id}" return f"{self._url}/share/{accessible_links[0].key}/photos/{asset_id}"
# Fall back to any non-expired link non_expired = [link for link in self._shared_links if not link.is_expired]
non_expired = self._get_non_expired_links(album_id)
if non_expired: if non_expired:
return f"{self._url}/share/{non_expired[0].key}/photos/{asset_id}" return f"{self._url}/share/{non_expired[0].key}/photos/{asset_id}"
return None return None
async def _async_update_data(self) -> dict[str, AlbumData]: async def _async_update_data(self) -> AlbumData | None:
"""Fetch data from Immich API.""" """Fetch data from Immich API."""
if self._session is None: if self._session is None:
self._session = async_get_clientsession(self.hass) self._session = async_get_clientsession(self.hass)
@@ -475,60 +445,52 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
if not self._users_cache: if not self._users_cache:
await self._async_fetch_users() 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() await self._async_fetch_shared_links()
headers = {"x-api-key": self._api_key} headers = {"x-api-key": self._api_key}
albums_data: dict[str, AlbumData] = {}
for album_id in self._album_ids: try:
try: async with self._session.get(
async with self._session.get( f"{self._url}/api/albums/{self._album_id}",
f"{self._url}/api/albums/{album_id}", headers=headers,
headers=headers, ) as response:
) as response: if response.status == 404:
if response.status == 404: _LOGGER.warning("Album %s not found", self._album_id)
_LOGGER.warning("Album %s not found, skipping", album_id) return None
continue if response.status != 200:
if response.status != 200: raise UpdateFailed(
raise UpdateFailed( f"Error fetching album {self._album_id}: HTTP {response.status}"
f"Error fetching album {album_id}: HTTP {response.status}" )
)
data = await response.json() data = await response.json()
album = AlbumData.from_api_response(data, self._users_cache) album = AlbumData.from_api_response(data, self._users_cache)
# Detect changes and update flags # Detect changes
if album_id in self._previous_states: if self._previous_state:
change = self._detect_change( change = self._detect_change(self._previous_state, album)
self._previous_states[album_id], album if change:
) album.has_new_assets = change.added_count > 0
if change: album.last_change_time = datetime.now()
album.has_new_assets = change.added_count > 0 self._fire_events(change, album)
album.last_change_time = datetime.now() else:
self._fire_events(change, album) album.has_new_assets = False
else:
# First run, no changes
album.has_new_assets = False
# Preserve has_new_assets from previous state if still within window # Preserve has_new_assets from previous state if still within window
if album_id in self._previous_states: if self._previous_state:
prev = self._previous_states[album_id] prev = self._previous_state
if prev.has_new_assets and prev.last_change_time: 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
album.last_change_time = prev.last_change_time if not album.has_new_assets:
if not album.has_new_assets: album.has_new_assets = prev.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: return album
raise UpdateFailed(f"Error communicating with Immich: {err}") from err
# Update previous states except aiohttp.ClientError as err:
self._previous_states = albums_data.copy() raise UpdateFailed(f"Error communicating with Immich: {err}") from err
return albums_data
def _detect_change( def _detect_change(
self, old_state: AlbumData, new_state: AlbumData self, old_state: AlbumData, new_state: AlbumData
@@ -546,7 +508,6 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
elif removed_ids and not added_ids: elif removed_ids and not added_ids:
change_type = "assets_removed" change_type = "assets_removed"
# Get full asset info for added assets
added_assets = [ added_assets = [
new_state.assets[aid] for aid in added_ids if aid in new_state.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: def _fire_events(self, change: AlbumChange, album: AlbumData) -> None:
"""Fire Home Assistant events for album changes.""" """Fire Home Assistant events for album changes."""
# Build detailed asset info for events
added_assets_detail = [] added_assets_detail = []
for asset in change.added_assets: for asset in change.added_assets:
asset_detail = { asset_detail = {
@@ -576,8 +536,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
ATTR_ASSET_DESCRIPTION: asset.description, ATTR_ASSET_DESCRIPTION: asset.description,
ATTR_PEOPLE: asset.people, ATTR_PEOPLE: asset.people,
} }
# Add public URL if album has a shared link asset_url = self._get_asset_public_url(asset.id)
asset_url = self._get_asset_public_url(change.album_id, asset.id)
if asset_url: if asset_url:
asset_detail[ATTR_ASSET_URL] = asset_url asset_detail[ATTR_ASSET_URL] = asset_url
added_assets_detail.append(asset_detail) added_assets_detail.append(asset_detail)
@@ -593,12 +552,10 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
ATTR_PEOPLE: list(album.people), ATTR_PEOPLE: list(album.people),
} }
# Add album URL if it has a shared link (prefers accessible, falls back to protected) album_url = self.get_any_url()
album_url = self.get_album_any_url(change.album_id)
if album_url: if album_url:
event_data[ATTR_ALBUM_URL] = album_url event_data[ATTR_ALBUM_URL] = album_url
# Fire general change event
self.hass.bus.async_fire(EVENT_ALBUM_CHANGED, event_data) self.hass.bus.async_fire(EVENT_ALBUM_CHANGED, event_data)
_LOGGER.info( _LOGGER.info(
@@ -608,16 +565,15 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
change.removed_count, change.removed_count,
) )
# Fire specific events
if change.added_count > 0: if change.added_count > 0:
self.hass.bus.async_fire(EVENT_ASSETS_ADDED, event_data) self.hass.bus.async_fire(EVENT_ASSETS_ADDED, event_data)
if change.removed_count > 0: if change.removed_count > 0:
self.hass.bus.async_fire(EVENT_ASSETS_REMOVED, event_data) self.hass.bus.async_fire(EVENT_ASSETS_REMOVED, event_data)
def get_album_protected_link_id(self, album_id: str) -> str | None: def get_protected_link_id(self) -> str | None:
"""Get the ID of the first protected link (matches get_album_protected_url).""" """Get the ID of the first protected link."""
protected_links = self._get_protected_only_links(album_id) protected_links = self._get_protected_links()
if protected_links: if protected_links:
return protected_links[0].id return protected_links[0].id
return None return None
@@ -625,15 +581,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
async def async_set_shared_link_password( async def async_set_shared_link_password(
self, link_id: str, password: str | None self, link_id: str, password: str | None
) -> bool: ) -> bool:
"""Update the password for a shared link via Immich API. """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.
"""
if self._session is None: if self._session is None:
self._session = async_get_clientsession(self.hass) self._session = async_get_clientsession(self.hass)
@@ -642,7 +590,6 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
"Content-Type": "application/json", "Content-Type": "application/json",
} }
# Immich API expects null to remove password, or a string to set it
payload = {"password": password if password else None} payload = {"password": password if password else None}
try: try:
@@ -653,7 +600,6 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
) as response: ) as response:
if response.status == 200: if response.status == 200:
_LOGGER.info("Successfully updated shared link password") _LOGGER.info("Successfully updated shared link password")
# Refresh shared links cache to reflect the change
await self._async_fetch_shared_links() await self._async_fetch_shared_links()
return True return True
else: else:
@@ -666,8 +612,8 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
_LOGGER.error("Error updating shared link password: %s", err) _LOGGER.error("Error updating shared link password: %s", err)
return False return False
def clear_new_assets_flag(self, album_id: str) -> None: def clear_new_assets_flag(self) -> None:
"""Clear the new assets flag for an album.""" """Clear the new assets flag."""
if self.data and album_id in self.data: if self.data:
self.data[album_id].has_new_assets = False self.data.has_new_assets = False
self.data[album_id].last_change_time = None self.data.last_change_time = None

View File

@@ -8,5 +8,6 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://github.com/your-repo/immich-album-watcher/issues", "issue_tracker": "https://github.com/your-repo/immich-album-watcher/issues",
"requirements": [], "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, SensorEntity,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse, callback from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse, callback
from homeassistant.helpers import entity_platform from homeassistant.helpers import entity_platform
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ( from .const import (
ATTR_ALBUM_ID, ATTR_ALBUM_ID,
ATTR_ALBUM_PROTECTED_PASSWORD,
ATTR_ALBUM_PROTECTED_URL, ATTR_ALBUM_PROTECTED_URL,
ATTR_ALBUM_URL,
ATTR_ALBUM_URLS, ATTR_ALBUM_URLS,
ATTR_ASSET_COUNT, ATTR_ASSET_COUNT,
ATTR_CREATED_AT, ATTR_CREATED_AT,
@@ -35,7 +34,8 @@ from .const import (
ATTR_SHARED, ATTR_SHARED,
ATTR_THUMBNAIL_URL, ATTR_THUMBNAIL_URL,
ATTR_VIDEO_COUNT, ATTR_VIDEO_COUNT,
CONF_ALBUMS, CONF_ALBUM_ID,
CONF_ALBUM_NAME,
DOMAIN, DOMAIN,
SERVICE_GET_RECENT_ASSETS, SERVICE_GET_RECENT_ASSETS,
SERVICE_REFRESH, SERVICE_REFRESH,
@@ -51,22 +51,28 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Immich Album Watcher sensors from a config entry.""" """Set up Immich Album Watcher sensors from a config entry."""
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] # Iterate through all album subentries
album_ids = entry.options.get(CONF_ALBUMS, []) 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] = [] coordinator = subentry_data.coordinator
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))
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 # Register entity services
platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform()
@@ -98,26 +104,26 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator) super().__init__(coordinator)
self._album_id = album_id
self._entry = entry 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 @property
def _album_data(self) -> AlbumData | None: def _album_data(self) -> AlbumData | None:
"""Get the album data from coordinator.""" """Get the album data from coordinator."""
if self.coordinator.data is None: return self.coordinator.data
return None
return self.coordinator.data.get(self._album_id)
@property @property
def translation_placeholders(self) -> dict[str, str]: def translation_placeholders(self) -> dict[str, str]:
"""Return translation placeholders.""" """Return translation placeholders."""
if self._album_data: if self._album_data:
return {"album_name": self._album_data.name} return {"album_name": self._album_data.name}
return {"album_name": f"Album {self._album_id[:8]}"} return {"album_name": self._album_name}
@property @property
def available(self) -> bool: def available(self) -> bool:
@@ -126,12 +132,13 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return device info.""" """Return device info - one device per album."""
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)}, identifiers={(DOMAIN, self._subentry.subentry_id)},
name="Immich Album Watcher", name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type="service", entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
) )
@callback @callback
@@ -141,11 +148,11 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
async def async_refresh_album(self) -> None: async def async_refresh_album(self) -> None:
"""Refresh data for this album.""" """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: async def async_get_recent_assets(self, count: int = 10) -> ServiceResponse:
"""Get recent assets for this album.""" """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} return {"assets": assets}
@@ -160,11 +167,11 @@ class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor):
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator, entry, album_id) super().__init__(coordinator, entry, subentry)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_asset_count" self._attr_unique_id = f"{subentry.subentry_id}_asset_count"
@property @property
def native_value(self) -> int | None: def native_value(self) -> int | None:
@@ -191,7 +198,6 @@ class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor):
ATTR_PEOPLE: list(self._album_data.people), ATTR_PEOPLE: list(self._album_data.people),
} }
# Add thumbnail URL if available
if self._album_data.thumbnail_asset_id: if self._album_data.thumbnail_asset_id:
attrs[ATTR_THUMBNAIL_URL] = ( attrs[ATTR_THUMBNAIL_URL] = (
f"{self.coordinator.immich_url}/api/assets/" f"{self.coordinator.immich_url}/api/assets/"
@@ -212,11 +218,11 @@ class ImmichAlbumPhotoCountSensor(ImmichAlbumBaseSensor):
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator, entry, album_id) super().__init__(coordinator, entry, subentry)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_photo_count" self._attr_unique_id = f"{subentry.subentry_id}_photo_count"
@property @property
def native_value(self) -> int | None: def native_value(self) -> int | None:
@@ -237,11 +243,11 @@ class ImmichAlbumVideoCountSensor(ImmichAlbumBaseSensor):
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator, entry, album_id) super().__init__(coordinator, entry, subentry)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_video_count" self._attr_unique_id = f"{subentry.subentry_id}_video_count"
@property @property
def native_value(self) -> int | None: def native_value(self) -> int | None:
@@ -262,11 +268,11 @@ class ImmichAlbumLastUpdatedSensor(ImmichAlbumBaseSensor):
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator, entry, album_id) super().__init__(coordinator, entry, subentry)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_last_updated" self._attr_unique_id = f"{subentry.subentry_id}_last_updated"
@property @property
def native_value(self) -> datetime | None: def native_value(self) -> datetime | None:
@@ -292,11 +298,11 @@ class ImmichAlbumCreatedSensor(ImmichAlbumBaseSensor):
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator, entry, album_id) super().__init__(coordinator, entry, subentry)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_created" self._attr_unique_id = f"{subentry.subentry_id}_created"
@property @property
def native_value(self) -> datetime | None: def native_value(self) -> datetime | None:
@@ -322,11 +328,11 @@ class ImmichAlbumPeopleSensor(ImmichAlbumBaseSensor):
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator, entry, album_id) super().__init__(coordinator, entry, subentry)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_people_count" self._attr_unique_id = f"{subentry.subentry_id}_people_count"
@property @property
def native_value(self) -> int | None: def native_value(self) -> int | None:
@@ -356,17 +362,17 @@ class ImmichAlbumPublicUrlSensor(ImmichAlbumBaseSensor):
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator, entry, album_id) super().__init__(coordinator, entry, subentry)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_public_url" self._attr_unique_id = f"{subentry.subentry_id}_public_url"
@property @property
def native_value(self) -> str | None: def native_value(self) -> str | None:
"""Return the state of the sensor (public URL).""" """Return the state of the sensor (public URL)."""
if self._album_data: if self._album_data:
return self.coordinator.get_album_public_url(self._album_id) return self.coordinator.get_public_url()
return None return None
@property @property
@@ -380,13 +386,11 @@ class ImmichAlbumPublicUrlSensor(ImmichAlbumBaseSensor):
ATTR_SHARED: self._album_data.shared, ATTR_SHARED: self._album_data.shared,
} }
# Include all accessible URLs if there are multiple all_urls = self.coordinator.get_public_urls()
all_urls = self.coordinator.get_album_public_urls(self._album_id)
if len(all_urls) > 1: if len(all_urls) > 1:
attrs[ATTR_ALBUM_URLS] = all_urls attrs[ATTR_ALBUM_URLS] = all_urls
# Include detailed info about all shared links (including protected/expired) links_info = self.coordinator.get_shared_links_info()
links_info = self.coordinator.get_album_shared_links_info(self._album_id)
if links_info: if links_info:
attrs["shared_links"] = links_info attrs["shared_links"] = links_info
@@ -403,17 +407,17 @@ class ImmichAlbumProtectedUrlSensor(ImmichAlbumBaseSensor):
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator, entry, album_id) super().__init__(coordinator, entry, subentry)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_protected_url" self._attr_unique_id = f"{subentry.subentry_id}_protected_url"
@property @property
def native_value(self) -> str | None: def native_value(self) -> str | None:
"""Return the state of the sensor (protected URL).""" """Return the state of the sensor (protected URL)."""
if self._album_data: if self._album_data:
return self.coordinator.get_album_protected_url(self._album_id) return self.coordinator.get_protected_url()
return None return None
@property @property
@@ -426,8 +430,7 @@ class ImmichAlbumProtectedUrlSensor(ImmichAlbumBaseSensor):
ATTR_ALBUM_ID: self._album_data.id, ATTR_ALBUM_ID: self._album_data.id,
} }
# Include all protected URLs if there are multiple all_urls = self.coordinator.get_protected_urls()
all_urls = self.coordinator.get_album_protected_urls(self._album_id)
if len(all_urls) > 1: if len(all_urls) > 1:
attrs["protected_urls"] = all_urls attrs["protected_urls"] = all_urls
@@ -444,17 +447,17 @@ class ImmichAlbumProtectedPasswordSensor(ImmichAlbumBaseSensor):
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
super().__init__(coordinator, entry, album_id) super().__init__(coordinator, entry, subentry)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_protected_password" self._attr_unique_id = f"{subentry.subentry_id}_protected_password"
@property @property
def native_value(self) -> str | None: def native_value(self) -> str | None:
"""Return the state of the sensor (protected link password).""" """Return the state of the sensor (protected link password)."""
if self._album_data: if self._album_data:
return self.coordinator.get_album_protected_password(self._album_id) return self.coordinator.get_protected_password()
return None return None
@property @property
@@ -465,7 +468,5 @@ class ImmichAlbumProtectedPasswordSensor(ImmichAlbumBaseSensor):
return { return {
ATTR_ALBUM_ID: self._album_data.id, ATTR_ALBUM_ID: self._album_data.id,
ATTR_ALBUM_PROTECTED_URL: self.coordinator.get_album_protected_url( ATTR_ALBUM_PROTECTED_URL: self.coordinator.get_protected_url(),
self._album_id
),
} }

View File

@@ -5,8 +5,9 @@ from __future__ import annotations
import logging import logging
from homeassistant.components.text import TextEntity, TextMode 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.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -14,7 +15,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ( from .const import (
ATTR_ALBUM_ID, ATTR_ALBUM_ID,
ATTR_ALBUM_PROTECTED_URL, ATTR_ALBUM_PROTECTED_URL,
CONF_ALBUMS, CONF_ALBUM_ID,
CONF_ALBUM_NAME,
DOMAIN, DOMAIN,
) )
from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
@@ -28,14 +30,19 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Immich Album Watcher text entities from a config entry.""" """Set up Immich Album Watcher text entities from a config entry."""
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] # Iterate through all album subentries
album_ids = entry.options.get(CONF_ALBUMS, []) 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] = [] coordinator = subentry_data.coordinator
for album_id in album_ids:
entities.append(ImmichAlbumProtectedPasswordText(coordinator, entry, album_id))
async_add_entities(entities) async_add_entities(
[ImmichAlbumProtectedPasswordText(coordinator, entry, subentry)],
config_subentry_id=subentry_id,
)
class ImmichAlbumProtectedPasswordText( class ImmichAlbumProtectedPasswordText(
@@ -53,27 +60,27 @@ class ImmichAlbumProtectedPasswordText(
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the text entity.""" """Initialize the text entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._album_id = album_id
self._entry = entry 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 @property
def _album_data(self) -> AlbumData | None: def _album_data(self) -> AlbumData | None:
"""Get the album data from coordinator.""" """Get the album data from coordinator."""
if self.coordinator.data is None: return self.coordinator.data
return None
return self.coordinator.data.get(self._album_id)
@property @property
def translation_placeholders(self) -> dict[str, str]: def translation_placeholders(self) -> dict[str, str]:
"""Return translation placeholders.""" """Return translation placeholders."""
if self._album_data: if self._album_data:
return {"album_name": self._album_data.name} return {"album_name": self._album_data.name}
return {"album_name": f"Album {self._album_id[:8]}"} return {"album_name": self._album_name}
@property @property
def available(self) -> bool: def available(self) -> bool:
@@ -84,23 +91,24 @@ class ImmichAlbumProtectedPasswordText(
if not self.coordinator.last_update_success or self._album_data is None: if not self.coordinator.last_update_success or self._album_data is None:
return False return False
# Only available if there's a protected link to edit # 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 @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return device info.""" """Return device info - one device per album."""
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)}, identifiers={(DOMAIN, self._subentry.subentry_id)},
name="Immich Album Watcher", name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type="service", entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
) )
@property @property
def native_value(self) -> str | None: def native_value(self) -> str | None:
"""Return the current password value.""" """Return the current password value."""
if self._album_data: if self._album_data:
return self.coordinator.get_album_protected_password(self._album_id) return self.coordinator.get_protected_password()
return None return None
@property @property
@@ -113,7 +121,7 @@ class ImmichAlbumProtectedPasswordText(
ATTR_ALBUM_ID: self._album_data.id, 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: if protected_url:
attrs[ATTR_ALBUM_PROTECTED_URL] = protected_url attrs[ATTR_ALBUM_PROTECTED_URL] = protected_url
@@ -121,7 +129,7 @@ class ImmichAlbumProtectedPasswordText(
async def async_set_value(self, value: str) -> None: async def async_set_value(self, value: str) -> None:
"""Set the password for the protected shared link.""" """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: if not link_id:
_LOGGER.error( _LOGGER.error(
"Cannot set password: no protected link found for album %s", "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)", "immich_url": "The URL of your Immich server (e.g., http://192.168.1.100:2283)",
"api_key": "Your Immich API key" "api_key": "Your Immich API key"
} }
},
"albums": {
"title": "Select Albums",
"description": "Choose which albums to monitor for changes.",
"data": {
"albums": "Albums to watch"
}
} }
}, },
"error": { "error": {
"cannot_connect": "Failed to connect to Immich server", "cannot_connect": "Failed to connect to Immich server",
"invalid_auth": "Invalid API key", "invalid_auth": "Invalid API key",
"no_albums": "No albums found on the server", "no_albums": "No albums found on the server",
"no_albums_selected": "Please select at least one album",
"unknown": "Unexpected error occurred" "unknown": "Unexpected error occurred"
}, },
"abort": { "abort": {
"already_configured": "This Immich server is already configured" "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": { "options": {
"step": { "step": {
"init": { "init": {
"title": "Immich Album Watcher Options", "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": { "data": {
"albums": "Albums to watch",
"scan_interval": "Scan interval (seconds)" "scan_interval": "Scan interval (seconds)"
}, },
"data_description": { "data_description": {
"scan_interval": "How often to check for album changes (10-3600 seconds)" "scan_interval": "How often to check for album changes (10-3600 seconds)"
} }
} }
},
"error": {
"cannot_connect": "Failed to connect to Immich server"
} }
}, },
"services": { "services": {

View File

@@ -58,42 +58,56 @@
"immich_url": "URL вашего сервера Immich (например, http://192.168.1.100:2283)", "immich_url": "URL вашего сервера Immich (например, http://192.168.1.100:2283)",
"api_key": "Ваш API-ключ Immich" "api_key": "Ваш API-ключ Immich"
} }
},
"albums": {
"title": "Выбор альбомов",
"description": "Выберите альбомы для отслеживания изменений.",
"data": {
"albums": "Альбомы для отслеживания"
}
} }
}, },
"error": { "error": {
"cannot_connect": "Не удалось подключиться к серверу Immich", "cannot_connect": "Не удалось подключиться к серверу Immich",
"invalid_auth": "Неверный API-ключ", "invalid_auth": "Неверный API-ключ",
"no_albums": "На сервере не найдено альбомов", "no_albums": "На сервере не найдено альбомов",
"no_albums_selected": "Пожалуйста, выберите хотя бы один альбом",
"unknown": "Произошла непредвиденная ошибка" "unknown": "Произошла непредвиденная ошибка"
}, },
"abort": { "abort": {
"already_configured": "Этот сервер Immich уже настроен" "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": { "options": {
"step": { "step": {
"init": { "init": {
"title": "Настройки Immich Album Watcher", "title": "Настройки Immich Album Watcher",
"description": "Настройте отслеживаемые альбомы и частоту проверки изменений.", "description": "Настройте интервал опроса для всех альбомов.",
"data": { "data": {
"albums": "Альбомы для отслеживания",
"scan_interval": "Интервал сканирования (секунды)" "scan_interval": "Интервал сканирования (секунды)"
}, },
"data_description": { "data_description": {
"scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)" "scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)"
} }
} }
},
"error": {
"cannot_connect": "Не удалось подключиться к серверу Immich"
} }
}, },
"services": { "services": {