Add Immich Album Watcher integration
This commit is contained in:
80
immich_album_watcher/__init__.py
Normal file
80
immich_album_watcher/__init__.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Immich Album Watcher integration for Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import (
|
||||
CONF_ALBUMS,
|
||||
CONF_API_KEY,
|
||||
CONF_IMMICH_URL,
|
||||
CONF_SCAN_INTERVAL,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import ImmichAlbumWatcherCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Immich Album Watcher from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
url = entry.data[CONF_IMMICH_URL]
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
album_ids = entry.options.get(CONF_ALBUMS, [])
|
||||
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
|
||||
coordinator = ImmichAlbumWatcherCoordinator(
|
||||
hass,
|
||||
url=url,
|
||||
api_key=api_key,
|
||||
album_ids=album_ids,
|
||||
scan_interval=scan_interval,
|
||||
)
|
||||
|
||||
# Fetch initial data
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
|
||||
# Set up platforms
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Register update listener for options changes
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
_LOGGER.info(
|
||||
"Immich Album Watcher set up successfully, watching %d albums",
|
||||
len(album_ids),
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
album_ids = entry.options.get(CONF_ALBUMS, [])
|
||||
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
|
||||
coordinator.update_config(album_ids, scan_interval)
|
||||
|
||||
# Reload the entry to update sensors
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
232
immich_album_watcher/config_flow.py
Normal file
232
immich_album_watcher/config_flow.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Config flow for Immich Album Watcher integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_ALBUMS,
|
||||
CONF_API_KEY,
|
||||
CONF_IMMICH_URL,
|
||||
CONF_SCAN_INTERVAL,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def validate_connection(
|
||||
session: aiohttp.ClientSession, url: str, api_key: str
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the Immich connection and return server info."""
|
||||
headers = {"x-api-key": api_key}
|
||||
async with session.get(
|
||||
f"{url.rstrip('/')}/api/server/ping", headers=headers
|
||||
) as response:
|
||||
if response.status == 401:
|
||||
raise InvalidAuth
|
||||
if response.status != 200:
|
||||
raise CannotConnect
|
||||
return await response.json()
|
||||
|
||||
|
||||
async def fetch_albums(
|
||||
session: aiohttp.ClientSession, url: str, api_key: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch all albums from Immich."""
|
||||
headers = {"x-api-key": api_key}
|
||||
async with session.get(
|
||||
f"{url.rstrip('/')}/api/albums", headers=headers
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise CannotConnect
|
||||
return await response.json()
|
||||
|
||||
|
||||
class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Immich Album Watcher."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._url: str | None = None
|
||||
self._api_key: str | None = None
|
||||
self._albums: list[dict[str, Any]] = []
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
return ImmichAlbumWatcherOptionsFlow(config_entry)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step - connection details."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._url = user_input[CONF_IMMICH_URL].rstrip("/")
|
||||
self._api_key = user_input[CONF_API_KEY]
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
try:
|
||||
await validate_connection(session, self._url, self._api_key)
|
||||
self._albums = await fetch_albums(session, self._url, self._api_key)
|
||||
|
||||
if not self._albums:
|
||||
errors["base"] = "no_albums"
|
||||
else:
|
||||
return await self.async_step_albums()
|
||||
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except aiohttp.ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_IMMICH_URL): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"docs_url": "https://immich.app/docs/features/command-line-interface#obtain-the-api-key"
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_albums(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle album selection step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
selected_albums = user_input.get(CONF_ALBUMS, [])
|
||||
|
||||
if not selected_albums:
|
||||
errors["base"] = "no_albums_selected"
|
||||
else:
|
||||
# Create unique ID based on URL
|
||||
await self.async_set_unique_id(self._url)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title="Immich Album Watcher",
|
||||
data={
|
||||
CONF_IMMICH_URL: self._url,
|
||||
CONF_API_KEY: self._api_key,
|
||||
},
|
||||
options={
|
||||
CONF_ALBUMS: selected_albums,
|
||||
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
|
||||
},
|
||||
)
|
||||
|
||||
# Build album selection list
|
||||
album_options = {
|
||||
album["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)"
|
||||
for album in self._albums
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="albums",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUMS): cv.multi_select(album_options),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
|
||||
"""Handle options flow for Immich Album Watcher."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self._config_entry = config_entry
|
||||
self._albums: list[dict[str, Any]] = []
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the options."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
# Fetch current albums from Immich
|
||||
session = async_get_clientsession(self.hass)
|
||||
url = self._config_entry.data[CONF_IMMICH_URL]
|
||||
api_key = self._config_entry.data[CONF_API_KEY]
|
||||
|
||||
try:
|
||||
self._albums = await fetch_albums(session, url, api_key)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to fetch albums")
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if user_input is not None and not errors:
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={
|
||||
CONF_ALBUMS: user_input.get(CONF_ALBUMS, []),
|
||||
CONF_SCAN_INTERVAL: user_input.get(
|
||||
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Build album selection list
|
||||
album_options = {
|
||||
album["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)"
|
||||
for album in self._albums
|
||||
}
|
||||
|
||||
current_albums = self._config_entry.options.get(CONF_ALBUMS, [])
|
||||
current_interval = self._config_entry.options.get(
|
||||
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUMS, default=current_albums): cv.multi_select(
|
||||
album_options
|
||||
),
|
||||
vol.Required(
|
||||
CONF_SCAN_INTERVAL, default=current_interval
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(Exception):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(Exception):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
37
immich_album_watcher/const.py
Normal file
37
immich_album_watcher/const.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Constants for the Immich Album Watcher integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "immich_album_watcher"
|
||||
|
||||
# Configuration keys
|
||||
CONF_IMMICH_URL: Final = "immich_url"
|
||||
CONF_API_KEY: Final = "api_key"
|
||||
CONF_ALBUMS: Final = "albums"
|
||||
CONF_SCAN_INTERVAL: Final = "scan_interval"
|
||||
|
||||
# Defaults
|
||||
DEFAULT_SCAN_INTERVAL: Final = 60 # seconds
|
||||
|
||||
# Events
|
||||
EVENT_ALBUM_CHANGED: Final = f"{DOMAIN}_album_changed"
|
||||
EVENT_ASSETS_ADDED: Final = f"{DOMAIN}_assets_added"
|
||||
EVENT_ASSETS_REMOVED: Final = f"{DOMAIN}_assets_removed"
|
||||
|
||||
# Attributes
|
||||
ATTR_ALBUM_ID: Final = "album_id"
|
||||
ATTR_ALBUM_NAME: Final = "album_name"
|
||||
ATTR_ASSET_COUNT: Final = "asset_count"
|
||||
ATTR_ADDED_COUNT: Final = "added_count"
|
||||
ATTR_REMOVED_COUNT: Final = "removed_count"
|
||||
ATTR_ADDED_ASSETS: Final = "added_assets"
|
||||
ATTR_REMOVED_ASSETS: Final = "removed_assets"
|
||||
ATTR_CHANGE_TYPE: Final = "change_type"
|
||||
ATTR_LAST_UPDATED: Final = "last_updated"
|
||||
ATTR_THUMBNAIL_URL: Final = "thumbnail_url"
|
||||
ATTR_SHARED: Final = "shared"
|
||||
ATTR_OWNER: Final = "owner"
|
||||
|
||||
# Platforms
|
||||
PLATFORMS: Final = ["sensor"]
|
||||
204
immich_album_watcher/coordinator.py
Normal file
204
immich_album_watcher/coordinator.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""Data coordinator for Immich Album Watcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
ATTR_ADDED_ASSETS,
|
||||
ATTR_ADDED_COUNT,
|
||||
ATTR_ALBUM_ID,
|
||||
ATTR_ALBUM_NAME,
|
||||
ATTR_CHANGE_TYPE,
|
||||
ATTR_REMOVED_ASSETS,
|
||||
ATTR_REMOVED_COUNT,
|
||||
DOMAIN,
|
||||
EVENT_ALBUM_CHANGED,
|
||||
EVENT_ASSETS_ADDED,
|
||||
EVENT_ASSETS_REMOVED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlbumData:
|
||||
"""Data class for album information."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
asset_count: int
|
||||
updated_at: str
|
||||
shared: bool
|
||||
owner: str
|
||||
thumbnail_asset_id: str | None
|
||||
asset_ids: set[str] = field(default_factory=set)
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict[str, Any]) -> AlbumData:
|
||||
"""Create AlbumData from API response."""
|
||||
asset_ids = {asset["id"] for asset in data.get("assets", [])}
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data.get("albumName", "Unnamed"),
|
||||
asset_count=data.get("assetCount", len(asset_ids)),
|
||||
updated_at=data.get("updatedAt", ""),
|
||||
shared=data.get("shared", False),
|
||||
owner=data.get("owner", {}).get("name", "Unknown"),
|
||||
thumbnail_asset_id=data.get("albumThumbnailAssetId"),
|
||||
asset_ids=asset_ids,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlbumChange:
|
||||
"""Data class for album changes."""
|
||||
|
||||
album_id: str
|
||||
album_name: str
|
||||
change_type: str
|
||||
added_count: int = 0
|
||||
removed_count: int = 0
|
||||
added_asset_ids: list[str] = field(default_factory=list)
|
||||
removed_asset_ids: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]):
|
||||
"""Coordinator for fetching Immich album data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
url: str,
|
||||
api_key: str,
|
||||
album_ids: list[str],
|
||||
scan_interval: int,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=scan_interval),
|
||||
)
|
||||
self._url = url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._album_ids = album_ids
|
||||
self._previous_states: dict[str, AlbumData] = {}
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
@property
|
||||
def immich_url(self) -> str:
|
||||
"""Return the Immich URL."""
|
||||
return self._url
|
||||
|
||||
def update_config(self, album_ids: list[str], scan_interval: int) -> None:
|
||||
"""Update configuration."""
|
||||
self._album_ids = album_ids
|
||||
self.update_interval = timedelta(seconds=scan_interval)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, AlbumData]:
|
||||
"""Fetch data from Immich API."""
|
||||
if self._session is None:
|
||||
self._session = async_get_clientsession(self.hass)
|
||||
|
||||
headers = {"x-api-key": self._api_key}
|
||||
albums_data: dict[str, AlbumData] = {}
|
||||
|
||||
for album_id in self._album_ids:
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/albums/{album_id}",
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status == 404:
|
||||
_LOGGER.warning("Album %s not found, skipping", album_id)
|
||||
continue
|
||||
if response.status != 200:
|
||||
raise UpdateFailed(
|
||||
f"Error fetching album {album_id}: HTTP {response.status}"
|
||||
)
|
||||
|
||||
data = await response.json()
|
||||
album = AlbumData.from_api_response(data)
|
||||
albums_data[album_id] = album
|
||||
|
||||
# Detect changes
|
||||
if album_id in self._previous_states:
|
||||
change = self._detect_change(
|
||||
self._previous_states[album_id], album
|
||||
)
|
||||
if change:
|
||||
self._fire_events(change)
|
||||
|
||||
except aiohttp.ClientError as err:
|
||||
raise UpdateFailed(f"Error communicating with Immich: {err}") from err
|
||||
|
||||
# Update previous states
|
||||
self._previous_states = albums_data.copy()
|
||||
|
||||
return albums_data
|
||||
|
||||
def _detect_change(
|
||||
self, old_state: AlbumData, new_state: AlbumData
|
||||
) -> AlbumChange | None:
|
||||
"""Detect changes between two album states."""
|
||||
added = new_state.asset_ids - old_state.asset_ids
|
||||
removed = old_state.asset_ids - new_state.asset_ids
|
||||
|
||||
if not added and not removed:
|
||||
return None
|
||||
|
||||
change_type = "changed"
|
||||
if added and not removed:
|
||||
change_type = "assets_added"
|
||||
elif removed and not added:
|
||||
change_type = "assets_removed"
|
||||
|
||||
return AlbumChange(
|
||||
album_id=new_state.id,
|
||||
album_name=new_state.name,
|
||||
change_type=change_type,
|
||||
added_count=len(added),
|
||||
removed_count=len(removed),
|
||||
added_asset_ids=list(added),
|
||||
removed_asset_ids=list(removed),
|
||||
)
|
||||
|
||||
def _fire_events(self, change: AlbumChange) -> None:
|
||||
"""Fire Home Assistant events for album changes."""
|
||||
event_data = {
|
||||
ATTR_ALBUM_ID: change.album_id,
|
||||
ATTR_ALBUM_NAME: change.album_name,
|
||||
ATTR_CHANGE_TYPE: change.change_type,
|
||||
ATTR_ADDED_COUNT: change.added_count,
|
||||
ATTR_REMOVED_COUNT: change.removed_count,
|
||||
ATTR_ADDED_ASSETS: change.added_asset_ids,
|
||||
ATTR_REMOVED_ASSETS: change.removed_asset_ids,
|
||||
}
|
||||
|
||||
# Fire general change event
|
||||
self.hass.bus.async_fire(EVENT_ALBUM_CHANGED, event_data)
|
||||
|
||||
_LOGGER.info(
|
||||
"Album '%s' changed: +%d -%d assets",
|
||||
change.album_name,
|
||||
change.added_count,
|
||||
change.removed_count,
|
||||
)
|
||||
|
||||
# Fire specific events
|
||||
if change.added_count > 0:
|
||||
self.hass.bus.async_fire(EVENT_ASSETS_ADDED, event_data)
|
||||
|
||||
if change.removed_count > 0:
|
||||
self.hass.bus.async_fire(EVENT_ASSETS_REMOVED, event_data)
|
||||
12
immich_album_watcher/manifest.json
Normal file
12
immich_album_watcher/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "immich_album_watcher",
|
||||
"name": "Immich Album Watcher",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/your-repo/immich-album-watcher",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/your-repo/immich-album-watcher/issues",
|
||||
"requirements": [],
|
||||
"version": "1.0.0"
|
||||
}
|
||||
134
immich_album_watcher/sensor.py
Normal file
134
immich_album_watcher/sensor.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Sensor platform for Immich Album Watcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
ATTR_ALBUM_ID,
|
||||
ATTR_ASSET_COUNT,
|
||||
ATTR_LAST_UPDATED,
|
||||
ATTR_OWNER,
|
||||
ATTR_SHARED,
|
||||
ATTR_THUMBNAIL_URL,
|
||||
CONF_ALBUMS,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Immich Album Watcher sensors from a config entry."""
|
||||
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
album_ids = entry.options.get(CONF_ALBUMS, [])
|
||||
|
||||
entities = [
|
||||
ImmichAlbumSensor(coordinator, entry, album_id)
|
||||
for album_id in album_ids
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ImmichAlbumSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], SensorEntity):
|
||||
"""Sensor representing an Immich album."""
|
||||
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
_attr_native_unit_of_measurement = "assets"
|
||||
_attr_icon = "mdi:image-album"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ImmichAlbumWatcherCoordinator,
|
||||
entry: ConfigEntry,
|
||||
album_id: str,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._album_id = album_id
|
||||
self._entry = entry
|
||||
|
||||
# Entity IDs and names will be set when data is available
|
||||
self._attr_unique_id = f"{entry.entry_id}_{album_id}"
|
||||
self._attr_has_entity_name = True
|
||||
|
||||
@property
|
||||
def _album_data(self) -> AlbumData | None:
|
||||
"""Get the album data from coordinator."""
|
||||
if self.coordinator.data is None:
|
||||
return None
|
||||
return self.coordinator.data.get(self._album_id)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the sensor."""
|
||||
if self._album_data:
|
||||
return self._album_data.name
|
||||
return f"Album {self._album_id[:8]}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the state of the sensor (asset count)."""
|
||||
if self._album_data:
|
||||
return self._album_data.asset_count
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return self.coordinator.last_update_success and self._album_data is not None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return extra state attributes."""
|
||||
if not self._album_data:
|
||||
return {}
|
||||
|
||||
attrs = {
|
||||
ATTR_ALBUM_ID: self._album_data.id,
|
||||
ATTR_ASSET_COUNT: self._album_data.asset_count,
|
||||
ATTR_LAST_UPDATED: self._album_data.updated_at,
|
||||
ATTR_SHARED: self._album_data.shared,
|
||||
ATTR_OWNER: self._album_data.owner,
|
||||
}
|
||||
|
||||
# Add thumbnail URL if available
|
||||
if self._album_data.thumbnail_asset_id:
|
||||
attrs[ATTR_THUMBNAIL_URL] = (
|
||||
f"{self.coordinator.immich_url}/api/assets/"
|
||||
f"{self._album_data.thumbnail_asset_id}/thumbnail"
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device info."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||
name="Immich Album Watcher",
|
||||
manufacturer="Immich",
|
||||
entry_type="service",
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.async_write_ha_state()
|
||||
53
immich_album_watcher/strings.json
Normal file
53
immich_album_watcher/strings.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to Immich",
|
||||
"description": "Enter your Immich server details. You can get an API key from Immich → User Settings → API Keys.",
|
||||
"data": {
|
||||
"immich_url": "Immich URL",
|
||||
"api_key": "API Key"
|
||||
},
|
||||
"data_description": {
|
||||
"immich_url": "The URL of your Immich server (e.g., http://192.168.1.100:2283)",
|
||||
"api_key": "Your Immich API key"
|
||||
}
|
||||
},
|
||||
"albums": {
|
||||
"title": "Select Albums",
|
||||
"description": "Choose which albums to monitor for changes.",
|
||||
"data": {
|
||||
"albums": "Albums to watch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to Immich server",
|
||||
"invalid_auth": "Invalid API key",
|
||||
"no_albums": "No albums found on the server",
|
||||
"no_albums_selected": "Please select at least one album",
|
||||
"unknown": "Unexpected error occurred"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This Immich server is already configured"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Immich Album Watcher Options",
|
||||
"description": "Configure which albums to monitor and how often to check for changes.",
|
||||
"data": {
|
||||
"albums": "Albums to watch",
|
||||
"scan_interval": "Scan interval (seconds)"
|
||||
},
|
||||
"data_description": {
|
||||
"scan_interval": "How often to check for album changes (10-3600 seconds)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to Immich server"
|
||||
}
|
||||
}
|
||||
}
|
||||
53
immich_album_watcher/translations/en.json
Normal file
53
immich_album_watcher/translations/en.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to Immich",
|
||||
"description": "Enter your Immich server details. You can get an API key from Immich → User Settings → API Keys.",
|
||||
"data": {
|
||||
"immich_url": "Immich URL",
|
||||
"api_key": "API Key"
|
||||
},
|
||||
"data_description": {
|
||||
"immich_url": "The URL of your Immich server (e.g., http://192.168.1.100:2283)",
|
||||
"api_key": "Your Immich API key"
|
||||
}
|
||||
},
|
||||
"albums": {
|
||||
"title": "Select Albums",
|
||||
"description": "Choose which albums to monitor for changes.",
|
||||
"data": {
|
||||
"albums": "Albums to watch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to Immich server",
|
||||
"invalid_auth": "Invalid API key",
|
||||
"no_albums": "No albums found on the server",
|
||||
"no_albums_selected": "Please select at least one album",
|
||||
"unknown": "Unexpected error occurred"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This Immich server is already configured"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Immich Album Watcher Options",
|
||||
"description": "Configure which albums to monitor and how often to check for changes.",
|
||||
"data": {
|
||||
"albums": "Albums to watch",
|
||||
"scan_interval": "Scan interval (seconds)"
|
||||
},
|
||||
"data_description": {
|
||||
"scan_interval": "How often to check for album changes (10-3600 seconds)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to Immich server"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user