Add new sensors to the Immich album tracker integration
This commit is contained in:
74
README.md
74
README.md
@@ -8,24 +8,47 @@ A collection of custom Home Assistant integrations.
|
|||||||
|
|
||||||
<img src="immich_album_watcher/icon.png" alt="Immich" width="64" height="64">
|
<img src="immich_album_watcher/icon.png" alt="Immich" width="64" height="64">
|
||||||
|
|
||||||
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
|
#### Features
|
||||||
|
|
||||||
- **Album Monitoring** - Watch selected Immich albums for asset additions and removals
|
- **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:
|
- **Event Firing** - Fires Home Assistant events when albums change:
|
||||||
- `immich_album_watcher_album_changed` - General album changes
|
- `immich_album_watcher_album_changed` - General album changes
|
||||||
- `immich_album_watcher_assets_added` - When new assets are added
|
- `immich_album_watcher_assets_added` - When new assets are added
|
||||||
- `immich_album_watcher_assets_removed` - When assets are removed
|
- `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)
|
- **Configurable Polling** - Adjustable scan interval (10-3600 seconds)
|
||||||
- **Rich Metadata** - Provides detailed album info including:
|
|
||||||
- Album name and ID
|
#### Entities Created (per album)
|
||||||
- Asset count
|
|
||||||
- Owner information
|
| Entity Type | Name | Description |
|
||||||
- Shared status
|
|-------------|------|-------------|
|
||||||
- Thumbnail URL
|
| Sensor | Asset Count | Total number of assets in the album |
|
||||||
- Last updated timestamp
|
| 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
|
#### Installation
|
||||||
|
|
||||||
@@ -45,6 +68,27 @@ Monitors [Immich](https://immich.app/) photo/video library albums for changes an
|
|||||||
| Albums | Albums to monitor | Required |
|
| Albums | Albums to monitor | Required |
|
||||||
| Scan Interval | How often to check for changes (seconds) | 60 |
|
| 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
|
#### Events
|
||||||
|
|
||||||
Use these events in your automations:
|
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 }}"
|
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
|
#### Requirements
|
||||||
|
|
||||||
- Home Assistant 2024.1.0 or newer
|
- Home Assistant 2024.1.0 or newer
|
||||||
- Immich server with API access
|
- Immich server with API access
|
||||||
- Valid Immich API key
|
- Valid Immich API key with `album.read` and `asset.read` permissions
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
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 (
|
from .const import (
|
||||||
|
ATTR_ALBUM_ID,
|
||||||
CONF_ALBUMS,
|
CONF_ALBUMS,
|
||||||
CONF_API_KEY,
|
CONF_API_KEY,
|
||||||
CONF_IMMICH_URL,
|
CONF_IMMICH_URL,
|
||||||
@@ -15,11 +18,25 @@ from .const import (
|
|||||||
DEFAULT_SCAN_INTERVAL,
|
DEFAULT_SCAN_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
|
SERVICE_GET_RECENT_ASSETS,
|
||||||
|
SERVICE_REFRESH,
|
||||||
)
|
)
|
||||||
from .coordinator import ImmichAlbumWatcherCoordinator
|
from .coordinator import ImmichAlbumWatcherCoordinator
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Immich Album Watcher from a config entry."""
|
"""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
|
# Register update listener for options changes
|
||||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||||
|
|
||||||
|
# Register services (only once)
|
||||||
|
await async_setup_services(hass)
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Immich Album Watcher set up successfully, watching %d albums",
|
"Immich Album Watcher set up successfully, watching %d albums",
|
||||||
len(album_ids),
|
len(album_ids),
|
||||||
@@ -57,6 +77,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return True
|
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:
|
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Handle options update."""
|
"""Handle options update."""
|
||||||
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id]
|
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:
|
if unload_ok:
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
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
|
return unload_ok
|
||||||
|
|||||||
143
immich_album_watcher/binary_sensor.py
Normal file
143
immich_album_watcher/binary_sensor.py
Normal file
@@ -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()
|
||||||
155
immich_album_watcher/camera.py
Normal file
155
immich_album_watcher/camera.py
Normal file
@@ -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()
|
||||||
@@ -13,6 +13,7 @@ CONF_SCAN_INTERVAL: Final = "scan_interval"
|
|||||||
|
|
||||||
# Defaults
|
# Defaults
|
||||||
DEFAULT_SCAN_INTERVAL: Final = 60 # seconds
|
DEFAULT_SCAN_INTERVAL: Final = 60 # seconds
|
||||||
|
NEW_ASSETS_RESET_DELAY: Final = 300 # 5 minutes
|
||||||
|
|
||||||
# Events
|
# Events
|
||||||
EVENT_ALBUM_CHANGED: Final = f"{DOMAIN}_album_changed"
|
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_ID: Final = "album_id"
|
||||||
ATTR_ALBUM_NAME: Final = "album_name"
|
ATTR_ALBUM_NAME: Final = "album_name"
|
||||||
ATTR_ASSET_COUNT: Final = "asset_count"
|
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_ADDED_COUNT: Final = "added_count"
|
||||||
ATTR_REMOVED_COUNT: Final = "removed_count"
|
ATTR_REMOVED_COUNT: Final = "removed_count"
|
||||||
ATTR_ADDED_ASSETS: Final = "added_assets"
|
ATTR_ADDED_ASSETS: Final = "added_assets"
|
||||||
ATTR_REMOVED_ASSETS: Final = "removed_assets"
|
ATTR_REMOVED_ASSETS: Final = "removed_assets"
|
||||||
ATTR_CHANGE_TYPE: Final = "change_type"
|
ATTR_CHANGE_TYPE: Final = "change_type"
|
||||||
ATTR_LAST_UPDATED: Final = "last_updated"
|
ATTR_LAST_UPDATED: Final = "last_updated"
|
||||||
|
ATTR_CREATED_AT: Final = "created_at"
|
||||||
ATTR_THUMBNAIL_URL: Final = "thumbnail_url"
|
ATTR_THUMBNAIL_URL: Final = "thumbnail_url"
|
||||||
ATTR_SHARED: Final = "shared"
|
ATTR_SHARED: Final = "shared"
|
||||||
ATTR_OWNER: Final = "owner"
|
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
|
||||||
PLATFORMS: Final = ["sensor"]
|
PLATFORMS: Final = ["sensor", "binary_sensor", "camera"]
|
||||||
|
|
||||||
|
# Services
|
||||||
|
SERVICE_REFRESH: Final = "refresh"
|
||||||
|
SERVICE_GET_RECENT_ASSETS: Final = "get_recent_assets"
|
||||||
|
|||||||
@@ -14,11 +14,17 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
ASSET_TYPE_IMAGE,
|
||||||
|
ASSET_TYPE_VIDEO,
|
||||||
ATTR_ADDED_ASSETS,
|
ATTR_ADDED_ASSETS,
|
||||||
ATTR_ADDED_COUNT,
|
ATTR_ADDED_COUNT,
|
||||||
ATTR_ALBUM_ID,
|
ATTR_ALBUM_ID,
|
||||||
ATTR_ALBUM_NAME,
|
ATTR_ALBUM_NAME,
|
||||||
|
ATTR_ASSET_CREATED,
|
||||||
|
ATTR_ASSET_FILENAME,
|
||||||
|
ATTR_ASSET_TYPE,
|
||||||
ATTR_CHANGE_TYPE,
|
ATTR_CHANGE_TYPE,
|
||||||
|
ATTR_PEOPLE,
|
||||||
ATTR_REMOVED_ASSETS,
|
ATTR_REMOVED_ASSETS,
|
||||||
ATTR_REMOVED_COUNT,
|
ATTR_REMOVED_COUNT,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -30,6 +36,31 @@ from .const import (
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_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
|
@dataclass
|
||||||
class AlbumData:
|
class AlbumData:
|
||||||
"""Data class for album information."""
|
"""Data class for album information."""
|
||||||
@@ -37,25 +68,53 @@ class AlbumData:
|
|||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
asset_count: int
|
asset_count: int
|
||||||
|
photo_count: int
|
||||||
|
video_count: int
|
||||||
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
shared: bool
|
shared: bool
|
||||||
owner: str
|
owner: str
|
||||||
thumbnail_asset_id: str | None
|
thumbnail_asset_id: str | None
|
||||||
asset_ids: set[str] = field(default_factory=set)
|
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
|
@classmethod
|
||||||
def from_api_response(cls, data: dict[str, Any]) -> AlbumData:
|
def from_api_response(cls, data: dict[str, Any]) -> AlbumData:
|
||||||
"""Create AlbumData from API response."""
|
"""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(
|
return cls(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
name=data.get("albumName", "Unnamed"),
|
name=data.get("albumName", "Unnamed"),
|
||||||
asset_count=data.get("assetCount", len(asset_ids)),
|
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", ""),
|
updated_at=data.get("updatedAt", ""),
|
||||||
shared=data.get("shared", False),
|
shared=data.get("shared", False),
|
||||||
owner=data.get("owner", {}).get("name", "Unknown"),
|
owner=data.get("owner", {}).get("name", "Unknown"),
|
||||||
thumbnail_asset_id=data.get("albumThumbnailAssetId"),
|
thumbnail_asset_id=data.get("albumThumbnailAssetId"),
|
||||||
asset_ids=asset_ids,
|
asset_ids=asset_ids,
|
||||||
|
assets=assets,
|
||||||
|
people=people,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,7 +127,7 @@ class AlbumChange:
|
|||||||
change_type: str
|
change_type: str
|
||||||
added_count: int = 0
|
added_count: int = 0
|
||||||
removed_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)
|
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._album_ids = album_ids
|
||||||
self._previous_states: dict[str, AlbumData] = {}
|
self._previous_states: dict[str, AlbumData] = {}
|
||||||
self._session: aiohttp.ClientSession | None = None
|
self._session: aiohttp.ClientSession | None = None
|
||||||
|
self._people_cache: dict[str, str] = {} # person_id -> name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def immich_url(self) -> str:
|
def immich_url(self) -> str:
|
||||||
"""Return the Immich URL."""
|
"""Return the Immich URL."""
|
||||||
return self._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:
|
def update_config(self, album_ids: list[str], scan_interval: int) -> None:
|
||||||
"""Update configuration."""
|
"""Update configuration."""
|
||||||
self._album_ids = album_ids
|
self._album_ids = album_ids
|
||||||
self.update_interval = timedelta(seconds=scan_interval)
|
self.update_interval = timedelta(seconds=scan_interval)
|
||||||
|
|
||||||
|
async def async_refresh_now(self) -> None:
|
||||||
|
"""Force an immediate refresh."""
|
||||||
|
await self.async_request_refresh()
|
||||||
|
|
||||||
|
async def async_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]:
|
async def _async_update_data(self) -> dict[str, AlbumData]:
|
||||||
"""Fetch data from Immich API."""
|
"""Fetch data from Immich API."""
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
@@ -130,15 +250,30 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
|
|||||||
|
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
album = AlbumData.from_api_response(data)
|
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:
|
if album_id in self._previous_states:
|
||||||
change = self._detect_change(
|
change = self._detect_change(
|
||||||
self._previous_states[album_id], album
|
self._previous_states[album_id], album
|
||||||
)
|
)
|
||||||
if change:
|
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:
|
except aiohttp.ClientError as err:
|
||||||
raise UpdateFailed(f"Error communicating with Immich: {err}") from 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
|
self, old_state: AlbumData, new_state: AlbumData
|
||||||
) -> AlbumChange | None:
|
) -> AlbumChange | None:
|
||||||
"""Detect changes between two album states."""
|
"""Detect changes between two album states."""
|
||||||
added = new_state.asset_ids - old_state.asset_ids
|
added_ids = new_state.asset_ids - old_state.asset_ids
|
||||||
removed = old_state.asset_ids - new_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
|
return None
|
||||||
|
|
||||||
change_type = "changed"
|
change_type = "changed"
|
||||||
if added and not removed:
|
if added_ids and not removed_ids:
|
||||||
change_type = "assets_added"
|
change_type = "assets_added"
|
||||||
elif removed and not added:
|
elif removed_ids and not added_ids:
|
||||||
change_type = "assets_removed"
|
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(
|
return AlbumChange(
|
||||||
album_id=new_state.id,
|
album_id=new_state.id,
|
||||||
album_name=new_state.name,
|
album_name=new_state.name,
|
||||||
change_type=change_type,
|
change_type=change_type,
|
||||||
added_count=len(added),
|
added_count=len(added_ids),
|
||||||
removed_count=len(removed),
|
removed_count=len(removed_ids),
|
||||||
added_asset_ids=list(added),
|
added_assets=added_assets,
|
||||||
removed_asset_ids=list(removed),
|
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."""
|
"""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 = {
|
event_data = {
|
||||||
ATTR_ALBUM_ID: change.album_id,
|
ATTR_ALBUM_ID: change.album_id,
|
||||||
ATTR_ALBUM_NAME: change.album_name,
|
ATTR_ALBUM_NAME: change.album_name,
|
||||||
ATTR_CHANGE_TYPE: change.change_type,
|
ATTR_CHANGE_TYPE: change.change_type,
|
||||||
ATTR_ADDED_COUNT: change.added_count,
|
ATTR_ADDED_COUNT: change.added_count,
|
||||||
ATTR_REMOVED_COUNT: change.removed_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_REMOVED_ASSETS: change.removed_asset_ids,
|
||||||
|
ATTR_PEOPLE: list(album.people),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fire general change event
|
# Fire general change event
|
||||||
@@ -202,3 +355,9 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
|
|||||||
|
|
||||||
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 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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
@@ -10,7 +11,6 @@ from homeassistant.components.sensor import (
|
|||||||
SensorEntity,
|
SensorEntity,
|
||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from datetime import datetime
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
@@ -20,10 +20,14 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|||||||
from .const import (
|
from .const import (
|
||||||
ATTR_ALBUM_ID,
|
ATTR_ALBUM_ID,
|
||||||
ATTR_ASSET_COUNT,
|
ATTR_ASSET_COUNT,
|
||||||
|
ATTR_CREATED_AT,
|
||||||
ATTR_LAST_UPDATED,
|
ATTR_LAST_UPDATED,
|
||||||
ATTR_OWNER,
|
ATTR_OWNER,
|
||||||
|
ATTR_PEOPLE,
|
||||||
|
ATTR_PHOTO_COUNT,
|
||||||
ATTR_SHARED,
|
ATTR_SHARED,
|
||||||
ATTR_THUMBNAIL_URL,
|
ATTR_THUMBNAIL_URL,
|
||||||
|
ATTR_VIDEO_COUNT,
|
||||||
CONF_ALBUMS,
|
CONF_ALBUMS,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
@@ -44,7 +48,11 @@ async def async_setup_entry(
|
|||||||
entities: list[SensorEntity] = []
|
entities: list[SensorEntity] = []
|
||||||
for album_id in album_ids:
|
for album_id in album_ids:
|
||||||
entities.append(ImmichAlbumAssetCountSensor(coordinator, entry, album_id))
|
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(ImmichAlbumLastUpdatedSensor(coordinator, entry, album_id))
|
||||||
|
entities.append(ImmichAlbumCreatedSensor(coordinator, entry, album_id))
|
||||||
|
entities.append(ImmichAlbumPeopleSensor(coordinator, entry, album_id))
|
||||||
|
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
@@ -133,9 +141,13 @@ class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor):
|
|||||||
attrs = {
|
attrs = {
|
||||||
ATTR_ALBUM_ID: self._album_data.id,
|
ATTR_ALBUM_ID: self._album_data.id,
|
||||||
ATTR_ASSET_COUNT: self._album_data.asset_count,
|
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_LAST_UPDATED: self._album_data.updated_at,
|
||||||
|
ATTR_CREATED_AT: self._album_data.created_at,
|
||||||
ATTR_SHARED: self._album_data.shared,
|
ATTR_SHARED: self._album_data.shared,
|
||||||
ATTR_OWNER: self._album_data.owner,
|
ATTR_OWNER: self._album_data.owner,
|
||||||
|
ATTR_PEOPLE: list(self._album_data.people),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add thumbnail URL if available
|
# Add thumbnail URL if available
|
||||||
@@ -148,6 +160,56 @@ class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor):
|
|||||||
return attrs
|
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):
|
class ImmichAlbumLastUpdatedSensor(ImmichAlbumBaseSensor):
|
||||||
"""Sensor representing an Immich album last updated time."""
|
"""Sensor representing an Immich album last updated time."""
|
||||||
|
|
||||||
@@ -170,7 +232,74 @@ class ImmichAlbumLastUpdatedSensor(ImmichAlbumBaseSensor):
|
|||||||
"""Return the state of the sensor (last updated datetime)."""
|
"""Return the state of the sensor (last updated datetime)."""
|
||||||
if self._album_data and self._album_data.updated_at:
|
if self._album_data and self._album_data.updated_at:
|
||||||
try:
|
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:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
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),
|
||||||
|
}
|
||||||
|
|||||||
24
immich_album_watcher/services.yaml
Normal file
24
immich_album_watcher/services.yaml
Normal file
@@ -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
|
||||||
@@ -4,8 +4,30 @@
|
|||||||
"album_asset_count": {
|
"album_asset_count": {
|
||||||
"name": "{album_name}: 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": {
|
"album_last_updated": {
|
||||||
"name": "{album_name}: 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": {
|
"error": {
|
||||||
"cannot_connect": "Failed to connect to Immich server"
|
"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)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,30 @@
|
|||||||
"album_asset_count": {
|
"album_asset_count": {
|
||||||
"name": "{album_name}: 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": {
|
"album_last_updated": {
|
||||||
"name": "{album_name}: 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": {
|
"error": {
|
||||||
"cannot_connect": "Failed to connect to Immich server"
|
"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)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
@@ -50,13 +82,23 @@
|
|||||||
"cannot_connect": "Не удалось подключиться к серверу Immich"
|
"cannot_connect": "Не удалось подключиться к серверу Immich"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"services": {
|
||||||
"sensor": {
|
"refresh": {
|
||||||
"album_asset_count": {
|
"name": "Обновить",
|
||||||
"name": "{album_name}: Число файлов"
|
"description": "Принудительно обновить данные всех альбомов из Immich."
|
||||||
},
|
},
|
||||||
"album_last_updated": {
|
"get_recent_assets": {
|
||||||
"name": "{album_name}: Последнее обновление"
|
"name": "Получить последние файлы",
|
||||||
|
"description": "Получить последние файлы из указанного альбома.",
|
||||||
|
"fields": {
|
||||||
|
"album_id": {
|
||||||
|
"name": "ID альбома",
|
||||||
|
"description": "ID альбома для получения последних файлов."
|
||||||
|
},
|
||||||
|
"count": {
|
||||||
|
"name": "Количество",
|
||||||
|
"description": "Количество возвращаемых файлов (1-100)."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user