More inprovements on Immich integration

This commit is contained in:
2026-01-30 00:46:31 +03:00
parent 7b54465c5f
commit 9a768b24f8
6 changed files with 209 additions and 9 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -18,6 +18,9 @@
},
"album_people_count": {
"name": "{album_name}: People Count"
},
"album_public_url": {
"name": "{album_name}: Public URL"
}
},
"binary_sensor": {

View File

@@ -18,6 +18,9 @@
},
"album_people_count": {
"name": "{album_name}: Число людей"
},
"album_public_url": {
"name": "{album_name}: Публичная ссылка"
}
},
"binary_sensor": {