17 Commits

Author SHA1 Message Date
02c0535f50 Add telegram media sender as service. Also fixed the via_device warnings that would break in HA 2025.12.0.
All checks were successful
Validate / Hassfest (push) Successful in 3s
2026-01-31 04:07:13 +03:00
c570e157be Skip the validation step on Gitea
All checks were successful
Validate / Hassfest (push) Successful in 4s
2026-01-31 01:59:05 +03:00
56d249b598 No luck, let's run it at least on GitHub
Some checks failed
Validate / Hassfest (push) Failing after 4s
2026-01-31 01:54:20 +03:00
ebed587f6f Let's try this actions one more time
Some checks failed
Validate / Hassfest (push) Failing after 1m4s
2026-01-31 01:53:00 +03:00
a89d45268d Attemp to also support Gitea for actions
Some checks failed
Validate / Hassfest (push) Failing after 1m25s
2026-01-31 01:50:02 +03:00
950fe0fd91 Add hassfest validation action
Some checks failed
Validate / Hassfest (push) Failing after 19s
2026-01-31 01:39:28 +03:00
91c30e086d Actualize entities and asset fields info in README.md 2026-01-30 23:45:34 +03:00
6f39a8175d Update root README.md 2026-01-30 23:41:52 +03:00
e6619cb1c5 Album ID sensor now also exposes album name 2026-01-30 15:48:16 +03:00
eedc7792c8 Add album id sensor that has primary share link url attribute 2026-01-30 15:41:58 +03:00
3a0573e432 Add persistent storage 2026-01-30 15:30:05 +03:00
7c53110c07 Update README.md as we only have one integration now 2026-01-30 14:54:03 +03:00
03430df5fb Add license file and improve CLAUDE.md context file 2026-01-30 14:50:01 +03:00
2ca26e178a Add CLAUDE.md with versioning rules 2026-01-30 14:45:46 +03:00
847c39eaa8 Add a note about handy blueprint 2026-01-30 14:42:17 +03:00
9013c5e0c3 Update README to use github remote url 2026-01-30 14:40:50 +03:00
557ec91f05 Update remote url for HACS config and README 2026-01-30 14:12:46 +03:00
18 changed files with 616 additions and 204 deletions

17
.github/workflows/validate.yaml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Validate
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
hassfest:
name: Hassfest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: home-assistant/actions/hassfest@master
if: github.server_url == 'https://github.com'

16
CLAUDE.md Normal file
View File

@@ -0,0 +1,16 @@
# Project Guidelines
## Version Management
Update the integration version in `custom_components/immich_album_watcher/manifest.json` only when changes are made to the **integration content** (files inside `custom_components/immich_album_watcher/`).
Do NOT bump version for:
- Repository setup (hacs.json, root README.md, LICENSE, CLAUDE.md)
- CI/CD configuration
- Other repository-level changes
Use semantic versioning:
- **MAJOR** (x.0.0): Breaking changes
- **MINOR** (0.x.0): New features, backward compatible
- **PATCH** (0.0.x): Bug fixes, integration documentation updates

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Alexei Dolgolyov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

182
README.md
View File

@@ -1,12 +1,42 @@
# HAOS Integrations # Immich Album Watcher
A collection of custom integrations for Home Assistant. <img src="custom_components/immich_album_watcher/icon.png" alt="Immich" width="64" height="64">
## Available Integrations A Home Assistant custom integration that monitors [Immich](https://immich.app/) photo/video library albums for changes and exposes them as Home Assistant entities with event-firing capabilities.
| Integration | Description | Documentation | ## Features
|-------------|-------------|---------------|
| [Immich Album Watcher](custom_components/immich_album_watcher/) | Monitor Immich albums for changes with sensors, events, and face recognition | [README](custom_components/immich_album_watcher/README.md) | - **Album Monitoring** - Watch selected Immich albums for asset additions and removals
- **Rich Sensor Data** - Multiple sensors per album:
- Album ID (with share URL attribute)
- Asset count (with detected people list)
- Photo count
- Video count
- Last updated timestamp
- Creation date
- **Camera Entity** - Album thumbnail displayed as a camera entity for dashboards
- **Binary Sensor** - "New Assets" indicator that turns on when assets are added
- **Face Recognition** - Detects and lists people recognized in album photos
- **Event Firing** - Fires Home Assistant events when albums change:
- `immich_album_watcher_album_changed` - General album changes
- `immich_album_watcher_assets_added` - When new assets are added
- `immich_album_watcher_assets_removed` - When assets are removed
- **Enhanced Event Data** - Events include detailed asset info:
- Asset type (photo/video)
- Filename
- Creation date
- Asset owner (who uploaded the asset)
- Asset description/caption
- 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
- `immich_album_watcher.get_recent_assets` - Get recent assets from an album
- **Share Link Management** - Button entities to create and delete share links:
- Create/delete public (unprotected) share links
- Create/delete password-protected share links
- Edit protected link passwords via Text entity
- **Configurable Polling** - Adjustable scan interval (10-3600 seconds)
## Installation ## Installation
@@ -15,7 +45,7 @@ A collection of custom integrations for Home Assistant.
1. Open HACS in Home Assistant 1. Open HACS in Home Assistant
2. Click on the three dots in the top right corner 2. Click on the three dots in the top right corner
3. Select **Custom repositories** 3. Select **Custom repositories**
4. Add this repository URL: `https://git.dolgolyov-family.by/alexei.dolgolyov/haos-integrations` 4. Add this repository URL: `https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher`
5. Select **Integration** as the category 5. Select **Integration** as the category
6. Click **Add** 6. Click **Add**
7. Search for "Immich Album Watcher" in HACS and install it 7. Search for "Immich Album Watcher" in HACS and install it
@@ -29,6 +59,144 @@ A collection of custom integrations for Home Assistant.
3. Restart Home Assistant 3. Restart Home Assistant
4. Add the integration via **Settings****Devices & Services****Add Integration** 4. Add the integration via **Settings****Devices & Services****Add Integration**
> **Tip:** For the best experience, use this integration with the [Immich Album Watcher Blueprint](https://github.com/DolgolyovAlexei/haos-blueprints/blob/main/Common/Immich%20Album%20Watcher.yaml) to easily create automations for album change notifications.
## Configuration
| Option | Description | Default |
|--------|-------------|---------|
| Server URL | Your Immich server URL (e.g., `https://immich.example.com`) | Required |
| API Key | Your Immich API key | Required |
| Albums | Albums to monitor | Required |
| Scan Interval | How often to check for changes (seconds) | 60 |
## Entities Created (per album)
| Entity Type | Name | Description |
|-------------|------|-------------|
| Sensor | Album ID | Album identifier with `album_name` and `share_url` attributes |
| Sensor | Asset Count | Total number of assets (includes `people` list in attributes) |
| Sensor | Photo Count | Number of photos in the album |
| Sensor | Video Count | Number of videos in the album |
| Sensor | Last Updated | When the album was last modified |
| Sensor | Created | When the album was created |
| Sensor | Public URL | Public share link URL (accessible links without password) |
| Sensor | Protected URL | Password-protected share link URL (if any exist) |
| Sensor | Protected Password | Password for the protected share link (read-only) |
| Binary Sensor | New Assets | On when new assets were recently added |
| Camera | Thumbnail | Album cover image |
| Text | Protected Password | Editable password for the protected share link |
| Button | Create Share Link | Creates an unprotected public share link |
| Button | Delete Share Link | Deletes the unprotected public share link |
| Button | Create Protected Link | Creates a password-protected share link |
| Button | Delete Protected Link | Deletes the password-protected share link |
## Services
### Refresh
Force an immediate refresh of all album data:
```yaml
service: immich_album_watcher.refresh
```
### Get Recent Assets
Get the most recent assets from a specific album (returns response data):
```yaml
service: immich_album_watcher.get_recent_assets
data:
album_id: "your-album-id-here"
count: 10
```
## Events
Use these events in your automations:
```yaml
automation:
- alias: "New photos added to album"
trigger:
- platform: event
event_type: immich_album_watcher_assets_added
action:
- service: notify.mobile_app
data:
title: "New Photos"
message: "{{ trigger.event.data.added_count }} new photos in {{ trigger.event.data.album_name }}"
```
### Event Data
| Field | Description |
|-------|-------------|
| `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 (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_description` | Description/caption of the asset (from EXIF data) |
| `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
- Immich server with API access
- Valid Immich API key with the following permissions:
### Required API Permissions
| Permission | Required | Description |
| ---------- | -------- | ----------- |
| `album.read` | Yes | Read album data and asset lists |
| `asset.read` | Yes | Read asset details (type, filename, creation date) |
| `user.read` | Yes | Resolve asset owner names |
| `person.read` | Yes | Read face recognition / people data |
| `sharedLink.read` | Yes | Read shared links for public/protected URL sensors |
| `sharedLink.create` | Optional | Create share links via the Button entities |
| `sharedLink.edit` | Optional | Edit shared link passwords via the Text entity |
| `sharedLink.delete` | Optional | Delete share links via the Button entities |
> **Note:** Without optional permissions, the corresponding entities will be unavailable or non-functional.
## Contributing ## Contributing
Contributions are welcome! Please feel free to submit issues or pull requests. Contributions are welcome! Please feel free to submit issues or pull requests.

View File

@@ -1,178 +0,0 @@
# Immich Album Watcher
<img src="icon.png" alt="Immich" width="64" height="64">
A Home Assistant custom integration that monitors [Immich](https://immich.app/) photo/video library albums for changes and exposes them as Home Assistant entities with event-firing capabilities.
## Features
- **Album Monitoring** - Watch selected Immich albums for asset additions and removals
- **Rich Sensor Data** - Multiple sensors per album:
- Asset count (total)
- Photo count
- Video count
- People count (detected faces)
- Last updated timestamp
- Creation date
- **Camera Entity** - Album thumbnail displayed as a camera entity for dashboards
- **Binary Sensor** - "New Assets" indicator that turns on when assets are added
- **Face Recognition** - Detects and lists people recognized in album photos
- **Event Firing** - Fires Home Assistant events when albums change:
- `immich_album_watcher_album_changed` - General album changes
- `immich_album_watcher_assets_added` - When new assets are added
- `immich_album_watcher_assets_removed` - When assets are removed
- **Enhanced Event Data** - Events include detailed asset info:
- Asset type (photo/video)
- Filename
- Creation date
- Asset owner (who uploaded the asset)
- Asset description/caption
- 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
- `immich_album_watcher.get_recent_assets` - Get recent assets from an album
- **Configurable Polling** - Adjustable scan interval (10-3600 seconds)
## Entities Created (per album)
| Entity Type | Name | Description |
|-------------|------|-------------|
| Sensor | Asset Count | Total number of assets in the album |
| Sensor | Photo Count | Number of photos in the album |
| Sensor | Video Count | Number of videos in the album |
| Sensor | People Count | Number of unique people detected |
| Sensor | Last Updated | When the album was last modified |
| Sensor | Created | When the album was created |
| Sensor | Public URL | Public share link URL (accessible links without password) |
| Sensor | Protected URL | Password-protected share link URL (if any exist) |
| Sensor | Protected Password | Password for the protected share link (read-only) |
| Binary Sensor | New Assets | On when new assets were recently added |
| Camera | Thumbnail | Album cover image |
| Text | Share Password | Editable password for the protected share link |
## Installation
1. Copy the `immich_album_watcher` folder to your Home Assistant `custom_components` directory
2. Restart Home Assistant
3. Go to **Settings****Devices & Services****Add Integration**
4. Search for "Immich Album Watcher"
5. Enter your Immich server URL and API key
6. Select the albums you want to monitor
## Configuration
| Option | Description | Default |
|--------|-------------|---------|
| Server URL | Your Immich server URL (e.g., `https://immich.example.com`) | Required |
| API Key | Your Immich API key | Required |
| Albums | Albums to monitor | Required |
| Scan Interval | How often to check for changes (seconds) | 60 |
## Services
### Refresh
Force an immediate refresh of all album data:
```yaml
service: immich_album_watcher.refresh
```
### Get Recent Assets
Get the most recent assets from a specific album (returns response data):
```yaml
service: immich_album_watcher.get_recent_assets
data:
album_id: "your-album-id-here"
count: 10
```
## Events
Use these events in your automations:
```yaml
automation:
- alias: "New photos added to album"
trigger:
- platform: event
event_type: immich_album_watcher_assets_added
action:
- service: notify.mobile_app
data:
title: "New Photos"
message: "{{ trigger.event.data.added_count }} new photos in {{ trigger.event.data.album_name }}"
```
### Event Data
| Field | Description |
|-------|-------------|
| `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 (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_description` | Description/caption of the asset (from EXIF data) |
| `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
- Immich server with API access
- Valid Immich API key with the following permissions:
### Required API Permissions
| Permission | Required | Description |
| ---------- | -------- | ----------- |
| `album.read` | Yes | Read album data and asset lists |
| `asset.read` | Yes | Read asset details (type, filename, creation date) |
| `user.read` | Yes | Resolve asset owner names |
| `person.read` | Yes | Read face recognition / people data |
| `sharedLink.read` | Yes | Read shared links for public/protected URL sensors |
| `sharedLink.edit` | Optional | Edit shared link passwords via the Text entity |
> **Note:** If you don't grant `sharedLink.edit` permission, the "Share Password" text entity will not be able to update passwords but will still display the current password.
## License
MIT License - see the [LICENSE](../LICENSE) file for details.

View File

@@ -20,6 +20,7 @@ from .const import (
PLATFORMS, PLATFORMS,
) )
from .coordinator import ImmichAlbumWatcherCoordinator from .coordinator import ImmichAlbumWatcherCoordinator
from .storage import ImmichAlbumStorage
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -63,10 +64,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo
scan_interval=scan_interval, scan_interval=scan_interval,
) )
# Create storage for persisting album state across restarts
storage = ImmichAlbumStorage(hass, entry.entry_id)
await storage.async_load()
# Store hub reference # Store hub reference
hass.data[DOMAIN][entry.entry_id] = { hass.data[DOMAIN][entry.entry_id] = {
"hub": entry.runtime_data, "hub": entry.runtime_data,
"subentries": {}, "subentries": {},
"storage": storage,
} }
# Track loaded subentries to detect changes # Track loaded subentries to detect changes
@@ -97,6 +103,7 @@ async def _async_setup_subentry_coordinator(
hub_data: ImmichHubData = entry.runtime_data hub_data: ImmichHubData = entry.runtime_data
album_id = subentry.data[CONF_ALBUM_ID] album_id = subentry.data[CONF_ALBUM_ID]
album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
storage: ImmichAlbumStorage = hass.data[DOMAIN][entry.entry_id]["storage"]
_LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id) _LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id)
@@ -109,8 +116,12 @@ async def _async_setup_subentry_coordinator(
album_name=album_name, album_name=album_name,
scan_interval=hub_data.scan_interval, scan_interval=hub_data.scan_interval,
hub_name=hub_data.name, hub_name=hub_data.name,
storage=storage,
) )
# Load persisted state before first refresh to detect changes during downtime
await coordinator.async_load_persisted_state()
# Fetch initial data # Fetch initial data
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()

View File

@@ -137,7 +137,6 @@ class ImmichAlbumNewAssetsSensor(
name=self._album_name, name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
) )
@callback @callback

View File

@@ -109,7 +109,6 @@ class ImmichCreateShareLinkButton(
name=self._album_name, name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
) )
@property @property
@@ -200,7 +199,6 @@ class ImmichDeleteShareLinkButton(
name=self._album_name, name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
) )
@property @property
@@ -298,7 +296,6 @@ class ImmichCreateProtectedLinkButton(
name=self._album_name, name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
) )
@property @property
@@ -393,7 +390,6 @@ class ImmichDeleteProtectedLinkButton(
name=self._album_name, name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
) )
@property @property

View File

@@ -102,7 +102,6 @@ class ImmichAlbumThumbnailCamera(
name=self._album_name, name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
) )
@property @property

View File

@@ -26,6 +26,7 @@ from .const import (
CONF_HUB_NAME, CONF_HUB_NAME,
CONF_IMMICH_URL, CONF_IMMICH_URL,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
CONF_TELEGRAM_BOT_TOKEN,
DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL,
DOMAIN, DOMAIN,
SUBENTRY_TYPE_ALBUM, SUBENTRY_TYPE_ALBUM,
@@ -248,12 +249,18 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
CONF_SCAN_INTERVAL: user_input.get( CONF_SCAN_INTERVAL: user_input.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
), ),
CONF_TELEGRAM_BOT_TOKEN: user_input.get(
CONF_TELEGRAM_BOT_TOKEN, ""
),
}, },
) )
current_interval = self._config_entry.options.get( current_interval = self._config_entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
) )
current_bot_token = self._config_entry.options.get(
CONF_TELEGRAM_BOT_TOKEN, ""
)
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
@@ -262,6 +269,9 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
vol.Required( vol.Required(
CONF_SCAN_INTERVAL, default=current_interval CONF_SCAN_INTERVAL, default=current_interval
): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), ): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)),
vol.Optional(
CONF_TELEGRAM_BOT_TOKEN, default=current_bot_token
): str,
} }
), ),
) )

View File

@@ -13,6 +13,7 @@ CONF_ALBUMS: Final = "albums"
CONF_ALBUM_ID: Final = "album_id" CONF_ALBUM_ID: Final = "album_id"
CONF_ALBUM_NAME: Final = "album_name" CONF_ALBUM_NAME: Final = "album_name"
CONF_SCAN_INTERVAL: Final = "scan_interval" CONF_SCAN_INTERVAL: Final = "scan_interval"
CONF_TELEGRAM_BOT_TOKEN: Final = "telegram_bot_token"
# Subentry type # Subentry type
SUBENTRY_TYPE_ALBUM: Final = "album" SUBENTRY_TYPE_ALBUM: Final = "album"
@@ -69,3 +70,4 @@ PLATFORMS: Final = ["sensor", "binary_sensor", "camera", "text", "button"]
# Services # Services
SERVICE_REFRESH: Final = "refresh" SERVICE_REFRESH: Final = "refresh"
SERVICE_GET_RECENT_ASSETS: Final = "get_recent_assets" SERVICE_GET_RECENT_ASSETS: Final = "get_recent_assets"
SERVICE_SEND_TELEGRAM_MEDIA_GROUP: Final = "send_telegram_media_group"

View File

@@ -5,7 +5,10 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .storage import ImmichAlbumStorage
import aiohttp import aiohttp
@@ -221,6 +224,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
album_name: str, album_name: str,
scan_interval: int, scan_interval: int,
hub_name: str = "Immich", hub_name: str = "Immich",
storage: ImmichAlbumStorage | None = None,
) -> None: ) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
super().__init__( super().__init__(
@@ -239,6 +243,8 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
self._people_cache: dict[str, str] = {} # person_id -> name self._people_cache: dict[str, str] = {} # person_id -> name
self._users_cache: dict[str, str] = {} # user_id -> name self._users_cache: dict[str, str] = {} # user_id -> name
self._shared_links: list[SharedLinkInfo] = [] self._shared_links: list[SharedLinkInfo] = []
self._storage = storage
self._persisted_asset_ids: set[str] | None = None
@property @property
def immich_url(self) -> str: def immich_url(self) -> str:
@@ -268,6 +274,23 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
"""Force an immediate refresh.""" """Force an immediate refresh."""
await self.async_request_refresh() await self.async_request_refresh()
async def async_load_persisted_state(self) -> None:
"""Load persisted asset IDs from storage.
This should be called before the first refresh to enable
detection of changes that occurred during downtime.
"""
if self._storage:
self._persisted_asset_ids = self._storage.get_album_asset_ids(
self._album_id
)
if self._persisted_asset_ids is not None:
_LOGGER.debug(
"Loaded %d persisted asset IDs for album '%s'",
len(self._persisted_asset_ids),
self._album_name,
)
async def async_get_recent_assets(self, count: int = 10) -> list[dict[str, Any]]: async def async_get_recent_assets(self, count: int = 10) -> list[dict[str, Any]]:
"""Get recent assets from the album.""" """Get recent assets from the album."""
if self.data is None: if self.data is None:
@@ -503,6 +526,47 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
album.has_new_assets = change.added_count > 0 album.has_new_assets = change.added_count > 0
album.last_change_time = datetime.now() album.last_change_time = datetime.now()
self._fire_events(change, album) self._fire_events(change, album)
elif self._persisted_asset_ids is not None:
# First refresh after restart - compare with persisted state
added_ids = album.asset_ids - self._persisted_asset_ids
removed_ids = self._persisted_asset_ids - album.asset_ids
if added_ids or removed_ids:
change_type = "changed"
if added_ids and not removed_ids:
change_type = "assets_added"
elif removed_ids and not added_ids:
change_type = "assets_removed"
added_assets = [
album.assets[aid]
for aid in added_ids
if aid in album.assets
]
change = AlbumChange(
album_id=album.id,
album_name=album.name,
change_type=change_type,
added_count=len(added_ids),
removed_count=len(removed_ids),
added_assets=added_assets,
removed_asset_ids=list(removed_ids),
)
album.has_new_assets = change.added_count > 0
album.last_change_time = datetime.now()
self._fire_events(change, album)
_LOGGER.info(
"Detected changes during downtime for album '%s': +%d -%d",
album.name,
len(added_ids),
len(removed_ids),
)
else:
album.has_new_assets = False
# Clear persisted state after first comparison
self._persisted_asset_ids = None
else: else:
album.has_new_assets = False album.has_new_assets = False
@@ -517,6 +581,12 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
# Update previous state # Update previous state
self._previous_state = album self._previous_state = album
# Persist current state for recovery after restart
if self._storage:
await self._storage.async_save_album_state(
self._album_id, album.asset_ids
)
return album return album
except aiohttp.ClientError as err: except aiohttp.ClientError as err:

View File

@@ -4,9 +4,9 @@
"codeowners": ["@alexei.dolgolyov"], "codeowners": ["@alexei.dolgolyov"],
"config_flow": true, "config_flow": true,
"dependencies": [], "dependencies": [],
"documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/haos-integrations", "documentation": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/haos-integrations/issues", "issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
"requirements": [], "requirements": [],
"version": "1.2.0" "version": "1.4.0"
} }

View File

@@ -38,9 +38,11 @@ from .const import (
CONF_ALBUM_ID, CONF_ALBUM_ID,
CONF_ALBUM_NAME, CONF_ALBUM_NAME,
CONF_HUB_NAME, CONF_HUB_NAME,
CONF_TELEGRAM_BOT_TOKEN,
DOMAIN, DOMAIN,
SERVICE_GET_RECENT_ASSETS, SERVICE_GET_RECENT_ASSETS,
SERVICE_REFRESH, SERVICE_REFRESH,
SERVICE_SEND_TELEGRAM_MEDIA_GROUP,
) )
from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
@@ -63,6 +65,7 @@ async def async_setup_entry(
coordinator = subentry_data.coordinator coordinator = subentry_data.coordinator
entities: list[SensorEntity] = [ entities: list[SensorEntity] = [
ImmichAlbumIdSensor(coordinator, entry, subentry),
ImmichAlbumAssetCountSensor(coordinator, entry, subentry), ImmichAlbumAssetCountSensor(coordinator, entry, subentry),
ImmichAlbumPhotoCountSensor(coordinator, entry, subentry), ImmichAlbumPhotoCountSensor(coordinator, entry, subentry),
ImmichAlbumVideoCountSensor(coordinator, entry, subentry), ImmichAlbumVideoCountSensor(coordinator, entry, subentry),
@@ -95,6 +98,19 @@ async def async_setup_entry(
supports_response=SupportsResponse.ONLY, supports_response=SupportsResponse.ONLY,
) )
platform.async_register_entity_service(
SERVICE_SEND_TELEGRAM_MEDIA_GROUP,
{
vol.Optional("bot_token"): str,
vol.Required("chat_id"): vol.Coerce(str),
vol.Required("urls"): vol.All(list, vol.Length(min=1, max=10)),
vol.Optional("caption"): str,
vol.Optional("reply_to_message_id"): vol.Coerce(int),
},
"async_send_telegram_media_group",
supports_response=SupportsResponse.ONLY,
)
class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], SensorEntity): class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], SensorEntity):
"""Base sensor for Immich album.""" """Base sensor for Immich album."""
@@ -142,7 +158,6 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
name=self._album_name, name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
) )
@callback @callback
@@ -159,6 +174,162 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
assets = await self.coordinator.async_get_recent_assets(count) assets = await self.coordinator.async_get_recent_assets(count)
return {"assets": assets} return {"assets": assets}
async def async_send_telegram_media_group(
self,
chat_id: str,
urls: list[dict[str, str]],
bot_token: str | None = None,
caption: str | None = None,
reply_to_message_id: int | None = None,
) -> ServiceResponse:
"""Send media URLs to Telegram as a media group.
Each item in urls should be a dict with 'url' and 'type' (photo/video).
Downloads media and uploads to Telegram to bypass CORS restrictions.
"""
import json
import aiohttp
from aiohttp import FormData
from homeassistant.helpers.aiohttp_client import async_get_clientsession
# Get bot token from parameter or config
token = bot_token or self._entry.options.get(CONF_TELEGRAM_BOT_TOKEN)
if not token:
return {
"success": False,
"error": "No bot token provided. Set it in integration options or pass as parameter.",
}
session = async_get_clientsession(self.hass)
# Download all media files
media_files: list[tuple[str, bytes, str]] = []
for i, item in enumerate(urls):
url = item.get("url")
media_type = item.get("type", "photo")
if not url:
return {
"success": False,
"error": f"Missing 'url' in item {i}",
}
if media_type not in ("photo", "video"):
return {
"success": False,
"error": f"Invalid type '{media_type}' in item {i}. Must be 'photo' or 'video'.",
}
try:
_LOGGER.debug("Downloading media %d from %s", i, url[:80])
async with session.get(url) as resp:
if resp.status != 200:
return {
"success": False,
"error": f"Failed to download media {i}: HTTP {resp.status}",
}
data = await resp.read()
ext = "jpg" if media_type == "photo" else "mp4"
filename = f"media_{i}.{ext}"
media_files.append((media_type, data, filename))
_LOGGER.debug("Downloaded media %d: %d bytes", i, len(data))
except aiohttp.ClientError as err:
return {
"success": False,
"error": f"Failed to download media {i}: {err}",
}
# Build multipart form
form = FormData()
form.add_field("chat_id", chat_id)
if reply_to_message_id:
form.add_field("reply_to_message_id", str(reply_to_message_id))
# Build media JSON with attach:// references
media_json = []
for i, (media_type, data, filename) in enumerate(media_files):
attach_name = f"file{i}"
media_item: dict[str, Any] = {
"type": media_type,
"media": f"attach://{attach_name}",
}
if i == 0 and caption:
media_item["caption"] = caption
media_json.append(media_item)
content_type = "image/jpeg" if media_type == "photo" else "video/mp4"
form.add_field(attach_name, data, filename=filename, content_type=content_type)
form.add_field("media", json.dumps(media_json))
# Send to Telegram
telegram_url = f"https://api.telegram.org/bot{token}/sendMediaGroup"
try:
_LOGGER.debug("Uploading %d files to Telegram", len(media_files))
async with session.post(telegram_url, data=form) as response:
result = await response.json()
_LOGGER.debug("Telegram API response: status=%d, ok=%s", response.status, result.get("ok"))
if response.status == 200 and result.get("ok"):
return {
"success": True,
"message_ids": [
msg.get("message_id") for msg in result.get("result", [])
],
}
else:
_LOGGER.error("Telegram API error: %s", result)
return {
"success": False,
"error": result.get("description", "Unknown Telegram error"),
"error_code": result.get("error_code"),
}
except aiohttp.ClientError as err:
_LOGGER.error("Telegram upload failed: %s", err)
return {"success": False, "error": str(err)}
class ImmichAlbumIdSensor(ImmichAlbumBaseSensor):
"""Sensor exposing the Immich album ID."""
_attr_icon = "mdi:identifier"
_attr_translation_key = "album_id"
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, entry, subentry)
self._attr_unique_id = f"{self._unique_id_prefix}_album_id"
@property
def native_value(self) -> str | None:
"""Return the album ID."""
if self._album_data:
return self._album_data.id
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes."""
if not self._album_data:
return {}
attrs: dict[str, Any] = {
"album_name": self._album_data.name,
}
# Primary share URL (prefers public, falls back to protected)
share_url = self.coordinator.get_any_url()
if share_url:
attrs["share_url"] = share_url
return attrs
class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor): class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor):
"""Sensor representing an Immich album asset count.""" """Sensor representing an Immich album asset count."""

View File

@@ -24,3 +24,44 @@ get_recent_assets:
min: 1 min: 1
max: 100 max: 100
mode: slider mode: slider
send_telegram_media_group:
name: Send Telegram Media Group
description: Send specified media URLs to a Telegram chat as a media group.
target:
entity:
integration: immich_album_watcher
domain: sensor
fields:
bot_token:
name: Bot Token
description: Telegram bot token. Uses configured token if not provided.
required: false
selector:
text:
chat_id:
name: Chat ID
description: Telegram chat ID to send to.
required: true
selector:
text:
urls:
name: URLs
description: List of media URLs to send (max 10). Each item should have 'url' and 'type' (photo/video).
required: true
selector:
object:
caption:
name: Caption
description: Optional caption for the media group (applied to first item).
required: false
selector:
text:
multiline: true
reply_to_message_id:
name: Reply To Message ID
description: Message ID to reply to.
required: false
selector:
number:
mode: box

View File

@@ -0,0 +1,65 @@
"""Storage helpers for Immich Album Watcher."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION = 1
STORAGE_KEY_PREFIX = "immich_album_watcher"
class ImmichAlbumStorage:
"""Handles persistence of album state across restarts."""
def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
"""Initialize the storage."""
self._store: Store[dict[str, Any]] = Store(
hass, STORAGE_VERSION, f"{STORAGE_KEY_PREFIX}.{entry_id}"
)
self._data: dict[str, Any] | None = None
async def async_load(self) -> dict[str, Any]:
"""Load data from storage."""
self._data = await self._store.async_load() or {"albums": {}}
_LOGGER.debug("Loaded storage data with %d albums", len(self._data.get("albums", {})))
return self._data
async def async_save_album_state(self, album_id: str, asset_ids: set[str]) -> None:
"""Save album asset IDs to storage."""
if self._data is None:
self._data = {"albums": {}}
self._data["albums"][album_id] = {
"asset_ids": list(asset_ids),
"last_updated": datetime.now(timezone.utc).isoformat(),
}
await self._store.async_save(self._data)
def get_album_asset_ids(self, album_id: str) -> set[str] | None:
"""Get persisted asset IDs for an album.
Returns None if no persisted state exists for the album.
"""
if self._data and "albums" in self._data:
album_data = self._data["albums"].get(album_id)
if album_data:
return set(album_data.get("asset_ids", []))
return None
async def async_remove_album(self, album_id: str) -> None:
"""Remove an album from storage."""
if self._data and "albums" in self._data:
self._data["albums"].pop(album_id, None)
await self._store.async_save(self._data)
async def async_remove(self) -> None:
"""Remove all storage data."""
await self._store.async_remove()
self._data = None

View File

@@ -1,6 +1,9 @@
{ {
"entity": { "entity": {
"sensor": { "sensor": {
"album_id": {
"name": "{album_name}: Album ID"
},
"album_asset_count": { "album_asset_count": {
"name": "{album_name}: Asset Count" "name": "{album_name}: Asset Count"
}, },
@@ -68,13 +71,15 @@
"step": { "step": {
"init": { "init": {
"title": "Immich Album Watcher Options", "title": "Immich Album Watcher Options",
"description": "Configure which albums to monitor and how often to check for changes.", "description": "Configure how often to check for changes and optional Telegram integration.",
"data": { "data": {
"albums": "Albums to watch", "albums": "Albums to watch",
"scan_interval": "Scan interval (seconds)" "scan_interval": "Scan interval (seconds)",
"telegram_bot_token": "Telegram Bot Token"
}, },
"data_description": { "data_description": {
"scan_interval": "How often to check for album changes (10-3600 seconds)" "scan_interval": "How often to check for album changes (10-3600 seconds)",
"telegram_bot_token": "Bot token for sending media to Telegram (optional)"
} }
} }
}, },

View File

@@ -105,7 +105,6 @@ class ImmichAlbumProtectedPasswordText(
name=self._album_name, name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
) )
@property @property