Compare commits
17 Commits
436139ede9
...
v1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 02c0535f50 | |||
| c570e157be | |||
| 56d249b598 | |||
| ebed587f6f | |||
| a89d45268d | |||
| 950fe0fd91 | |||
| 91c30e086d | |||
| 6f39a8175d | |||
| e6619cb1c5 | |||
| eedc7792c8 | |||
| 3a0573e432 | |||
| 7c53110c07 | |||
| 03430df5fb | |||
| 2ca26e178a | |||
| 847c39eaa8 | |||
| 9013c5e0c3 | |||
| 557ec91f05 |
17
.github/workflows/validate.yaml
vendored
Normal file
17
.github/workflows/validate.yaml
vendored
Normal 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
16
CLAUDE.md
Normal 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
21
LICENSE
Normal 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
182
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
65
custom_components/immich_album_watcher/storage.py
Normal file
65
custom_components/immich_album_watcher/storage.py
Normal 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
|
||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user