More inprovements on Immich integration
This commit is contained in:
@@ -25,6 +25,8 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
|
||||
- Asset type (photo/video)
|
||||
- Filename
|
||||
- Creation date
|
||||
- Asset owner (who uploaded the asset)
|
||||
- Public URL (if album has a shared link)
|
||||
- Detected people in the asset
|
||||
- **Services** - Custom service calls:
|
||||
- `immich_album_watcher.refresh` - Force immediate data refresh
|
||||
@@ -41,6 +43,7 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
|
||||
| Sensor | People Count | Number of unique people detected |
|
||||
| Sensor | Last Updated | When the album was last modified |
|
||||
| Sensor | Created | When the album was created |
|
||||
| Sensor | Public URL | Public share link URL (if album is shared) |
|
||||
| Binary Sensor | New Assets | On when new assets were recently added |
|
||||
| Camera | Thumbnail | Album cover image |
|
||||
|
||||
@@ -106,13 +109,46 @@ automation:
|
||||
|-------|-------------|
|
||||
| `album_id` | Album ID |
|
||||
| `album_name` | Album name |
|
||||
| `album_url` | Public URL to view the album (only present if album has a shared link) |
|
||||
| `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) |
|
||||
| `added_assets` | List of added assets with details (see below) |
|
||||
| `removed_assets` | List of removed asset IDs |
|
||||
| `people` | List of all people detected in the album |
|
||||
|
||||
### Added Assets Fields
|
||||
|
||||
Each item in the `added_assets` list contains the following fields:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `id` | Unique asset ID |
|
||||
| `asset_type` | Type of asset (`IMAGE` or `VIDEO`) |
|
||||
| `asset_filename` | Original filename of the asset |
|
||||
| `asset_created` | Date/time when the asset was originally created |
|
||||
| `asset_owner` | Display name of the user who owns the asset |
|
||||
| `asset_owner_id` | Unique ID of the user who owns the asset |
|
||||
| `asset_url` | Public URL to view the asset (only present if album has a shared link) |
|
||||
| `people` | List of people detected in this specific asset |
|
||||
|
||||
Example accessing asset owner in an automation:
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
- alias: "Notify when someone adds photos"
|
||||
trigger:
|
||||
- platform: event
|
||||
event_type: immich_album_watcher_assets_added
|
||||
action:
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
title: "New Photos"
|
||||
message: >
|
||||
{{ trigger.event.data.added_assets[0].asset_owner }} added
|
||||
{{ trigger.event.data.added_count }} photos to {{ trigger.event.data.album_name }}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Home Assistant 2024.1.0 or newer
|
||||
|
||||
@@ -23,6 +23,7 @@ EVENT_ASSETS_REMOVED: Final = f"{DOMAIN}_assets_removed"
|
||||
# Attributes
|
||||
ATTR_ALBUM_ID: Final = "album_id"
|
||||
ATTR_ALBUM_NAME: Final = "album_name"
|
||||
ATTR_ALBUM_URL: Final = "album_url"
|
||||
ATTR_ASSET_COUNT: Final = "asset_count"
|
||||
ATTR_PHOTO_COUNT: Final = "photo_count"
|
||||
ATTR_VIDEO_COUNT: Final = "video_count"
|
||||
@@ -40,6 +41,9 @@ ATTR_PEOPLE: Final = "people"
|
||||
ATTR_ASSET_TYPE: Final = "asset_type"
|
||||
ATTR_ASSET_FILENAME: Final = "asset_filename"
|
||||
ATTR_ASSET_CREATED: Final = "asset_created"
|
||||
ATTR_ASSET_OWNER: Final = "asset_owner"
|
||||
ATTR_ASSET_OWNER_ID: Final = "asset_owner_id"
|
||||
ATTR_ASSET_URL: Final = "asset_url"
|
||||
|
||||
# Asset types
|
||||
ASSET_TYPE_IMAGE: Final = "IMAGE"
|
||||
|
||||
@@ -20,9 +20,13 @@ from .const import (
|
||||
ATTR_ADDED_COUNT,
|
||||
ATTR_ALBUM_ID,
|
||||
ATTR_ALBUM_NAME,
|
||||
ATTR_ALBUM_URL,
|
||||
ATTR_ASSET_CREATED,
|
||||
ATTR_ASSET_FILENAME,
|
||||
ATTR_ASSET_OWNER,
|
||||
ATTR_ASSET_OWNER_ID,
|
||||
ATTR_ASSET_TYPE,
|
||||
ATTR_ASSET_URL,
|
||||
ATTR_CHANGE_TYPE,
|
||||
ATTR_PEOPLE,
|
||||
ATTR_REMOVED_ASSETS,
|
||||
@@ -44,19 +48,31 @@ class AssetInfo:
|
||||
type: str # IMAGE or VIDEO
|
||||
filename: str
|
||||
created_at: str
|
||||
owner_id: str = ""
|
||||
owner_name: str = ""
|
||||
people: list[str] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict[str, Any]) -> AssetInfo:
|
||||
def from_api_response(
|
||||
cls, data: dict[str, Any], users_cache: dict[str, str] | None = None
|
||||
) -> AssetInfo:
|
||||
"""Create AssetInfo from API response."""
|
||||
people = []
|
||||
if "people" in data:
|
||||
people = [p.get("name", "") for p in data["people"] if p.get("name")]
|
||||
|
||||
owner_id = data.get("ownerId", "")
|
||||
owner_name = ""
|
||||
if users_cache and owner_id:
|
||||
owner_name = users_cache.get(owner_id, "")
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
type=data.get("type", ASSET_TYPE_IMAGE),
|
||||
filename=data.get("originalFileName", ""),
|
||||
created_at=data.get("fileCreatedAt", ""),
|
||||
owner_id=owner_id,
|
||||
owner_name=owner_name,
|
||||
people=people,
|
||||
)
|
||||
|
||||
@@ -82,7 +98,9 @@ class AlbumData:
|
||||
last_change_time: datetime | None = None
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict[str, Any]) -> AlbumData:
|
||||
def from_api_response(
|
||||
cls, data: dict[str, Any], users_cache: dict[str, str] | None = None
|
||||
) -> AlbumData:
|
||||
"""Create AlbumData from API response."""
|
||||
assets_data = data.get("assets", [])
|
||||
asset_ids = set()
|
||||
@@ -92,7 +110,7 @@ class AlbumData:
|
||||
video_count = 0
|
||||
|
||||
for asset_data in assets_data:
|
||||
asset = AssetInfo.from_api_response(asset_data)
|
||||
asset = AssetInfo.from_api_response(asset_data, users_cache)
|
||||
asset_ids.add(asset.id)
|
||||
assets[asset.id] = asset
|
||||
people.update(asset.people)
|
||||
@@ -155,6 +173,8 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
|
||||
self._previous_states: dict[str, AlbumData] = {}
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
self._people_cache: dict[str, str] = {} # person_id -> name
|
||||
self._users_cache: dict[str, str] = {} # user_id -> name
|
||||
self._shared_links_cache: dict[str, str] = {} # album_id -> share_key
|
||||
|
||||
@property
|
||||
def immich_url(self) -> str:
|
||||
@@ -226,11 +246,97 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
|
||||
|
||||
return self._people_cache
|
||||
|
||||
async def _async_fetch_users(self) -> dict[str, str]:
|
||||
"""Fetch all users from Immich and cache them."""
|
||||
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/users",
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
self._users_cache = {
|
||||
u["id"]: u.get("name", u.get("email", "Unknown"))
|
||||
for u in data
|
||||
if u.get("id")
|
||||
}
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch users: %s", err)
|
||||
|
||||
return self._users_cache
|
||||
|
||||
async def _async_fetch_shared_links(self) -> dict[str, str]:
|
||||
"""Fetch shared links from Immich and cache album_id -> share_key mapping."""
|
||||
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/shared-links",
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
_LOGGER.debug("Fetched %d shared links from Immich", len(data))
|
||||
self._shared_links_cache.clear()
|
||||
for link in data:
|
||||
# Only process album-type shared links
|
||||
link_type = link.get("type", "")
|
||||
album = link.get("album")
|
||||
key = link.get("key")
|
||||
_LOGGER.debug(
|
||||
"Shared link: type=%s, key=%s, album_id=%s",
|
||||
link_type,
|
||||
key[:8] if key else None,
|
||||
album.get("id") if album else None,
|
||||
)
|
||||
if album and key:
|
||||
album_id = album.get("id")
|
||||
if album_id:
|
||||
self._shared_links_cache[album_id] = key
|
||||
_LOGGER.debug(
|
||||
"Cached %d album shared links", len(self._shared_links_cache)
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch shared links: HTTP %s", response.status
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch shared links: %s", err)
|
||||
|
||||
return self._shared_links_cache
|
||||
|
||||
def get_album_public_url(self, album_id: str) -> str | None:
|
||||
"""Get the public URL for an album if it has a shared link."""
|
||||
share_key = self._shared_links_cache.get(album_id)
|
||||
if share_key:
|
||||
return f"{self._url}/share/{share_key}"
|
||||
return None
|
||||
|
||||
def _get_asset_public_url(self, album_id: str, asset_id: str) -> str | None:
|
||||
"""Get the public URL for an asset if the album has a shared link."""
|
||||
share_key = self._shared_links_cache.get(album_id)
|
||||
if share_key:
|
||||
return f"{self._url}/share/{share_key}/photos/{asset_id}"
|
||||
return None
|
||||
|
||||
async def _async_update_data(self) -> dict[str, AlbumData]:
|
||||
"""Fetch data from Immich API."""
|
||||
if self._session is None:
|
||||
self._session = async_get_clientsession(self.hass)
|
||||
|
||||
# Fetch users to resolve owner names
|
||||
if not self._users_cache:
|
||||
await self._async_fetch_users()
|
||||
|
||||
# Fetch shared links to resolve public URLs (refresh each time as links can change)
|
||||
await self._async_fetch_shared_links()
|
||||
|
||||
headers = {"x-api-key": self._api_key}
|
||||
albums_data: dict[str, AlbumData] = {}
|
||||
|
||||
@@ -249,7 +355,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
|
||||
)
|
||||
|
||||
data = await response.json()
|
||||
album = AlbumData.from_api_response(data)
|
||||
album = AlbumData.from_api_response(data, self._users_cache)
|
||||
|
||||
# Detect changes and update flags
|
||||
if album_id in self._previous_states:
|
||||
@@ -317,16 +423,22 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
|
||||
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 = [
|
||||
{
|
||||
added_assets_detail = []
|
||||
for asset in change.added_assets:
|
||||
asset_detail = {
|
||||
"id": asset.id,
|
||||
ATTR_ASSET_TYPE: asset.type,
|
||||
ATTR_ASSET_FILENAME: asset.filename,
|
||||
ATTR_ASSET_CREATED: asset.created_at,
|
||||
ATTR_ASSET_OWNER: asset.owner_name,
|
||||
ATTR_ASSET_OWNER_ID: asset.owner_id,
|
||||
ATTR_PEOPLE: asset.people,
|
||||
}
|
||||
for asset in change.added_assets
|
||||
]
|
||||
# Add public URL if album has a shared link
|
||||
asset_url = self._get_asset_public_url(change.album_id, asset.id)
|
||||
if asset_url:
|
||||
asset_detail[ATTR_ASSET_URL] = asset_url
|
||||
added_assets_detail.append(asset_detail)
|
||||
|
||||
event_data = {
|
||||
ATTR_ALBUM_ID: change.album_id,
|
||||
@@ -339,6 +451,11 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]])
|
||||
ATTR_PEOPLE: list(album.people),
|
||||
}
|
||||
|
||||
# Add album public URL if it has a shared link
|
||||
album_url = self.get_album_public_url(change.album_id)
|
||||
if album_url:
|
||||
event_data[ATTR_ALBUM_URL] = album_url
|
||||
|
||||
# Fire general change event
|
||||
self.hass.bus.async_fire(EVENT_ALBUM_CHANGED, event_data)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import (
|
||||
ATTR_ALBUM_ID,
|
||||
ATTR_ALBUM_URL,
|
||||
ATTR_ASSET_COUNT,
|
||||
ATTR_CREATED_AT,
|
||||
ATTR_LAST_UPDATED,
|
||||
@@ -53,6 +54,7 @@ async def async_setup_entry(
|
||||
entities.append(ImmichAlbumLastUpdatedSensor(coordinator, entry, album_id))
|
||||
entities.append(ImmichAlbumCreatedSensor(coordinator, entry, album_id))
|
||||
entities.append(ImmichAlbumPeopleSensor(coordinator, entry, album_id))
|
||||
entities.append(ImmichAlbumPublicUrlSensor(coordinator, entry, album_id))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -303,3 +305,38 @@ class ImmichAlbumPeopleSensor(ImmichAlbumBaseSensor):
|
||||
return {
|
||||
ATTR_PEOPLE: list(self._album_data.people),
|
||||
}
|
||||
|
||||
|
||||
class ImmichAlbumPublicUrlSensor(ImmichAlbumBaseSensor):
|
||||
"""Sensor representing an Immich album public URL."""
|
||||
|
||||
_attr_icon = "mdi:link-variant"
|
||||
_attr_translation_key = "album_public_url"
|
||||
|
||||
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}_public_url"
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the state of the sensor (public URL)."""
|
||||
if self._album_data:
|
||||
return self.coordinator.get_album_public_url(self._album_id)
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return extra state attributes."""
|
||||
if not self._album_data:
|
||||
return {}
|
||||
|
||||
return {
|
||||
ATTR_ALBUM_ID: self._album_data.id,
|
||||
ATTR_SHARED: self._album_data.shared,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
},
|
||||
"album_people_count": {
|
||||
"name": "{album_name}: People Count"
|
||||
},
|
||||
"album_public_url": {
|
||||
"name": "{album_name}: Public URL"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
},
|
||||
"album_people_count": {
|
||||
"name": "{album_name}: Число людей"
|
||||
},
|
||||
"album_public_url": {
|
||||
"name": "{album_name}: Публичная ссылка"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
|
||||
Reference in New Issue
Block a user