Implement hub and subenty approach based on telegram bot integration implementation
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user