diff --git a/immich_album_watcher/README.md b/immich_album_watcher/README.md index 9d05ee8..f0db2f8 100644 --- a/immich_album_watcher/README.md +++ b/immich_album_watcher/README.md @@ -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 diff --git a/immich_album_watcher/const.py b/immich_album_watcher/const.py index 9fd9381..ecdfbfb 100644 --- a/immich_album_watcher/const.py +++ b/immich_album_watcher/const.py @@ -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" diff --git a/immich_album_watcher/coordinator.py b/immich_album_watcher/coordinator.py index 81bb871..16c29b6 100644 --- a/immich_album_watcher/coordinator.py +++ b/immich_album_watcher/coordinator.py @@ -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) diff --git a/immich_album_watcher/sensor.py b/immich_album_watcher/sensor.py index 14de6ae..5a91dba 100644 --- a/immich_album_watcher/sensor.py +++ b/immich_album_watcher/sensor.py @@ -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, + } diff --git a/immich_album_watcher/translations/en.json b/immich_album_watcher/translations/en.json index bc1102c..2740d38 100644 --- a/immich_album_watcher/translations/en.json +++ b/immich_album_watcher/translations/en.json @@ -18,6 +18,9 @@ }, "album_people_count": { "name": "{album_name}: People Count" + }, + "album_public_url": { + "name": "{album_name}: Public URL" } }, "binary_sensor": { diff --git a/immich_album_watcher/translations/ru.json b/immich_album_watcher/translations/ru.json index b6dc3e2..d78b2a9 100644 --- a/immich_album_watcher/translations/ru.json +++ b/immich_album_watcher/translations/ru.json @@ -18,6 +18,9 @@ }, "album_people_count": { "name": "{album_name}: Число людей" + }, + "album_public_url": { + "name": "{album_name}: Публичная ссылка" } }, "binary_sensor": {