Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fde2d0ae31 | |||
| 31663852f9 | |||
| 5cee3ccc79 | |||
| 3b133dc4bb | |||
| a8ea9ab46a | |||
| e88fd0fa3a | |||
| 3cf916dc77 | |||
| df446390f2 | |||
| 1d61f05552 | |||
| 38a2a6ad7a | |||
| 0bb7e71a1e | |||
| c29fc2fbcf | |||
| 011f105823 | |||
| ee45fdc177 | |||
| 4b0f3b8b12 | |||
| e5e45f0fbf | |||
| 8714685d5e | |||
| bbcd97e1ac | |||
| 04dd63825c | |||
| 71d3714f6a | |||
| 459f5ef1e5 | |||
| 42b2d912c9 | |||
| 2007b020ba | |||
| 2ae706d700 | |||
| 1cc5d7cc7d | |||
| 5d878cfbd0 | |||
| c7ed037e2e | |||
| d26e212c82 |
46
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
46
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug or unexpected behavior
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Describe the Bug
|
||||
|
||||
A clear description of what the bug is.
|
||||
|
||||
## Environment
|
||||
|
||||
- **Home Assistant version:**
|
||||
- **Integration version:**
|
||||
- **Immich version:**
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
What you expected to happen.
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
What actually happened.
|
||||
|
||||
## Logs
|
||||
|
||||
<details>
|
||||
<summary>Relevant log entries</summary>
|
||||
|
||||
```
|
||||
Paste logs here
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Additional Context
|
||||
|
||||
Any other context about the problem.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Home Assistant Community
|
||||
url: https://community.home-assistant.io/
|
||||
about: Ask questions about Home Assistant
|
||||
- name: Immich Documentation
|
||||
url: https://immich.app/docs
|
||||
about: Immich official documentation
|
||||
27
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
27
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or enhancement
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Feature Description
|
||||
|
||||
A clear description of what you would like to see added.
|
||||
|
||||
## Use Case
|
||||
|
||||
Describe the problem this feature would solve or the use case it enables.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
If you have ideas on how to implement this, describe them here.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
Any alternative solutions or features you've considered.
|
||||
|
||||
## Additional Context
|
||||
|
||||
Any other context, screenshots, or examples.
|
||||
20
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
20
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
## Description
|
||||
|
||||
Brief description of the changes.
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Documentation update
|
||||
- [ ] Other (describe):
|
||||
|
||||
## Testing
|
||||
|
||||
Describe how you tested these changes.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] Changes have been tested locally
|
||||
- [ ] Documentation updated (if applicable)
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -3,6 +3,7 @@
|
||||
## 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/`).
|
||||
**IMPORTANT** ALWAYS ask for version bump before doing it.
|
||||
|
||||
Do NOT bump version for:
|
||||
|
||||
@@ -14,3 +15,18 @@ 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
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
**IMPORTANT**: Always keep the README.md synchronized with integration changes.
|
||||
|
||||
When modifying the integration interface, you MUST update the corresponding documentation:
|
||||
|
||||
- **Service parameters**: Update parameter tables and examples in README.md
|
||||
- **New events**: Add event documentation with examples and field descriptions
|
||||
- **New entities**: Document entity types, attributes, and usage
|
||||
- **Configuration options**: Update configuration documentation
|
||||
- **Translation files**: Add translations for new parameters/entities in `en.json` and `ru.json`
|
||||
- **services.yaml**: Keep service definitions in sync with implementation
|
||||
|
||||
The README is the primary user-facing documentation and must accurately reflect the current state of the integration.
|
||||
|
||||
49
CONTRIBUTING.md
Normal file
49
CONTRIBUTING.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Contributing to Immich Album Watcher
|
||||
|
||||
Thank you for your interest in contributing to this Home Assistant integration!
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork locally
|
||||
3. Create a new branch for your changes
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Set up a Home Assistant development environment
|
||||
2. Copy the `custom_components/immich_album_watcher` folder to your HA config
|
||||
3. Restart Home Assistant to load changes
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow [Home Assistant's development guidelines](https://developers.home-assistant.io/docs/development_guidelines)
|
||||
- Use type hints for all function parameters and return values
|
||||
- Keep code compatible with Python 3.11+
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
1. Test your changes thoroughly
|
||||
2. Update documentation if needed
|
||||
3. Create a pull request with a clear description of changes
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When reporting bugs, please include:
|
||||
|
||||
- Home Assistant version
|
||||
- Integration version
|
||||
- Immich server version
|
||||
- Relevant log entries
|
||||
- Steps to reproduce
|
||||
|
||||
## Version Numbering
|
||||
|
||||
This project uses semantic versioning:
|
||||
|
||||
- **MAJOR** (x.0.0): Breaking changes
|
||||
- **MINOR** (0.x.0): New features, backward compatible
|
||||
- **PATCH** (0.0.x): Bug fixes
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an issue for any questions about contributing.
|
||||
449
README.md
449
README.md
@@ -4,16 +4,21 @@
|
||||
|
||||
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.
|
||||
|
||||
> **Tip:** For the best experience, use this integration with the [Immich Album Watcher Blueprint](https://github.com/DolgolyovAlexei/haos-blueprints/tree/main/Common/Immich%20Album%20Watcher) to easily create automations for album change notifications.
|
||||
|
||||
## Features
|
||||
|
||||
- **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
|
||||
- Album ID (with album name and share URL attributes)
|
||||
- Asset Count (total assets with detected people list)
|
||||
- Photo Count (number of photos)
|
||||
- Video Count (number of videos)
|
||||
- Last Updated (last modification timestamp)
|
||||
- Created (album creation date)
|
||||
- Public URL (public share link)
|
||||
- Protected URL (password-protected share link)
|
||||
- Protected Password (password for protected link)
|
||||
- **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
|
||||
@@ -31,12 +36,16 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
|
||||
- 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
|
||||
- `immich_album_watcher.get_assets` - Get assets from an album with filtering and ordering
|
||||
- `immich_album_watcher.send_telegram_notification` - Send text, photo, video, or media group to Telegram
|
||||
- **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)
|
||||
- **Localization** - Available in multiple languages:
|
||||
- English
|
||||
- Russian (Русский)
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -59,8 +68,6 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
|
||||
3. Restart Home Assistant
|
||||
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 |
|
||||
@@ -69,12 +76,37 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
|
||||
| API Key | Your Immich API key | Required |
|
||||
| Albums | Albums to monitor | Required |
|
||||
| Scan Interval | How often to check for changes (seconds) | 60 |
|
||||
| Telegram Bot Token | Bot token for sending media to Telegram (optional) | - |
|
||||
| Telegram Cache TTL | How long to cache uploaded file IDs (hours, 1-168) | 48 |
|
||||
|
||||
### External Domain Support
|
||||
|
||||
The integration supports connecting to a local Immich server while using an external domain for user-facing URLs. This is useful when:
|
||||
|
||||
- Your Home Assistant connects to Immich via local network (e.g., `http://192.168.1.100:2283`)
|
||||
- But you want share links and asset URLs to use your public domain (e.g., `https://photos.example.com`)
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Configure "External domain" in Immich: **Administration → Settings → Server → External Domain**
|
||||
2. The integration automatically fetches this setting on startup
|
||||
3. All user-facing URLs (share links, asset URLs in events) use the external domain
|
||||
4. API calls and file downloads still use the local connection URL for faster performance
|
||||
|
||||
**Example:**
|
||||
|
||||
- Server URL (in integration config): `http://192.168.1.100:2283`
|
||||
- External Domain (in Immich settings): `https://photos.example.com`
|
||||
- Share links in events: `https://photos.example.com/share/...`
|
||||
- Telegram downloads: via `http://192.168.1.100:2283` (fast local network)
|
||||
|
||||
If no external domain is configured in Immich, all URLs will use the Server URL from the integration configuration.
|
||||
|
||||
## Entities Created (per album)
|
||||
|
||||
| Entity Type | Name | Description |
|
||||
|-------------|------|-------------|
|
||||
| Sensor | Album ID | Album identifier with `album_name` and `share_url` attributes |
|
||||
| Sensor | Album ID | Album identifier with `album_name`, `asset_count`, `share_url`, `last_updated_at`, and `created_at` 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 |
|
||||
@@ -101,20 +133,325 @@ Force an immediate refresh of all album data:
|
||||
service: immich_album_watcher.refresh
|
||||
```
|
||||
|
||||
### Get Recent Assets
|
||||
### Get Assets
|
||||
|
||||
Get the most recent assets from a specific album (returns response data):
|
||||
Get assets from a specific album with optional filtering and ordering (returns response data). Only returns fully processed assets (videos with completed transcoding, photos with generated thumbnails).
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.get_recent_assets
|
||||
service: immich_album_watcher.get_assets
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
album_id: "your-album-id-here"
|
||||
count: 10
|
||||
limit: 10 # Maximum number of assets (1-100)
|
||||
offset: 0 # Number of assets to skip (for pagination)
|
||||
favorite_only: false # true = favorites only, false = all assets
|
||||
filter_min_rating: 4 # Min rating (1-5)
|
||||
order_by: "date" # Options: "date", "rating", "name", "random"
|
||||
order: "descending" # Options: "ascending", "descending"
|
||||
asset_type: "all" # Options: "all", "photo", "video"
|
||||
min_date: "2024-01-01" # Optional: assets created on or after this date
|
||||
max_date: "2024-12-31" # Optional: assets created on or before this date
|
||||
memory_date: "2024-02-14" # Optional: memories filter (excludes same year)
|
||||
city: "Paris" # Optional: filter by city name
|
||||
state: "California" # Optional: filter by state/region
|
||||
country: "France" # Optional: filter by country
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `limit` (optional, default: 10): Maximum number of assets to return (1-100)
|
||||
- `offset` (optional, default: 0): Number of assets to skip before returning results. Use with `limit` for pagination (e.g., `offset: 0, limit: 10` for first page, `offset: 10, limit: 10` for second page)
|
||||
- `favorite_only` (optional, default: false): Filter to show only favorite assets
|
||||
- `filter_min_rating` (optional, default: 1): Minimum rating for assets (1-5 stars). Applied independently of `favorite_only`
|
||||
- `order_by` (optional, default: "date"): Field to sort assets by
|
||||
- `"date"`: Sort by creation date
|
||||
- `"rating"`: Sort by rating (assets without rating are placed last)
|
||||
- `"name"`: Sort by filename
|
||||
- `"random"`: Random order (ignores `order`)
|
||||
- `order` (optional, default: "descending"): Sort direction
|
||||
- `"ascending"`: Ascending order
|
||||
- `"descending"`: Descending order
|
||||
- `asset_type` (optional, default: "all"): Filter by asset type
|
||||
- `"all"`: No type filtering, return both photos and videos
|
||||
- `"photo"`: Return only photos
|
||||
- `"video"`: Return only videos
|
||||
- `min_date` (optional): Filter assets created on or after this date. Use ISO 8601 format (e.g., `"2024-01-01"` or `"2024-01-01T10:30:00"`)
|
||||
- `max_date` (optional): Filter assets created on or before this date. Use ISO 8601 format (e.g., `"2024-12-31"` or `"2024-12-31T23:59:59"`)
|
||||
- `memory_date` (optional): Filter assets by matching month and day, excluding the same year (memories filter like Google Photos). Provide a date in ISO 8601 format (e.g., `"2024-02-14"`) to get all assets taken on February 14th from previous years
|
||||
- `city` (optional): Filter assets by city name (case-insensitive substring match). Based on reverse geocoded location from asset GPS data
|
||||
- `state` (optional): Filter assets by state/region name (case-insensitive substring match). Based on reverse geocoded location from asset GPS data
|
||||
- `country` (optional): Filter assets by country name (case-insensitive substring match). Based on reverse geocoded location from asset GPS data
|
||||
|
||||
**Examples:**
|
||||
|
||||
Get 5 most recent favorite assets:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.get_assets
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
limit: 5
|
||||
favorite_only: true
|
||||
order_by: "date"
|
||||
order: "descending"
|
||||
```
|
||||
|
||||
Get 10 random assets rated 3 stars or higher:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.get_assets
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
limit: 10
|
||||
filter_min_rating: 3
|
||||
order_by: "random"
|
||||
```
|
||||
|
||||
Get 20 most recent photos only:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.get_assets
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
limit: 20
|
||||
asset_type: "photo"
|
||||
order_by: "date"
|
||||
order: "descending"
|
||||
```
|
||||
|
||||
Get top 10 highest rated favorite videos:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.get_assets
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
limit: 10
|
||||
favorite_only: true
|
||||
asset_type: "video"
|
||||
order_by: "rating"
|
||||
order: "descending"
|
||||
```
|
||||
|
||||
Get photos sorted alphabetically by name:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.get_assets
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
limit: 20
|
||||
asset_type: "photo"
|
||||
order_by: "name"
|
||||
order: "ascending"
|
||||
```
|
||||
|
||||
Get photos from a specific date range:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.get_assets
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
limit: 50
|
||||
asset_type: "photo"
|
||||
min_date: "2024-06-01"
|
||||
max_date: "2024-06-30"
|
||||
order_by: "date"
|
||||
order: "descending"
|
||||
```
|
||||
|
||||
Get "On This Day" memories (photos from today's date in previous years):
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.get_assets
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
limit: 20
|
||||
memory_date: "{{ now().strftime('%Y-%m-%d') }}"
|
||||
order_by: "date"
|
||||
order: "ascending"
|
||||
```
|
||||
|
||||
Paginate through all assets (first page):
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.get_assets
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
limit: 10
|
||||
offset: 0
|
||||
order_by: "date"
|
||||
order: "descending"
|
||||
```
|
||||
|
||||
Paginate through all assets (second page):
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.get_assets
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
limit: 10
|
||||
offset: 10
|
||||
order_by: "date"
|
||||
order: "descending"
|
||||
```
|
||||
|
||||
Get photos taken in a specific city:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.get_assets
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
limit: 50
|
||||
city: "Paris"
|
||||
asset_type: "photo"
|
||||
order_by: "date"
|
||||
order: "descending"
|
||||
```
|
||||
|
||||
Get all assets from a specific country:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.get_assets
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
limit: 100
|
||||
country: "Japan"
|
||||
order_by: "date"
|
||||
order: "ascending"
|
||||
```
|
||||
|
||||
### Send Telegram Notification
|
||||
|
||||
Send notifications to Telegram. Supports multiple formats:
|
||||
|
||||
- **Text message** - When `urls` is empty or not provided
|
||||
- **Single photo** - When `urls` contains one photo
|
||||
- **Single video** - When `urls` contains one video
|
||||
- **Media group** - When `urls` contains multiple items
|
||||
|
||||
The service downloads media from Immich and uploads it to Telegram, bypassing any CORS restrictions. Large lists of media are automatically split into multiple media groups based on the `max_group_size` parameter (default: 10 items per group).
|
||||
|
||||
**File ID Caching:** When media is uploaded to Telegram, the service caches the returned `file_id`. Subsequent sends of the same media will use the cached `file_id` instead of re-uploading, significantly improving performance. The cache TTL is configurable in hub options (default: 48 hours, range: 1-168 hours). The cache is persistent across Home Assistant restarts and is stored per album.
|
||||
|
||||
**Examples:**
|
||||
|
||||
Text message:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
caption: "Check out the new album!"
|
||||
disable_web_page_preview: true
|
||||
```
|
||||
|
||||
Single photo:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
urls:
|
||||
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||
type: photo
|
||||
caption: "Beautiful sunset!"
|
||||
```
|
||||
|
||||
Media group:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
urls:
|
||||
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||
type: photo
|
||||
- url: "https://immich.example.com/api/assets/zzz/video/playback?key=yyy"
|
||||
type: video
|
||||
caption: "New photos from the album!"
|
||||
reply_to_message_id: 123
|
||||
```
|
||||
|
||||
HTML formatting:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
caption: |
|
||||
<b>Album Updated!</b>
|
||||
New photos by <i>{{ trigger.event.data.added_assets[0].owner }}</i>
|
||||
<a href="https://immich.example.com/album">View Album</a>
|
||||
parse_mode: "HTML" # Default, can be omitted
|
||||
```
|
||||
|
||||
Non-blocking mode (fire-and-forget):
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
urls:
|
||||
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||
type: photo
|
||||
caption: "Quick notification"
|
||||
wait_for_response: false # Automation continues immediately
|
||||
```
|
||||
|
||||
| Field | Description | Required |
|
||||
|-------|-------------|----------|
|
||||
| `chat_id` | Telegram chat ID to send to | Yes |
|
||||
| `urls` | List of media items with `url` and `type` (photo/video). Empty for text message. | No |
|
||||
| `bot_token` | Telegram bot token (uses configured token if not provided) | No |
|
||||
| `caption` | For media: caption applied to first item. For text: the message text. Supports HTML formatting by default. | No |
|
||||
| `reply_to_message_id` | Message ID to reply to | No |
|
||||
| `disable_web_page_preview` | Disable link previews in text messages | No |
|
||||
| `parse_mode` | How to parse caption/text. Options: `HTML`, `Markdown`, `MarkdownV2`, or empty string for plain text. Default: `HTML` | No |
|
||||
| `max_group_size` | Maximum media items per group (2-10). Large lists split into multiple groups. Default: 10 | No |
|
||||
| `chunk_delay` | Delay in milliseconds between sending multiple groups (0-60000). Useful for rate limiting. Default: 0 | No |
|
||||
| `wait_for_response` | Wait for Telegram to finish processing. Set to `false` for fire-and-forget (automation continues immediately). Default: `true` | No |
|
||||
| `max_asset_data_size` | Maximum asset size in bytes. Assets exceeding this limit will be skipped. Default: no limit | No |
|
||||
| `send_large_photos_as_documents` | Handle photos exceeding Telegram limits (10MB or 10000px dimension sum). If `true`, send as documents. If `false`, skip oversized photos. Default: `false` | No |
|
||||
| `chat_action` | Chat action to display while processing media (`typing`, `upload_photo`, `upload_video`, `upload_document`). Set to empty string to disable. Default: `typing` | No |
|
||||
|
||||
The service returns a response with `success` status and `message_id` (single message), `message_ids` (media group), or `groups_sent` (number of groups when split). When `wait_for_response` is `false`, the service returns immediately with `{"success": true, "status": "queued"}` while processing continues in the background.
|
||||
|
||||
## Events
|
||||
|
||||
Use these events in your automations:
|
||||
The integration fires multiple event types that you can use in your automations:
|
||||
|
||||
### Available Events
|
||||
|
||||
| Event Type | Description | When Fired |
|
||||
|------------|-------------|------------|
|
||||
| `immich_album_watcher_album_changed` | General album change event | Fired for any album change |
|
||||
| `immich_album_watcher_assets_added` | Assets were added to the album | When new photos/videos are added |
|
||||
| `immich_album_watcher_assets_removed` | Assets were removed from the album | When photos/videos are removed |
|
||||
| `immich_album_watcher_album_renamed` | Album name was changed | When the album is renamed |
|
||||
| `immich_album_watcher_album_deleted` | Album was deleted | When the album is deleted from Immich |
|
||||
| `immich_album_watcher_album_sharing_changed` | Album sharing status changed | When album is shared or unshared |
|
||||
|
||||
### Example Usage
|
||||
|
||||
```yaml
|
||||
automation:
|
||||
@@ -126,22 +463,48 @@ automation:
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
title: "New Photos"
|
||||
message: "{{ trigger.event.data.added_count }} new photos in {{ trigger.event.data.album_name }}"
|
||||
message: "{{ trigger.event.data.added_limit }} new photos in {{ trigger.event.data.album_name }}"
|
||||
|
||||
- alias: "Album renamed"
|
||||
trigger:
|
||||
- platform: event
|
||||
event_type: immich_album_watcher_album_renamed
|
||||
action:
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
title: "Album Renamed"
|
||||
message: "Album '{{ trigger.event.data.old_name }}' renamed to '{{ trigger.event.data.new_name }}'"
|
||||
|
||||
- alias: "Album deleted"
|
||||
trigger:
|
||||
- platform: event
|
||||
event_type: immich_album_watcher_album_deleted
|
||||
action:
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
title: "Album Deleted"
|
||||
message: "Album '{{ trigger.event.data.album_name }}' was deleted"
|
||||
```
|
||||
|
||||
### 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 |
|
||||
| Field | Description | Available In |
|
||||
|-------|-------------|--------------|
|
||||
| `hub_name` | Hub name configured in integration | All events |
|
||||
| `album_id` | Album ID | All events |
|
||||
| `album_name` | Current album name | All events |
|
||||
| `album_url` | Public URL to view the album (only present if album has a shared link) | All events except `album_deleted` |
|
||||
| `change_type` | Type of change (assets_added, assets_removed, album_renamed, album_sharing_changed, changed) | All events except `album_deleted` |
|
||||
| `shared` | Current sharing status of the album | All events except `album_deleted` |
|
||||
| `added_limit` | Number of assets added | `album_changed`, `assets_added` |
|
||||
| `removed_limit` | Number of assets removed | `album_changed`, `assets_removed` |
|
||||
| `added_assets` | List of added assets with details (see below) | `album_changed`, `assets_added` |
|
||||
| `removed_assets` | List of removed asset IDs | `album_changed`, `assets_removed` |
|
||||
| `people` | List of all people detected in the album | All events except `album_deleted` |
|
||||
| `old_name` | Previous album name | `album_renamed` |
|
||||
| `new_name` | New album name | `album_renamed` |
|
||||
| `old_shared` | Previous sharing status | `album_sharing_changed` |
|
||||
| `new_shared` | New sharing status | `album_sharing_changed` |
|
||||
|
||||
### Added Assets Fields
|
||||
|
||||
@@ -150,15 +513,27 @@ 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) |
|
||||
| `type` | Type of asset (`IMAGE` or `VIDEO`) |
|
||||
| `filename` | Original filename of the asset |
|
||||
| `created_at` | Date/time when the asset was originally created |
|
||||
| `owner` | Display name of the user who owns the asset |
|
||||
| `owner_id` | Unique ID of the user who owns the asset |
|
||||
| `description` | Description/caption of the asset (from EXIF data) |
|
||||
| `is_favorite` | Whether the asset is marked as favorite (`true` or `false`) |
|
||||
| `rating` | User rating of the asset (1-5 stars, or `null` if not rated) |
|
||||
| `latitude` | GPS latitude coordinate (or `null` if no geolocation) |
|
||||
| `longitude` | GPS longitude coordinate (or `null` if no geolocation) |
|
||||
| `city` | City name from reverse geocoding (or `null` if unavailable) |
|
||||
| `state` | State/region name from reverse geocoding (or `null` if unavailable) |
|
||||
| `country` | Country name from reverse geocoding (or `null` if unavailable) |
|
||||
| `url` | Public URL to view the asset (only present if album has a shared link) |
|
||||
| `download_url` | Direct download URL for the original file (if shared link exists) |
|
||||
| `playback_url` | Video playback URL (for VIDEO assets only, if shared link exists) |
|
||||
| `photo_url` | Photo preview URL (for IMAGE assets only, if shared link exists) |
|
||||
| `people` | List of people detected in this specific asset |
|
||||
|
||||
> **Note:** Assets are only included in events and service responses when they are fully processed by Immich. For videos, this means transcoding must be complete (with `encodedVideoPath`). For photos, thumbnail generation must be complete (with `thumbhash`). This ensures that all media URLs are valid and accessible. Unprocessed assets are silently ignored until their processing completes.
|
||||
|
||||
Example accessing asset owner in an automation:
|
||||
|
||||
```yaml
|
||||
@@ -172,8 +547,8 @@ automation:
|
||||
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 }}
|
||||
{{ trigger.event.data.added_assets[0].owner }} added
|
||||
{{ trigger.event.data.added_limit }} photos to {{ trigger.event.data.album_name }}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -15,12 +15,14 @@ from .const import (
|
||||
CONF_HUB_NAME,
|
||||
CONF_IMMICH_URL,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_TELEGRAM_CACHE_TTL,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_TELEGRAM_CACHE_TTL,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import ImmichAlbumWatcherCoordinator
|
||||
from .storage import ImmichAlbumStorage
|
||||
from .storage import ImmichAlbumStorage, TelegramFileCache
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,6 +35,7 @@ class ImmichHubData:
|
||||
url: str
|
||||
api_key: str
|
||||
scan_interval: int
|
||||
telegram_cache_ttl: int
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -55,6 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo
|
||||
url = entry.data[CONF_IMMICH_URL]
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
telegram_cache_ttl = entry.options.get(CONF_TELEGRAM_CACHE_TTL, DEFAULT_TELEGRAM_CACHE_TTL)
|
||||
|
||||
# Store hub data
|
||||
entry.runtime_data = ImmichHubData(
|
||||
@@ -62,6 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo
|
||||
url=url,
|
||||
api_key=api_key,
|
||||
scan_interval=scan_interval,
|
||||
telegram_cache_ttl=telegram_cache_ttl,
|
||||
)
|
||||
|
||||
# Create storage for persisting album state across restarts
|
||||
@@ -107,6 +112,12 @@ async def _async_setup_subentry_coordinator(
|
||||
|
||||
_LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id)
|
||||
|
||||
# Create and load Telegram file cache for this album
|
||||
# TTL is in hours from config, convert to seconds
|
||||
cache_ttl_seconds = hub_data.telegram_cache_ttl * 60 * 60
|
||||
telegram_cache = TelegramFileCache(hass, album_id, ttl_seconds=cache_ttl_seconds)
|
||||
await telegram_cache.async_load()
|
||||
|
||||
# Create coordinator for this album
|
||||
coordinator = ImmichAlbumWatcherCoordinator(
|
||||
hass,
|
||||
@@ -117,6 +128,7 @@ async def _async_setup_subentry_coordinator(
|
||||
scan_interval=hub_data.scan_interval,
|
||||
hub_name=hub_data.name,
|
||||
storage=storage,
|
||||
telegram_cache=telegram_cache,
|
||||
)
|
||||
|
||||
# Load persisted state before first refresh to detect changes during downtime
|
||||
|
||||
@@ -82,13 +82,6 @@ class ImmichAlbumNewAssetsSensor(
|
||||
"""Get the album data from coordinator."""
|
||||
return self.coordinator.data
|
||||
|
||||
@property
|
||||
def translation_placeholders(self) -> dict[str, str]:
|
||||
"""Return translation placeholders."""
|
||||
if self._album_data:
|
||||
return {"album_name": self._album_data.name}
|
||||
return {"album_name": self._album_name}
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if new assets were recently added."""
|
||||
|
||||
@@ -83,13 +83,6 @@ class ImmichCreateShareLinkButton(
|
||||
"""Get the album data from coordinator."""
|
||||
return self.coordinator.data
|
||||
|
||||
@property
|
||||
def translation_placeholders(self) -> dict[str, str]:
|
||||
"""Return translation placeholders."""
|
||||
if self._album_data:
|
||||
return {"album_name": self._album_data.name}
|
||||
return {"album_name": self._album_name}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available.
|
||||
@@ -173,13 +166,6 @@ class ImmichDeleteShareLinkButton(
|
||||
"""Get the album data from coordinator."""
|
||||
return self.coordinator.data
|
||||
|
||||
@property
|
||||
def translation_placeholders(self) -> dict[str, str]:
|
||||
"""Return translation placeholders."""
|
||||
if self._album_data:
|
||||
return {"album_name": self._album_data.name}
|
||||
return {"album_name": self._album_name}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available.
|
||||
@@ -270,13 +256,6 @@ class ImmichCreateProtectedLinkButton(
|
||||
"""Get the album data from coordinator."""
|
||||
return self.coordinator.data
|
||||
|
||||
@property
|
||||
def translation_placeholders(self) -> dict[str, str]:
|
||||
"""Return translation placeholders."""
|
||||
if self._album_data:
|
||||
return {"album_name": self._album_data.name}
|
||||
return {"album_name": self._album_name}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available.
|
||||
@@ -364,13 +343,6 @@ class ImmichDeleteProtectedLinkButton(
|
||||
"""Get the album data from coordinator."""
|
||||
return self.coordinator.data
|
||||
|
||||
@property
|
||||
def translation_placeholders(self) -> dict[str, str]:
|
||||
"""Return translation placeholders."""
|
||||
if self._album_data:
|
||||
return {"album_name": self._album_data.name}
|
||||
return {"album_name": self._album_name}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available.
|
||||
|
||||
@@ -78,13 +78,6 @@ class ImmichAlbumThumbnailCamera(
|
||||
"""Get the album data from coordinator."""
|
||||
return self.coordinator.data
|
||||
|
||||
@property
|
||||
def translation_placeholders(self) -> dict[str, str]:
|
||||
"""Return translation placeholders."""
|
||||
if self._album_data:
|
||||
return {"album_name": self._album_data.name}
|
||||
return {"album_name": self._album_name}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
|
||||
@@ -27,7 +27,9 @@ from .const import (
|
||||
CONF_IMMICH_URL,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_TELEGRAM_BOT_TOKEN,
|
||||
CONF_TELEGRAM_CACHE_TTL,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_TELEGRAM_CACHE_TTL,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_ALBUM,
|
||||
)
|
||||
@@ -252,6 +254,9 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
|
||||
CONF_TELEGRAM_BOT_TOKEN: user_input.get(
|
||||
CONF_TELEGRAM_BOT_TOKEN, ""
|
||||
),
|
||||
CONF_TELEGRAM_CACHE_TTL: user_input.get(
|
||||
CONF_TELEGRAM_CACHE_TTL, DEFAULT_TELEGRAM_CACHE_TTL
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -261,6 +266,9 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
|
||||
current_bot_token = self._config_entry.options.get(
|
||||
CONF_TELEGRAM_BOT_TOKEN, ""
|
||||
)
|
||||
current_cache_ttl = self._config_entry.options.get(
|
||||
CONF_TELEGRAM_CACHE_TTL, DEFAULT_TELEGRAM_CACHE_TTL
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
@@ -272,6 +280,9 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
|
||||
vol.Optional(
|
||||
CONF_TELEGRAM_BOT_TOKEN, default=current_bot_token
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_TELEGRAM_CACHE_TTL, default=current_cache_ttl
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=1, max=168)),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,12 +14,14 @@ CONF_ALBUM_ID: Final = "album_id"
|
||||
CONF_ALBUM_NAME: Final = "album_name"
|
||||
CONF_SCAN_INTERVAL: Final = "scan_interval"
|
||||
CONF_TELEGRAM_BOT_TOKEN: Final = "telegram_bot_token"
|
||||
CONF_TELEGRAM_CACHE_TTL: Final = "telegram_cache_ttl"
|
||||
|
||||
# Subentry type
|
||||
SUBENTRY_TYPE_ALBUM: Final = "album"
|
||||
|
||||
# Defaults
|
||||
DEFAULT_SCAN_INTERVAL: Final = 60 # seconds
|
||||
DEFAULT_TELEGRAM_CACHE_TTL: Final = 48 # hours
|
||||
NEW_ASSETS_RESET_DELAY: Final = 300 # 5 minutes
|
||||
DEFAULT_SHARE_PASSWORD: Final = "immich123"
|
||||
|
||||
@@ -27,6 +29,9 @@ DEFAULT_SHARE_PASSWORD: Final = "immich123"
|
||||
EVENT_ALBUM_CHANGED: Final = f"{DOMAIN}_album_changed"
|
||||
EVENT_ASSETS_ADDED: Final = f"{DOMAIN}_assets_added"
|
||||
EVENT_ASSETS_REMOVED: Final = f"{DOMAIN}_assets_removed"
|
||||
EVENT_ALBUM_RENAMED: Final = f"{DOMAIN}_album_renamed"
|
||||
EVENT_ALBUM_DELETED: Final = f"{DOMAIN}_album_deleted"
|
||||
EVENT_ALBUM_SHARING_CHANGED: Final = f"{DOMAIN}_album_sharing_changed"
|
||||
|
||||
# Attributes
|
||||
ATTR_HUB_NAME: Final = "hub_name"
|
||||
@@ -44,21 +49,32 @@ ATTR_REMOVED_COUNT: Final = "removed_count"
|
||||
ATTR_ADDED_ASSETS: Final = "added_assets"
|
||||
ATTR_REMOVED_ASSETS: Final = "removed_assets"
|
||||
ATTR_CHANGE_TYPE: Final = "change_type"
|
||||
ATTR_LAST_UPDATED: Final = "last_updated"
|
||||
ATTR_LAST_UPDATED: Final = "last_updated_at"
|
||||
ATTR_CREATED_AT: Final = "created_at"
|
||||
ATTR_THUMBNAIL_URL: Final = "thumbnail_url"
|
||||
ATTR_SHARED: Final = "shared"
|
||||
ATTR_OWNER: Final = "owner"
|
||||
ATTR_PEOPLE: Final = "people"
|
||||
ATTR_ASSET_TYPE: Final = "asset_type"
|
||||
ATTR_ASSET_FILENAME: Final = "asset_filename"
|
||||
ATTR_ASSET_CREATED: Final = "asset_created"
|
||||
ATTR_ASSET_OWNER: Final = "asset_owner"
|
||||
ATTR_ASSET_OWNER_ID: Final = "asset_owner_id"
|
||||
ATTR_ASSET_URL: Final = "asset_url"
|
||||
ATTR_ASSET_DOWNLOAD_URL: Final = "asset_download_url"
|
||||
ATTR_ASSET_PLAYBACK_URL: Final = "asset_playback_url"
|
||||
ATTR_ASSET_DESCRIPTION: Final = "asset_description"
|
||||
ATTR_OLD_NAME: Final = "old_name"
|
||||
ATTR_NEW_NAME: Final = "new_name"
|
||||
ATTR_OLD_SHARED: Final = "old_shared"
|
||||
ATTR_NEW_SHARED: Final = "new_shared"
|
||||
ATTR_ASSET_TYPE: Final = "type"
|
||||
ATTR_ASSET_FILENAME: Final = "filename"
|
||||
ATTR_ASSET_CREATED: Final = "created_at"
|
||||
ATTR_ASSET_OWNER: Final = "owner"
|
||||
ATTR_ASSET_OWNER_ID: Final = "owner_id"
|
||||
ATTR_ASSET_URL: Final = "url"
|
||||
ATTR_ASSET_DOWNLOAD_URL: Final = "download_url"
|
||||
ATTR_ASSET_PLAYBACK_URL: Final = "playback_url"
|
||||
ATTR_ASSET_DESCRIPTION: Final = "description"
|
||||
ATTR_ASSET_IS_FAVORITE: Final = "is_favorite"
|
||||
ATTR_ASSET_RATING: Final = "rating"
|
||||
ATTR_ASSET_LATITUDE: Final = "latitude"
|
||||
ATTR_ASSET_LONGITUDE: Final = "longitude"
|
||||
ATTR_ASSET_CITY: Final = "city"
|
||||
ATTR_ASSET_STATE: Final = "state"
|
||||
ATTR_ASSET_COUNTRY: Final = "country"
|
||||
|
||||
# Asset types
|
||||
ASSET_TYPE_IMAGE: Final = "IMAGE"
|
||||
@@ -69,5 +85,5 @@ PLATFORMS: Final = ["sensor", "binary_sensor", "camera", "text", "button"]
|
||||
|
||||
# Services
|
||||
SERVICE_REFRESH: Final = "refresh"
|
||||
SERVICE_GET_RECENT_ASSETS: Final = "get_recent_assets"
|
||||
SERVICE_SEND_TELEGRAM_MEDIA_GROUP: Final = "send_telegram_media_group"
|
||||
SERVICE_GET_ASSETS: Final = "get_assets"
|
||||
SERVICE_SEND_TELEGRAM_NOTIFICATION: Final = "send_telegram_notification"
|
||||
|
||||
@@ -8,7 +8,7 @@ from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .storage import ImmichAlbumStorage
|
||||
from .storage import ImmichAlbumStorage, TelegramFileCache
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -28,20 +28,36 @@ from .const import (
|
||||
ATTR_ASSET_DESCRIPTION,
|
||||
ATTR_ASSET_DOWNLOAD_URL,
|
||||
ATTR_ASSET_FILENAME,
|
||||
ATTR_ASSET_IS_FAVORITE,
|
||||
ATTR_ASSET_LATITUDE,
|
||||
ATTR_ASSET_LONGITUDE,
|
||||
ATTR_ASSET_CITY,
|
||||
ATTR_ASSET_STATE,
|
||||
ATTR_ASSET_COUNTRY,
|
||||
ATTR_ASSET_OWNER,
|
||||
ATTR_ASSET_OWNER_ID,
|
||||
ATTR_ASSET_PLAYBACK_URL,
|
||||
ATTR_ASSET_RATING,
|
||||
ATTR_ASSET_TYPE,
|
||||
ATTR_ASSET_URL,
|
||||
ATTR_ASSET_PLAYBACK_URL,
|
||||
ATTR_CHANGE_TYPE,
|
||||
ATTR_HUB_NAME,
|
||||
ATTR_PEOPLE,
|
||||
ATTR_REMOVED_ASSETS,
|
||||
ATTR_REMOVED_COUNT,
|
||||
ATTR_OLD_NAME,
|
||||
ATTR_NEW_NAME,
|
||||
ATTR_OLD_SHARED,
|
||||
ATTR_NEW_SHARED,
|
||||
ATTR_SHARED,
|
||||
ATTR_THUMBNAIL_URL,
|
||||
DOMAIN,
|
||||
EVENT_ALBUM_CHANGED,
|
||||
EVENT_ASSETS_ADDED,
|
||||
EVENT_ASSETS_REMOVED,
|
||||
EVENT_ALBUM_RENAMED,
|
||||
EVENT_ALBUM_DELETED,
|
||||
EVENT_ALBUM_SHARING_CHANGED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -107,6 +123,14 @@ class AssetInfo:
|
||||
owner_name: str = ""
|
||||
description: str = ""
|
||||
people: list[str] = field(default_factory=list)
|
||||
is_favorite: bool = False
|
||||
rating: int | None = None
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
city: str | None = None
|
||||
state: str | None = None
|
||||
country: str | None = None
|
||||
is_processed: bool = True # Whether asset is fully processed by Immich
|
||||
|
||||
@classmethod
|
||||
def from_api_response(
|
||||
@@ -122,23 +146,105 @@ class AssetInfo:
|
||||
if users_cache and owner_id:
|
||||
owner_name = users_cache.get(owner_id, "")
|
||||
|
||||
# Get description from exifInfo if available
|
||||
description = ""
|
||||
# Get description - prioritize user-added description over EXIF description
|
||||
description = data.get("description", "") or ""
|
||||
exif_info = data.get("exifInfo")
|
||||
if exif_info:
|
||||
if not description and exif_info:
|
||||
# Fall back to EXIF description if no user description
|
||||
description = exif_info.get("description", "") or ""
|
||||
|
||||
# Get favorites and rating
|
||||
is_favorite = data.get("isFavorite", False)
|
||||
rating = exif_info.get("rating") if exif_info else None
|
||||
|
||||
# Get geolocation
|
||||
latitude = exif_info.get("latitude") if exif_info else None
|
||||
longitude = exif_info.get("longitude") if exif_info else None
|
||||
|
||||
# Get reverse geocoded location
|
||||
city = exif_info.get("city") if exif_info else None
|
||||
state = exif_info.get("state") if exif_info else None
|
||||
country = exif_info.get("country") if exif_info else None
|
||||
|
||||
# Check if asset is fully processed by Immich
|
||||
asset_type = data.get("type", ASSET_TYPE_IMAGE)
|
||||
is_processed = cls._check_processing_status(data, asset_type)
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
type=data.get("type", ASSET_TYPE_IMAGE),
|
||||
type=asset_type,
|
||||
filename=data.get("originalFileName", ""),
|
||||
created_at=data.get("fileCreatedAt", ""),
|
||||
owner_id=owner_id,
|
||||
owner_name=owner_name,
|
||||
description=description,
|
||||
people=people,
|
||||
is_favorite=is_favorite,
|
||||
rating=rating,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
city=city,
|
||||
state=state,
|
||||
country=country,
|
||||
is_processed=is_processed,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _check_processing_status(data: dict[str, Any], _asset_type: str) -> bool:
|
||||
"""Check if asset has been fully processed by Immich.
|
||||
|
||||
For all assets: Check if thumbnails have been generated (thumbhash exists).
|
||||
Immich generates thumbnails for both photos and videos regardless of
|
||||
whether video transcoding is needed.
|
||||
|
||||
Args:
|
||||
data: Asset data from API response
|
||||
_asset_type: Asset type (IMAGE or VIDEO) - unused but kept for API stability
|
||||
|
||||
Returns:
|
||||
True if asset is fully processed and not trashed/offline/archived, False otherwise
|
||||
"""
|
||||
asset_id = data.get("id", "unknown")
|
||||
asset_type = data.get("type", "unknown")
|
||||
is_offline = data.get("isOffline", False)
|
||||
is_trashed = data.get("isTrashed", False)
|
||||
is_archived = data.get("isArchived", False)
|
||||
thumbhash = data.get("thumbhash")
|
||||
|
||||
_LOGGER.debug(
|
||||
"Asset %s (%s): isOffline=%s, isTrashed=%s, isArchived=%s, thumbhash=%s",
|
||||
asset_id,
|
||||
asset_type,
|
||||
is_offline,
|
||||
is_trashed,
|
||||
is_archived,
|
||||
bool(thumbhash),
|
||||
)
|
||||
|
||||
# Exclude offline assets
|
||||
if is_offline:
|
||||
_LOGGER.debug("Asset %s excluded: offline", asset_id)
|
||||
return False
|
||||
|
||||
# Exclude trashed assets
|
||||
if is_trashed:
|
||||
_LOGGER.debug("Asset %s excluded: trashed", asset_id)
|
||||
return False
|
||||
|
||||
# Exclude archived assets
|
||||
if is_archived:
|
||||
_LOGGER.debug("Asset %s excluded: archived", asset_id)
|
||||
return False
|
||||
|
||||
# Check if thumbnails have been generated
|
||||
# This works for both photos and videos - Immich always generates thumbnails
|
||||
# Note: The API doesn't expose video transcoding status (encodedVideoPath),
|
||||
# but thumbhash is sufficient since Immich generates thumbnails for all assets
|
||||
is_processed = bool(thumbhash)
|
||||
if not is_processed:
|
||||
_LOGGER.debug("Asset %s excluded: no thumbhash", asset_id)
|
||||
return is_processed
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlbumData:
|
||||
@@ -210,6 +316,10 @@ class AlbumChange:
|
||||
removed_count: int = 0
|
||||
added_assets: list[AssetInfo] = field(default_factory=list)
|
||||
removed_asset_ids: list[str] = field(default_factory=list)
|
||||
old_name: str | None = None
|
||||
new_name: str | None = None
|
||||
old_shared: bool | None = None
|
||||
new_shared: bool | None = None
|
||||
|
||||
|
||||
class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
@@ -225,6 +335,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
scan_interval: int,
|
||||
hub_name: str = "Immich",
|
||||
storage: ImmichAlbumStorage | None = None,
|
||||
telegram_cache: TelegramFileCache | None = None,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
@@ -244,13 +355,45 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
self._users_cache: dict[str, str] = {} # user_id -> name
|
||||
self._shared_links: list[SharedLinkInfo] = []
|
||||
self._storage = storage
|
||||
self._telegram_cache = telegram_cache
|
||||
self._persisted_asset_ids: set[str] | None = None
|
||||
self._external_domain: str | None = None # Fetched from server config
|
||||
self._pending_asset_ids: set[str] = set() # Assets detected but not yet processed
|
||||
|
||||
@property
|
||||
def immich_url(self) -> str:
|
||||
"""Return the Immich URL."""
|
||||
"""Return the Immich URL (for API calls)."""
|
||||
return self._url
|
||||
|
||||
@property
|
||||
def external_url(self) -> str:
|
||||
"""Return the external URL for links.
|
||||
|
||||
Uses externalDomain from Immich server config if set,
|
||||
otherwise falls back to the connection URL.
|
||||
"""
|
||||
if self._external_domain:
|
||||
return self._external_domain.rstrip("/")
|
||||
return self._url
|
||||
|
||||
def get_internal_download_url(self, url: str) -> str:
|
||||
"""Convert an external URL to internal URL for faster downloads.
|
||||
|
||||
If the URL starts with the external domain, replace it with the
|
||||
internal connection URL to download via local network.
|
||||
|
||||
Args:
|
||||
url: The URL to convert (may be external or internal)
|
||||
|
||||
Returns:
|
||||
URL using internal connection for downloads
|
||||
"""
|
||||
if self._external_domain:
|
||||
external = self._external_domain.rstrip("/")
|
||||
if url.startswith(external):
|
||||
return url.replace(external, self._url, 1)
|
||||
return url
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
"""Return the API key."""
|
||||
@@ -266,6 +409,11 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
"""Return the album name."""
|
||||
return self._album_name
|
||||
|
||||
@property
|
||||
def telegram_cache(self) -> TelegramFileCache | None:
|
||||
"""Return the Telegram file cache."""
|
||||
return self._telegram_cache
|
||||
|
||||
def update_scan_interval(self, scan_interval: int) -> None:
|
||||
"""Update the scan interval."""
|
||||
self.update_interval = timedelta(seconds=scan_interval)
|
||||
@@ -291,33 +439,138 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
self._album_name,
|
||||
)
|
||||
|
||||
async def async_get_recent_assets(self, count: int = 10) -> list[dict[str, Any]]:
|
||||
"""Get recent assets from the album."""
|
||||
async def async_get_assets(
|
||||
self,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
favorite_only: bool = False,
|
||||
filter_min_rating: int = 1,
|
||||
order_by: str = "date",
|
||||
order: str = "descending",
|
||||
asset_type: str = "all",
|
||||
min_date: str | None = None,
|
||||
max_date: str | None = None,
|
||||
memory_date: str | None = None,
|
||||
city: str | None = None,
|
||||
state: str | None = None,
|
||||
country: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get assets from the album with optional filtering and ordering.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of assets to return (1-100)
|
||||
offset: Number of assets to skip before returning results (for pagination)
|
||||
favorite_only: Filter to show only favorite assets
|
||||
filter_min_rating: Minimum rating for assets (1-5)
|
||||
order_by: Field to sort by - 'date', 'rating', or 'name'
|
||||
order: Sort direction - 'ascending', 'descending', or 'random'
|
||||
asset_type: Asset type filter - 'all', 'photo', or 'video'
|
||||
min_date: Filter assets created on or after this date (ISO 8601 format)
|
||||
max_date: Filter assets created on or before this date (ISO 8601 format)
|
||||
memory_date: Filter assets by matching month and day, excluding the same year (memories filter)
|
||||
city: Filter assets by city (case-insensitive substring match)
|
||||
state: Filter assets by state/region (case-insensitive substring match)
|
||||
country: Filter assets by country (case-insensitive substring match)
|
||||
|
||||
Returns:
|
||||
List of asset data dictionaries
|
||||
"""
|
||||
if self.data is None:
|
||||
return []
|
||||
|
||||
# Sort assets by created_at descending
|
||||
sorted_assets = sorted(
|
||||
self.data.assets.values(),
|
||||
key=lambda a: a.created_at,
|
||||
reverse=True,
|
||||
)[:count]
|
||||
# Start with all processed assets only
|
||||
assets = [a for a in self.data.assets.values() if a.is_processed]
|
||||
|
||||
# Apply favorite filter
|
||||
if favorite_only:
|
||||
assets = [a for a in assets if a.is_favorite]
|
||||
|
||||
# Apply rating filter
|
||||
if filter_min_rating > 1:
|
||||
assets = [a for a in assets if a.rating is not None and a.rating >= filter_min_rating]
|
||||
|
||||
# Apply asset type filtering
|
||||
if asset_type == "photo":
|
||||
assets = [a for a in assets if a.type == ASSET_TYPE_IMAGE]
|
||||
elif asset_type == "video":
|
||||
assets = [a for a in assets if a.type == ASSET_TYPE_VIDEO]
|
||||
|
||||
# Apply date filtering
|
||||
if min_date:
|
||||
assets = [a for a in assets if a.created_at >= min_date]
|
||||
if max_date:
|
||||
assets = [a for a in assets if a.created_at <= max_date]
|
||||
|
||||
# Apply memory date filtering (match month and day, exclude same year)
|
||||
if memory_date:
|
||||
try:
|
||||
# Parse the reference date (supports ISO 8601 format)
|
||||
ref_date = datetime.fromisoformat(memory_date.replace("Z", "+00:00"))
|
||||
ref_year = ref_date.year
|
||||
ref_month = ref_date.month
|
||||
ref_day = ref_date.day
|
||||
|
||||
def matches_memory(asset: AssetInfo) -> bool:
|
||||
"""Check if asset matches memory criteria (same month/day, different year)."""
|
||||
try:
|
||||
asset_date = datetime.fromisoformat(
|
||||
asset.created_at.replace("Z", "+00:00")
|
||||
)
|
||||
# Match month and day, but exclude same year (true memories behavior)
|
||||
return (
|
||||
asset_date.month == ref_month
|
||||
and asset_date.day == ref_day
|
||||
and asset_date.year != ref_year
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
assets = [a for a in assets if matches_memory(a)]
|
||||
except ValueError:
|
||||
_LOGGER.warning("Invalid memory_date format: %s", memory_date)
|
||||
|
||||
# Apply geolocation filtering (case-insensitive substring match)
|
||||
if city:
|
||||
city_lower = city.lower()
|
||||
assets = [a for a in assets if a.city and city_lower in a.city.lower()]
|
||||
if state:
|
||||
state_lower = state.lower()
|
||||
assets = [a for a in assets if a.state and state_lower in a.state.lower()]
|
||||
if country:
|
||||
country_lower = country.lower()
|
||||
assets = [a for a in assets if a.country and country_lower in a.country.lower()]
|
||||
|
||||
# Apply ordering
|
||||
if order_by == "random":
|
||||
import random
|
||||
random.shuffle(assets)
|
||||
elif order_by == "rating":
|
||||
# Sort by rating, putting None values last
|
||||
assets = sorted(
|
||||
assets,
|
||||
key=lambda a: (a.rating is None, a.rating if a.rating is not None else 0),
|
||||
reverse=(order == "descending")
|
||||
)
|
||||
elif order_by == "name":
|
||||
assets = sorted(
|
||||
assets,
|
||||
key=lambda a: a.filename.lower(),
|
||||
reverse=(order == "descending")
|
||||
)
|
||||
else: # date (default)
|
||||
assets = sorted(
|
||||
assets,
|
||||
key=lambda a: a.created_at,
|
||||
reverse=(order == "descending")
|
||||
)
|
||||
|
||||
# Apply offset and limit for pagination
|
||||
assets = assets[offset : offset + limit]
|
||||
|
||||
# Build result with all available asset data (matching event data)
|
||||
result = []
|
||||
for asset in sorted_assets:
|
||||
asset_data = {
|
||||
"id": asset.id,
|
||||
"type": asset.type,
|
||||
"filename": asset.filename,
|
||||
"created_at": asset.created_at,
|
||||
"description": asset.description,
|
||||
"people": asset.people,
|
||||
"thumbnail_url": f"{self._url}/api/assets/{asset.id}/thumbnail",
|
||||
}
|
||||
if asset.type == ASSET_TYPE_VIDEO:
|
||||
video_url = self._get_asset_video_url(asset.id)
|
||||
if video_url:
|
||||
asset_data["video_url"] = video_url
|
||||
for asset in assets:
|
||||
asset_data = self._build_asset_detail(asset, include_thumbnail=True)
|
||||
result.append(asset_data)
|
||||
return result
|
||||
|
||||
@@ -368,6 +621,36 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
|
||||
return self._users_cache
|
||||
|
||||
async def _async_fetch_server_config(self) -> None:
|
||||
"""Fetch server config from Immich to get external domain."""
|
||||
if self._session is None:
|
||||
self._session = async_get_clientsession(self.hass)
|
||||
|
||||
headers = {"x-api-key": self._api_key}
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/server/config",
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
external_domain = data.get("externalDomain", "") or ""
|
||||
self._external_domain = external_domain
|
||||
if external_domain:
|
||||
_LOGGER.debug(
|
||||
"Using external domain from Immich: %s", external_domain
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"No external domain configured in Immich, using connection URL"
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch server config: HTTP %s", response.status
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch server config: %s", err)
|
||||
|
||||
async def _async_fetch_shared_links(self) -> list[SharedLinkInfo]:
|
||||
"""Fetch shared links for this album from Immich."""
|
||||
if self._session is None:
|
||||
@@ -411,29 +694,29 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
"""Get the public URL if album has an accessible shared link."""
|
||||
accessible_links = self._get_accessible_links()
|
||||
if accessible_links:
|
||||
return f"{self._url}/share/{accessible_links[0].key}"
|
||||
return f"{self.external_url}/share/{accessible_links[0].key}"
|
||||
return None
|
||||
|
||||
def get_any_url(self) -> str | None:
|
||||
"""Get any non-expired URL (prefers accessible, falls back to protected)."""
|
||||
accessible_links = self._get_accessible_links()
|
||||
if accessible_links:
|
||||
return f"{self._url}/share/{accessible_links[0].key}"
|
||||
return f"{self.external_url}/share/{accessible_links[0].key}"
|
||||
non_expired = [link for link in self._shared_links if not link.is_expired]
|
||||
if non_expired:
|
||||
return f"{self._url}/share/{non_expired[0].key}"
|
||||
return f"{self.external_url}/share/{non_expired[0].key}"
|
||||
return None
|
||||
|
||||
def get_protected_url(self) -> str | None:
|
||||
"""Get a protected URL if any password-protected link exists."""
|
||||
protected_links = self._get_protected_links()
|
||||
if protected_links:
|
||||
return f"{self._url}/share/{protected_links[0].key}"
|
||||
return f"{self.external_url}/share/{protected_links[0].key}"
|
||||
return None
|
||||
|
||||
def get_protected_urls(self) -> list[str]:
|
||||
"""Get all password-protected URLs."""
|
||||
return [f"{self._url}/share/{link.key}" for link in self._get_protected_links()]
|
||||
return [f"{self.external_url}/share/{link.key}" for link in self._get_protected_links()]
|
||||
|
||||
def get_protected_password(self) -> str | None:
|
||||
"""Get the password for the first protected link."""
|
||||
@@ -444,13 +727,13 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
|
||||
def get_public_urls(self) -> list[str]:
|
||||
"""Get all accessible public URLs."""
|
||||
return [f"{self._url}/share/{link.key}" for link in self._get_accessible_links()]
|
||||
return [f"{self.external_url}/share/{link.key}" for link in self._get_accessible_links()]
|
||||
|
||||
def get_shared_links_info(self) -> list[dict[str, Any]]:
|
||||
"""Get detailed info about all shared links."""
|
||||
return [
|
||||
{
|
||||
"url": f"{self._url}/share/{link.key}",
|
||||
"url": f"{self.external_url}/share/{link.key}",
|
||||
"has_password": link.has_password,
|
||||
"is_expired": link.is_expired,
|
||||
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
||||
@@ -463,37 +746,108 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
"""Get the public viewer URL for an asset (web page)."""
|
||||
accessible_links = self._get_accessible_links()
|
||||
if accessible_links:
|
||||
return f"{self._url}/share/{accessible_links[0].key}/photos/{asset_id}"
|
||||
return f"{self.external_url}/share/{accessible_links[0].key}/photos/{asset_id}"
|
||||
non_expired = [link for link in self._shared_links if not link.is_expired]
|
||||
if non_expired:
|
||||
return f"{self._url}/share/{non_expired[0].key}/photos/{asset_id}"
|
||||
return f"{self.external_url}/share/{non_expired[0].key}/photos/{asset_id}"
|
||||
return None
|
||||
|
||||
def _get_asset_download_url(self, asset_id: str) -> str | None:
|
||||
"""Get the direct download URL for an asset (media file)."""
|
||||
accessible_links = self._get_accessible_links()
|
||||
if accessible_links:
|
||||
return f"{self._url}/api/assets/{asset_id}/original?key={accessible_links[0].key}"
|
||||
return f"{self.external_url}/api/assets/{asset_id}/original?key={accessible_links[0].key}"
|
||||
non_expired = [link for link in self._shared_links if not link.is_expired]
|
||||
if non_expired:
|
||||
return f"{self._url}/api/assets/{asset_id}/original?key={non_expired[0].key}"
|
||||
return f"{self.external_url}/api/assets/{asset_id}/original?key={non_expired[0].key}"
|
||||
return None
|
||||
|
||||
def _get_asset_video_url(self, asset_id: str) -> str | None:
|
||||
"""Get the transcoded video playback URL for a video asset."""
|
||||
accessible_links = self._get_accessible_links()
|
||||
if accessible_links:
|
||||
return f"{self._url}/api/assets/{asset_id}/video/playback?key={accessible_links[0].key}"
|
||||
return f"{self.external_url}/api/assets/{asset_id}/video/playback?key={accessible_links[0].key}"
|
||||
non_expired = [link for link in self._shared_links if not link.is_expired]
|
||||
if non_expired:
|
||||
return f"{self._url}/api/assets/{asset_id}/video/playback?key={non_expired[0].key}"
|
||||
return f"{self.external_url}/api/assets/{asset_id}/video/playback?key={non_expired[0].key}"
|
||||
return None
|
||||
|
||||
def _get_asset_photo_url(self, asset_id: str) -> str | None:
|
||||
"""Get the preview-sized thumbnail URL for a photo asset."""
|
||||
accessible_links = self._get_accessible_links()
|
||||
if accessible_links:
|
||||
return f"{self.external_url}/api/assets/{asset_id}/thumbnail?size=preview&key={accessible_links[0].key}"
|
||||
non_expired = [link for link in self._shared_links if not link.is_expired]
|
||||
if non_expired:
|
||||
return f"{self.external_url}/api/assets/{asset_id}/thumbnail?size=preview&key={non_expired[0].key}"
|
||||
return None
|
||||
|
||||
def _build_asset_detail(
|
||||
self, asset: AssetInfo, include_thumbnail: bool = True
|
||||
) -> dict[str, Any]:
|
||||
"""Build asset detail dictionary with all available data.
|
||||
|
||||
Args:
|
||||
asset: AssetInfo object
|
||||
include_thumbnail: If True, include thumbnail_url
|
||||
|
||||
Returns:
|
||||
Dictionary with asset details (using ATTR_* constants for consistency)
|
||||
"""
|
||||
# Base asset data
|
||||
asset_detail = {
|
||||
"id": asset.id,
|
||||
ATTR_ASSET_TYPE: asset.type,
|
||||
ATTR_ASSET_FILENAME: asset.filename,
|
||||
ATTR_ASSET_CREATED: asset.created_at,
|
||||
ATTR_ASSET_OWNER: asset.owner_name,
|
||||
ATTR_ASSET_OWNER_ID: asset.owner_id,
|
||||
ATTR_ASSET_DESCRIPTION: asset.description,
|
||||
ATTR_PEOPLE: asset.people,
|
||||
ATTR_ASSET_IS_FAVORITE: asset.is_favorite,
|
||||
ATTR_ASSET_RATING: asset.rating,
|
||||
ATTR_ASSET_LATITUDE: asset.latitude,
|
||||
ATTR_ASSET_LONGITUDE: asset.longitude,
|
||||
ATTR_ASSET_CITY: asset.city,
|
||||
ATTR_ASSET_STATE: asset.state,
|
||||
ATTR_ASSET_COUNTRY: asset.country,
|
||||
}
|
||||
|
||||
# Add thumbnail URL if requested
|
||||
if include_thumbnail:
|
||||
asset_detail[ATTR_THUMBNAIL_URL] = f"{self.external_url}/api/assets/{asset.id}/thumbnail"
|
||||
|
||||
# Add public viewer URL (web page)
|
||||
asset_url = self._get_asset_public_url(asset.id)
|
||||
if asset_url:
|
||||
asset_detail[ATTR_ASSET_URL] = asset_url
|
||||
|
||||
# Add download URL (direct media file)
|
||||
asset_download_url = self._get_asset_download_url(asset.id)
|
||||
if asset_download_url:
|
||||
asset_detail[ATTR_ASSET_DOWNLOAD_URL] = asset_download_url
|
||||
|
||||
# Add type-specific URLs
|
||||
if asset.type == ASSET_TYPE_VIDEO:
|
||||
asset_video_url = self._get_asset_video_url(asset.id)
|
||||
if asset_video_url:
|
||||
asset_detail[ATTR_ASSET_PLAYBACK_URL] = asset_video_url
|
||||
elif asset.type == ASSET_TYPE_IMAGE:
|
||||
asset_photo_url = self._get_asset_photo_url(asset.id)
|
||||
if asset_photo_url:
|
||||
asset_detail["photo_url"] = asset_photo_url # TODO: Add ATTR_ASSET_PHOTO_URL constant
|
||||
|
||||
return asset_detail
|
||||
|
||||
async def _async_update_data(self) -> AlbumData | None:
|
||||
"""Fetch data from Immich API."""
|
||||
if self._session is None:
|
||||
self._session = async_get_clientsession(self.hass)
|
||||
|
||||
# Fetch server config to get external domain (once)
|
||||
if self._external_domain is None:
|
||||
await self._async_fetch_server_config()
|
||||
|
||||
# Fetch users to resolve owner names
|
||||
if not self._users_cache:
|
||||
await self._async_fetch_users()
|
||||
@@ -510,6 +864,15 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
) as response:
|
||||
if response.status == 404:
|
||||
_LOGGER.warning("Album %s not found", self._album_id)
|
||||
# Fire album_deleted event if we had previous state (album was deleted)
|
||||
if self._previous_state:
|
||||
event_data = {
|
||||
ATTR_HUB_NAME: self._hub_name,
|
||||
ATTR_ALBUM_ID: self._album_id,
|
||||
ATTR_ALBUM_NAME: self._previous_state.name,
|
||||
}
|
||||
self.hass.bus.async_fire(EVENT_ALBUM_DELETED, event_data)
|
||||
_LOGGER.info("Album '%s' was deleted", self._previous_state.name)
|
||||
return None
|
||||
if response.status != 200:
|
||||
raise UpdateFailed(
|
||||
@@ -538,11 +901,16 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
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
|
||||
]
|
||||
added_assets = []
|
||||
for aid in added_ids:
|
||||
if aid not in album.assets:
|
||||
continue
|
||||
asset = album.assets[aid]
|
||||
if asset.is_processed:
|
||||
added_assets.append(asset)
|
||||
else:
|
||||
# Track unprocessed assets for later
|
||||
self._pending_asset_ids.add(aid)
|
||||
|
||||
change = AlbumChange(
|
||||
album_id=album.id,
|
||||
@@ -599,53 +967,96 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
added_ids = new_state.asset_ids - old_state.asset_ids
|
||||
removed_ids = old_state.asset_ids - new_state.asset_ids
|
||||
|
||||
if not added_ids and not removed_ids:
|
||||
_LOGGER.debug(
|
||||
"Change detection: added_ids=%d, removed_ids=%d, pending=%d",
|
||||
len(added_ids),
|
||||
len(removed_ids),
|
||||
len(self._pending_asset_ids),
|
||||
)
|
||||
|
||||
# Track new unprocessed assets and collect processed ones
|
||||
added_assets = []
|
||||
for aid in added_ids:
|
||||
if aid not in new_state.assets:
|
||||
_LOGGER.debug("Asset %s: not in assets dict", aid)
|
||||
continue
|
||||
asset = new_state.assets[aid]
|
||||
_LOGGER.debug(
|
||||
"New asset %s (%s): is_processed=%s, filename=%s",
|
||||
aid,
|
||||
asset.type,
|
||||
asset.is_processed,
|
||||
asset.filename,
|
||||
)
|
||||
if asset.is_processed:
|
||||
added_assets.append(asset)
|
||||
else:
|
||||
# Track unprocessed assets for later
|
||||
self._pending_asset_ids.add(aid)
|
||||
_LOGGER.debug("Asset %s added to pending (not yet processed)", aid)
|
||||
|
||||
# Check if any pending assets are now processed
|
||||
newly_processed = []
|
||||
for aid in list(self._pending_asset_ids):
|
||||
if aid not in new_state.assets:
|
||||
# Asset was removed, no longer pending
|
||||
self._pending_asset_ids.discard(aid)
|
||||
continue
|
||||
asset = new_state.assets[aid]
|
||||
if asset.is_processed:
|
||||
_LOGGER.debug(
|
||||
"Pending asset %s (%s) is now processed: filename=%s",
|
||||
aid,
|
||||
asset.type,
|
||||
asset.filename,
|
||||
)
|
||||
newly_processed.append(asset)
|
||||
self._pending_asset_ids.discard(aid)
|
||||
|
||||
# Include newly processed pending assets
|
||||
added_assets.extend(newly_processed)
|
||||
|
||||
# Detect metadata changes
|
||||
name_changed = old_state.name != new_state.name
|
||||
sharing_changed = old_state.shared != new_state.shared
|
||||
|
||||
# Return None only if nothing changed at all
|
||||
if not added_assets and not removed_ids and not name_changed and not sharing_changed:
|
||||
return None
|
||||
|
||||
# Determine primary change type (use added_assets not added_ids)
|
||||
change_type = "changed"
|
||||
if added_ids and not removed_ids:
|
||||
if name_changed and not added_assets and not removed_ids and not sharing_changed:
|
||||
change_type = "album_renamed"
|
||||
elif sharing_changed and not added_assets and not removed_ids and not name_changed:
|
||||
change_type = "album_sharing_changed"
|
||||
elif added_assets and not removed_ids and not name_changed and not sharing_changed:
|
||||
change_type = "assets_added"
|
||||
elif removed_ids and not added_ids:
|
||||
elif removed_ids and not added_assets and not name_changed and not sharing_changed:
|
||||
change_type = "assets_removed"
|
||||
|
||||
added_assets = [
|
||||
new_state.assets[aid] for aid in added_ids if aid in new_state.assets
|
||||
]
|
||||
|
||||
return AlbumChange(
|
||||
album_id=new_state.id,
|
||||
album_name=new_state.name,
|
||||
change_type=change_type,
|
||||
added_count=len(added_ids),
|
||||
added_count=len(added_assets), # Count only processed assets
|
||||
removed_count=len(removed_ids),
|
||||
added_assets=added_assets,
|
||||
removed_asset_ids=list(removed_ids),
|
||||
old_name=old_state.name if name_changed else None,
|
||||
new_name=new_state.name if name_changed else None,
|
||||
old_shared=old_state.shared if sharing_changed else None,
|
||||
new_shared=new_state.shared if sharing_changed else None,
|
||||
)
|
||||
|
||||
def _fire_events(self, change: AlbumChange, album: AlbumData) -> None:
|
||||
"""Fire Home Assistant events for album changes."""
|
||||
added_assets_detail = []
|
||||
for asset in change.added_assets:
|
||||
asset_detail = {
|
||||
"id": asset.id,
|
||||
ATTR_ASSET_TYPE: asset.type,
|
||||
ATTR_ASSET_FILENAME: asset.filename,
|
||||
ATTR_ASSET_CREATED: asset.created_at,
|
||||
ATTR_ASSET_OWNER: asset.owner_name,
|
||||
ATTR_ASSET_OWNER_ID: asset.owner_id,
|
||||
ATTR_ASSET_DESCRIPTION: asset.description,
|
||||
ATTR_PEOPLE: asset.people,
|
||||
}
|
||||
asset_url = self._get_asset_public_url(asset.id)
|
||||
if asset_url:
|
||||
asset_detail[ATTR_ASSET_URL] = asset_url
|
||||
asset_download_url = self._get_asset_download_url(asset.id)
|
||||
if asset_download_url:
|
||||
asset_detail[ATTR_ASSET_DOWNLOAD_URL] = asset_download_url
|
||||
if asset.type == ASSET_TYPE_VIDEO:
|
||||
asset_video_url = self._get_asset_video_url(asset.id)
|
||||
if asset_video_url:
|
||||
asset_detail[ATTR_ASSET_PLAYBACK_URL] = asset_video_url
|
||||
# Only include fully processed assets
|
||||
if not asset.is_processed:
|
||||
continue
|
||||
asset_detail = self._build_asset_detail(asset, include_thumbnail=False)
|
||||
added_assets_detail.append(asset_detail)
|
||||
|
||||
event_data = {
|
||||
@@ -658,8 +1069,18 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
ATTR_ADDED_ASSETS: added_assets_detail,
|
||||
ATTR_REMOVED_ASSETS: change.removed_asset_ids,
|
||||
ATTR_PEOPLE: list(album.people),
|
||||
ATTR_SHARED: album.shared,
|
||||
}
|
||||
|
||||
# Add metadata change attributes if applicable
|
||||
if change.old_name is not None:
|
||||
event_data[ATTR_OLD_NAME] = change.old_name
|
||||
event_data[ATTR_NEW_NAME] = change.new_name
|
||||
|
||||
if change.old_shared is not None:
|
||||
event_data[ATTR_OLD_SHARED] = change.old_shared
|
||||
event_data[ATTR_NEW_SHARED] = change.new_shared
|
||||
|
||||
album_url = self.get_any_url()
|
||||
if album_url:
|
||||
event_data[ATTR_ALBUM_URL] = album_url
|
||||
@@ -679,6 +1100,24 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
if change.removed_count > 0:
|
||||
self.hass.bus.async_fire(EVENT_ASSETS_REMOVED, event_data)
|
||||
|
||||
# Fire specific events for metadata changes
|
||||
if change.old_name is not None:
|
||||
self.hass.bus.async_fire(EVENT_ALBUM_RENAMED, event_data)
|
||||
_LOGGER.info(
|
||||
"Album renamed: '%s' -> '%s'",
|
||||
change.old_name,
|
||||
change.new_name,
|
||||
)
|
||||
|
||||
if change.old_shared is not None:
|
||||
self.hass.bus.async_fire(EVENT_ALBUM_SHARING_CHANGED, event_data)
|
||||
_LOGGER.info(
|
||||
"Album '%s' sharing changed: %s -> %s",
|
||||
change.album_name,
|
||||
change.old_shared,
|
||||
change.new_shared,
|
||||
)
|
||||
|
||||
def get_protected_link_id(self) -> str | None:
|
||||
"""Get the ID of the first protected link."""
|
||||
protected_links = self._get_protected_links()
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
|
||||
"requirements": [],
|
||||
"version": "1.4.0"
|
||||
"version": "2.7.1"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,17 @@ refresh:
|
||||
integration: immich_album_watcher
|
||||
domain: sensor
|
||||
|
||||
get_recent_assets:
|
||||
name: Get Recent Assets
|
||||
description: Get the most recent assets from the targeted album.
|
||||
get_assets:
|
||||
name: Get Assets
|
||||
description: Get assets from the targeted album with optional filtering and ordering.
|
||||
target:
|
||||
entity:
|
||||
integration: immich_album_watcher
|
||||
domain: sensor
|
||||
fields:
|
||||
count:
|
||||
name: Count
|
||||
description: Number of recent assets to return (1-100).
|
||||
limit:
|
||||
name: Limit
|
||||
description: Maximum number of assets to return (1-100).
|
||||
required: false
|
||||
default: 10
|
||||
selector:
|
||||
@@ -24,10 +24,114 @@ get_recent_assets:
|
||||
min: 1
|
||||
max: 100
|
||||
mode: slider
|
||||
offset:
|
||||
name: Offset
|
||||
description: Number of assets to skip before returning results (for pagination). Use with limit to fetch assets in pages.
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
mode: box
|
||||
favorite_only:
|
||||
name: Favorite Only
|
||||
description: Filter to show only favorite assets.
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
filter_min_rating:
|
||||
name: Minimum Rating
|
||||
description: Minimum rating for assets (1-5). Set to filter by rating.
|
||||
required: false
|
||||
default: 1
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 5
|
||||
mode: slider
|
||||
order_by:
|
||||
name: Order By
|
||||
description: Field to sort assets by.
|
||||
required: false
|
||||
default: "date"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- label: "Date"
|
||||
value: "date"
|
||||
- label: "Rating"
|
||||
value: "rating"
|
||||
- label: "Name"
|
||||
value: "name"
|
||||
- label: "Random"
|
||||
value: "random"
|
||||
order:
|
||||
name: Order
|
||||
description: Sort direction.
|
||||
required: false
|
||||
default: "descending"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- label: "Ascending"
|
||||
value: "ascending"
|
||||
- label: "Descending"
|
||||
value: "descending"
|
||||
asset_type:
|
||||
name: Asset Type
|
||||
description: Filter assets by type (all, photo, or video).
|
||||
required: false
|
||||
default: "all"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- label: "All (no type filtering)"
|
||||
value: "all"
|
||||
- label: "Photos only"
|
||||
value: "photo"
|
||||
- label: "Videos only"
|
||||
value: "video"
|
||||
min_date:
|
||||
name: Minimum Date
|
||||
description: Filter assets created on or after this date (ISO 8601 format, e.g., 2024-01-01 or 2024-01-01T10:30:00).
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
max_date:
|
||||
name: Maximum Date
|
||||
description: Filter assets created on or before this date (ISO 8601 format, e.g., 2024-12-31 or 2024-12-31T23:59:59).
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
memory_date:
|
||||
name: Memory Date
|
||||
description: Filter assets by matching month and day, excluding the same year (memories filter like Google Photos). Provide a date in ISO 8601 format (e.g., 2024-02-14) to get assets from February 14th of previous years.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
city:
|
||||
name: City
|
||||
description: Filter assets by city name (case-insensitive substring match). Based on reverse geocoded location from asset GPS data.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
state:
|
||||
name: State
|
||||
description: Filter assets by state/region name (case-insensitive substring match). Based on reverse geocoded location from asset GPS data.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
country:
|
||||
name: Country
|
||||
description: Filter assets by country name (case-insensitive substring match). Based on reverse geocoded location from asset GPS data.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
send_telegram_media_group:
|
||||
name: Send Telegram Media Group
|
||||
description: Send specified media URLs to a Telegram chat as a media group.
|
||||
send_telegram_notification:
|
||||
name: Send Telegram Notification
|
||||
description: Send a notification to Telegram (text, photo, video, or media group).
|
||||
target:
|
||||
entity:
|
||||
integration: immich_album_watcher
|
||||
@@ -47,13 +151,13 @@ send_telegram_media_group:
|
||||
text:
|
||||
urls:
|
||||
name: URLs
|
||||
description: List of media URLs to send (max 10). Each item should have 'url' and 'type' (photo/video).
|
||||
required: true
|
||||
description: List of media URLs to send. Each item should have 'url' and 'type' (photo/video). If empty, sends a text message. Large lists are automatically split into multiple media groups.
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
caption:
|
||||
name: Caption
|
||||
description: Optional caption for the media group (applied to first item).
|
||||
description: Caption text. For media, applied to first item. For empty URLs, this is the message text.
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
@@ -65,3 +169,90 @@ send_telegram_media_group:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
disable_web_page_preview:
|
||||
name: Disable Web Page Preview
|
||||
description: Disable link previews in text messages.
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
parse_mode:
|
||||
name: Parse Mode
|
||||
description: How to parse the caption/text. Options are "HTML", "Markdown", "MarkdownV2", or empty string for plain text.
|
||||
required: false
|
||||
default: "HTML"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- label: "HTML"
|
||||
value: "HTML"
|
||||
- label: "Markdown"
|
||||
value: "Markdown"
|
||||
- label: "MarkdownV2"
|
||||
value: "MarkdownV2"
|
||||
- label: "Plain Text"
|
||||
value: ""
|
||||
max_group_size:
|
||||
name: Max Group Size
|
||||
description: Maximum number of media items per media group (2-10). Large lists will be split into multiple groups.
|
||||
required: false
|
||||
default: 10
|
||||
selector:
|
||||
number:
|
||||
min: 2
|
||||
max: 10
|
||||
mode: slider
|
||||
chunk_delay:
|
||||
name: Chunk Delay
|
||||
description: Delay in milliseconds between sending multiple media groups (0-60000). Useful for rate limiting.
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 60000
|
||||
step: 100
|
||||
unit_of_measurement: "ms"
|
||||
mode: slider
|
||||
wait_for_response:
|
||||
name: Wait For Response
|
||||
description: Wait for Telegram to finish processing before returning. Set to false for fire-and-forget (automation continues immediately).
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
max_asset_data_size:
|
||||
name: Max Asset Data Size
|
||||
description: Maximum asset size in bytes. Assets exceeding this limit will be skipped. Leave empty for no limit.
|
||||
required: false
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 52428800
|
||||
step: 1048576
|
||||
unit_of_measurement: "bytes"
|
||||
mode: box
|
||||
send_large_photos_as_documents:
|
||||
name: Send Large Photos As Documents
|
||||
description: How to handle photos exceeding Telegram's limits (10MB or 10000px dimension sum). If true, send as documents. If false, skip oversized photos.
|
||||
required: false
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
chat_action:
|
||||
name: Chat Action
|
||||
description: Chat action to display while processing (typing, upload_photo, upload_video, upload_document). Set to empty to disable.
|
||||
required: false
|
||||
default: "typing"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- label: "Typing"
|
||||
value: "typing"
|
||||
- label: "Uploading Photo"
|
||||
value: "upload_photo"
|
||||
- label: "Uploading Video"
|
||||
value: "upload_video"
|
||||
- label: "Uploading Document"
|
||||
value: "upload_document"
|
||||
- label: "Disabled"
|
||||
value: ""
|
||||
|
||||
@@ -14,6 +14,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY_PREFIX = "immich_album_watcher"
|
||||
|
||||
# Default TTL for Telegram file_id cache (48 hours in seconds)
|
||||
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
|
||||
|
||||
|
||||
class ImmichAlbumStorage:
|
||||
"""Handles persistence of album state across restarts."""
|
||||
@@ -63,3 +66,116 @@ class ImmichAlbumStorage:
|
||||
"""Remove all storage data."""
|
||||
await self._store.async_remove()
|
||||
self._data = None
|
||||
|
||||
|
||||
class TelegramFileCache:
|
||||
"""Cache for Telegram file_ids to avoid re-uploading media.
|
||||
|
||||
When a file is uploaded to Telegram, it returns a file_id that can be reused
|
||||
to send the same file without re-uploading. This cache stores these file_ids
|
||||
keyed by the source URL.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
album_id: str,
|
||||
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
|
||||
) -> None:
|
||||
"""Initialize the Telegram file cache.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
album_id: Album ID for scoping the cache
|
||||
ttl_seconds: Time-to-live for cache entries in seconds (default: 48 hours)
|
||||
"""
|
||||
self._store: Store[dict[str, Any]] = Store(
|
||||
hass, STORAGE_VERSION, f"{STORAGE_KEY_PREFIX}.telegram_cache.{album_id}"
|
||||
)
|
||||
self._data: dict[str, Any] | None = None
|
||||
self._ttl_seconds = ttl_seconds
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load cache data from storage."""
|
||||
self._data = await self._store.async_load() or {"files": {}}
|
||||
# Clean up expired entries on load
|
||||
await self._cleanup_expired()
|
||||
_LOGGER.debug(
|
||||
"Loaded Telegram file cache with %d entries",
|
||||
len(self._data.get("files", {})),
|
||||
)
|
||||
|
||||
async def _cleanup_expired(self) -> None:
|
||||
"""Remove expired cache entries."""
|
||||
if not self._data or "files" not in self._data:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expired_keys = []
|
||||
|
||||
for url, entry in self._data["files"].items():
|
||||
cached_at_str = entry.get("cached_at")
|
||||
if cached_at_str:
|
||||
cached_at = datetime.fromisoformat(cached_at_str)
|
||||
age_seconds = (now - cached_at).total_seconds()
|
||||
if age_seconds > self._ttl_seconds:
|
||||
expired_keys.append(url)
|
||||
|
||||
if expired_keys:
|
||||
for key in expired_keys:
|
||||
del self._data["files"][key]
|
||||
await self._store.async_save(self._data)
|
||||
_LOGGER.debug("Cleaned up %d expired Telegram cache entries", len(expired_keys))
|
||||
|
||||
def get(self, url: str) -> dict[str, Any] | None:
|
||||
"""Get cached file_id for a URL.
|
||||
|
||||
Args:
|
||||
url: The source URL of the media
|
||||
|
||||
Returns:
|
||||
Dict with 'file_id' and 'type' if cached and not expired, None otherwise
|
||||
"""
|
||||
if not self._data or "files" not in self._data:
|
||||
return None
|
||||
|
||||
entry = self._data["files"].get(url)
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
# Check if expired
|
||||
cached_at_str = entry.get("cached_at")
|
||||
if cached_at_str:
|
||||
cached_at = datetime.fromisoformat(cached_at_str)
|
||||
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
|
||||
if age_seconds > self._ttl_seconds:
|
||||
return None
|
||||
|
||||
return {
|
||||
"file_id": entry.get("file_id"),
|
||||
"type": entry.get("type"),
|
||||
}
|
||||
|
||||
async def async_set(self, url: str, file_id: str, media_type: str) -> None:
|
||||
"""Store a file_id for a URL.
|
||||
|
||||
Args:
|
||||
url: The source URL of the media
|
||||
file_id: The Telegram file_id
|
||||
media_type: The type of media ('photo', 'video', 'document')
|
||||
"""
|
||||
if self._data is None:
|
||||
self._data = {"files": {}}
|
||||
|
||||
self._data["files"][url] = {
|
||||
"file_id": file_id,
|
||||
"type": media_type,
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await self._store.async_save(self._data)
|
||||
_LOGGER.debug("Cached Telegram file_id for URL (type: %s)", media_type)
|
||||
|
||||
async def async_remove(self) -> None:
|
||||
"""Remove all cache data."""
|
||||
await self._store.async_remove()
|
||||
self._data = None
|
||||
|
||||
@@ -79,13 +79,6 @@ class ImmichAlbumProtectedPasswordText(
|
||||
"""Get the album data from coordinator."""
|
||||
return self.coordinator.data
|
||||
|
||||
@property
|
||||
def translation_placeholders(self) -> dict[str, str]:
|
||||
"""Return translation placeholders."""
|
||||
if self._album_data:
|
||||
return {"album_name": self._album_data.name}
|
||||
return {"album_name": self._album_name}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available.
|
||||
|
||||
@@ -1,58 +1,61 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"album_id": {
|
||||
"name": "Album ID"
|
||||
},
|
||||
"album_asset_count": {
|
||||
"name": "{album_name}: Asset Count"
|
||||
"name": "Asset Count"
|
||||
},
|
||||
"album_photo_count": {
|
||||
"name": "{album_name}: Photo Count"
|
||||
"name": "Photo Count"
|
||||
},
|
||||
"album_video_count": {
|
||||
"name": "{album_name}: Video Count"
|
||||
"name": "Video Count"
|
||||
},
|
||||
"album_last_updated": {
|
||||
"name": "{album_name}: Last Updated"
|
||||
"name": "Last Updated"
|
||||
},
|
||||
"album_created": {
|
||||
"name": "{album_name}: Created"
|
||||
"name": "Created"
|
||||
},
|
||||
"album_public_url": {
|
||||
"name": "{album_name}: Public URL"
|
||||
"name": "Public URL"
|
||||
},
|
||||
"album_protected_url": {
|
||||
"name": "{album_name}: Protected URL"
|
||||
"name": "Protected URL"
|
||||
},
|
||||
"album_protected_password": {
|
||||
"name": "{album_name}: Protected Password"
|
||||
"name": "Protected Password"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"album_new_assets": {
|
||||
"name": "{album_name}: New Assets"
|
||||
"name": "New Assets"
|
||||
}
|
||||
},
|
||||
"camera": {
|
||||
"album_thumbnail": {
|
||||
"name": "{album_name}: Thumbnail"
|
||||
"name": "Thumbnail"
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"album_protected_password_edit": {
|
||||
"name": "{album_name}: Share Password"
|
||||
"name": "Share Password"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"create_share_link": {
|
||||
"name": "{album_name}: Create Share Link"
|
||||
"name": "Create Share Link"
|
||||
},
|
||||
"delete_share_link": {
|
||||
"name": "{album_name}: Delete Share Link"
|
||||
"name": "Delete Share Link"
|
||||
},
|
||||
"create_protected_link": {
|
||||
"name": "{album_name}: Create Protected Link"
|
||||
"name": "Create Protected Link"
|
||||
},
|
||||
"delete_protected_link": {
|
||||
"name": "{album_name}: Delete Protected Link"
|
||||
"name": "Delete Protected Link"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -113,12 +116,16 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Immich Album Watcher Options",
|
||||
"description": "Configure the polling interval for all albums.",
|
||||
"description": "Configure the polling interval and Telegram settings for all albums.",
|
||||
"data": {
|
||||
"scan_interval": "Scan interval (seconds)"
|
||||
"scan_interval": "Scan interval (seconds)",
|
||||
"telegram_bot_token": "Telegram Bot Token",
|
||||
"telegram_cache_ttl": "Telegram Cache TTL (hours)"
|
||||
},
|
||||
"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 notifications to Telegram",
|
||||
"telegram_cache_ttl": "How long to cache uploaded file IDs to avoid re-uploading (1-168 hours, default: 48)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,13 +135,119 @@
|
||||
"name": "Refresh",
|
||||
"description": "Force an immediate refresh of album data from Immich."
|
||||
},
|
||||
"get_recent_assets": {
|
||||
"name": "Get Recent Assets",
|
||||
"description": "Get the most recent assets from the targeted album.",
|
||||
"get_assets": {
|
||||
"name": "Get Assets",
|
||||
"description": "Get assets from the targeted album with optional filtering and ordering.",
|
||||
"fields": {
|
||||
"count": {
|
||||
"name": "Count",
|
||||
"description": "Number of recent assets to return (1-100)."
|
||||
"limit": {
|
||||
"name": "Limit",
|
||||
"description": "Maximum number of assets to return (1-100)."
|
||||
},
|
||||
"offset": {
|
||||
"name": "Offset",
|
||||
"description": "Number of assets to skip (for pagination)."
|
||||
},
|
||||
"favorite_only": {
|
||||
"name": "Favorite Only",
|
||||
"description": "Filter to show only favorite assets."
|
||||
},
|
||||
"filter_min_rating": {
|
||||
"name": "Minimum Rating",
|
||||
"description": "Minimum rating for assets (1-5)."
|
||||
},
|
||||
"order_by": {
|
||||
"name": "Order By",
|
||||
"description": "Field to sort assets by (date, rating, name, or random)."
|
||||
},
|
||||
"order": {
|
||||
"name": "Order",
|
||||
"description": "Sort direction (ascending or descending)."
|
||||
},
|
||||
"asset_type": {
|
||||
"name": "Asset Type",
|
||||
"description": "Filter assets by type (all, photo, or video)."
|
||||
},
|
||||
"min_date": {
|
||||
"name": "Minimum Date",
|
||||
"description": "Filter assets created on or after this date (ISO 8601 format)."
|
||||
},
|
||||
"max_date": {
|
||||
"name": "Maximum Date",
|
||||
"description": "Filter assets created on or before this date (ISO 8601 format)."
|
||||
},
|
||||
"memory_date": {
|
||||
"name": "Memory Date",
|
||||
"description": "Filter assets by matching month and day, excluding the same year (memories filter)."
|
||||
},
|
||||
"city": {
|
||||
"name": "City",
|
||||
"description": "Filter assets by city name (case-insensitive)."
|
||||
},
|
||||
"state": {
|
||||
"name": "State",
|
||||
"description": "Filter assets by state/region name (case-insensitive)."
|
||||
},
|
||||
"country": {
|
||||
"name": "Country",
|
||||
"description": "Filter assets by country name (case-insensitive)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"send_telegram_notification": {
|
||||
"name": "Send Telegram Notification",
|
||||
"description": "Send a notification to Telegram (text, photo, video, or media group).",
|
||||
"fields": {
|
||||
"bot_token": {
|
||||
"name": "Bot Token",
|
||||
"description": "Telegram bot token (optional if configured in integration options)."
|
||||
},
|
||||
"chat_id": {
|
||||
"name": "Chat ID",
|
||||
"description": "Telegram chat ID to send to."
|
||||
},
|
||||
"urls": {
|
||||
"name": "URLs",
|
||||
"description": "List of media URLs with type (photo/video). If empty, sends a text message. Large lists are automatically split into multiple media groups."
|
||||
},
|
||||
"caption": {
|
||||
"name": "Caption",
|
||||
"description": "Caption text. For media, applied to first item. For empty URLs, this is the message text."
|
||||
},
|
||||
"reply_to_message_id": {
|
||||
"name": "Reply To",
|
||||
"description": "Optional message ID to reply to."
|
||||
},
|
||||
"disable_web_page_preview": {
|
||||
"name": "Disable Web Page Preview",
|
||||
"description": "Disable link previews in text messages."
|
||||
},
|
||||
"parse_mode": {
|
||||
"name": "Parse Mode",
|
||||
"description": "How to parse the caption/text. Options are HTML, Markdown, MarkdownV2, or empty string for plain text."
|
||||
},
|
||||
"max_group_size": {
|
||||
"name": "Max Group Size",
|
||||
"description": "Maximum number of media items per media group (2-10). Large lists will be split into multiple groups."
|
||||
},
|
||||
"chunk_delay": {
|
||||
"name": "Chunk Delay",
|
||||
"description": "Delay in milliseconds between sending multiple media groups (0-60000). Useful for rate limiting."
|
||||
},
|
||||
"wait_for_response": {
|
||||
"name": "Wait For Response",
|
||||
"description": "Wait for Telegram to finish processing before returning. Set to false for fire-and-forget (automation continues immediately)."
|
||||
},
|
||||
"max_asset_data_size": {
|
||||
"name": "Max Asset Data Size",
|
||||
"description": "Maximum asset size in bytes. Assets exceeding this limit will be skipped. Leave empty for no limit."
|
||||
},
|
||||
"send_large_photos_as_documents": {
|
||||
"name": "Send Large Photos As Documents",
|
||||
"description": "How to handle photos exceeding Telegram's limits (10MB or 10000px dimension sum). If true, send as documents. If false, downsize to fit limits."
|
||||
},
|
||||
"chat_action": {
|
||||
"name": "Chat Action",
|
||||
"description": "Chat action to display while processing (typing, upload_photo, upload_video, upload_document). Set to empty to disable."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,61 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"album_id": {
|
||||
"name": "ID альбома"
|
||||
},
|
||||
"album_asset_count": {
|
||||
"name": "{album_name}: Число файлов"
|
||||
"name": "Число файлов"
|
||||
},
|
||||
"album_photo_count": {
|
||||
"name": "{album_name}: Число фото"
|
||||
"name": "Число фото"
|
||||
},
|
||||
"album_video_count": {
|
||||
"name": "{album_name}: Число видео"
|
||||
"name": "Число видео"
|
||||
},
|
||||
"album_last_updated": {
|
||||
"name": "{album_name}: Последнее обновление"
|
||||
"name": "Последнее обновление"
|
||||
},
|
||||
"album_created": {
|
||||
"name": "{album_name}: Дата создания"
|
||||
"name": "Дата создания"
|
||||
},
|
||||
"album_public_url": {
|
||||
"name": "{album_name}: Публичная ссылка"
|
||||
"name": "Публичная ссылка"
|
||||
},
|
||||
"album_protected_url": {
|
||||
"name": "{album_name}: Защищённая ссылка"
|
||||
"name": "Защищённая ссылка"
|
||||
},
|
||||
"album_protected_password": {
|
||||
"name": "{album_name}: Пароль ссылки"
|
||||
"name": "Пароль ссылки"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"album_new_assets": {
|
||||
"name": "{album_name}: Новые файлы"
|
||||
"name": "Новые файлы"
|
||||
}
|
||||
},
|
||||
"camera": {
|
||||
"album_thumbnail": {
|
||||
"name": "{album_name}: Превью"
|
||||
"name": "Превью"
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"album_protected_password_edit": {
|
||||
"name": "{album_name}: Пароль ссылки"
|
||||
"name": "Пароль ссылки"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"create_share_link": {
|
||||
"name": "{album_name}: Создать ссылку"
|
||||
"name": "Создать ссылку"
|
||||
},
|
||||
"delete_share_link": {
|
||||
"name": "{album_name}: Удалить ссылку"
|
||||
"name": "Удалить ссылку"
|
||||
},
|
||||
"create_protected_link": {
|
||||
"name": "{album_name}: Создать защищённую ссылку"
|
||||
"name": "Создать защищённую ссылку"
|
||||
},
|
||||
"delete_protected_link": {
|
||||
"name": "{album_name}: Удалить защищённую ссылку"
|
||||
"name": "Удалить защищённую ссылку"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -113,12 +116,16 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Настройки Immich Album Watcher",
|
||||
"description": "Настройте интервал опроса для всех альбомов.",
|
||||
"description": "Настройте интервал опроса и параметры Telegram для всех альбомов.",
|
||||
"data": {
|
||||
"scan_interval": "Интервал сканирования (секунды)"
|
||||
"scan_interval": "Интервал сканирования (секунды)",
|
||||
"telegram_bot_token": "Токен Telegram бота",
|
||||
"telegram_cache_ttl": "Время жизни кэша Telegram (часы)"
|
||||
},
|
||||
"data_description": {
|
||||
"scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)"
|
||||
"scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)",
|
||||
"telegram_bot_token": "Токен бота для отправки уведомлений в Telegram",
|
||||
"telegram_cache_ttl": "Сколько хранить ID загруженных файлов для повторной отправки без загрузки (1-168 часов, по умолчанию: 48)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,13 +135,119 @@
|
||||
"name": "Обновить",
|
||||
"description": "Принудительно обновить данные альбома из Immich."
|
||||
},
|
||||
"get_recent_assets": {
|
||||
"name": "Получить последние файлы",
|
||||
"description": "Получить последние файлы из выбранного альбома.",
|
||||
"get_assets": {
|
||||
"name": "Получить файлы",
|
||||
"description": "Получить файлы из выбранного альбома с возможностью фильтрации и сортировки.",
|
||||
"fields": {
|
||||
"count": {
|
||||
"name": "Количество",
|
||||
"description": "Количество возвращаемых файлов (1-100)."
|
||||
"limit": {
|
||||
"name": "Лимит",
|
||||
"description": "Максимальное количество возвращаемых файлов (1-100)."
|
||||
},
|
||||
"offset": {
|
||||
"name": "Смещение",
|
||||
"description": "Количество файлов для пропуска (для пагинации)."
|
||||
},
|
||||
"favorite_only": {
|
||||
"name": "Только избранные",
|
||||
"description": "Фильтр для отображения только избранных файлов."
|
||||
},
|
||||
"filter_min_rating": {
|
||||
"name": "Минимальный рейтинг",
|
||||
"description": "Минимальный рейтинг для файлов (1-5)."
|
||||
},
|
||||
"order_by": {
|
||||
"name": "Сортировать по",
|
||||
"description": "Поле для сортировки файлов (date - дата, rating - рейтинг, name - имя, random - случайный)."
|
||||
},
|
||||
"order": {
|
||||
"name": "Порядок",
|
||||
"description": "Направление сортировки (ascending - по возрастанию, descending - по убыванию)."
|
||||
},
|
||||
"asset_type": {
|
||||
"name": "Тип файла",
|
||||
"description": "Фильтровать файлы по типу (all - все, photo - только фото, video - только видео)."
|
||||
},
|
||||
"min_date": {
|
||||
"name": "Минимальная дата",
|
||||
"description": "Фильтровать файлы, созданные в эту дату или после (формат ISO 8601)."
|
||||
},
|
||||
"max_date": {
|
||||
"name": "Максимальная дата",
|
||||
"description": "Фильтровать файлы, созданные в эту дату или до (формат ISO 8601)."
|
||||
},
|
||||
"memory_date": {
|
||||
"name": "Дата воспоминания",
|
||||
"description": "Фильтр по совпадению месяца и дня, исключая тот же год (воспоминания)."
|
||||
},
|
||||
"city": {
|
||||
"name": "Город",
|
||||
"description": "Фильтр по названию города (без учёта регистра)."
|
||||
},
|
||||
"state": {
|
||||
"name": "Регион",
|
||||
"description": "Фильтр по названию региона/области (без учёта регистра)."
|
||||
},
|
||||
"country": {
|
||||
"name": "Страна",
|
||||
"description": "Фильтр по названию страны (без учёта регистра)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"send_telegram_notification": {
|
||||
"name": "Отправить уведомление в Telegram",
|
||||
"description": "Отправить уведомление в Telegram (текст, фото, видео или медиа-группу).",
|
||||
"fields": {
|
||||
"bot_token": {
|
||||
"name": "Токен бота",
|
||||
"description": "Токен Telegram бота (необязательно, если настроен в опциях интеграции)."
|
||||
},
|
||||
"chat_id": {
|
||||
"name": "ID чата",
|
||||
"description": "ID чата Telegram для отправки."
|
||||
},
|
||||
"urls": {
|
||||
"name": "URL-адреса",
|
||||
"description": "Список URL медиа-файлов с типом (photo/video). Если пусто, отправляет текстовое сообщение. Большие списки автоматически разделяются на несколько медиа-групп."
|
||||
},
|
||||
"caption": {
|
||||
"name": "Подпись",
|
||||
"description": "Текст подписи. Для медиа применяется к первому элементу. Для пустых URLs это текст сообщения."
|
||||
},
|
||||
"reply_to_message_id": {
|
||||
"name": "Ответ на",
|
||||
"description": "ID сообщения для ответа (необязательно)."
|
||||
},
|
||||
"disable_web_page_preview": {
|
||||
"name": "Отключить предпросмотр ссылок",
|
||||
"description": "Отключить предпросмотр ссылок в текстовых сообщениях."
|
||||
},
|
||||
"parse_mode": {
|
||||
"name": "Режим парсинга",
|
||||
"description": "Как парсить подпись/текст. Варианты: HTML, Markdown, MarkdownV2, или пустая строка для обычного текста."
|
||||
},
|
||||
"max_group_size": {
|
||||
"name": "Макс. размер группы",
|
||||
"description": "Максимальное количество медиа-файлов в одной группе (2-10). Большие списки будут разделены на несколько групп."
|
||||
},
|
||||
"chunk_delay": {
|
||||
"name": "Задержка между группами",
|
||||
"description": "Задержка в миллисекундах между отправкой нескольких медиа-групп (0-60000). Полезно для ограничения скорости."
|
||||
},
|
||||
"wait_for_response": {
|
||||
"name": "Ждать ответа",
|
||||
"description": "Ждать завершения отправки в Telegram перед возвратом. Установите false для фоновой отправки (автоматизация продолжается немедленно)."
|
||||
},
|
||||
"max_asset_data_size": {
|
||||
"name": "Макс. размер ресурса",
|
||||
"description": "Максимальный размер ресурса в байтах. Ресурсы, превышающие этот лимит, будут пропущены. Оставьте пустым для отсутствия ограничения."
|
||||
},
|
||||
"send_large_photos_as_documents": {
|
||||
"name": "Большие фото как документы",
|
||||
"description": "Как обрабатывать фото, превышающие лимиты Telegram (10МБ или сумма размеров 10000пкс). Если true, отправлять как документы. Если false, уменьшать для соответствия лимитам."
|
||||
},
|
||||
"chat_action": {
|
||||
"name": "Действие в чате",
|
||||
"description": "Действие для отображения во время обработки (typing, upload_photo, upload_video, upload_document). Оставьте пустым для отключения."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user