Compare commits
34 Commits
v1.4.0
...
71b79cd919
| Author | SHA1 | Date | |
|---|---|---|---|
| 71b79cd919 | |||
| 678e8a6e62 | |||
| dd7032b411 | |||
| 65ca81a3f3 | |||
| 3ba33a36cf | |||
| 6ca3cae5df | |||
| 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
|
## 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/`).
|
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:
|
Do NOT bump version for:
|
||||||
|
|
||||||
@@ -14,3 +15,18 @@ Use semantic versioning:
|
|||||||
- **MAJOR** (x.0.0): Breaking changes
|
- **MAJOR** (x.0.0): Breaking changes
|
||||||
- **MINOR** (0.x.0): New features, backward compatible
|
- **MINOR** (0.x.0): New features, backward compatible
|
||||||
- **PATCH** (0.0.x): Bug fixes, integration documentation updates
|
- **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.
|
||||||
493
README.md
493
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.
|
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://git.dolgolyov-family.by/alexei.dolgolyov/haos-blueprints/src/branch/main/Common/Immich%20Album%20Watcher) to easily create automations for album change notifications.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Album Monitoring** - Watch selected Immich albums for asset additions and removals
|
- **Album Monitoring** - Watch selected Immich albums for asset additions and removals
|
||||||
- **Rich Sensor Data** - Multiple sensors per album:
|
- **Rich Sensor Data** - Multiple sensors per album:
|
||||||
- Album ID (with share URL attribute)
|
- Album ID (with album name and share URL attributes)
|
||||||
- Asset count (with detected people list)
|
- Asset Count (total assets with detected people list)
|
||||||
- Photo count
|
- Photo Count (number of photos)
|
||||||
- Video count
|
- Video Count (number of videos)
|
||||||
- Last updated timestamp
|
- Last Updated (last modification timestamp)
|
||||||
- Creation date
|
- 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
|
- **Camera Entity** - Album thumbnail displayed as a camera entity for dashboards
|
||||||
- **Binary Sensor** - "New Assets" indicator that turns on when assets are added
|
- **Binary Sensor** - "New Assets" indicator that turns on when assets are added
|
||||||
- **Face Recognition** - Detects and lists people recognized in album photos
|
- **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
|
- Detected people in the asset
|
||||||
- **Services** - Custom service calls:
|
- **Services** - Custom service calls:
|
||||||
- `immich_album_watcher.refresh` - Force immediate data refresh
|
- `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, document, or media group to Telegram
|
||||||
- **Share Link Management** - Button entities to create and delete share links:
|
- **Share Link Management** - Button entities to create and delete share links:
|
||||||
- Create/delete public (unprotected) share links
|
- Create/delete public (unprotected) share links
|
||||||
- Create/delete password-protected share links
|
- Create/delete password-protected share links
|
||||||
- Edit protected link passwords via Text entity
|
- Edit protected link passwords via Text entity
|
||||||
- **Configurable Polling** - Adjustable scan interval (10-3600 seconds)
|
- **Configurable Polling** - Adjustable scan interval (10-3600 seconds)
|
||||||
|
- **Localization** - Available in multiple languages:
|
||||||
|
- English
|
||||||
|
- Russian (Русский)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -59,8 +68,6 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
|
|||||||
3. Restart Home Assistant
|
3. Restart Home Assistant
|
||||||
4. Add the integration via **Settings** → **Devices & Services** → **Add Integration**
|
4. Add the integration via **Settings** → **Devices & Services** → **Add Integration**
|
||||||
|
|
||||||
> **Tip:** For the best experience, use this integration with the [Immich Album Watcher Blueprint](https://github.com/DolgolyovAlexei/haos-blueprints/blob/main/Common/Immich%20Album%20Watcher.yaml) to easily create automations for album change notifications.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Option | Description | Default |
|
| 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 |
|
| API Key | Your Immich API key | Required |
|
||||||
| Albums | Albums to monitor | Required |
|
| Albums | Albums to monitor | Required |
|
||||||
| Scan Interval | How often to check for changes (seconds) | 60 |
|
| 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)
|
## Entities Created (per album)
|
||||||
|
|
||||||
| Entity Type | Name | Description |
|
| 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 | Asset Count | Total number of assets (includes `people` list in attributes) |
|
||||||
| Sensor | Photo Count | Number of photos in the album |
|
| Sensor | Photo Count | Number of photos in the album |
|
||||||
| Sensor | Video Count | Number of videos in the album |
|
| Sensor | Video Count | Number of videos in the album |
|
||||||
@@ -101,20 +133,369 @@ Force an immediate refresh of all album data:
|
|||||||
service: immich_album_watcher.refresh
|
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
|
```yaml
|
||||||
service: immich_album_watcher.get_recent_assets
|
service: immich_album_watcher.get_assets
|
||||||
|
target:
|
||||||
|
entity_id: sensor.album_name_asset_limit
|
||||||
data:
|
data:
|
||||||
album_id: "your-album-id-here"
|
limit: 10 # Maximum number of assets (1-100)
|
||||||
count: 10
|
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 `assets` is empty or not provided
|
||||||
|
- **Single document** - When `assets` contains one document (default type)
|
||||||
|
- **Single photo** - When `assets` contains one photo (`type: photo`)
|
||||||
|
- **Single video** - When `assets` contains one video (`type: video`)
|
||||||
|
- **Media group** - When `assets` contains multiple photos/videos (documents are sent separately)
|
||||||
|
|
||||||
|
The service downloads media from Immich and uploads it to Telegram, bypassing any CORS restrictions. Large lists of photos and videos are automatically split into multiple media groups based on the `max_group_size` parameter (default: 10 items per group). Documents cannot be grouped and are sent individually.
|
||||||
|
|
||||||
|
**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 shared across all albums in the hub.
|
||||||
|
|
||||||
|
**Dual Cache System:** The integration maintains two separate caches for optimal performance:
|
||||||
|
|
||||||
|
- **Asset ID Cache** - For Immich assets with extractable asset IDs (UUIDs). The same asset accessed via different URL types (thumbnail, original, video playback, share links) shares the same cache entry.
|
||||||
|
- **URL Cache** - For non-Immich URLs or URLs without extractable asset IDs. Also used when a custom `cache_key` is provided.
|
||||||
|
|
||||||
|
**Smart Cache Keys:** The service automatically extracts asset IDs from Immich URLs. Supported URL patterns:
|
||||||
|
|
||||||
|
- `/api/assets/{asset_id}/original`
|
||||||
|
- `/api/assets/{asset_id}/thumbnail`
|
||||||
|
- `/api/assets/{asset_id}/video/playback`
|
||||||
|
- `/share/{key}/photos/{asset_id}`
|
||||||
|
|
||||||
|
You can provide a custom `cache_key` per asset to override this behavior (stored in URL cache).
|
||||||
|
|
||||||
|
**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 document (default):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: immich_album_watcher.send_telegram_notification
|
||||||
|
target:
|
||||||
|
entity_id: sensor.album_name_asset_limit
|
||||||
|
data:
|
||||||
|
chat_id: "-1001234567890"
|
||||||
|
assets:
|
||||||
|
- url: "https://immich.example.com/api/assets/xxx/original?key=yyy"
|
||||||
|
content_type: "image/heic" # Optional: explicit MIME type
|
||||||
|
caption: "Original file"
|
||||||
|
```
|
||||||
|
|
||||||
|
Single photo:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: immich_album_watcher.send_telegram_notification
|
||||||
|
target:
|
||||||
|
entity_id: sensor.album_name_asset_limit
|
||||||
|
data:
|
||||||
|
chat_id: "-1001234567890"
|
||||||
|
assets:
|
||||||
|
- 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"
|
||||||
|
assets:
|
||||||
|
- 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"
|
||||||
|
assets:
|
||||||
|
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||||
|
type: photo
|
||||||
|
caption: "Quick notification"
|
||||||
|
wait_for_response: false # Automation continues immediately
|
||||||
|
```
|
||||||
|
|
||||||
|
Using custom cache_key (useful when same media has different URLs):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: immich_album_watcher.send_telegram_notification
|
||||||
|
target:
|
||||||
|
entity_id: sensor.album_name_asset_limit
|
||||||
|
data:
|
||||||
|
chat_id: "-1001234567890"
|
||||||
|
assets:
|
||||||
|
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||||
|
type: photo
|
||||||
|
cache_key: "asset_xxx" # Custom key for caching instead of URL
|
||||||
|
caption: "Photo with custom cache key"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description | Required |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| `chat_id` | Telegram chat ID to send to | Yes |
|
||||||
|
| `assets` | List of media items with `url`, optional `type` (document/photo/video, default: document), optional `content_type` (MIME type, e.g., `image/jpeg`), and optional `cache_key` (custom key for caching). Empty for text message. Photos and videos can be grouped; documents are sent separately. | 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
|
## 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
|
```yaml
|
||||||
automation:
|
automation:
|
||||||
@@ -126,22 +507,48 @@ automation:
|
|||||||
- service: notify.mobile_app
|
- service: notify.mobile_app
|
||||||
data:
|
data:
|
||||||
title: "New Photos"
|
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
|
### Event Data
|
||||||
|
|
||||||
| Field | Description |
|
| Field | Description | Available In |
|
||||||
|-------|-------------|
|
|-------|-------------|--------------|
|
||||||
| `album_id` | Album ID |
|
| `hub_name` | Hub name configured in integration | All events |
|
||||||
| `album_name` | Album name |
|
| `album_id` | Album ID | All events |
|
||||||
| `album_url` | Public URL to view the album (only present if album has a shared link) |
|
| `album_name` | Current album name | All events |
|
||||||
| `change_type` | Type of change (assets_added, assets_removed, changed) |
|
| `album_url` | Public URL to view the album (only present if album has a shared link) | All events except `album_deleted` |
|
||||||
| `added_count` | Number of assets added |
|
| `change_type` | Type of change (assets_added, assets_removed, album_renamed, album_sharing_changed, changed) | All events except `album_deleted` |
|
||||||
| `removed_count` | Number of assets removed |
|
| `shared` | Current sharing status of the album | All events except `album_deleted` |
|
||||||
| `added_assets` | List of added assets with details (see below) |
|
| `added_limit` | Number of assets added | `album_changed`, `assets_added` |
|
||||||
| `removed_assets` | List of removed asset IDs |
|
| `removed_limit` | Number of assets removed | `album_changed`, `assets_removed` |
|
||||||
| `people` | List of all people detected in the album |
|
| `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
|
### Added Assets Fields
|
||||||
|
|
||||||
@@ -150,15 +557,27 @@ Each item in the `added_assets` list contains the following fields:
|
|||||||
| Field | Description |
|
| Field | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `id` | Unique asset ID |
|
| `id` | Unique asset ID |
|
||||||
| `asset_type` | Type of asset (`IMAGE` or `VIDEO`) |
|
| `type` | Type of asset (`IMAGE` or `VIDEO`) |
|
||||||
| `asset_filename` | Original filename of the asset |
|
| `filename` | Original filename of the asset |
|
||||||
| `asset_created` | Date/time when the asset was originally created |
|
| `created_at` | Date/time when the asset was originally created |
|
||||||
| `asset_owner` | Display name of the user who owns the asset |
|
| `owner` | Display name of the user who owns the asset |
|
||||||
| `asset_owner_id` | Unique ID of the user who owns the asset |
|
| `owner_id` | Unique ID of the user who owns the asset |
|
||||||
| `asset_description` | Description/caption of the asset (from EXIF data) |
|
| `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) |
|
| `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 |
|
| `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:
|
Example accessing asset owner in an automation:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -172,8 +591,8 @@ automation:
|
|||||||
data:
|
data:
|
||||||
title: "New Photos"
|
title: "New Photos"
|
||||||
message: >
|
message: >
|
||||||
{{ trigger.event.data.added_assets[0].asset_owner }} added
|
{{ trigger.event.data.added_assets[0].owner }} added
|
||||||
{{ trigger.event.data.added_count }} photos to {{ trigger.event.data.album_name }}
|
{{ trigger.event.data.added_limit }} photos to {{ trigger.event.data.album_name }}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, time as dt_time
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.event import async_track_time_change
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ALBUM_ID,
|
CONF_ALBUM_ID,
|
||||||
@@ -15,12 +18,14 @@ from .const import (
|
|||||||
CONF_HUB_NAME,
|
CONF_HUB_NAME,
|
||||||
CONF_IMMICH_URL,
|
CONF_IMMICH_URL,
|
||||||
CONF_SCAN_INTERVAL,
|
CONF_SCAN_INTERVAL,
|
||||||
|
CONF_TELEGRAM_CACHE_TTL,
|
||||||
DEFAULT_SCAN_INTERVAL,
|
DEFAULT_SCAN_INTERVAL,
|
||||||
|
DEFAULT_TELEGRAM_CACHE_TTL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
)
|
)
|
||||||
from .coordinator import ImmichAlbumWatcherCoordinator
|
from .coordinator import ImmichAlbumWatcherCoordinator
|
||||||
from .storage import ImmichAlbumStorage
|
from .storage import ImmichAlbumStorage, NotificationQueue, TelegramFileCache
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -33,6 +38,7 @@ class ImmichHubData:
|
|||||||
url: str
|
url: str
|
||||||
api_key: str
|
api_key: str
|
||||||
scan_interval: int
|
scan_interval: int
|
||||||
|
telegram_cache_ttl: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -55,6 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo
|
|||||||
url = entry.data[CONF_IMMICH_URL]
|
url = entry.data[CONF_IMMICH_URL]
|
||||||
api_key = entry.data[CONF_API_KEY]
|
api_key = entry.data[CONF_API_KEY]
|
||||||
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
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
|
# Store hub data
|
||||||
entry.runtime_data = ImmichHubData(
|
entry.runtime_data = ImmichHubData(
|
||||||
@@ -62,17 +69,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo
|
|||||||
url=url,
|
url=url,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
scan_interval=scan_interval,
|
scan_interval=scan_interval,
|
||||||
|
telegram_cache_ttl=telegram_cache_ttl,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create storage for persisting album state across restarts
|
# Create storage for persisting album state across restarts
|
||||||
storage = ImmichAlbumStorage(hass, entry.entry_id)
|
storage = ImmichAlbumStorage(hass, entry.entry_id)
|
||||||
await storage.async_load()
|
await storage.async_load()
|
||||||
|
|
||||||
|
# Create and load Telegram file caches once per hub (shared across all albums)
|
||||||
|
# TTL is in hours from config, convert to seconds
|
||||||
|
cache_ttl_seconds = telegram_cache_ttl * 60 * 60
|
||||||
|
# URL-based cache for non-Immich URLs or URLs without extractable asset IDs
|
||||||
|
telegram_cache = TelegramFileCache(hass, entry.entry_id, ttl_seconds=cache_ttl_seconds)
|
||||||
|
await telegram_cache.async_load()
|
||||||
|
# Asset ID-based cache for Immich URLs — uses thumbhash validation instead of TTL
|
||||||
|
telegram_asset_cache = TelegramFileCache(
|
||||||
|
hass, f"{entry.entry_id}_assets", use_thumbhash=True
|
||||||
|
)
|
||||||
|
await telegram_asset_cache.async_load()
|
||||||
|
|
||||||
|
# Create notification queue for quiet hours
|
||||||
|
notification_queue = NotificationQueue(hass, entry.entry_id)
|
||||||
|
await notification_queue.async_load()
|
||||||
|
|
||||||
# Store hub reference
|
# Store hub reference
|
||||||
hass.data[DOMAIN][entry.entry_id] = {
|
hass.data[DOMAIN][entry.entry_id] = {
|
||||||
"hub": entry.runtime_data,
|
"hub": entry.runtime_data,
|
||||||
"subentries": {},
|
"subentries": {},
|
||||||
"storage": storage,
|
"storage": storage,
|
||||||
|
"telegram_cache": telegram_cache,
|
||||||
|
"telegram_asset_cache": telegram_asset_cache,
|
||||||
|
"notification_queue": notification_queue,
|
||||||
|
"quiet_hours_unsubs": {}, # keyed by "HH:MM" end time
|
||||||
}
|
}
|
||||||
|
|
||||||
# Track loaded subentries to detect changes
|
# Track loaded subentries to detect changes
|
||||||
@@ -85,6 +113,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo
|
|||||||
# Forward platform setup once - platforms will iterate through subentries
|
# Forward platform setup once - platforms will iterate through subentries
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
# Check if there are queued notifications from before restart
|
||||||
|
if notification_queue.has_pending():
|
||||||
|
_register_queue_timers(hass, entry)
|
||||||
|
# Process any items whose quiet hours have already ended
|
||||||
|
hass.async_create_task(_process_ready_notifications(hass, entry))
|
||||||
|
|
||||||
# Register update listener for options and subentry changes
|
# Register update listener for options and subentry changes
|
||||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||||
|
|
||||||
@@ -104,6 +138,8 @@ async def _async_setup_subentry_coordinator(
|
|||||||
album_id = subentry.data[CONF_ALBUM_ID]
|
album_id = subentry.data[CONF_ALBUM_ID]
|
||||||
album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||||
storage: ImmichAlbumStorage = hass.data[DOMAIN][entry.entry_id]["storage"]
|
storage: ImmichAlbumStorage = hass.data[DOMAIN][entry.entry_id]["storage"]
|
||||||
|
telegram_cache: TelegramFileCache = hass.data[DOMAIN][entry.entry_id]["telegram_cache"]
|
||||||
|
telegram_asset_cache: TelegramFileCache = hass.data[DOMAIN][entry.entry_id]["telegram_asset_cache"]
|
||||||
|
|
||||||
_LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id)
|
_LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id)
|
||||||
|
|
||||||
@@ -117,6 +153,8 @@ async def _async_setup_subentry_coordinator(
|
|||||||
scan_interval=hub_data.scan_interval,
|
scan_interval=hub_data.scan_interval,
|
||||||
hub_name=hub_data.name,
|
hub_name=hub_data.name,
|
||||||
storage=storage,
|
storage=storage,
|
||||||
|
telegram_cache=telegram_cache,
|
||||||
|
telegram_asset_cache=telegram_asset_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load persisted state before first refresh to detect changes during downtime
|
# Load persisted state before first refresh to detect changes during downtime
|
||||||
@@ -136,6 +174,195 @@ async def _async_setup_subentry_coordinator(
|
|||||||
_LOGGER.info("Coordinator for album '%s' set up successfully", album_name)
|
_LOGGER.info("Coordinator for album '%s' set up successfully", album_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_quiet_hours(start_str: str, end_str: str) -> bool:
|
||||||
|
"""Check if current time is within quiet hours."""
|
||||||
|
if not start_str or not end_str:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = dt_util.now().time()
|
||||||
|
start_time = dt_time.fromisoformat(start_str)
|
||||||
|
end_time = dt_time.fromisoformat(end_str)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if start_time <= end_time:
|
||||||
|
return start_time <= now < end_time
|
||||||
|
else:
|
||||||
|
# Crosses midnight (e.g., 22:00 - 08:00)
|
||||||
|
return now >= start_time or now < end_time
|
||||||
|
|
||||||
|
|
||||||
|
def _register_queue_timers(hass: HomeAssistant, entry: ImmichConfigEntry) -> None:
|
||||||
|
"""Register timers for each unique quiet_hours_end in the queue."""
|
||||||
|
entry_data = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
queue: NotificationQueue = entry_data["notification_queue"]
|
||||||
|
unsubs: dict[str, list] = entry_data["quiet_hours_unsubs"]
|
||||||
|
|
||||||
|
# Collect unique end times from queued items
|
||||||
|
end_times: set[str] = set()
|
||||||
|
for item in queue.get_all():
|
||||||
|
end_str = item.get("params", {}).get("quiet_hours_end", "")
|
||||||
|
if end_str:
|
||||||
|
end_times.add(end_str)
|
||||||
|
|
||||||
|
for end_str in end_times:
|
||||||
|
if end_str in unsubs:
|
||||||
|
continue # Timer already registered for this end time
|
||||||
|
|
||||||
|
try:
|
||||||
|
end_time = dt_time.fromisoformat(end_str)
|
||||||
|
except ValueError:
|
||||||
|
_LOGGER.warning("Invalid quiet hours end time in queue: %s", end_str)
|
||||||
|
continue
|
||||||
|
|
||||||
|
async def _on_quiet_hours_end(_now: datetime, _end_str: str = end_str) -> None:
|
||||||
|
"""Handle quiet hours end — process matching queued notifications."""
|
||||||
|
_LOGGER.info("Quiet hours ended (%s), processing queued notifications", _end_str)
|
||||||
|
await _process_notifications_for_end_time(hass, entry, _end_str)
|
||||||
|
|
||||||
|
unsub = async_track_time_change(
|
||||||
|
hass, _on_quiet_hours_end, hour=end_time.hour, minute=end_time.minute, second=0
|
||||||
|
)
|
||||||
|
unsubs[end_str] = unsub
|
||||||
|
entry.async_on_unload(unsub)
|
||||||
|
|
||||||
|
_LOGGER.debug("Registered quiet hours timer for %s", end_str)
|
||||||
|
|
||||||
|
|
||||||
|
def _unregister_queue_timer(hass: HomeAssistant, entry: ImmichConfigEntry, end_str: str) -> None:
|
||||||
|
"""Unregister a quiet hours timer if no more items need it."""
|
||||||
|
entry_data = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
queue: NotificationQueue = entry_data["notification_queue"]
|
||||||
|
unsubs: dict[str, list] = entry_data["quiet_hours_unsubs"]
|
||||||
|
|
||||||
|
# Check if any remaining items still use this end time
|
||||||
|
for item in queue.get_all():
|
||||||
|
if item.get("params", {}).get("quiet_hours_end", "") == end_str:
|
||||||
|
return # Still needed
|
||||||
|
|
||||||
|
unsub = unsubs.pop(end_str, None)
|
||||||
|
if unsub:
|
||||||
|
unsub()
|
||||||
|
_LOGGER.debug("Unregistered quiet hours timer for %s (no more items)", end_str)
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_ready_notifications(
|
||||||
|
hass: HomeAssistant, entry: ImmichConfigEntry
|
||||||
|
) -> None:
|
||||||
|
"""Process queued notifications whose quiet hours have already ended."""
|
||||||
|
entry_data = hass.data[DOMAIN].get(entry.entry_id)
|
||||||
|
if not entry_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
queue: NotificationQueue = entry_data["notification_queue"]
|
||||||
|
items = queue.get_all()
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find items whose quiet hours have ended
|
||||||
|
ready_indices = []
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
params = item.get("params", {})
|
||||||
|
start_str = params.get("quiet_hours_start", "")
|
||||||
|
end_str = params.get("quiet_hours_end", "")
|
||||||
|
if not _is_quiet_hours(start_str, end_str):
|
||||||
|
ready_indices.append(i)
|
||||||
|
|
||||||
|
if not ready_indices:
|
||||||
|
return
|
||||||
|
|
||||||
|
_LOGGER.info("Found %d queued notifications ready to send (quiet hours ended)", len(ready_indices))
|
||||||
|
await _send_queued_items(hass, entry, ready_indices)
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_notifications_for_end_time(
|
||||||
|
hass: HomeAssistant, entry: ImmichConfigEntry, end_str: str
|
||||||
|
) -> None:
|
||||||
|
"""Process queued notifications matching a specific quiet_hours_end time."""
|
||||||
|
entry_data = hass.data[DOMAIN].get(entry.entry_id)
|
||||||
|
if not entry_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
queue: NotificationQueue = entry_data["notification_queue"]
|
||||||
|
items = queue.get_all()
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find items matching this end time that are no longer in quiet hours
|
||||||
|
matching_indices = []
|
||||||
|
for i, item in enumerate(items):
|
||||||
|
params = item.get("params", {})
|
||||||
|
if params.get("quiet_hours_end", "") == end_str:
|
||||||
|
start_str = params.get("quiet_hours_start", "")
|
||||||
|
if not _is_quiet_hours(start_str, end_str):
|
||||||
|
matching_indices.append(i)
|
||||||
|
|
||||||
|
if not matching_indices:
|
||||||
|
return
|
||||||
|
|
||||||
|
_LOGGER.info("Processing %d queued notifications for quiet hours end %s", len(matching_indices), end_str)
|
||||||
|
await _send_queued_items(hass, entry, matching_indices)
|
||||||
|
|
||||||
|
# Clean up timer if no more items need it
|
||||||
|
_unregister_queue_timer(hass, entry, end_str)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_queued_items(
|
||||||
|
hass: HomeAssistant, entry: ImmichConfigEntry, indices: list[int]
|
||||||
|
) -> None:
|
||||||
|
"""Send specific queued notifications by index and remove them from the queue."""
|
||||||
|
import asyncio
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
|
entry_data = hass.data[DOMAIN].get(entry.entry_id)
|
||||||
|
if not entry_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
queue: NotificationQueue = entry_data["notification_queue"]
|
||||||
|
|
||||||
|
# Find a fallback sensor entity
|
||||||
|
ent_reg = er.async_get(hass)
|
||||||
|
fallback_entity_id = None
|
||||||
|
for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
|
||||||
|
if ent.domain == "sensor":
|
||||||
|
fallback_entity_id = ent.entity_id
|
||||||
|
break
|
||||||
|
|
||||||
|
if not fallback_entity_id:
|
||||||
|
_LOGGER.warning("No sensor entity found to process notification queue")
|
||||||
|
return
|
||||||
|
|
||||||
|
items = queue.get_all()
|
||||||
|
sent_count = 0
|
||||||
|
for i in indices:
|
||||||
|
if i >= len(items):
|
||||||
|
continue
|
||||||
|
params = dict(items[i].get("params", {}))
|
||||||
|
try:
|
||||||
|
target_entity_id = params.pop("entity_id", None) or fallback_entity_id
|
||||||
|
# Remove quiet hours params so the replay doesn't re-queue
|
||||||
|
params.pop("quiet_hours_start", None)
|
||||||
|
params.pop("quiet_hours_end", None)
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
"send_telegram_notification",
|
||||||
|
params,
|
||||||
|
target={"entity_id": target_entity_id},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
sent_count += 1
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.exception("Failed to send queued notification %d", i + 1)
|
||||||
|
|
||||||
|
# Small delay between notifications to avoid rate limiting
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Remove sent items from queue (in reverse order to preserve indices)
|
||||||
|
await queue.async_remove_indices(sorted(indices, reverse=True))
|
||||||
|
_LOGGER.info("Sent %d/%d queued notifications", sent_count, len(indices))
|
||||||
|
|
||||||
|
|
||||||
async def _async_update_listener(
|
async def _async_update_listener(
|
||||||
hass: HomeAssistant, entry: ImmichConfigEntry
|
hass: HomeAssistant, entry: ImmichConfigEntry
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -154,7 +381,7 @@ async def _async_update_listener(
|
|||||||
await hass.config_entries.async_reload(entry.entry_id)
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Handle options-only update (scan interval change)
|
# Handle options-only update
|
||||||
new_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
new_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||||
|
|
||||||
# Update hub data
|
# Update hub data
|
||||||
@@ -165,11 +392,16 @@ async def _async_update_listener(
|
|||||||
for subentry_data in subentries_data.values():
|
for subentry_data in subentries_data.values():
|
||||||
subentry_data.coordinator.update_scan_interval(new_interval)
|
subentry_data.coordinator.update_scan_interval(new_interval)
|
||||||
|
|
||||||
_LOGGER.info("Updated scan interval to %d seconds", new_interval)
|
_LOGGER.info("Updated hub options (scan_interval=%d)", new_interval)
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
|
# Cancel all quiet hours timers
|
||||||
|
entry_data = hass.data[DOMAIN].get(entry.entry_id, {})
|
||||||
|
for unsub in entry_data.get("quiet_hours_unsubs", {}).values():
|
||||||
|
unsub()
|
||||||
|
|
||||||
# Unload all platforms
|
# Unload all platforms
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
|||||||
@@ -82,13 +82,6 @@ class ImmichAlbumNewAssetsSensor(
|
|||||||
"""Get the album data from coordinator."""
|
"""Get the album data from coordinator."""
|
||||||
return self.coordinator.data
|
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
|
@property
|
||||||
def is_on(self) -> bool | None:
|
def is_on(self) -> bool | None:
|
||||||
"""Return true if new assets were recently added."""
|
"""Return true if new assets were recently added."""
|
||||||
|
|||||||
@@ -83,13 +83,6 @@ class ImmichCreateShareLinkButton(
|
|||||||
"""Get the album data from coordinator."""
|
"""Get the album data from coordinator."""
|
||||||
return self.coordinator.data
|
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
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return if entity is available.
|
"""Return if entity is available.
|
||||||
@@ -173,13 +166,6 @@ class ImmichDeleteShareLinkButton(
|
|||||||
"""Get the album data from coordinator."""
|
"""Get the album data from coordinator."""
|
||||||
return self.coordinator.data
|
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
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return if entity is available.
|
"""Return if entity is available.
|
||||||
@@ -270,13 +256,6 @@ class ImmichCreateProtectedLinkButton(
|
|||||||
"""Get the album data from coordinator."""
|
"""Get the album data from coordinator."""
|
||||||
return self.coordinator.data
|
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
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return if entity is available.
|
"""Return if entity is available.
|
||||||
@@ -364,13 +343,6 @@ class ImmichDeleteProtectedLinkButton(
|
|||||||
"""Get the album data from coordinator."""
|
"""Get the album data from coordinator."""
|
||||||
return self.coordinator.data
|
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
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return if entity is available.
|
"""Return if entity is available.
|
||||||
|
|||||||
@@ -78,13 +78,6 @@ class ImmichAlbumThumbnailCamera(
|
|||||||
"""Get the album data from coordinator."""
|
"""Get the album data from coordinator."""
|
||||||
return self.coordinator.data
|
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
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return if entity is available."""
|
"""Return if entity is available."""
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ from .const import (
|
|||||||
CONF_IMMICH_URL,
|
CONF_IMMICH_URL,
|
||||||
CONF_SCAN_INTERVAL,
|
CONF_SCAN_INTERVAL,
|
||||||
CONF_TELEGRAM_BOT_TOKEN,
|
CONF_TELEGRAM_BOT_TOKEN,
|
||||||
|
CONF_TELEGRAM_CACHE_TTL,
|
||||||
DEFAULT_SCAN_INTERVAL,
|
DEFAULT_SCAN_INTERVAL,
|
||||||
|
DEFAULT_TELEGRAM_CACHE_TTL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SUBENTRY_TYPE_ALBUM,
|
SUBENTRY_TYPE_ALBUM,
|
||||||
)
|
)
|
||||||
@@ -252,28 +254,41 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
|
|||||||
CONF_TELEGRAM_BOT_TOKEN: user_input.get(
|
CONF_TELEGRAM_BOT_TOKEN: user_input.get(
|
||||||
CONF_TELEGRAM_BOT_TOKEN, ""
|
CONF_TELEGRAM_BOT_TOKEN, ""
|
||||||
),
|
),
|
||||||
|
CONF_TELEGRAM_CACHE_TTL: user_input.get(
|
||||||
|
CONF_TELEGRAM_CACHE_TTL, DEFAULT_TELEGRAM_CACHE_TTL
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="init",
|
||||||
|
data_schema=self._build_options_schema(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_options_schema(self) -> vol.Schema:
|
||||||
|
"""Build the options form schema."""
|
||||||
current_interval = self._config_entry.options.get(
|
current_interval = self._config_entry.options.get(
|
||||||
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
||||||
)
|
)
|
||||||
current_bot_token = self._config_entry.options.get(
|
current_bot_token = self._config_entry.options.get(
|
||||||
CONF_TELEGRAM_BOT_TOKEN, ""
|
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(
|
return vol.Schema(
|
||||||
step_id="init",
|
{
|
||||||
data_schema=vol.Schema(
|
vol.Required(
|
||||||
{
|
CONF_SCAN_INTERVAL, default=current_interval
|
||||||
vol.Required(
|
): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)),
|
||||||
CONF_SCAN_INTERVAL, default=current_interval
|
vol.Optional(
|
||||||
): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)),
|
CONF_TELEGRAM_BOT_TOKEN, default=current_bot_token
|
||||||
vol.Optional(
|
): str,
|
||||||
CONF_TELEGRAM_BOT_TOKEN, default=current_bot_token
|
vol.Optional(
|
||||||
): str,
|
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_ALBUM_NAME: Final = "album_name"
|
||||||
CONF_SCAN_INTERVAL: Final = "scan_interval"
|
CONF_SCAN_INTERVAL: Final = "scan_interval"
|
||||||
CONF_TELEGRAM_BOT_TOKEN: Final = "telegram_bot_token"
|
CONF_TELEGRAM_BOT_TOKEN: Final = "telegram_bot_token"
|
||||||
|
CONF_TELEGRAM_CACHE_TTL: Final = "telegram_cache_ttl"
|
||||||
|
|
||||||
# Subentry type
|
# Subentry type
|
||||||
SUBENTRY_TYPE_ALBUM: Final = "album"
|
SUBENTRY_TYPE_ALBUM: Final = "album"
|
||||||
|
|
||||||
# Defaults
|
# Defaults
|
||||||
DEFAULT_SCAN_INTERVAL: Final = 60 # seconds
|
DEFAULT_SCAN_INTERVAL: Final = 60 # seconds
|
||||||
|
DEFAULT_TELEGRAM_CACHE_TTL: Final = 48 # hours
|
||||||
NEW_ASSETS_RESET_DELAY: Final = 300 # 5 minutes
|
NEW_ASSETS_RESET_DELAY: Final = 300 # 5 minutes
|
||||||
DEFAULT_SHARE_PASSWORD: Final = "immich123"
|
DEFAULT_SHARE_PASSWORD: Final = "immich123"
|
||||||
|
|
||||||
@@ -27,6 +29,9 @@ DEFAULT_SHARE_PASSWORD: Final = "immich123"
|
|||||||
EVENT_ALBUM_CHANGED: Final = f"{DOMAIN}_album_changed"
|
EVENT_ALBUM_CHANGED: Final = f"{DOMAIN}_album_changed"
|
||||||
EVENT_ASSETS_ADDED: Final = f"{DOMAIN}_assets_added"
|
EVENT_ASSETS_ADDED: Final = f"{DOMAIN}_assets_added"
|
||||||
EVENT_ASSETS_REMOVED: Final = f"{DOMAIN}_assets_removed"
|
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
|
# Attributes
|
||||||
ATTR_HUB_NAME: Final = "hub_name"
|
ATTR_HUB_NAME: Final = "hub_name"
|
||||||
@@ -44,21 +49,32 @@ ATTR_REMOVED_COUNT: Final = "removed_count"
|
|||||||
ATTR_ADDED_ASSETS: Final = "added_assets"
|
ATTR_ADDED_ASSETS: Final = "added_assets"
|
||||||
ATTR_REMOVED_ASSETS: Final = "removed_assets"
|
ATTR_REMOVED_ASSETS: Final = "removed_assets"
|
||||||
ATTR_CHANGE_TYPE: Final = "change_type"
|
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_CREATED_AT: Final = "created_at"
|
||||||
ATTR_THUMBNAIL_URL: Final = "thumbnail_url"
|
ATTR_THUMBNAIL_URL: Final = "thumbnail_url"
|
||||||
ATTR_SHARED: Final = "shared"
|
ATTR_SHARED: Final = "shared"
|
||||||
ATTR_OWNER: Final = "owner"
|
ATTR_OWNER: Final = "owner"
|
||||||
ATTR_PEOPLE: Final = "people"
|
ATTR_PEOPLE: Final = "people"
|
||||||
ATTR_ASSET_TYPE: Final = "asset_type"
|
ATTR_OLD_NAME: Final = "old_name"
|
||||||
ATTR_ASSET_FILENAME: Final = "asset_filename"
|
ATTR_NEW_NAME: Final = "new_name"
|
||||||
ATTR_ASSET_CREATED: Final = "asset_created"
|
ATTR_OLD_SHARED: Final = "old_shared"
|
||||||
ATTR_ASSET_OWNER: Final = "asset_owner"
|
ATTR_NEW_SHARED: Final = "new_shared"
|
||||||
ATTR_ASSET_OWNER_ID: Final = "asset_owner_id"
|
ATTR_ASSET_TYPE: Final = "type"
|
||||||
ATTR_ASSET_URL: Final = "asset_url"
|
ATTR_ASSET_FILENAME: Final = "filename"
|
||||||
ATTR_ASSET_DOWNLOAD_URL: Final = "asset_download_url"
|
ATTR_ASSET_CREATED: Final = "created_at"
|
||||||
ATTR_ASSET_PLAYBACK_URL: Final = "asset_playback_url"
|
ATTR_ASSET_OWNER: Final = "owner"
|
||||||
ATTR_ASSET_DESCRIPTION: Final = "asset_description"
|
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 types
|
||||||
ASSET_TYPE_IMAGE: Final = "IMAGE"
|
ASSET_TYPE_IMAGE: Final = "IMAGE"
|
||||||
@@ -69,5 +85,5 @@ PLATFORMS: Final = ["sensor", "binary_sensor", "camera", "text", "button"]
|
|||||||
|
|
||||||
# Services
|
# Services
|
||||||
SERVICE_REFRESH: Final = "refresh"
|
SERVICE_REFRESH: Final = "refresh"
|
||||||
SERVICE_GET_RECENT_ASSETS: Final = "get_recent_assets"
|
SERVICE_GET_ASSETS: Final = "get_assets"
|
||||||
SERVICE_SEND_TELEGRAM_MEDIA_GROUP: Final = "send_telegram_media_group"
|
SERVICE_SEND_TELEGRAM_NOTIFICATION: Final = "send_telegram_notification"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from datetime import datetime, timedelta
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .storage import ImmichAlbumStorage
|
from .storage import ImmichAlbumStorage, TelegramFileCache
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
@@ -28,20 +28,36 @@ from .const import (
|
|||||||
ATTR_ASSET_DESCRIPTION,
|
ATTR_ASSET_DESCRIPTION,
|
||||||
ATTR_ASSET_DOWNLOAD_URL,
|
ATTR_ASSET_DOWNLOAD_URL,
|
||||||
ATTR_ASSET_FILENAME,
|
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,
|
||||||
ATTR_ASSET_OWNER_ID,
|
ATTR_ASSET_OWNER_ID,
|
||||||
|
ATTR_ASSET_PLAYBACK_URL,
|
||||||
|
ATTR_ASSET_RATING,
|
||||||
ATTR_ASSET_TYPE,
|
ATTR_ASSET_TYPE,
|
||||||
ATTR_ASSET_URL,
|
ATTR_ASSET_URL,
|
||||||
ATTR_ASSET_PLAYBACK_URL,
|
|
||||||
ATTR_CHANGE_TYPE,
|
ATTR_CHANGE_TYPE,
|
||||||
ATTR_HUB_NAME,
|
ATTR_HUB_NAME,
|
||||||
ATTR_PEOPLE,
|
ATTR_PEOPLE,
|
||||||
ATTR_REMOVED_ASSETS,
|
ATTR_REMOVED_ASSETS,
|
||||||
ATTR_REMOVED_COUNT,
|
ATTR_REMOVED_COUNT,
|
||||||
|
ATTR_OLD_NAME,
|
||||||
|
ATTR_NEW_NAME,
|
||||||
|
ATTR_OLD_SHARED,
|
||||||
|
ATTR_NEW_SHARED,
|
||||||
|
ATTR_SHARED,
|
||||||
|
ATTR_THUMBNAIL_URL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
EVENT_ALBUM_CHANGED,
|
EVENT_ALBUM_CHANGED,
|
||||||
EVENT_ASSETS_ADDED,
|
EVENT_ASSETS_ADDED,
|
||||||
EVENT_ASSETS_REMOVED,
|
EVENT_ASSETS_REMOVED,
|
||||||
|
EVENT_ALBUM_RENAMED,
|
||||||
|
EVENT_ALBUM_DELETED,
|
||||||
|
EVENT_ALBUM_SHARING_CHANGED,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -107,6 +123,15 @@ class AssetInfo:
|
|||||||
owner_name: str = ""
|
owner_name: str = ""
|
||||||
description: str = ""
|
description: str = ""
|
||||||
people: list[str] = field(default_factory=list)
|
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
|
||||||
|
thumbhash: str | None = None # Perceptual hash for cache validation
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_api_response(
|
def from_api_response(
|
||||||
@@ -122,23 +147,107 @@ class AssetInfo:
|
|||||||
if users_cache and owner_id:
|
if users_cache and owner_id:
|
||||||
owner_name = users_cache.get(owner_id, "")
|
owner_name = users_cache.get(owner_id, "")
|
||||||
|
|
||||||
# Get description from exifInfo if available
|
# Get description - prioritize user-added description over EXIF description
|
||||||
description = ""
|
description = data.get("description", "") or ""
|
||||||
exif_info = data.get("exifInfo")
|
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 ""
|
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)
|
||||||
|
thumbhash = data.get("thumbhash")
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
type=data.get("type", ASSET_TYPE_IMAGE),
|
type=asset_type,
|
||||||
filename=data.get("originalFileName", ""),
|
filename=data.get("originalFileName", ""),
|
||||||
created_at=data.get("fileCreatedAt", ""),
|
created_at=data.get("fileCreatedAt", ""),
|
||||||
owner_id=owner_id,
|
owner_id=owner_id,
|
||||||
owner_name=owner_name,
|
owner_name=owner_name,
|
||||||
description=description,
|
description=description,
|
||||||
people=people,
|
people=people,
|
||||||
|
is_favorite=is_favorite,
|
||||||
|
rating=rating,
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
city=city,
|
||||||
|
state=state,
|
||||||
|
country=country,
|
||||||
|
is_processed=is_processed,
|
||||||
|
thumbhash=thumbhash,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@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
|
@dataclass
|
||||||
class AlbumData:
|
class AlbumData:
|
||||||
@@ -210,6 +319,10 @@ class AlbumChange:
|
|||||||
removed_count: int = 0
|
removed_count: int = 0
|
||||||
added_assets: list[AssetInfo] = field(default_factory=list)
|
added_assets: list[AssetInfo] = field(default_factory=list)
|
||||||
removed_asset_ids: list[str] = 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]):
|
class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||||
@@ -225,6 +338,8 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
scan_interval: int,
|
scan_interval: int,
|
||||||
hub_name: str = "Immich",
|
hub_name: str = "Immich",
|
||||||
storage: ImmichAlbumStorage | None = None,
|
storage: ImmichAlbumStorage | None = None,
|
||||||
|
telegram_cache: TelegramFileCache | None = None,
|
||||||
|
telegram_asset_cache: TelegramFileCache | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the coordinator."""
|
"""Initialize the coordinator."""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -244,13 +359,46 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
self._users_cache: dict[str, str] = {} # user_id -> name
|
self._users_cache: dict[str, str] = {} # user_id -> name
|
||||||
self._shared_links: list[SharedLinkInfo] = []
|
self._shared_links: list[SharedLinkInfo] = []
|
||||||
self._storage = storage
|
self._storage = storage
|
||||||
|
self._telegram_cache = telegram_cache
|
||||||
|
self._telegram_asset_cache = telegram_asset_cache
|
||||||
self._persisted_asset_ids: set[str] | None = None
|
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
|
@property
|
||||||
def immich_url(self) -> str:
|
def immich_url(self) -> str:
|
||||||
"""Return the Immich URL."""
|
"""Return the Immich URL (for API calls)."""
|
||||||
return self._url
|
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
|
@property
|
||||||
def api_key(self) -> str:
|
def api_key(self) -> str:
|
||||||
"""Return the API key."""
|
"""Return the API key."""
|
||||||
@@ -266,6 +414,22 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
"""Return the album name."""
|
"""Return the album name."""
|
||||||
return self._album_name
|
return self._album_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telegram_cache(self) -> TelegramFileCache | None:
|
||||||
|
"""Return the Telegram file cache (URL-based)."""
|
||||||
|
return self._telegram_cache
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telegram_asset_cache(self) -> TelegramFileCache | None:
|
||||||
|
"""Return the Telegram asset cache (asset ID-based)."""
|
||||||
|
return self._telegram_asset_cache
|
||||||
|
|
||||||
|
def get_asset_thumbhash(self, asset_id: str) -> str | None:
|
||||||
|
"""Get the current thumbhash for an asset from coordinator data."""
|
||||||
|
if self.data and asset_id in self.data.assets:
|
||||||
|
return self.data.assets[asset_id].thumbhash
|
||||||
|
return None
|
||||||
|
|
||||||
def update_scan_interval(self, scan_interval: int) -> None:
|
def update_scan_interval(self, scan_interval: int) -> None:
|
||||||
"""Update the scan interval."""
|
"""Update the scan interval."""
|
||||||
self.update_interval = timedelta(seconds=scan_interval)
|
self.update_interval = timedelta(seconds=scan_interval)
|
||||||
@@ -291,33 +455,138 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
self._album_name,
|
self._album_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_get_recent_assets(self, count: int = 10) -> list[dict[str, Any]]:
|
async def async_get_assets(
|
||||||
"""Get recent assets from the album."""
|
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:
|
if self.data is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Sort assets by created_at descending
|
# Start with all processed assets only
|
||||||
sorted_assets = sorted(
|
assets = [a for a in self.data.assets.values() if a.is_processed]
|
||||||
self.data.assets.values(),
|
|
||||||
key=lambda a: a.created_at,
|
|
||||||
reverse=True,
|
|
||||||
)[:count]
|
|
||||||
|
|
||||||
|
# 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 = []
|
result = []
|
||||||
for asset in sorted_assets:
|
for asset in assets:
|
||||||
asset_data = {
|
asset_data = self._build_asset_detail(asset, include_thumbnail=True)
|
||||||
"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
|
|
||||||
result.append(asset_data)
|
result.append(asset_data)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -368,6 +637,36 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
|
|
||||||
return self._users_cache
|
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]:
|
async def _async_fetch_shared_links(self) -> list[SharedLinkInfo]:
|
||||||
"""Fetch shared links for this album from Immich."""
|
"""Fetch shared links for this album from Immich."""
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
@@ -411,29 +710,29 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
"""Get the public URL if album has an accessible shared link."""
|
"""Get the public URL if album has an accessible shared link."""
|
||||||
accessible_links = self._get_accessible_links()
|
accessible_links = self._get_accessible_links()
|
||||||
if 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
|
return None
|
||||||
|
|
||||||
def get_any_url(self) -> str | None:
|
def get_any_url(self) -> str | None:
|
||||||
"""Get any non-expired URL (prefers accessible, falls back to protected)."""
|
"""Get any non-expired URL (prefers accessible, falls back to protected)."""
|
||||||
accessible_links = self._get_accessible_links()
|
accessible_links = self._get_accessible_links()
|
||||||
if 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]
|
non_expired = [link for link in self._shared_links if not link.is_expired]
|
||||||
if non_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
|
return None
|
||||||
|
|
||||||
def get_protected_url(self) -> str | None:
|
def get_protected_url(self) -> str | None:
|
||||||
"""Get a protected URL if any password-protected link exists."""
|
"""Get a protected URL if any password-protected link exists."""
|
||||||
protected_links = self._get_protected_links()
|
protected_links = self._get_protected_links()
|
||||||
if 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
|
return None
|
||||||
|
|
||||||
def get_protected_urls(self) -> list[str]:
|
def get_protected_urls(self) -> list[str]:
|
||||||
"""Get all password-protected URLs."""
|
"""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:
|
def get_protected_password(self) -> str | None:
|
||||||
"""Get the password for the first protected link."""
|
"""Get the password for the first protected link."""
|
||||||
@@ -444,13 +743,13 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
|
|
||||||
def get_public_urls(self) -> list[str]:
|
def get_public_urls(self) -> list[str]:
|
||||||
"""Get all accessible public URLs."""
|
"""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]]:
|
def get_shared_links_info(self) -> list[dict[str, Any]]:
|
||||||
"""Get detailed info about all shared links."""
|
"""Get detailed info about all shared links."""
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"url": f"{self._url}/share/{link.key}",
|
"url": f"{self.external_url}/share/{link.key}",
|
||||||
"has_password": link.has_password,
|
"has_password": link.has_password,
|
||||||
"is_expired": link.is_expired,
|
"is_expired": link.is_expired,
|
||||||
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
||||||
@@ -463,37 +762,108 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
"""Get the public viewer URL for an asset (web page)."""
|
"""Get the public viewer URL for an asset (web page)."""
|
||||||
accessible_links = self._get_accessible_links()
|
accessible_links = self._get_accessible_links()
|
||||||
if 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]
|
non_expired = [link for link in self._shared_links if not link.is_expired]
|
||||||
if non_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
|
return None
|
||||||
|
|
||||||
def _get_asset_download_url(self, asset_id: str) -> str | None:
|
def _get_asset_download_url(self, asset_id: str) -> str | None:
|
||||||
"""Get the direct download URL for an asset (media file)."""
|
"""Get the direct download URL for an asset (media file)."""
|
||||||
accessible_links = self._get_accessible_links()
|
accessible_links = self._get_accessible_links()
|
||||||
if 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]
|
non_expired = [link for link in self._shared_links if not link.is_expired]
|
||||||
if non_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
|
return None
|
||||||
|
|
||||||
def _get_asset_video_url(self, asset_id: str) -> str | None:
|
def _get_asset_video_url(self, asset_id: str) -> str | None:
|
||||||
"""Get the transcoded video playback URL for a video asset."""
|
"""Get the transcoded video playback URL for a video asset."""
|
||||||
accessible_links = self._get_accessible_links()
|
accessible_links = self._get_accessible_links()
|
||||||
if 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]
|
non_expired = [link for link in self._shared_links if not link.is_expired]
|
||||||
if non_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
|
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:
|
async def _async_update_data(self) -> AlbumData | None:
|
||||||
"""Fetch data from Immich API."""
|
"""Fetch data from Immich API."""
|
||||||
if self._session is None:
|
if self._session is None:
|
||||||
self._session = async_get_clientsession(self.hass)
|
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
|
# Fetch users to resolve owner names
|
||||||
if not self._users_cache:
|
if not self._users_cache:
|
||||||
await self._async_fetch_users()
|
await self._async_fetch_users()
|
||||||
@@ -510,6 +880,15 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
) as response:
|
) as response:
|
||||||
if response.status == 404:
|
if response.status == 404:
|
||||||
_LOGGER.warning("Album %s not found", self._album_id)
|
_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
|
return None
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
raise UpdateFailed(
|
raise UpdateFailed(
|
||||||
@@ -538,11 +917,16 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
elif removed_ids and not added_ids:
|
elif removed_ids and not added_ids:
|
||||||
change_type = "assets_removed"
|
change_type = "assets_removed"
|
||||||
|
|
||||||
added_assets = [
|
added_assets = []
|
||||||
album.assets[aid]
|
for aid in added_ids:
|
||||||
for aid in added_ids
|
if aid not in album.assets:
|
||||||
if aid 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(
|
change = AlbumChange(
|
||||||
album_id=album.id,
|
album_id=album.id,
|
||||||
@@ -599,53 +983,96 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
added_ids = new_state.asset_ids - old_state.asset_ids
|
added_ids = new_state.asset_ids - old_state.asset_ids
|
||||||
removed_ids = old_state.asset_ids - new_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
|
return None
|
||||||
|
|
||||||
|
# Determine primary change type (use added_assets not added_ids)
|
||||||
change_type = "changed"
|
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"
|
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"
|
change_type = "assets_removed"
|
||||||
|
|
||||||
added_assets = [
|
|
||||||
new_state.assets[aid] for aid in added_ids if aid in new_state.assets
|
|
||||||
]
|
|
||||||
|
|
||||||
return AlbumChange(
|
return AlbumChange(
|
||||||
album_id=new_state.id,
|
album_id=new_state.id,
|
||||||
album_name=new_state.name,
|
album_name=new_state.name,
|
||||||
change_type=change_type,
|
change_type=change_type,
|
||||||
added_count=len(added_ids),
|
added_count=len(added_assets), # Count only processed assets
|
||||||
removed_count=len(removed_ids),
|
removed_count=len(removed_ids),
|
||||||
added_assets=added_assets,
|
added_assets=added_assets,
|
||||||
removed_asset_ids=list(removed_ids),
|
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:
|
def _fire_events(self, change: AlbumChange, album: AlbumData) -> None:
|
||||||
"""Fire Home Assistant events for album changes."""
|
"""Fire Home Assistant events for album changes."""
|
||||||
added_assets_detail = []
|
added_assets_detail = []
|
||||||
for asset in change.added_assets:
|
for asset in change.added_assets:
|
||||||
asset_detail = {
|
# Only include fully processed assets
|
||||||
"id": asset.id,
|
if not asset.is_processed:
|
||||||
ATTR_ASSET_TYPE: asset.type,
|
continue
|
||||||
ATTR_ASSET_FILENAME: asset.filename,
|
asset_detail = self._build_asset_detail(asset, include_thumbnail=False)
|
||||||
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
|
|
||||||
added_assets_detail.append(asset_detail)
|
added_assets_detail.append(asset_detail)
|
||||||
|
|
||||||
event_data = {
|
event_data = {
|
||||||
@@ -658,8 +1085,18 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
ATTR_ADDED_ASSETS: added_assets_detail,
|
ATTR_ADDED_ASSETS: added_assets_detail,
|
||||||
ATTR_REMOVED_ASSETS: change.removed_asset_ids,
|
ATTR_REMOVED_ASSETS: change.removed_asset_ids,
|
||||||
ATTR_PEOPLE: list(album.people),
|
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()
|
album_url = self.get_any_url()
|
||||||
if album_url:
|
if album_url:
|
||||||
event_data[ATTR_ALBUM_URL] = album_url
|
event_data[ATTR_ALBUM_URL] = album_url
|
||||||
@@ -679,6 +1116,24 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
if change.removed_count > 0:
|
if change.removed_count > 0:
|
||||||
self.hass.bus.async_fire(EVENT_ASSETS_REMOVED, event_data)
|
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:
|
def get_protected_link_id(self) -> str | None:
|
||||||
"""Get the ID of the first protected link."""
|
"""Get the ID of the first protected link."""
|
||||||
protected_links = self._get_protected_links()
|
protected_links = self._get_protected_links()
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
|
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "1.4.0"
|
"version": "2.8.0"
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,17 @@ refresh:
|
|||||||
integration: immich_album_watcher
|
integration: immich_album_watcher
|
||||||
domain: sensor
|
domain: sensor
|
||||||
|
|
||||||
get_recent_assets:
|
get_assets:
|
||||||
name: Get Recent Assets
|
name: Get Assets
|
||||||
description: Get the most recent assets from the targeted album.
|
description: Get assets from the targeted album with optional filtering and ordering.
|
||||||
target:
|
target:
|
||||||
entity:
|
entity:
|
||||||
integration: immich_album_watcher
|
integration: immich_album_watcher
|
||||||
domain: sensor
|
domain: sensor
|
||||||
fields:
|
fields:
|
||||||
count:
|
limit:
|
||||||
name: Count
|
name: Limit
|
||||||
description: Number of recent assets to return (1-100).
|
description: Maximum number of assets to return (1-100).
|
||||||
required: false
|
required: false
|
||||||
default: 10
|
default: 10
|
||||||
selector:
|
selector:
|
||||||
@@ -24,10 +24,114 @@ get_recent_assets:
|
|||||||
min: 1
|
min: 1
|
||||||
max: 100
|
max: 100
|
||||||
mode: slider
|
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:
|
send_telegram_notification:
|
||||||
name: Send Telegram Media Group
|
name: Send Telegram Notification
|
||||||
description: Send specified media URLs to a Telegram chat as a media group.
|
description: Send a notification to Telegram (text, photo, video, document, or media group).
|
||||||
target:
|
target:
|
||||||
entity:
|
entity:
|
||||||
integration: immich_album_watcher
|
integration: immich_album_watcher
|
||||||
@@ -45,15 +149,15 @@ send_telegram_media_group:
|
|||||||
required: true
|
required: true
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
urls:
|
assets:
|
||||||
name: URLs
|
name: Assets
|
||||||
description: List of media URLs to send (max 10). Each item should have 'url' and 'type' (photo/video).
|
description: "List of media assets to send. Each item should have 'url', optional 'type' (document/photo/video, default: document), optional 'content_type' (MIME type, e.g., 'image/jpeg'), and optional 'cache_key' (custom key for caching instead of URL). If empty, sends a text message. Photos and videos can be grouped; documents are sent separately."
|
||||||
required: true
|
required: false
|
||||||
selector:
|
selector:
|
||||||
object:
|
object:
|
||||||
caption:
|
caption:
|
||||||
name: 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
|
required: false
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
@@ -65,3 +169,102 @@ send_telegram_media_group:
|
|||||||
selector:
|
selector:
|
||||||
number:
|
number:
|
||||||
mode: box
|
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: ""
|
||||||
|
quiet_hours_start:
|
||||||
|
name: Quiet Hours Start
|
||||||
|
description: "Start time for quiet hours (HH:MM format, e.g. 22:00). When set along with quiet_hours_end, notifications during this period are queued and sent when quiet hours end. Omit to send immediately."
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
quiet_hours_end:
|
||||||
|
name: Quiet Hours End
|
||||||
|
description: "End time for quiet hours (HH:MM format, e.g. 08:00). Queued notifications will be sent at this time."
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
STORAGE_KEY_PREFIX = "immich_album_watcher"
|
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:
|
class ImmichAlbumStorage:
|
||||||
"""Handles persistence of album state across restarts."""
|
"""Handles persistence of album state across restarts."""
|
||||||
@@ -63,3 +66,262 @@ class ImmichAlbumStorage:
|
|||||||
"""Remove all storage data."""
|
"""Remove all storage data."""
|
||||||
await self._store.async_remove()
|
await self._store.async_remove()
|
||||||
self._data = None
|
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 or asset ID.
|
||||||
|
|
||||||
|
Supports two validation modes:
|
||||||
|
- TTL mode (default): entries expire after a configured time-to-live
|
||||||
|
- Thumbhash mode: entries are validated by comparing stored thumbhash with
|
||||||
|
the current asset thumbhash from Immich
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry_id: str,
|
||||||
|
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
|
||||||
|
use_thumbhash: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the Telegram file cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hass: Home Assistant instance
|
||||||
|
entry_id: Config entry ID for scoping the cache (per hub)
|
||||||
|
ttl_seconds: Time-to-live for cache entries in seconds (TTL mode only)
|
||||||
|
use_thumbhash: Use thumbhash-based validation instead of TTL
|
||||||
|
"""
|
||||||
|
self._store: Store[dict[str, Any]] = Store(
|
||||||
|
hass, STORAGE_VERSION, f"{STORAGE_KEY_PREFIX}.telegram_cache.{entry_id}"
|
||||||
|
)
|
||||||
|
self._data: dict[str, Any] | None = None
|
||||||
|
self._ttl_seconds = ttl_seconds
|
||||||
|
self._use_thumbhash = use_thumbhash
|
||||||
|
|
||||||
|
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 (TTL mode only)
|
||||||
|
await self._cleanup_expired()
|
||||||
|
mode = "thumbhash" if self._use_thumbhash else "TTL"
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Loaded Telegram file cache with %d entries (mode: %s)",
|
||||||
|
len(self._data.get("files", {})),
|
||||||
|
mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Maximum number of entries to keep in thumbhash mode to prevent unbounded growth
|
||||||
|
THUMBHASH_MAX_ENTRIES = 2000
|
||||||
|
|
||||||
|
async def _cleanup_expired(self) -> None:
|
||||||
|
"""Remove expired cache entries (TTL mode) or trim old entries (thumbhash mode)."""
|
||||||
|
if self._use_thumbhash:
|
||||||
|
files = self._data.get("files", {}) if self._data else {}
|
||||||
|
if len(files) > self.THUMBHASH_MAX_ENTRIES:
|
||||||
|
sorted_keys = sorted(
|
||||||
|
files, key=lambda k: files[k].get("cached_at", "")
|
||||||
|
)
|
||||||
|
keys_to_remove = sorted_keys[: len(files) - self.THUMBHASH_MAX_ENTRIES]
|
||||||
|
for key in keys_to_remove:
|
||||||
|
del files[key]
|
||||||
|
await self._store.async_save(self._data)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Trimmed thumbhash cache from %d to %d entries",
|
||||||
|
len(keys_to_remove) + self.THUMBHASH_MAX_ENTRIES,
|
||||||
|
self.THUMBHASH_MAX_ENTRIES,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
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, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
|
||||||
|
"""Get cached file_id for a key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The cache key (URL or asset ID)
|
||||||
|
thumbhash: Current thumbhash for validation (thumbhash mode only).
|
||||||
|
If provided, compares with stored thumbhash. Mismatch = cache miss.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'file_id' and 'type' if cached and valid, None otherwise
|
||||||
|
"""
|
||||||
|
if not self._data or "files" not in self._data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
entry = self._data["files"].get(key)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self._use_thumbhash:
|
||||||
|
# Thumbhash-based validation
|
||||||
|
if thumbhash is not None:
|
||||||
|
stored_thumbhash = entry.get("thumbhash")
|
||||||
|
if stored_thumbhash and stored_thumbhash != thumbhash:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Cache miss for %s: thumbhash changed, removing stale entry",
|
||||||
|
key[:36],
|
||||||
|
)
|
||||||
|
del self._data["files"][key]
|
||||||
|
return None
|
||||||
|
# If no thumbhash provided (asset not in monitored album),
|
||||||
|
# return cached entry anyway — self-heals on Telegram rejection
|
||||||
|
else:
|
||||||
|
# TTL-based validation
|
||||||
|
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, key: str, file_id: str, media_type: str, thumbhash: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Store a file_id for a key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The cache key (URL or asset ID)
|
||||||
|
file_id: The Telegram file_id
|
||||||
|
media_type: The type of media ('photo', 'video', 'document')
|
||||||
|
thumbhash: Current thumbhash to store alongside file_id (thumbhash mode only)
|
||||||
|
"""
|
||||||
|
if self._data is None:
|
||||||
|
self._data = {"files": {}}
|
||||||
|
|
||||||
|
entry_data: dict[str, Any] = {
|
||||||
|
"file_id": file_id,
|
||||||
|
"type": media_type,
|
||||||
|
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
if thumbhash is not None:
|
||||||
|
entry_data["thumbhash"] = thumbhash
|
||||||
|
|
||||||
|
self._data["files"][key] = entry_data
|
||||||
|
await self._store.async_save(self._data)
|
||||||
|
_LOGGER.debug("Cached Telegram file_id for key (type: %s)", media_type)
|
||||||
|
|
||||||
|
async def async_set_many(
|
||||||
|
self, entries: list[tuple[str, str, str, str | None]]
|
||||||
|
) -> None:
|
||||||
|
"""Store multiple file_ids in a single disk write.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entries: List of (key, file_id, media_type, thumbhash) tuples
|
||||||
|
"""
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._data is None:
|
||||||
|
self._data = {"files": {}}
|
||||||
|
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
for key, file_id, media_type, thumbhash in entries:
|
||||||
|
entry_data: dict[str, Any] = {
|
||||||
|
"file_id": file_id,
|
||||||
|
"type": media_type,
|
||||||
|
"cached_at": now_iso,
|
||||||
|
}
|
||||||
|
if thumbhash is not None:
|
||||||
|
entry_data["thumbhash"] = thumbhash
|
||||||
|
self._data["files"][key] = entry_data
|
||||||
|
|
||||||
|
await self._store.async_save(self._data)
|
||||||
|
_LOGGER.debug("Batch cached %d Telegram file_ids", len(entries))
|
||||||
|
|
||||||
|
async def async_remove(self) -> None:
|
||||||
|
"""Remove all cache data."""
|
||||||
|
await self._store.async_remove()
|
||||||
|
self._data = None
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationQueue:
|
||||||
|
"""Persistent queue for notifications deferred during quiet hours.
|
||||||
|
|
||||||
|
Stores full service call parameters so notifications can be replayed
|
||||||
|
exactly as they were originally called.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
|
||||||
|
"""Initialize the notification queue."""
|
||||||
|
self._store: Store[dict[str, Any]] = Store(
|
||||||
|
hass, STORAGE_VERSION, f"{STORAGE_KEY_PREFIX}.notification_queue.{entry_id}"
|
||||||
|
)
|
||||||
|
self._data: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
async def async_load(self) -> None:
|
||||||
|
"""Load queue data from storage."""
|
||||||
|
self._data = await self._store.async_load() or {"queue": []}
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Loaded notification queue with %d items",
|
||||||
|
len(self._data.get("queue", [])),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_enqueue(self, notification_params: dict[str, Any]) -> None:
|
||||||
|
"""Add a notification to the queue."""
|
||||||
|
if self._data is None:
|
||||||
|
self._data = {"queue": []}
|
||||||
|
|
||||||
|
self._data["queue"].append({
|
||||||
|
"params": notification_params,
|
||||||
|
"queued_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
await self._store.async_save(self._data)
|
||||||
|
_LOGGER.debug("Queued notification during quiet hours (total: %d)", len(self._data["queue"]))
|
||||||
|
|
||||||
|
def get_all(self) -> list[dict[str, Any]]:
|
||||||
|
"""Get all queued notifications."""
|
||||||
|
if not self._data:
|
||||||
|
return []
|
||||||
|
return list(self._data.get("queue", []))
|
||||||
|
|
||||||
|
def has_pending(self) -> bool:
|
||||||
|
"""Check if there are pending notifications."""
|
||||||
|
return bool(self._data and self._data.get("queue"))
|
||||||
|
|
||||||
|
async def async_remove_indices(self, indices: list[int]) -> None:
|
||||||
|
"""Remove specific items by index (indices must be in descending order)."""
|
||||||
|
if not self._data or not indices:
|
||||||
|
return
|
||||||
|
for idx in indices:
|
||||||
|
if 0 <= idx < len(self._data["queue"]):
|
||||||
|
del self._data["queue"][idx]
|
||||||
|
await self._store.async_save(self._data)
|
||||||
|
|
||||||
|
async def async_clear(self) -> None:
|
||||||
|
"""Clear all queued notifications."""
|
||||||
|
if self._data:
|
||||||
|
self._data["queue"] = []
|
||||||
|
await self._store.async_save(self._data)
|
||||||
|
|
||||||
|
async def async_remove(self) -> None:
|
||||||
|
"""Remove all queue data."""
|
||||||
|
await self._store.async_remove()
|
||||||
|
self._data = None
|
||||||
|
|||||||
@@ -79,13 +79,6 @@ class ImmichAlbumProtectedPasswordText(
|
|||||||
"""Get the album data from coordinator."""
|
"""Get the album data from coordinator."""
|
||||||
return self.coordinator.data
|
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
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return if entity is available.
|
"""Return if entity is available.
|
||||||
|
|||||||
@@ -1,58 +1,61 @@
|
|||||||
{
|
{
|
||||||
"entity": {
|
"entity": {
|
||||||
"sensor": {
|
"sensor": {
|
||||||
|
"album_id": {
|
||||||
|
"name": "Album ID"
|
||||||
|
},
|
||||||
"album_asset_count": {
|
"album_asset_count": {
|
||||||
"name": "{album_name}: Asset Count"
|
"name": "Asset Count"
|
||||||
},
|
},
|
||||||
"album_photo_count": {
|
"album_photo_count": {
|
||||||
"name": "{album_name}: Photo Count"
|
"name": "Photo Count"
|
||||||
},
|
},
|
||||||
"album_video_count": {
|
"album_video_count": {
|
||||||
"name": "{album_name}: Video Count"
|
"name": "Video Count"
|
||||||
},
|
},
|
||||||
"album_last_updated": {
|
"album_last_updated": {
|
||||||
"name": "{album_name}: Last Updated"
|
"name": "Last Updated"
|
||||||
},
|
},
|
||||||
"album_created": {
|
"album_created": {
|
||||||
"name": "{album_name}: Created"
|
"name": "Created"
|
||||||
},
|
},
|
||||||
"album_public_url": {
|
"album_public_url": {
|
||||||
"name": "{album_name}: Public URL"
|
"name": "Public URL"
|
||||||
},
|
},
|
||||||
"album_protected_url": {
|
"album_protected_url": {
|
||||||
"name": "{album_name}: Protected URL"
|
"name": "Protected URL"
|
||||||
},
|
},
|
||||||
"album_protected_password": {
|
"album_protected_password": {
|
||||||
"name": "{album_name}: Protected Password"
|
"name": "Protected Password"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
"album_new_assets": {
|
"album_new_assets": {
|
||||||
"name": "{album_name}: New Assets"
|
"name": "New Assets"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"camera": {
|
"camera": {
|
||||||
"album_thumbnail": {
|
"album_thumbnail": {
|
||||||
"name": "{album_name}: Thumbnail"
|
"name": "Thumbnail"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"text": {
|
"text": {
|
||||||
"album_protected_password_edit": {
|
"album_protected_password_edit": {
|
||||||
"name": "{album_name}: Share Password"
|
"name": "Share Password"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"create_share_link": {
|
"create_share_link": {
|
||||||
"name": "{album_name}: Create Share Link"
|
"name": "Create Share Link"
|
||||||
},
|
},
|
||||||
"delete_share_link": {
|
"delete_share_link": {
|
||||||
"name": "{album_name}: Delete Share Link"
|
"name": "Delete Share Link"
|
||||||
},
|
},
|
||||||
"create_protected_link": {
|
"create_protected_link": {
|
||||||
"name": "{album_name}: Create Protected Link"
|
"name": "Create Protected Link"
|
||||||
},
|
},
|
||||||
"delete_protected_link": {
|
"delete_protected_link": {
|
||||||
"name": "{album_name}: Delete Protected Link"
|
"name": "Delete Protected Link"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -113,12 +116,16 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Immich Album Watcher Options",
|
"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": {
|
"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": {
|
"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,127 @@
|
|||||||
"name": "Refresh",
|
"name": "Refresh",
|
||||||
"description": "Force an immediate refresh of album data from Immich."
|
"description": "Force an immediate refresh of album data from Immich."
|
||||||
},
|
},
|
||||||
"get_recent_assets": {
|
"get_assets": {
|
||||||
"name": "Get Recent Assets",
|
"name": "Get Assets",
|
||||||
"description": "Get the most recent assets from the targeted album.",
|
"description": "Get assets from the targeted album with optional filtering and ordering.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"count": {
|
"limit": {
|
||||||
"name": "Count",
|
"name": "Limit",
|
||||||
"description": "Number of recent assets to return (1-100)."
|
"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, document, 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."
|
||||||
|
},
|
||||||
|
"assets": {
|
||||||
|
"name": "Assets",
|
||||||
|
"description": "List of media assets with 'url', optional 'type' (document/photo/video, default: document), optional 'content_type' (MIME type), and optional 'cache_key' (custom key for caching instead of URL). If empty, sends a text message. Photos and videos can be grouped; documents are sent separately."
|
||||||
|
},
|
||||||
|
"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, skip oversized photos."
|
||||||
|
},
|
||||||
|
"chat_action": {
|
||||||
|
"name": "Chat Action",
|
||||||
|
"description": "Chat action to display while processing (typing, upload_photo, upload_video, upload_document). Set to empty to disable."
|
||||||
|
},
|
||||||
|
"quiet_hours_start": {
|
||||||
|
"name": "Quiet Hours Start",
|
||||||
|
"description": "Start time for quiet hours (HH:MM format, e.g. 22:00). Notifications during this period are queued and sent when quiet hours end. Omit to send immediately."
|
||||||
|
},
|
||||||
|
"quiet_hours_end": {
|
||||||
|
"name": "Quiet Hours End",
|
||||||
|
"description": "End time for quiet hours (HH:MM format, e.g. 08:00). Queued notifications will be sent at this time."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,61 @@
|
|||||||
{
|
{
|
||||||
"entity": {
|
"entity": {
|
||||||
"sensor": {
|
"sensor": {
|
||||||
|
"album_id": {
|
||||||
|
"name": "ID альбома"
|
||||||
|
},
|
||||||
"album_asset_count": {
|
"album_asset_count": {
|
||||||
"name": "{album_name}: Число файлов"
|
"name": "Число файлов"
|
||||||
},
|
},
|
||||||
"album_photo_count": {
|
"album_photo_count": {
|
||||||
"name": "{album_name}: Число фото"
|
"name": "Число фото"
|
||||||
},
|
},
|
||||||
"album_video_count": {
|
"album_video_count": {
|
||||||
"name": "{album_name}: Число видео"
|
"name": "Число видео"
|
||||||
},
|
},
|
||||||
"album_last_updated": {
|
"album_last_updated": {
|
||||||
"name": "{album_name}: Последнее обновление"
|
"name": "Последнее обновление"
|
||||||
},
|
},
|
||||||
"album_created": {
|
"album_created": {
|
||||||
"name": "{album_name}: Дата создания"
|
"name": "Дата создания"
|
||||||
},
|
},
|
||||||
"album_public_url": {
|
"album_public_url": {
|
||||||
"name": "{album_name}: Публичная ссылка"
|
"name": "Публичная ссылка"
|
||||||
},
|
},
|
||||||
"album_protected_url": {
|
"album_protected_url": {
|
||||||
"name": "{album_name}: Защищённая ссылка"
|
"name": "Защищённая ссылка"
|
||||||
},
|
},
|
||||||
"album_protected_password": {
|
"album_protected_password": {
|
||||||
"name": "{album_name}: Пароль ссылки"
|
"name": "Пароль ссылки"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
"album_new_assets": {
|
"album_new_assets": {
|
||||||
"name": "{album_name}: Новые файлы"
|
"name": "Новые файлы"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"camera": {
|
"camera": {
|
||||||
"album_thumbnail": {
|
"album_thumbnail": {
|
||||||
"name": "{album_name}: Превью"
|
"name": "Превью"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"text": {
|
"text": {
|
||||||
"album_protected_password_edit": {
|
"album_protected_password_edit": {
|
||||||
"name": "{album_name}: Пароль ссылки"
|
"name": "Пароль ссылки"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"create_share_link": {
|
"create_share_link": {
|
||||||
"name": "{album_name}: Создать ссылку"
|
"name": "Создать ссылку"
|
||||||
},
|
},
|
||||||
"delete_share_link": {
|
"delete_share_link": {
|
||||||
"name": "{album_name}: Удалить ссылку"
|
"name": "Удалить ссылку"
|
||||||
},
|
},
|
||||||
"create_protected_link": {
|
"create_protected_link": {
|
||||||
"name": "{album_name}: Создать защищённую ссылку"
|
"name": "Создать защищённую ссылку"
|
||||||
},
|
},
|
||||||
"delete_protected_link": {
|
"delete_protected_link": {
|
||||||
"name": "{album_name}: Удалить защищённую ссылку"
|
"name": "Удалить защищённую ссылку"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -113,12 +116,16 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Настройки Immich Album Watcher",
|
"title": "Настройки Immich Album Watcher",
|
||||||
"description": "Настройте интервал опроса для всех альбомов.",
|
"description": "Настройте интервал опроса и параметры Telegram для всех альбомов.",
|
||||||
"data": {
|
"data": {
|
||||||
"scan_interval": "Интервал сканирования (секунды)"
|
"scan_interval": "Интервал сканирования (секунды)",
|
||||||
|
"telegram_bot_token": "Токен Telegram бота",
|
||||||
|
"telegram_cache_ttl": "Время жизни кэша Telegram (часы)"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)"
|
"scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)",
|
||||||
|
"telegram_bot_token": "Токен бота для отправки уведомлений в Telegram",
|
||||||
|
"telegram_cache_ttl": "Сколько хранить ID загруженных файлов для повторной отправки без загрузки (1-168 часов, по умолчанию: 48)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,13 +135,127 @@
|
|||||||
"name": "Обновить",
|
"name": "Обновить",
|
||||||
"description": "Принудительно обновить данные альбома из Immich."
|
"description": "Принудительно обновить данные альбома из Immich."
|
||||||
},
|
},
|
||||||
"get_recent_assets": {
|
"get_assets": {
|
||||||
"name": "Получить последние файлы",
|
"name": "Получить файлы",
|
||||||
"description": "Получить последние файлы из выбранного альбома.",
|
"description": "Получить файлы из выбранного альбома с возможностью фильтрации и сортировки.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"count": {
|
"limit": {
|
||||||
"name": "Количество",
|
"name": "Лимит",
|
||||||
"description": "Количество возвращаемых файлов (1-100)."
|
"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 для отправки."
|
||||||
|
},
|
||||||
|
"assets": {
|
||||||
|
"name": "Ресурсы",
|
||||||
|
"description": "Список медиа-ресурсов с 'url', опциональным 'type' (document/photo/video, по умолчанию document), опциональным 'content_type' (MIME-тип) и опциональным 'cache_key' (свой ключ кэширования вместо URL). Если пусто, отправляет текстовое сообщение. Фото и видео группируются; документы отправляются отдельно."
|
||||||
|
},
|
||||||
|
"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). Оставьте пустым для отключения."
|
||||||
|
},
|
||||||
|
"quiet_hours_start": {
|
||||||
|
"name": "Начало тихих часов",
|
||||||
|
"description": "Время начала тихих часов (формат ЧЧ:ММ, например 22:00). Уведомления в этот период ставятся в очередь и отправляются по окончании. Не указывайте для немедленной отправки."
|
||||||
|
},
|
||||||
|
"quiet_hours_end": {
|
||||||
|
"name": "Конец тихих часов",
|
||||||
|
"description": "Время окончания тихих часов (формат ЧЧ:ММ, например 08:00). Уведомления из очереди будут отправлены в это время."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user