diff --git a/README.md b/README.md index 56ca577..5edd2b0 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,47 @@ A collection of custom Home Assistant integrations. Immich -Monitors [Immich](https://immich.app/) photo/video library albums for changes and exposes them as Home Assistant sensors with event-firing capabilities. +Monitors [Immich](https://immich.app/) photo/video library albums for changes and exposes them as Home Assistant entities with event-firing capabilities. #### Features - **Album Monitoring** - Watch selected Immich albums for asset additions and removals -- **Sensor Integration** - Creates Home Assistant sensors showing current asset count per album +- **Rich Sensor Data** - Multiple sensors per album: + - Asset count (total) + - Photo count + - Video count + - People count (detected faces) + - Last updated timestamp + - Creation date +- **Camera Entity** - Album thumbnail displayed as a camera entity for dashboards +- **Binary Sensor** - "New Assets" indicator that turns on when assets are added +- **Face Recognition** - Detects and lists people recognized in album photos - **Event Firing** - Fires Home Assistant events when albums change: - `immich_album_watcher_album_changed` - General album changes - `immich_album_watcher_assets_added` - When new assets are added - `immich_album_watcher_assets_removed` - When assets are removed +- **Enhanced Event Data** - Events include detailed asset info: + - Asset type (photo/video) + - Filename + - Creation date + - Detected people in the asset +- **Services** - Custom service calls: + - `immich_album_watcher.refresh` - Force immediate data refresh + - `immich_album_watcher.get_recent_assets` - Get recent assets from an album - **Configurable Polling** - Adjustable scan interval (10-3600 seconds) -- **Rich Metadata** - Provides detailed album info including: - - Album name and ID - - Asset count - - Owner information - - Shared status - - Thumbnail URL - - Last updated timestamp + +#### Entities Created (per album) + +| Entity Type | Name | Description | +|-------------|------|-------------| +| Sensor | Asset Count | Total number of assets in the album | +| Sensor | Photo Count | Number of photos in the album | +| Sensor | Video Count | Number of videos in the album | +| Sensor | People Count | Number of unique people detected | +| Sensor | Last Updated | When the album was last modified | +| Sensor | Created | When the album was created | +| Binary Sensor | New Assets | On when new assets were recently added | +| Camera | Thumbnail | Album cover image | #### Installation @@ -45,6 +68,27 @@ Monitors [Immich](https://immich.app/) photo/video library albums for changes an | Albums | Albums to monitor | Required | | Scan Interval | How often to check for changes (seconds) | 60 | +#### Services + +##### Refresh + +Force an immediate refresh of all album data: + +```yaml +service: immich_album_watcher.refresh +``` + +##### Get Recent Assets + +Get the most recent assets from a specific album (returns response data): + +```yaml +service: immich_album_watcher.get_recent_assets +data: + album_id: "your-album-id-here" + count: 10 +``` + #### Events Use these events in your automations: @@ -62,11 +106,21 @@ automation: message: "{{ trigger.event.data.added_count }} new photos in {{ trigger.event.data.album_name }}" ``` +Event data includes: +- `album_id` - Album ID +- `album_name` - Album name +- `change_type` - Type of change (assets_added, assets_removed, changed) +- `added_count` - Number of assets added +- `removed_count` - Number of assets removed +- `added_assets` - List of added assets with details (type, filename, created date, people) +- `removed_assets` - List of removed asset IDs +- `people` - List of all people detected in the album + #### Requirements - Home Assistant 2024.1.0 or newer - Immich server with API access -- Valid Immich API key +- Valid Immich API key with `album.read` and `asset.read` permissions ## License diff --git a/immich_album_watcher/__init__.py b/immich_album_watcher/__init__.py index e96ae60..5e0c8ef 100644 --- a/immich_album_watcher/__init__.py +++ b/immich_album_watcher/__init__.py @@ -3,11 +3,14 @@ from __future__ import annotations import logging +import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse +from homeassistant.helpers import config_validation as cv from .const import ( + ATTR_ALBUM_ID, CONF_ALBUMS, CONF_API_KEY, CONF_IMMICH_URL, @@ -15,11 +18,25 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS, + SERVICE_GET_RECENT_ASSETS, + SERVICE_REFRESH, ) from .coordinator import ImmichAlbumWatcherCoordinator _LOGGER = logging.getLogger(__name__) +# Service schemas +SERVICE_REFRESH_SCHEMA = vol.Schema({}) + +SERVICE_GET_RECENT_ASSETS_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ALBUM_ID): cv.string, + vol.Optional("count", default=10): vol.All( + vol.Coerce(int), vol.Range(min=1, max=100) + ), + } +) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Immich Album Watcher from a config entry.""" @@ -49,6 +66,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Register update listener for options changes entry.async_on_unload(entry.add_update_listener(async_update_options)) + # Register services (only once) + await async_setup_services(hass) + _LOGGER.info( "Immich Album Watcher set up successfully, watching %d albums", len(album_ids), @@ -57,6 +77,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Immich Album Watcher.""" + if hass.services.has_service(DOMAIN, SERVICE_REFRESH): + return # Services already registered + + async def handle_refresh(call: ServiceCall) -> None: + """Handle the refresh service call.""" + for coordinator in hass.data[DOMAIN].values(): + if isinstance(coordinator, ImmichAlbumWatcherCoordinator): + await coordinator.async_refresh_now() + + async def handle_get_recent_assets(call: ServiceCall) -> ServiceResponse: + """Handle the get_recent_assets service call.""" + album_id = call.data[ATTR_ALBUM_ID] + count = call.data.get("count", 10) + + for coordinator in hass.data[DOMAIN].values(): + if isinstance(coordinator, ImmichAlbumWatcherCoordinator): + if coordinator.data and album_id in coordinator.data: + assets = await coordinator.async_get_recent_assets(album_id, count) + return {"assets": assets} + + return {"assets": [], "error": f"Album {album_id} not found"} + + hass.services.async_register( + DOMAIN, + SERVICE_REFRESH, + handle_refresh, + schema=SERVICE_REFRESH_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_RECENT_ASSETS, + handle_get_recent_assets, + schema=SERVICE_GET_RECENT_ASSETS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] @@ -77,4 +137,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + # Unregister services if no more entries + if not hass.data[DOMAIN]: + hass.services.async_remove(DOMAIN, SERVICE_REFRESH) + hass.services.async_remove(DOMAIN, SERVICE_GET_RECENT_ASSETS) + return unload_ok diff --git a/immich_album_watcher/binary_sensor.py b/immich_album_watcher/binary_sensor.py new file mode 100644 index 0000000..30e28d1 --- /dev/null +++ b/immich_album_watcher/binary_sensor.py @@ -0,0 +1,143 @@ +"""Binary sensor platform for Immich Album Watcher.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +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.event import async_track_time_interval +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + ATTR_ADDED_COUNT, + ATTR_ALBUM_ID, + ATTR_ALBUM_NAME, + CONF_ALBUMS, + DOMAIN, + NEW_ASSETS_RESET_DELAY, +) +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 binary sensors from a config entry.""" + coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] + album_ids = entry.options.get(CONF_ALBUMS, []) + + entities = [ + ImmichAlbumNewAssetsSensor(coordinator, entry, album_id) + for album_id in album_ids + ] + + async_add_entities(entities) + + +class ImmichAlbumNewAssetsSensor( + CoordinatorEntity[ImmichAlbumWatcherCoordinator], BinarySensorEntity +): + """Binary sensor that turns on when new assets are detected in an album.""" + + _attr_device_class = BinarySensorDeviceClass.UPDATE + _attr_has_entity_name = True + _attr_translation_key = "album_new_assets" + + def __init__( + self, + coordinator: ImmichAlbumWatcherCoordinator, + entry: ConfigEntry, + album_id: str, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self._album_id = album_id + self._entry = entry + self._attr_unique_id = f"{entry.entry_id}_{album_id}_new_assets" + self._reset_unsub = None + + @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 translation_placeholders(self) -> dict[str, str]: + """Return translation placeholders.""" + if self._album_data: + return {"album_name": self._album_data.name} + return {"album_name": f"Album {self._album_id[:8]}"} + + @property + def is_on(self) -> bool | None: + """Return true if new assets were recently added.""" + if self._album_data is None: + return None + + if not self._album_data.has_new_assets: + return False + + # Check if we're still within the reset window + if self._album_data.last_change_time: + elapsed = datetime.now() - self._album_data.last_change_time + if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY): + # Auto-reset the flag + self.coordinator.clear_new_assets_flag(self._album_id) + return False + + return True + + @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, str | int | None]: + """Return extra state attributes.""" + if not self._album_data: + return {} + + attrs = { + ATTR_ALBUM_ID: self._album_data.id, + ATTR_ALBUM_NAME: self._album_data.name, + } + + if self._album_data.last_change_time: + attrs["last_change"] = self._album_data.last_change_time.isoformat() + + 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() + + async def async_turn_off(self, **kwargs) -> None: + """Turn off the sensor (clear new assets flag).""" + self.coordinator.clear_new_assets_flag(self._album_id) + self.async_write_ha_state() diff --git a/immich_album_watcher/camera.py b/immich_album_watcher/camera.py new file mode 100644 index 0000000..02ac711 --- /dev/null +++ b/immich_album_watcher/camera.py @@ -0,0 +1,155 @@ +"""Camera platform for Immich Album Watcher.""" + +from __future__ import annotations + +import logging +from datetime import timedelta + +import aiohttp + +from homeassistant.components.camera import Camera +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_ALBUMS, DOMAIN +from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=60) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Immich Album Watcher cameras from a config entry.""" + coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] + album_ids = entry.options.get(CONF_ALBUMS, []) + + entities = [ + ImmichAlbumThumbnailCamera(coordinator, entry, album_id) + for album_id in album_ids + ] + + async_add_entities(entities) + + +class ImmichAlbumThumbnailCamera( + CoordinatorEntity[ImmichAlbumWatcherCoordinator], Camera +): + """Camera entity showing the album thumbnail.""" + + _attr_has_entity_name = True + _attr_translation_key = "album_thumbnail" + + def __init__( + self, + coordinator: ImmichAlbumWatcherCoordinator, + entry: ConfigEntry, + album_id: str, + ) -> None: + """Initialize the camera.""" + CoordinatorEntity.__init__(self, coordinator) + Camera.__init__(self) + self._album_id = album_id + self._entry = entry + self._attr_unique_id = f"{entry.entry_id}_{album_id}_thumbnail" + self._cached_image: bytes | None = None + self._last_thumbnail_id: str | None = None + + @property + def _album_data(self) -> AlbumData | None: + """Get the album data from coordinator.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get(self._album_id) + + @property + def translation_placeholders(self) -> dict[str, str]: + """Return translation placeholders.""" + if self._album_data: + return {"album_name": self._album_data.name} + return {"album_name": f"Album {self._album_id[:8]}"} + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + self.coordinator.last_update_success + and self._album_data is not None + and self._album_data.thumbnail_asset_id is not None + ) + + @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", + ) + + @property + def extra_state_attributes(self) -> dict[str, str | None]: + """Return extra state attributes.""" + if not self._album_data: + return {} + + return { + "album_id": self._album_data.id, + "album_name": self._album_data.name, + "thumbnail_asset_id": self._album_data.thumbnail_asset_id, + } + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of camera image.""" + if not self._album_data or not self._album_data.thumbnail_asset_id: + return None + + # Check if thumbnail changed + if self._album_data.thumbnail_asset_id == self._last_thumbnail_id: + if self._cached_image: + return self._cached_image + + # Fetch new thumbnail + session = async_get_clientsession(self.hass) + headers = {"x-api-key": self.coordinator.api_key} + + thumbnail_url = ( + f"{self.coordinator.immich_url}/api/assets/" + f"{self._album_data.thumbnail_asset_id}/thumbnail" + ) + + try: + async with session.get(thumbnail_url, headers=headers) as response: + if response.status == 200: + self._cached_image = await response.read() + self._last_thumbnail_id = self._album_data.thumbnail_asset_id + return self._cached_image + else: + _LOGGER.warning( + "Failed to fetch thumbnail for album %s: HTTP %s", + self._album_data.name, + response.status, + ) + except aiohttp.ClientError as err: + _LOGGER.error("Error fetching thumbnail: %s", err) + + return self._cached_image + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + # Clear cache if thumbnail changed + if self._album_data and self._album_data.thumbnail_asset_id != self._last_thumbnail_id: + self._cached_image = None + self.async_write_ha_state() diff --git a/immich_album_watcher/const.py b/immich_album_watcher/const.py index a407bff..9fd9381 100644 --- a/immich_album_watcher/const.py +++ b/immich_album_watcher/const.py @@ -13,6 +13,7 @@ CONF_SCAN_INTERVAL: Final = "scan_interval" # Defaults DEFAULT_SCAN_INTERVAL: Final = 60 # seconds +NEW_ASSETS_RESET_DELAY: Final = 300 # 5 minutes # Events EVENT_ALBUM_CHANGED: Final = f"{DOMAIN}_album_changed" @@ -23,15 +24,30 @@ EVENT_ASSETS_REMOVED: Final = f"{DOMAIN}_assets_removed" ATTR_ALBUM_ID: Final = "album_id" ATTR_ALBUM_NAME: Final = "album_name" ATTR_ASSET_COUNT: Final = "asset_count" +ATTR_PHOTO_COUNT: Final = "photo_count" +ATTR_VIDEO_COUNT: Final = "video_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_CREATED_AT: Final = "created_at" ATTR_THUMBNAIL_URL: Final = "thumbnail_url" ATTR_SHARED: Final = "shared" ATTR_OWNER: Final = "owner" +ATTR_PEOPLE: Final = "people" +ATTR_ASSET_TYPE: Final = "asset_type" +ATTR_ASSET_FILENAME: Final = "asset_filename" +ATTR_ASSET_CREATED: Final = "asset_created" + +# Asset types +ASSET_TYPE_IMAGE: Final = "IMAGE" +ASSET_TYPE_VIDEO: Final = "VIDEO" # Platforms -PLATFORMS: Final = ["sensor"] +PLATFORMS: Final = ["sensor", "binary_sensor", "camera"] + +# Services +SERVICE_REFRESH: Final = "refresh" +SERVICE_GET_RECENT_ASSETS: Final = "get_recent_assets" diff --git a/immich_album_watcher/coordinator.py b/immich_album_watcher/coordinator.py index bd9f105..81bb871 100644 --- a/immich_album_watcher/coordinator.py +++ b/immich_album_watcher/coordinator.py @@ -14,11 +14,17 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + ASSET_TYPE_IMAGE, + ASSET_TYPE_VIDEO, ATTR_ADDED_ASSETS, ATTR_ADDED_COUNT, ATTR_ALBUM_ID, ATTR_ALBUM_NAME, + ATTR_ASSET_CREATED, + ATTR_ASSET_FILENAME, + ATTR_ASSET_TYPE, ATTR_CHANGE_TYPE, + ATTR_PEOPLE, ATTR_REMOVED_ASSETS, ATTR_REMOVED_COUNT, DOMAIN, @@ -30,6 +36,31 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +@dataclass +class AssetInfo: + """Data class for asset information.""" + + id: str + type: str # IMAGE or VIDEO + filename: str + created_at: str + people: list[str] = field(default_factory=list) + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> AssetInfo: + """Create AssetInfo from API response.""" + people = [] + if "people" in data: + people = [p.get("name", "") for p in data["people"] if p.get("name")] + return cls( + id=data["id"], + type=data.get("type", ASSET_TYPE_IMAGE), + filename=data.get("originalFileName", ""), + created_at=data.get("fileCreatedAt", ""), + people=people, + ) + + @dataclass class AlbumData: """Data class for album information.""" @@ -37,25 +68,53 @@ class AlbumData: id: str name: str asset_count: int + photo_count: int + video_count: int + created_at: str updated_at: str shared: bool owner: str thumbnail_asset_id: str | None asset_ids: set[str] = field(default_factory=set) + assets: dict[str, AssetInfo] = field(default_factory=dict) + people: set[str] = field(default_factory=set) + has_new_assets: bool = False + last_change_time: datetime | None = None @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", [])} + assets_data = data.get("assets", []) + asset_ids = set() + assets = {} + people = set() + photo_count = 0 + video_count = 0 + + for asset_data in assets_data: + asset = AssetInfo.from_api_response(asset_data) + asset_ids.add(asset.id) + assets[asset.id] = asset + people.update(asset.people) + if asset.type == ASSET_TYPE_IMAGE: + photo_count += 1 + elif asset.type == ASSET_TYPE_VIDEO: + video_count += 1 + return cls( id=data["id"], name=data.get("albumName", "Unnamed"), asset_count=data.get("assetCount", len(asset_ids)), + photo_count=photo_count, + video_count=video_count, + created_at=data.get("createdAt", ""), 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, + assets=assets, + people=people, ) @@ -68,7 +127,7 @@ class AlbumChange: change_type: str added_count: int = 0 removed_count: int = 0 - added_asset_ids: list[str] = field(default_factory=list) + added_assets: list[AssetInfo] = field(default_factory=list) removed_asset_ids: list[str] = field(default_factory=list) @@ -95,17 +154,78 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) self._album_ids = album_ids self._previous_states: dict[str, AlbumData] = {} self._session: aiohttp.ClientSession | None = None + self._people_cache: dict[str, str] = {} # person_id -> name @property def immich_url(self) -> str: """Return the Immich URL.""" return self._url + @property + def api_key(self) -> str: + """Return the API key.""" + return self._api_key + def update_config(self, album_ids: list[str], scan_interval: int) -> None: """Update configuration.""" self._album_ids = album_ids self.update_interval = timedelta(seconds=scan_interval) + async def async_refresh_now(self) -> None: + """Force an immediate refresh.""" + await self.async_request_refresh() + + async def async_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 [] + + album = self.data[album_id] + # Sort assets by created_at descending + sorted_assets = sorted( + album.assets.values(), + key=lambda a: a.created_at, + reverse=True, + )[:count] + + return [ + { + "id": asset.id, + "type": asset.type, + "filename": asset.filename, + "created_at": asset.created_at, + "people": asset.people, + "thumbnail_url": f"{self._url}/api/assets/{asset.id}/thumbnail", + } + for asset in sorted_assets + ] + + async def async_fetch_people(self) -> dict[str, str]: + """Fetch all people from Immich.""" + if self._session is None: + self._session = async_get_clientsession(self.hass) + + headers = {"x-api-key": self._api_key} + try: + async with self._session.get( + f"{self._url}/api/people", + headers=headers, + ) as response: + if response.status == 200: + data = await response.json() + people_list = data.get("people", data) if isinstance(data, dict) else data + self._people_cache = { + p["id"]: p.get("name", "") + for p in people_list + if p.get("name") + } + except aiohttp.ClientError as err: + _LOGGER.warning("Failed to fetch people: %s", err) + + return self._people_cache + async def _async_update_data(self) -> dict[str, AlbumData]: """Fetch data from Immich API.""" if self._session is None: @@ -130,15 +250,30 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) data = await response.json() album = AlbumData.from_api_response(data) - albums_data[album_id] = album - # Detect changes + # Detect changes and update flags if album_id in self._previous_states: change = self._detect_change( self._previous_states[album_id], album ) if change: - self._fire_events(change) + album.has_new_assets = change.added_count > 0 + album.last_change_time = datetime.now() + self._fire_events(change, album) + else: + # First run, no changes + album.has_new_assets = False + + # Preserve has_new_assets from previous state if still within window + if album_id in self._previous_states: + prev = self._previous_states[album_id] + if prev.has_new_assets and prev.last_change_time: + # Keep the flag if change was recent + album.last_change_time = prev.last_change_time + if not album.has_new_assets: + album.has_new_assets = prev.has_new_assets + + albums_data[album_id] = album except aiohttp.ClientError as err: raise UpdateFailed(f"Error communicating with Immich: {err}") from err @@ -152,38 +287,56 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) 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 + added_ids = new_state.asset_ids - old_state.asset_ids + removed_ids = old_state.asset_ids - new_state.asset_ids - if not added and not removed: + if not added_ids and not removed_ids: return None change_type = "changed" - if added and not removed: + if added_ids and not removed_ids: change_type = "assets_added" - elif removed and not added: + elif removed_ids and not added_ids: change_type = "assets_removed" + # Get full asset info for added assets + added_assets = [ + new_state.assets[aid] for aid in added_ids if aid in new_state.assets + ] + 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), + added_count=len(added_ids), + removed_count=len(removed_ids), + added_assets=added_assets, + removed_asset_ids=list(removed_ids), ) - def _fire_events(self, change: AlbumChange) -> None: + def _fire_events(self, change: AlbumChange, album: AlbumData) -> None: """Fire Home Assistant events for album changes.""" + # Build detailed asset info for events + added_assets_detail = [ + { + "id": asset.id, + ATTR_ASSET_TYPE: asset.type, + ATTR_ASSET_FILENAME: asset.filename, + ATTR_ASSET_CREATED: asset.created_at, + ATTR_PEOPLE: asset.people, + } + for asset in change.added_assets + ] + 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_ADDED_ASSETS: added_assets_detail, ATTR_REMOVED_ASSETS: change.removed_asset_ids, + ATTR_PEOPLE: list(album.people), } # Fire general change event @@ -202,3 +355,9 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]) if change.removed_count > 0: self.hass.bus.async_fire(EVENT_ASSETS_REMOVED, event_data) + + def clear_new_assets_flag(self, album_id: str) -> None: + """Clear the new assets flag for an album.""" + if self.data and album_id in self.data: + self.data[album_id].has_new_assets = False + self.data[album_id].last_change_time = None diff --git a/immich_album_watcher/sensor.py b/immich_album_watcher/sensor.py index 8c662b8..14de6ae 100644 --- a/immich_album_watcher/sensor.py +++ b/immich_album_watcher/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from datetime import datetime from typing import Any from homeassistant.components.sensor import ( @@ -10,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from datetime import datetime from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo @@ -20,10 +20,14 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_ALBUM_ID, ATTR_ASSET_COUNT, + ATTR_CREATED_AT, ATTR_LAST_UPDATED, ATTR_OWNER, + ATTR_PEOPLE, + ATTR_PHOTO_COUNT, ATTR_SHARED, ATTR_THUMBNAIL_URL, + ATTR_VIDEO_COUNT, CONF_ALBUMS, DOMAIN, ) @@ -44,7 +48,11 @@ async def async_setup_entry( entities: list[SensorEntity] = [] for album_id in album_ids: entities.append(ImmichAlbumAssetCountSensor(coordinator, entry, album_id)) + entities.append(ImmichAlbumPhotoCountSensor(coordinator, entry, album_id)) + entities.append(ImmichAlbumVideoCountSensor(coordinator, entry, album_id)) entities.append(ImmichAlbumLastUpdatedSensor(coordinator, entry, album_id)) + entities.append(ImmichAlbumCreatedSensor(coordinator, entry, album_id)) + entities.append(ImmichAlbumPeopleSensor(coordinator, entry, album_id)) async_add_entities(entities) @@ -133,9 +141,13 @@ class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor): attrs = { ATTR_ALBUM_ID: self._album_data.id, ATTR_ASSET_COUNT: self._album_data.asset_count, + ATTR_PHOTO_COUNT: self._album_data.photo_count, + ATTR_VIDEO_COUNT: self._album_data.video_count, ATTR_LAST_UPDATED: self._album_data.updated_at, + ATTR_CREATED_AT: self._album_data.created_at, ATTR_SHARED: self._album_data.shared, ATTR_OWNER: self._album_data.owner, + ATTR_PEOPLE: list(self._album_data.people), } # Add thumbnail URL if available @@ -148,6 +160,56 @@ class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor): return attrs +class ImmichAlbumPhotoCountSensor(ImmichAlbumBaseSensor): + """Sensor representing an Immich album photo count.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_icon = "mdi:image" + _attr_translation_key = "album_photo_count" + + def __init__( + self, + coordinator: ImmichAlbumWatcherCoordinator, + entry: ConfigEntry, + album_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, entry, album_id) + self._attr_unique_id = f"{entry.entry_id}_{album_id}_photo_count" + + @property + def native_value(self) -> int | None: + """Return the state of the sensor (photo count).""" + if self._album_data: + return self._album_data.photo_count + return None + + +class ImmichAlbumVideoCountSensor(ImmichAlbumBaseSensor): + """Sensor representing an Immich album video count.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_icon = "mdi:video" + _attr_translation_key = "album_video_count" + + def __init__( + self, + coordinator: ImmichAlbumWatcherCoordinator, + entry: ConfigEntry, + album_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, entry, album_id) + self._attr_unique_id = f"{entry.entry_id}_{album_id}_video_count" + + @property + def native_value(self) -> int | None: + """Return the state of the sensor (video count).""" + if self._album_data: + return self._album_data.video_count + return None + + class ImmichAlbumLastUpdatedSensor(ImmichAlbumBaseSensor): """Sensor representing an Immich album last updated time.""" @@ -170,7 +232,74 @@ class ImmichAlbumLastUpdatedSensor(ImmichAlbumBaseSensor): """Return the state of the sensor (last updated datetime).""" if self._album_data and self._album_data.updated_at: try: - return datetime.fromisoformat(self._album_data.updated_at.replace("Z", "+00:00")) + return datetime.fromisoformat( + self._album_data.updated_at.replace("Z", "+00:00") + ) except ValueError: return None return None + + +class ImmichAlbumCreatedSensor(ImmichAlbumBaseSensor): + """Sensor representing an Immich album creation date.""" + + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_icon = "mdi:calendar-plus" + _attr_translation_key = "album_created" + + def __init__( + self, + coordinator: ImmichAlbumWatcherCoordinator, + entry: ConfigEntry, + album_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, entry, album_id) + self._attr_unique_id = f"{entry.entry_id}_{album_id}_created" + + @property + def native_value(self) -> datetime | None: + """Return the state of the sensor (creation datetime).""" + if self._album_data and self._album_data.created_at: + try: + return datetime.fromisoformat( + self._album_data.created_at.replace("Z", "+00:00") + ) + except ValueError: + return None + return None + + +class ImmichAlbumPeopleSensor(ImmichAlbumBaseSensor): + """Sensor representing people detected in an Immich album.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_icon = "mdi:account-group" + _attr_translation_key = "album_people_count" + + def __init__( + self, + coordinator: ImmichAlbumWatcherCoordinator, + entry: ConfigEntry, + album_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, entry, album_id) + self._attr_unique_id = f"{entry.entry_id}_{album_id}_people_count" + + @property + def native_value(self) -> int | None: + """Return the state of the sensor (number of unique people).""" + if self._album_data: + return len(self._album_data.people) + return None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return extra state attributes.""" + if not self._album_data: + return {} + + return { + ATTR_PEOPLE: list(self._album_data.people), + } diff --git a/immich_album_watcher/services.yaml b/immich_album_watcher/services.yaml new file mode 100644 index 0000000..c13206e --- /dev/null +++ b/immich_album_watcher/services.yaml @@ -0,0 +1,24 @@ +refresh: + name: Refresh + description: Force an immediate refresh of all album data from Immich. + +get_recent_assets: + name: Get Recent Assets + description: Get the most recent assets from a specific album. + fields: + album_id: + name: Album ID + description: The ID of the album to get recent assets from. + required: true + selector: + text: + count: + name: Count + description: Number of recent assets to return (1-100). + required: false + default: 10 + selector: + number: + min: 1 + max: 100 + mode: slider diff --git a/immich_album_watcher/strings.json b/immich_album_watcher/strings.json index a723f1e..bc1102c 100644 --- a/immich_album_watcher/strings.json +++ b/immich_album_watcher/strings.json @@ -4,8 +4,30 @@ "album_asset_count": { "name": "{album_name}: Asset Count" }, + "album_photo_count": { + "name": "{album_name}: Photo Count" + }, + "album_video_count": { + "name": "{album_name}: Video Count" + }, "album_last_updated": { "name": "{album_name}: Last Updated" + }, + "album_created": { + "name": "{album_name}: Created" + }, + "album_people_count": { + "name": "{album_name}: People Count" + } + }, + "binary_sensor": { + "album_new_assets": { + "name": "{album_name}: New Assets" + } + }, + "camera": { + "album_thumbnail": { + "name": "{album_name}: Thumbnail" } } }, @@ -59,5 +81,25 @@ "error": { "cannot_connect": "Failed to connect to Immich server" } + }, + "services": { + "refresh": { + "name": "Refresh", + "description": "Force an immediate refresh of all album data from Immich." + }, + "get_recent_assets": { + "name": "Get Recent Assets", + "description": "Get the most recent assets from a specific album.", + "fields": { + "album_id": { + "name": "Album ID", + "description": "The ID of the album to get recent assets from." + }, + "count": { + "name": "Count", + "description": "Number of recent assets to return (1-100)." + } + } + } } } diff --git a/immich_album_watcher/translations/en.json b/immich_album_watcher/translations/en.json index a723f1e..bc1102c 100644 --- a/immich_album_watcher/translations/en.json +++ b/immich_album_watcher/translations/en.json @@ -4,8 +4,30 @@ "album_asset_count": { "name": "{album_name}: Asset Count" }, + "album_photo_count": { + "name": "{album_name}: Photo Count" + }, + "album_video_count": { + "name": "{album_name}: Video Count" + }, "album_last_updated": { "name": "{album_name}: Last Updated" + }, + "album_created": { + "name": "{album_name}: Created" + }, + "album_people_count": { + "name": "{album_name}: People Count" + } + }, + "binary_sensor": { + "album_new_assets": { + "name": "{album_name}: New Assets" + } + }, + "camera": { + "album_thumbnail": { + "name": "{album_name}: Thumbnail" } } }, @@ -59,5 +81,25 @@ "error": { "cannot_connect": "Failed to connect to Immich server" } + }, + "services": { + "refresh": { + "name": "Refresh", + "description": "Force an immediate refresh of all album data from Immich." + }, + "get_recent_assets": { + "name": "Get Recent Assets", + "description": "Get the most recent assets from a specific album.", + "fields": { + "album_id": { + "name": "Album ID", + "description": "The ID of the album to get recent assets from." + }, + "count": { + "name": "Count", + "description": "Number of recent assets to return (1-100)." + } + } + } } } diff --git a/immich_album_watcher/translations/ru.json b/immich_album_watcher/translations/ru.json index ab03682..b6dc3e2 100644 --- a/immich_album_watcher/translations/ru.json +++ b/immich_album_watcher/translations/ru.json @@ -1,4 +1,36 @@ { + "entity": { + "sensor": { + "album_asset_count": { + "name": "{album_name}: Число файлов" + }, + "album_photo_count": { + "name": "{album_name}: Число фото" + }, + "album_video_count": { + "name": "{album_name}: Число видео" + }, + "album_last_updated": { + "name": "{album_name}: Последнее обновление" + }, + "album_created": { + "name": "{album_name}: Дата создания" + }, + "album_people_count": { + "name": "{album_name}: Число людей" + } + }, + "binary_sensor": { + "album_new_assets": { + "name": "{album_name}: Новые файлы" + } + }, + "camera": { + "album_thumbnail": { + "name": "{album_name}: Превью" + } + } + }, "config": { "step": { "user": { @@ -50,13 +82,23 @@ "cannot_connect": "Не удалось подключиться к серверу Immich" } }, - "entity": { - "sensor": { - "album_asset_count": { - "name": "{album_name}: Число файлов" - }, - "album_last_updated": { - "name": "{album_name}: Последнее обновление" + "services": { + "refresh": { + "name": "Обновить", + "description": "Принудительно обновить данные всех альбомов из Immich." + }, + "get_recent_assets": { + "name": "Получить последние файлы", + "description": "Получить последние файлы из указанного альбома.", + "fields": { + "album_id": { + "name": "ID альбома", + "description": "ID альбома для получения последних файлов." + }, + "count": { + "name": "Количество", + "description": "Количество возвращаемых файлов (1-100)." + } } } }