Compare commits
31 Commits
436139ede9
...
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b0f3b8b12 | |||
| e5e45f0fbf | |||
| 8714685d5e | |||
| bbcd97e1ac | |||
| 04dd63825c | |||
| 71d3714f6a | |||
| 459f5ef1e5 | |||
| 42b2d912c9 | |||
| 2007b020ba | |||
| 2ae706d700 | |||
| 1cc5d7cc7d | |||
| 5d878cfbd0 | |||
| c7ed037e2e | |||
| d26e212c82 | |||
| 02c0535f50 | |||
| c570e157be | |||
| 56d249b598 | |||
| ebed587f6f | |||
| a89d45268d | |||
| 950fe0fd91 | |||
| 91c30e086d | |||
| 6f39a8175d | |||
| e6619cb1c5 | |||
| eedc7792c8 | |||
| 3a0573e432 | |||
| 7c53110c07 | |||
| 03430df5fb | |||
| 2ca26e178a | |||
| 847c39eaa8 | |||
| 9013c5e0c3 | |||
| 557ec91f05 |
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)
|
||||||
17
.github/workflows/validate.yaml
vendored
Normal file
17
.github/workflows/validate.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: Validate
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
hassfest:
|
||||||
|
name: Hassfest
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: home-assistant/actions/hassfest@master
|
||||||
|
if: github.server_url == 'https://github.com'
|
||||||
31
CLAUDE.md
Normal file
31
CLAUDE.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Project Guidelines
|
||||||
|
|
||||||
|
## Version Management
|
||||||
|
|
||||||
|
Update the integration version in `custom_components/immich_album_watcher/manifest.json` only when changes are made to the **integration content** (files inside `custom_components/immich_album_watcher/`).
|
||||||
|
|
||||||
|
Do NOT bump version for:
|
||||||
|
|
||||||
|
- Repository setup (hacs.json, root README.md, LICENSE, CLAUDE.md)
|
||||||
|
- CI/CD configuration
|
||||||
|
- Other repository-level changes
|
||||||
|
|
||||||
|
Use semantic versioning:
|
||||||
|
- **MAJOR** (x.0.0): Breaking changes
|
||||||
|
- **MINOR** (0.x.0): New features, backward compatible
|
||||||
|
- **PATCH** (0.0.x): Bug fixes, integration documentation updates
|
||||||
|
|
||||||
|
## 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.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Alexei Dolgolyov
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
449
README.md
449
README.md
@@ -1,12 +1,51 @@
|
|||||||
# HAOS Integrations
|
# Immich Album Watcher
|
||||||
|
|
||||||
A collection of custom integrations for Home Assistant.
|
<img src="custom_components/immich_album_watcher/icon.png" alt="Immich" width="64" height="64">
|
||||||
|
|
||||||
## Available Integrations
|
A Home Assistant custom integration that monitors [Immich](https://immich.app/) photo/video library albums for changes and exposes them as Home Assistant entities with event-firing capabilities.
|
||||||
|
|
||||||
| Integration | Description | Documentation |
|
> **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.
|
||||||
|-------------|-------------|---------------|
|
|
||||||
| [Immich Album Watcher](custom_components/immich_album_watcher/) | Monitor Immich albums for changes with sensors, events, and face recognition | [README](custom_components/immich_album_watcher/README.md) |
|
## Features
|
||||||
|
|
||||||
|
- **Album Monitoring** - Watch selected Immich albums for asset additions and removals
|
||||||
|
- **Rich Sensor Data** - Multiple sensors per album:
|
||||||
|
- Album ID (with album name and share URL attributes)
|
||||||
|
- Asset Count (total assets with detected people list)
|
||||||
|
- Photo Count (number of photos)
|
||||||
|
- Video Count (number of videos)
|
||||||
|
- Last Updated (last modification timestamp)
|
||||||
|
- Created (album creation date)
|
||||||
|
- Public URL (public share link)
|
||||||
|
- Protected URL (password-protected share link)
|
||||||
|
- Protected Password (password for protected link)
|
||||||
|
- **Camera Entity** - Album thumbnail displayed as a camera entity for dashboards
|
||||||
|
- **Binary Sensor** - "New Assets" indicator that turns on when assets are added
|
||||||
|
- **Face Recognition** - Detects and lists people recognized in album photos
|
||||||
|
- **Event Firing** - Fires Home Assistant events when albums change:
|
||||||
|
- `immich_album_watcher_album_changed` - General album changes
|
||||||
|
- `immich_album_watcher_assets_added` - When new assets are added
|
||||||
|
- `immich_album_watcher_assets_removed` - When assets are removed
|
||||||
|
- **Enhanced Event Data** - Events include detailed asset info:
|
||||||
|
- Asset type (photo/video)
|
||||||
|
- Filename
|
||||||
|
- Creation date
|
||||||
|
- Asset owner (who uploaded the asset)
|
||||||
|
- Asset description/caption
|
||||||
|
- Public URL (if album has a shared link)
|
||||||
|
- Detected people in the asset
|
||||||
|
- **Services** - Custom service calls:
|
||||||
|
- `immich_album_watcher.refresh` - Force immediate data refresh
|
||||||
|
- `immich_album_watcher.get_assets` - Get assets from an album with filtering and ordering
|
||||||
|
- `immich_album_watcher.send_telegram_notification` - Send text, photo, video, or media group to Telegram
|
||||||
|
- **Share Link Management** - Button entities to create and delete share links:
|
||||||
|
- Create/delete public (unprotected) share links
|
||||||
|
- Create/delete password-protected share links
|
||||||
|
- Edit protected link passwords via Text entity
|
||||||
|
- **Configurable Polling** - Adjustable scan interval (10-3600 seconds)
|
||||||
|
- **Localization** - Available in multiple languages:
|
||||||
|
- English
|
||||||
|
- Russian (Русский)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -15,7 +54,7 @@ A collection of custom integrations for Home Assistant.
|
|||||||
1. Open HACS in Home Assistant
|
1. Open HACS in Home Assistant
|
||||||
2. Click on the three dots in the top right corner
|
2. Click on the three dots in the top right corner
|
||||||
3. Select **Custom repositories**
|
3. Select **Custom repositories**
|
||||||
4. Add this repository URL: `https://git.dolgolyov-family.by/alexei.dolgolyov/haos-integrations`
|
4. Add this repository URL: `https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher`
|
||||||
5. Select **Integration** as the category
|
5. Select **Integration** as the category
|
||||||
6. Click **Add**
|
6. Click **Add**
|
||||||
7. Search for "Immich Album Watcher" in HACS and install it
|
7. Search for "Immich Album Watcher" in HACS and install it
|
||||||
@@ -29,6 +68,402 @@ A collection of custom integrations for Home Assistant.
|
|||||||
3. Restart Home Assistant
|
3. Restart Home Assistant
|
||||||
4. Add the integration via **Settings** → **Devices & Services** → **Add Integration**
|
4. Add the integration via **Settings** → **Devices & Services** → **Add Integration**
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| Server URL | Your Immich server URL (e.g., `https://immich.example.com`) | Required |
|
||||||
|
| API Key | Your Immich API key | Required |
|
||||||
|
| Albums | Albums to monitor | Required |
|
||||||
|
| Scan Interval | How often to check for changes (seconds) | 60 |
|
||||||
|
| Telegram Bot Token | Bot token for sending media to Telegram (optional) | - |
|
||||||
|
|
||||||
|
## Entities Created (per album)
|
||||||
|
|
||||||
|
| Entity Type | Name | Description |
|
||||||
|
|-------------|------|-------------|
|
||||||
|
| Sensor | Album ID | Album identifier with `album_name` and `share_url` attributes |
|
||||||
|
| Sensor | Asset Count | Total number of assets (includes `people` list in attributes) |
|
||||||
|
| Sensor | Photo Count | Number of photos in the album |
|
||||||
|
| Sensor | Video Count | Number of videos in the album |
|
||||||
|
| Sensor | Last Updated | When the album was last modified |
|
||||||
|
| Sensor | Created | When the album was created |
|
||||||
|
| Sensor | Public URL | Public share link URL (accessible links without password) |
|
||||||
|
| Sensor | Protected URL | Password-protected share link URL (if any exist) |
|
||||||
|
| Sensor | Protected Password | Password for the protected share link (read-only) |
|
||||||
|
| Binary Sensor | New Assets | On when new assets were recently added |
|
||||||
|
| Camera | Thumbnail | Album cover image |
|
||||||
|
| Text | Protected Password | Editable password for the protected share link |
|
||||||
|
| Button | Create Share Link | Creates an unprotected public share link |
|
||||||
|
| Button | Delete Share Link | Deletes the unprotected public share link |
|
||||||
|
| Button | Create Protected Link | Creates a password-protected share link |
|
||||||
|
| Button | Delete Protected Link | Deletes the password-protected share link |
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### Refresh
|
||||||
|
|
||||||
|
Force an immediate refresh of all album data:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: immich_album_watcher.refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Assets
|
||||||
|
|
||||||
|
Get assets from a specific album with optional filtering and ordering (returns response data). Only returns fully processed assets (videos with completed transcoding, photos with generated thumbnails).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: immich_album_watcher.get_assets
|
||||||
|
target:
|
||||||
|
entity_id: sensor.album_name_asset_limit
|
||||||
|
data:
|
||||||
|
limit: 10 # Maximum number of assets (1-100)
|
||||||
|
favorite_only: false # true = favorites only, false = all assets
|
||||||
|
filter_min_rating: 4 # Min rating (1-5)
|
||||||
|
order_by: "date" # Options: "date", "rating", "name"
|
||||||
|
order: "descending" # Options: "ascending", "descending", "random"
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
- `limit` (optional, default: 10): Maximum number of assets to return (1-100)
|
||||||
|
- `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
|
||||||
|
- `order` (optional, default: "descending"): Sort direction
|
||||||
|
- `"ascending"`: Ascending order
|
||||||
|
- `"descending"`: Descending order
|
||||||
|
- `"random"`: Random order (ignores `order_by`)
|
||||||
|
- `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"`)
|
||||||
|
|
||||||
|
**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: "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"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Send Telegram Notification
|
||||||
|
|
||||||
|
Send notifications to Telegram. Supports multiple formats:
|
||||||
|
|
||||||
|
- **Text message** - When `urls` is empty or not provided
|
||||||
|
- **Single photo** - When `urls` contains one photo
|
||||||
|
- **Single video** - When `urls` contains one video
|
||||||
|
- **Media group** - When `urls` contains multiple items
|
||||||
|
|
||||||
|
The service downloads media from Immich and uploads it to Telegram, bypassing any CORS restrictions. Large lists of media are automatically split into multiple media groups based on the `max_group_size` parameter (default: 10 items per group).
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
Text message:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: immich_album_watcher.send_telegram_notification
|
||||||
|
target:
|
||||||
|
entity_id: sensor.album_name_asset_limit
|
||||||
|
data:
|
||||||
|
chat_id: "-1001234567890"
|
||||||
|
caption: "Check out the new album!"
|
||||||
|
disable_web_page_preview: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Single photo:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: immich_album_watcher.send_telegram_notification
|
||||||
|
target:
|
||||||
|
entity_id: sensor.album_name_asset_limit
|
||||||
|
data:
|
||||||
|
chat_id: "-1001234567890"
|
||||||
|
urls:
|
||||||
|
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||||
|
type: photo
|
||||||
|
caption: "Beautiful sunset!"
|
||||||
|
```
|
||||||
|
|
||||||
|
Media group:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: immich_album_watcher.send_telegram_notification
|
||||||
|
target:
|
||||||
|
entity_id: sensor.album_name_asset_limit
|
||||||
|
data:
|
||||||
|
chat_id: "-1001234567890"
|
||||||
|
urls:
|
||||||
|
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||||
|
type: photo
|
||||||
|
- url: "https://immich.example.com/api/assets/zzz/video/playback?key=yyy"
|
||||||
|
type: video
|
||||||
|
caption: "New photos from the album!"
|
||||||
|
reply_to_message_id: 123
|
||||||
|
```
|
||||||
|
|
||||||
|
HTML formatting:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: immich_album_watcher.send_telegram_notification
|
||||||
|
target:
|
||||||
|
entity_id: sensor.album_name_asset_limit
|
||||||
|
data:
|
||||||
|
chat_id: "-1001234567890"
|
||||||
|
caption: |
|
||||||
|
<b>Album Updated!</b>
|
||||||
|
New photos by <i>{{ trigger.event.data.added_assets[0].owner }}</i>
|
||||||
|
<a href="https://immich.example.com/album">View Album</a>
|
||||||
|
parse_mode: "HTML" # Default, can be omitted
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-blocking mode (fire-and-forget):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
service: immich_album_watcher.send_telegram_notification
|
||||||
|
target:
|
||||||
|
entity_id: sensor.album_name_asset_limit
|
||||||
|
data:
|
||||||
|
chat_id: "-1001234567890"
|
||||||
|
urls:
|
||||||
|
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||||
|
type: photo
|
||||||
|
caption: "Quick notification"
|
||||||
|
wait_for_response: false # Automation continues immediately
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description | Required |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| `chat_id` | Telegram chat ID to send to | Yes |
|
||||||
|
| `urls` | List of media items with `url` and `type` (photo/video). Empty for text message. | No |
|
||||||
|
| `bot_token` | Telegram bot token (uses configured token if not provided) | No |
|
||||||
|
| `caption` | For media: caption applied to first item. For text: the message text. Supports HTML formatting by default. | No |
|
||||||
|
| `reply_to_message_id` | Message ID to reply to | No |
|
||||||
|
| `disable_web_page_preview` | Disable link previews in text messages | No |
|
||||||
|
| `parse_mode` | How to parse caption/text. Options: `HTML`, `Markdown`, `MarkdownV2`, or empty string for plain text. Default: `HTML` | No |
|
||||||
|
| `max_group_size` | Maximum media items per group (2-10). Large lists split into multiple groups. Default: 10 | No |
|
||||||
|
| `chunk_delay` | Delay in milliseconds between sending multiple groups (0-60000). Useful for rate limiting. Default: 0 | No |
|
||||||
|
| `wait_for_response` | Wait for Telegram to finish processing. Set to `false` for fire-and-forget (automation continues immediately). Default: `true` | No |
|
||||||
|
| `max_asset_data_size` | Maximum asset size in bytes. Assets exceeding this limit will be skipped. Default: no limit | No |
|
||||||
|
| `send_large_photos_as_documents` | Handle photos exceeding Telegram limits (10MB or 10000px dimension sum). If `true`, send as documents. If `false`, skip oversized photos. Default: `false` | No |
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
The integration fires multiple event types that you can use in your automations:
|
||||||
|
|
||||||
|
### Available Events
|
||||||
|
|
||||||
|
| Event Type | Description | When Fired |
|
||||||
|
|------------|-------------|------------|
|
||||||
|
| `immich_album_watcher_album_changed` | General album change event | Fired for any album change |
|
||||||
|
| `immich_album_watcher_assets_added` | Assets were added to the album | When new photos/videos are added |
|
||||||
|
| `immich_album_watcher_assets_removed` | Assets were removed from the album | When photos/videos are removed |
|
||||||
|
| `immich_album_watcher_album_renamed` | Album name was changed | When the album is renamed |
|
||||||
|
| `immich_album_watcher_album_deleted` | Album was deleted | When the album is deleted from Immich |
|
||||||
|
| `immich_album_watcher_album_sharing_changed` | Album sharing status changed | When album is shared or unshared |
|
||||||
|
|
||||||
|
### Example Usage
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
automation:
|
||||||
|
- alias: "New photos added to album"
|
||||||
|
trigger:
|
||||||
|
- platform: event
|
||||||
|
event_type: immich_album_watcher_assets_added
|
||||||
|
action:
|
||||||
|
- service: notify.mobile_app
|
||||||
|
data:
|
||||||
|
title: "New Photos"
|
||||||
|
message: "{{ trigger.event.data.added_limit }} new photos in {{ trigger.event.data.album_name }}"
|
||||||
|
|
||||||
|
- alias: "Album renamed"
|
||||||
|
trigger:
|
||||||
|
- platform: event
|
||||||
|
event_type: immich_album_watcher_album_renamed
|
||||||
|
action:
|
||||||
|
- service: notify.mobile_app
|
||||||
|
data:
|
||||||
|
title: "Album Renamed"
|
||||||
|
message: "Album '{{ trigger.event.data.old_name }}' renamed to '{{ trigger.event.data.new_name }}'"
|
||||||
|
|
||||||
|
- alias: "Album deleted"
|
||||||
|
trigger:
|
||||||
|
- platform: event
|
||||||
|
event_type: immich_album_watcher_album_deleted
|
||||||
|
action:
|
||||||
|
- service: notify.mobile_app
|
||||||
|
data:
|
||||||
|
title: "Album Deleted"
|
||||||
|
message: "Album '{{ trigger.event.data.album_name }}' was deleted"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Data
|
||||||
|
|
||||||
|
| Field | Description | Available In |
|
||||||
|
|-------|-------------|--------------|
|
||||||
|
| `hub_name` | Hub name configured in integration | All events |
|
||||||
|
| `album_id` | Album ID | All events |
|
||||||
|
| `album_name` | Current album name | All events |
|
||||||
|
| `album_url` | Public URL to view the album (only present if album has a shared link) | All events except `album_deleted` |
|
||||||
|
| `change_type` | Type of change (assets_added, assets_removed, album_renamed, album_sharing_changed, changed) | All events except `album_deleted` |
|
||||||
|
| `shared` | Current sharing status of the album | All events except `album_deleted` |
|
||||||
|
| `added_limit` | Number of assets added | `album_changed`, `assets_added` |
|
||||||
|
| `removed_limit` | Number of assets removed | `album_changed`, `assets_removed` |
|
||||||
|
| `added_assets` | List of added assets with details (see below) | `album_changed`, `assets_added` |
|
||||||
|
| `removed_assets` | List of removed asset IDs | `album_changed`, `assets_removed` |
|
||||||
|
| `people` | List of all people detected in the album | All events except `album_deleted` |
|
||||||
|
| `old_name` | Previous album name | `album_renamed` |
|
||||||
|
| `new_name` | New album name | `album_renamed` |
|
||||||
|
| `old_shared` | Previous sharing status | `album_sharing_changed` |
|
||||||
|
| `new_shared` | New sharing status | `album_sharing_changed` |
|
||||||
|
|
||||||
|
### Added Assets Fields
|
||||||
|
|
||||||
|
Each item in the `added_assets` list contains the following fields:
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `id` | Unique asset ID |
|
||||||
|
| `type` | Type of asset (`IMAGE` or `VIDEO`) |
|
||||||
|
| `filename` | Original filename of the asset |
|
||||||
|
| `created_at` | Date/time when the asset was originally created |
|
||||||
|
| `owner` | Display name of the user who owns the asset |
|
||||||
|
| `owner_id` | Unique ID of the user who owns the asset |
|
||||||
|
| `description` | Description/caption of the asset (from EXIF data) |
|
||||||
|
| `is_favorite` | Whether the asset is marked as favorite (`true` or `false`) |
|
||||||
|
| `rating` | User rating of the asset (1-5 stars, or `null` if not rated) |
|
||||||
|
| `url` | Public URL to view the asset (only present if album has a shared link) |
|
||||||
|
| `download_url` | Direct download URL for the original file (if shared link exists) |
|
||||||
|
| `playback_url` | Video playback URL (for VIDEO assets only, if shared link exists) |
|
||||||
|
| `photo_url` | Photo preview URL (for IMAGE assets only, if shared link exists) |
|
||||||
|
| `people` | List of people detected in this specific asset |
|
||||||
|
|
||||||
|
> **Note:** Assets are only included in events and service responses when they are fully processed by Immich. For videos, this means transcoding must be complete (with `encodedVideoPath`). For photos, thumbnail generation must be complete (with `thumbhash`). This ensures that all media URLs are valid and accessible. Unprocessed assets are silently ignored until their processing completes.
|
||||||
|
|
||||||
|
Example accessing asset owner in an automation:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
automation:
|
||||||
|
- alias: "Notify when someone adds photos"
|
||||||
|
trigger:
|
||||||
|
- platform: event
|
||||||
|
event_type: immich_album_watcher_assets_added
|
||||||
|
action:
|
||||||
|
- service: notify.mobile_app
|
||||||
|
data:
|
||||||
|
title: "New Photos"
|
||||||
|
message: >
|
||||||
|
{{ trigger.event.data.added_assets[0].owner }} added
|
||||||
|
{{ trigger.event.data.added_limit }} photos to {{ trigger.event.data.album_name }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Home Assistant 2024.1.0 or newer
|
||||||
|
- Immich server with API access
|
||||||
|
- Valid Immich API key with the following permissions:
|
||||||
|
|
||||||
|
### Required API Permissions
|
||||||
|
|
||||||
|
| Permission | Required | Description |
|
||||||
|
| ---------- | -------- | ----------- |
|
||||||
|
| `album.read` | Yes | Read album data and asset lists |
|
||||||
|
| `asset.read` | Yes | Read asset details (type, filename, creation date) |
|
||||||
|
| `user.read` | Yes | Resolve asset owner names |
|
||||||
|
| `person.read` | Yes | Read face recognition / people data |
|
||||||
|
| `sharedLink.read` | Yes | Read shared links for public/protected URL sensors |
|
||||||
|
| `sharedLink.create` | Optional | Create share links via the Button entities |
|
||||||
|
| `sharedLink.edit` | Optional | Edit shared link passwords via the Text entity |
|
||||||
|
| `sharedLink.delete` | Optional | Delete share links via the Button entities |
|
||||||
|
|
||||||
|
> **Note:** Without optional permissions, the corresponding entities will be unavailable or non-functional.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please feel free to submit issues or pull requests.
|
Contributions are welcome! Please feel free to submit issues or pull requests.
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
# Immich Album Watcher
|
|
||||||
|
|
||||||
<img src="icon.png" alt="Immich" width="64" height="64">
|
|
||||||
|
|
||||||
A Home Assistant custom integration that monitors [Immich](https://immich.app/) photo/video library albums for changes and exposes them as Home Assistant entities with event-firing capabilities.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Album Monitoring** - Watch selected Immich albums for asset additions and removals
|
|
||||||
- **Rich Sensor Data** - Multiple sensors per album:
|
|
||||||
- Asset count (total)
|
|
||||||
- Photo count
|
|
||||||
- Video count
|
|
||||||
- People count (detected faces)
|
|
||||||
- Last updated timestamp
|
|
||||||
- Creation date
|
|
||||||
- **Camera Entity** - Album thumbnail displayed as a camera entity for dashboards
|
|
||||||
- **Binary Sensor** - "New Assets" indicator that turns on when assets are added
|
|
||||||
- **Face Recognition** - Detects and lists people recognized in album photos
|
|
||||||
- **Event Firing** - Fires Home Assistant events when albums change:
|
|
||||||
- `immich_album_watcher_album_changed` - General album changes
|
|
||||||
- `immich_album_watcher_assets_added` - When new assets are added
|
|
||||||
- `immich_album_watcher_assets_removed` - When assets are removed
|
|
||||||
- **Enhanced Event Data** - Events include detailed asset info:
|
|
||||||
- Asset type (photo/video)
|
|
||||||
- Filename
|
|
||||||
- Creation date
|
|
||||||
- Asset owner (who uploaded the asset)
|
|
||||||
- Asset description/caption
|
|
||||||
- Public URL (if album has a shared link)
|
|
||||||
- Detected people in the asset
|
|
||||||
- **Services** - Custom service calls:
|
|
||||||
- `immich_album_watcher.refresh` - Force immediate data refresh
|
|
||||||
- `immich_album_watcher.get_recent_assets` - Get recent assets from an album
|
|
||||||
- **Configurable Polling** - Adjustable scan interval (10-3600 seconds)
|
|
||||||
|
|
||||||
## Entities Created (per album)
|
|
||||||
|
|
||||||
| Entity Type | Name | Description |
|
|
||||||
|-------------|------|-------------|
|
|
||||||
| Sensor | Asset Count | Total number of assets in the album |
|
|
||||||
| Sensor | Photo Count | Number of photos in the album |
|
|
||||||
| Sensor | Video Count | Number of videos in the album |
|
|
||||||
| Sensor | People Count | Number of unique people detected |
|
|
||||||
| Sensor | Last Updated | When the album was last modified |
|
|
||||||
| Sensor | Created | When the album was created |
|
|
||||||
| Sensor | Public URL | Public share link URL (accessible links without password) |
|
|
||||||
| Sensor | Protected URL | Password-protected share link URL (if any exist) |
|
|
||||||
| Sensor | Protected Password | Password for the protected share link (read-only) |
|
|
||||||
| Binary Sensor | New Assets | On when new assets were recently added |
|
|
||||||
| Camera | Thumbnail | Album cover image |
|
|
||||||
| Text | Share Password | Editable password for the protected share link |
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
1. Copy the `immich_album_watcher` folder to your Home Assistant `custom_components` directory
|
|
||||||
2. Restart Home Assistant
|
|
||||||
3. Go to **Settings** → **Devices & Services** → **Add Integration**
|
|
||||||
4. Search for "Immich Album Watcher"
|
|
||||||
5. Enter your Immich server URL and API key
|
|
||||||
6. Select the albums you want to monitor
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
| Option | Description | Default |
|
|
||||||
|--------|-------------|---------|
|
|
||||||
| Server URL | Your Immich server URL (e.g., `https://immich.example.com`) | Required |
|
|
||||||
| API Key | Your Immich API key | Required |
|
|
||||||
| Albums | Albums to monitor | Required |
|
|
||||||
| Scan Interval | How often to check for changes (seconds) | 60 |
|
|
||||||
|
|
||||||
## Services
|
|
||||||
|
|
||||||
### Refresh
|
|
||||||
|
|
||||||
Force an immediate refresh of all album data:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
service: immich_album_watcher.refresh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get Recent Assets
|
|
||||||
|
|
||||||
Get the most recent assets from a specific album (returns response data):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
service: immich_album_watcher.get_recent_assets
|
|
||||||
data:
|
|
||||||
album_id: "your-album-id-here"
|
|
||||||
count: 10
|
|
||||||
```
|
|
||||||
|
|
||||||
## Events
|
|
||||||
|
|
||||||
Use these events in your automations:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
automation:
|
|
||||||
- alias: "New photos added to album"
|
|
||||||
trigger:
|
|
||||||
- platform: event
|
|
||||||
event_type: immich_album_watcher_assets_added
|
|
||||||
action:
|
|
||||||
- service: notify.mobile_app
|
|
||||||
data:
|
|
||||||
title: "New Photos"
|
|
||||||
message: "{{ trigger.event.data.added_count }} new photos in {{ trigger.event.data.album_name }}"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event Data
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| `album_id` | Album ID |
|
|
||||||
| `album_name` | Album name |
|
|
||||||
| `album_url` | Public URL to view the album (only present if album has a shared link) |
|
|
||||||
| `change_type` | Type of change (assets_added, assets_removed, changed) |
|
|
||||||
| `added_count` | Number of assets added |
|
|
||||||
| `removed_count` | Number of assets removed |
|
|
||||||
| `added_assets` | List of added assets with details (see below) |
|
|
||||||
| `removed_assets` | List of removed asset IDs |
|
|
||||||
| `people` | List of all people detected in the album |
|
|
||||||
|
|
||||||
### Added Assets Fields
|
|
||||||
|
|
||||||
Each item in the `added_assets` list contains the following fields:
|
|
||||||
|
|
||||||
| Field | Description |
|
|
||||||
|-------|-------------|
|
|
||||||
| `id` | Unique asset ID |
|
|
||||||
| `asset_type` | Type of asset (`IMAGE` or `VIDEO`) |
|
|
||||||
| `asset_filename` | Original filename of the asset |
|
|
||||||
| `asset_created` | Date/time when the asset was originally created |
|
|
||||||
| `asset_owner` | Display name of the user who owns the asset |
|
|
||||||
| `asset_owner_id` | Unique ID of the user who owns the asset |
|
|
||||||
| `asset_description` | Description/caption of the asset (from EXIF data) |
|
|
||||||
| `asset_url` | Public URL to view the asset (only present if album has a shared link) |
|
|
||||||
| `people` | List of people detected in this specific asset |
|
|
||||||
|
|
||||||
Example accessing asset owner in an automation:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
automation:
|
|
||||||
- alias: "Notify when someone adds photos"
|
|
||||||
trigger:
|
|
||||||
- platform: event
|
|
||||||
event_type: immich_album_watcher_assets_added
|
|
||||||
action:
|
|
||||||
- service: notify.mobile_app
|
|
||||||
data:
|
|
||||||
title: "New Photos"
|
|
||||||
message: >
|
|
||||||
{{ trigger.event.data.added_assets[0].asset_owner }} added
|
|
||||||
{{ trigger.event.data.added_count }} photos to {{ trigger.event.data.album_name }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Home Assistant 2024.1.0 or newer
|
|
||||||
- Immich server with API access
|
|
||||||
- Valid Immich API key with the following permissions:
|
|
||||||
|
|
||||||
### Required API Permissions
|
|
||||||
|
|
||||||
| Permission | Required | Description |
|
|
||||||
| ---------- | -------- | ----------- |
|
|
||||||
| `album.read` | Yes | Read album data and asset lists |
|
|
||||||
| `asset.read` | Yes | Read asset details (type, filename, creation date) |
|
|
||||||
| `user.read` | Yes | Resolve asset owner names |
|
|
||||||
| `person.read` | Yes | Read face recognition / people data |
|
|
||||||
| `sharedLink.read` | Yes | Read shared links for public/protected URL sensors |
|
|
||||||
| `sharedLink.edit` | Optional | Edit shared link passwords via the Text entity |
|
|
||||||
|
|
||||||
> **Note:** If you don't grant `sharedLink.edit` permission, the "Share Password" text entity will not be able to update passwords but will still display the current password.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT License - see the [LICENSE](../LICENSE) file for details.
|
|
||||||
@@ -20,6 +20,7 @@ from .const import (
|
|||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
)
|
)
|
||||||
from .coordinator import ImmichAlbumWatcherCoordinator
|
from .coordinator import ImmichAlbumWatcherCoordinator
|
||||||
|
from .storage import ImmichAlbumStorage
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -63,10 +64,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo
|
|||||||
scan_interval=scan_interval,
|
scan_interval=scan_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create storage for persisting album state across restarts
|
||||||
|
storage = ImmichAlbumStorage(hass, entry.entry_id)
|
||||||
|
await storage.async_load()
|
||||||
|
|
||||||
# Store hub reference
|
# Store hub reference
|
||||||
hass.data[DOMAIN][entry.entry_id] = {
|
hass.data[DOMAIN][entry.entry_id] = {
|
||||||
"hub": entry.runtime_data,
|
"hub": entry.runtime_data,
|
||||||
"subentries": {},
|
"subentries": {},
|
||||||
|
"storage": storage,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Track loaded subentries to detect changes
|
# Track loaded subentries to detect changes
|
||||||
@@ -97,6 +103,7 @@ async def _async_setup_subentry_coordinator(
|
|||||||
hub_data: ImmichHubData = entry.runtime_data
|
hub_data: ImmichHubData = entry.runtime_data
|
||||||
album_id = subentry.data[CONF_ALBUM_ID]
|
album_id = subentry.data[CONF_ALBUM_ID]
|
||||||
album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||||
|
storage: ImmichAlbumStorage = hass.data[DOMAIN][entry.entry_id]["storage"]
|
||||||
|
|
||||||
_LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id)
|
_LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id)
|
||||||
|
|
||||||
@@ -109,8 +116,12 @@ async def _async_setup_subentry_coordinator(
|
|||||||
album_name=album_name,
|
album_name=album_name,
|
||||||
scan_interval=hub_data.scan_interval,
|
scan_interval=hub_data.scan_interval,
|
||||||
hub_name=hub_data.name,
|
hub_name=hub_data.name,
|
||||||
|
storage=storage,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Load persisted state before first refresh to detect changes during downtime
|
||||||
|
await coordinator.async_load_persisted_state()
|
||||||
|
|
||||||
# Fetch initial data
|
# Fetch initial data
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
@@ -137,7 +130,6 @@ class ImmichAlbumNewAssetsSensor(
|
|||||||
name=self._album_name,
|
name=self._album_name,
|
||||||
manufacturer="Immich",
|
manufacturer="Immich",
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
via_device=(DOMAIN, self._entry.entry_id),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -109,8 +102,7 @@ class ImmichCreateShareLinkButton(
|
|||||||
name=self._album_name,
|
name=self._album_name,
|
||||||
manufacturer="Immich",
|
manufacturer="Immich",
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
via_device=(DOMAIN, self._entry.entry_id),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, str]:
|
def extra_state_attributes(self) -> dict[str, str]:
|
||||||
@@ -174,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.
|
||||||
@@ -200,8 +185,7 @@ class ImmichDeleteShareLinkButton(
|
|||||||
name=self._album_name,
|
name=self._album_name,
|
||||||
manufacturer="Immich",
|
manufacturer="Immich",
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
via_device=(DOMAIN, self._entry.entry_id),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, str]:
|
def extra_state_attributes(self) -> dict[str, str]:
|
||||||
@@ -272,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.
|
||||||
@@ -298,8 +275,7 @@ class ImmichCreateProtectedLinkButton(
|
|||||||
name=self._album_name,
|
name=self._album_name,
|
||||||
manufacturer="Immich",
|
manufacturer="Immich",
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
via_device=(DOMAIN, self._entry.entry_id),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, str]:
|
def extra_state_attributes(self) -> dict[str, str]:
|
||||||
@@ -367,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.
|
||||||
@@ -393,8 +362,7 @@ class ImmichDeleteProtectedLinkButton(
|
|||||||
name=self._album_name,
|
name=self._album_name,
|
||||||
manufacturer="Immich",
|
manufacturer="Immich",
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
via_device=(DOMAIN, self._entry.entry_id),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> dict[str, str]:
|
def extra_state_attributes(self) -> dict[str, str]:
|
||||||
|
|||||||
@@ -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."""
|
||||||
@@ -102,7 +95,6 @@ class ImmichAlbumThumbnailCamera(
|
|||||||
name=self._album_name,
|
name=self._album_name,
|
||||||
manufacturer="Immich",
|
manufacturer="Immich",
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
via_device=(DOMAIN, self._entry.entry_id),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from .const import (
|
|||||||
CONF_HUB_NAME,
|
CONF_HUB_NAME,
|
||||||
CONF_IMMICH_URL,
|
CONF_IMMICH_URL,
|
||||||
CONF_SCAN_INTERVAL,
|
CONF_SCAN_INTERVAL,
|
||||||
|
CONF_TELEGRAM_BOT_TOKEN,
|
||||||
DEFAULT_SCAN_INTERVAL,
|
DEFAULT_SCAN_INTERVAL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SUBENTRY_TYPE_ALBUM,
|
SUBENTRY_TYPE_ALBUM,
|
||||||
@@ -248,12 +249,18 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
|
|||||||
CONF_SCAN_INTERVAL: user_input.get(
|
CONF_SCAN_INTERVAL: user_input.get(
|
||||||
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
||||||
),
|
),
|
||||||
|
CONF_TELEGRAM_BOT_TOKEN: user_input.get(
|
||||||
|
CONF_TELEGRAM_BOT_TOKEN, ""
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
current_interval = self._config_entry.options.get(
|
current_interval = self._config_entry.options.get(
|
||||||
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
||||||
)
|
)
|
||||||
|
current_bot_token = self._config_entry.options.get(
|
||||||
|
CONF_TELEGRAM_BOT_TOKEN, ""
|
||||||
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
@@ -262,6 +269,9 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
|
|||||||
vol.Required(
|
vol.Required(
|
||||||
CONF_SCAN_INTERVAL, default=current_interval
|
CONF_SCAN_INTERVAL, default=current_interval
|
||||||
): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)),
|
): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_TELEGRAM_BOT_TOKEN, default=current_bot_token
|
||||||
|
): str,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ CONF_ALBUMS: Final = "albums"
|
|||||||
CONF_ALBUM_ID: Final = "album_id"
|
CONF_ALBUM_ID: Final = "album_id"
|
||||||
CONF_ALBUM_NAME: Final = "album_name"
|
CONF_ALBUM_NAME: Final = "album_name"
|
||||||
CONF_SCAN_INTERVAL: Final = "scan_interval"
|
CONF_SCAN_INTERVAL: Final = "scan_interval"
|
||||||
|
CONF_TELEGRAM_BOT_TOKEN: Final = "telegram_bot_token"
|
||||||
|
|
||||||
# Subentry type
|
# Subentry type
|
||||||
SUBENTRY_TYPE_ALBUM: Final = "album"
|
SUBENTRY_TYPE_ALBUM: Final = "album"
|
||||||
@@ -26,6 +27,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"
|
||||||
@@ -49,15 +53,21 @@ 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"
|
||||||
|
|
||||||
# Asset types
|
# Asset types
|
||||||
ASSET_TYPE_IMAGE: Final = "IMAGE"
|
ASSET_TYPE_IMAGE: Final = "IMAGE"
|
||||||
@@ -68,4 +78,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_NOTIFICATION: Final = "send_telegram_notification"
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .storage import ImmichAlbumStorage
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
@@ -25,20 +28,31 @@ 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_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__)
|
||||||
@@ -104,6 +118,9 @@ 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
|
||||||
|
is_processed: bool = True # Whether asset is fully processed by Immich
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_api_response(
|
def from_api_response(
|
||||||
@@ -119,23 +136,74 @@ 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
|
||||||
|
|
||||||
|
# Check if asset is fully processed by Immich
|
||||||
|
asset_type = data.get("type", ASSET_TYPE_IMAGE)
|
||||||
|
is_processed = cls._check_processing_status(data, asset_type)
|
||||||
|
|
||||||
return cls(
|
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,
|
||||||
|
is_processed=is_processed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_processing_status(data: dict[str, Any], asset_type: str) -> bool:
|
||||||
|
"""Check if asset has been fully processed by Immich.
|
||||||
|
|
||||||
|
For photos: Check if thumbnails/previews have been generated
|
||||||
|
For videos: Check if video transcoding is complete
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Asset data from API response
|
||||||
|
asset_type: Asset type (IMAGE or VIDEO)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if asset is fully processed, False otherwise
|
||||||
|
"""
|
||||||
|
if asset_type == ASSET_TYPE_VIDEO:
|
||||||
|
# For videos, check if transcoding is complete
|
||||||
|
# Video is processed if it has an encoded video path or if isOffline is False
|
||||||
|
is_offline = data.get("isOffline", False)
|
||||||
|
if is_offline:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if video has been transcoded (has encoded video path)
|
||||||
|
# Immich uses "encodedVideoPath" or similar field when transcoding is done
|
||||||
|
has_encoded_video = bool(data.get("encodedVideoPath"))
|
||||||
|
return has_encoded_video
|
||||||
|
|
||||||
|
else: # ASSET_TYPE_IMAGE
|
||||||
|
# For photos, check if thumbnails have been generated
|
||||||
|
# Photos are processed if they have thumbnail/preview paths
|
||||||
|
is_offline = data.get("isOffline", False)
|
||||||
|
if is_offline:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if thumbnails exist
|
||||||
|
has_thumbhash = bool(data.get("thumbhash"))
|
||||||
|
has_thumbnail = has_thumbhash # If thumbhash exists, thumbnails should exist
|
||||||
|
|
||||||
|
return has_thumbnail
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AlbumData:
|
class AlbumData:
|
||||||
@@ -207,6 +275,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]):
|
||||||
@@ -221,6 +293,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
album_name: str,
|
album_name: str,
|
||||||
scan_interval: int,
|
scan_interval: int,
|
||||||
hub_name: str = "Immich",
|
hub_name: str = "Immich",
|
||||||
|
storage: ImmichAlbumStorage | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the coordinator."""
|
"""Initialize the coordinator."""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -239,6 +312,8 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
self._people_cache: dict[str, str] = {} # person_id -> name
|
self._people_cache: dict[str, str] = {} # person_id -> name
|
||||||
self._users_cache: dict[str, str] = {} # user_id -> name
|
self._users_cache: dict[str, str] = {} # user_id -> name
|
||||||
self._shared_links: list[SharedLinkInfo] = []
|
self._shared_links: list[SharedLinkInfo] = []
|
||||||
|
self._storage = storage
|
||||||
|
self._persisted_asset_ids: set[str] | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def immich_url(self) -> str:
|
def immich_url(self) -> str:
|
||||||
@@ -268,33 +343,108 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
"""Force an immediate refresh."""
|
"""Force an immediate refresh."""
|
||||||
await self.async_request_refresh()
|
await self.async_request_refresh()
|
||||||
|
|
||||||
async def async_get_recent_assets(self, count: int = 10) -> list[dict[str, Any]]:
|
async def async_load_persisted_state(self) -> None:
|
||||||
"""Get recent assets from the album."""
|
"""Load persisted asset IDs from storage.
|
||||||
|
|
||||||
|
This should be called before the first refresh to enable
|
||||||
|
detection of changes that occurred during downtime.
|
||||||
|
"""
|
||||||
|
if self._storage:
|
||||||
|
self._persisted_asset_ids = self._storage.get_album_asset_ids(
|
||||||
|
self._album_id
|
||||||
|
)
|
||||||
|
if self._persisted_asset_ids is not None:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Loaded %d persisted asset IDs for album '%s'",
|
||||||
|
len(self._persisted_asset_ids),
|
||||||
|
self._album_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_get_assets(
|
||||||
|
self,
|
||||||
|
limit: int = 10,
|
||||||
|
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,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get assets from the album with optional filtering and ordering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of assets to return (1-100)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 ordering
|
||||||
|
if order == "random":
|
||||||
|
import random
|
||||||
|
random.shuffle(assets)
|
||||||
|
else:
|
||||||
|
# Determine sort key based on order_by
|
||||||
|
if 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")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Limit results
|
||||||
|
assets = assets[: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
|
||||||
|
|
||||||
@@ -466,6 +616,68 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
return f"{self._url}/api/assets/{asset_id}/video/playback?key={non_expired[0].key}"
|
return f"{self._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._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._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,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add thumbnail URL if requested
|
||||||
|
if include_thumbnail:
|
||||||
|
asset_detail[ATTR_THUMBNAIL_URL] = f"{self._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:
|
||||||
@@ -487,6 +699,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(
|
||||||
@@ -503,6 +724,47 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
album.has_new_assets = change.added_count > 0
|
album.has_new_assets = change.added_count > 0
|
||||||
album.last_change_time = datetime.now()
|
album.last_change_time = datetime.now()
|
||||||
self._fire_events(change, album)
|
self._fire_events(change, album)
|
||||||
|
elif self._persisted_asset_ids is not None:
|
||||||
|
# First refresh after restart - compare with persisted state
|
||||||
|
added_ids = album.asset_ids - self._persisted_asset_ids
|
||||||
|
removed_ids = self._persisted_asset_ids - album.asset_ids
|
||||||
|
|
||||||
|
if added_ids or removed_ids:
|
||||||
|
change_type = "changed"
|
||||||
|
if added_ids and not removed_ids:
|
||||||
|
change_type = "assets_added"
|
||||||
|
elif removed_ids and not added_ids:
|
||||||
|
change_type = "assets_removed"
|
||||||
|
|
||||||
|
added_assets = [
|
||||||
|
album.assets[aid]
|
||||||
|
for aid in added_ids
|
||||||
|
if aid in album.assets
|
||||||
|
]
|
||||||
|
|
||||||
|
change = AlbumChange(
|
||||||
|
album_id=album.id,
|
||||||
|
album_name=album.name,
|
||||||
|
change_type=change_type,
|
||||||
|
added_count=len(added_ids),
|
||||||
|
removed_count=len(removed_ids),
|
||||||
|
added_assets=added_assets,
|
||||||
|
removed_asset_ids=list(removed_ids),
|
||||||
|
)
|
||||||
|
album.has_new_assets = change.added_count > 0
|
||||||
|
album.last_change_time = datetime.now()
|
||||||
|
self._fire_events(change, album)
|
||||||
|
_LOGGER.info(
|
||||||
|
"Detected changes during downtime for album '%s': +%d -%d",
|
||||||
|
album.name,
|
||||||
|
len(added_ids),
|
||||||
|
len(removed_ids),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
album.has_new_assets = False
|
||||||
|
|
||||||
|
# Clear persisted state after first comparison
|
||||||
|
self._persisted_asset_ids = None
|
||||||
else:
|
else:
|
||||||
album.has_new_assets = False
|
album.has_new_assets = False
|
||||||
|
|
||||||
@@ -517,6 +779,12 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
|||||||
# Update previous state
|
# Update previous state
|
||||||
self._previous_state = album
|
self._previous_state = album
|
||||||
|
|
||||||
|
# Persist current state for recovery after restart
|
||||||
|
if self._storage:
|
||||||
|
await self._storage.async_save_album_state(
|
||||||
|
self._album_id, album.asset_ids
|
||||||
|
)
|
||||||
|
|
||||||
return album
|
return album
|
||||||
|
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
@@ -529,53 +797,54 @@ 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:
|
# Only include fully processed assets in added_assets
|
||||||
|
added_assets = [
|
||||||
|
new_state.assets[aid]
|
||||||
|
for aid in added_ids
|
||||||
|
if aid in new_state.assets and new_state.assets[aid].is_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 = {
|
||||||
@@ -588,8 +857,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
|
||||||
@@ -609,6 +888,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()
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
"codeowners": ["@alexei.dolgolyov"],
|
"codeowners": ["@alexei.dolgolyov"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/haos-integrations",
|
"documentation": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/haos-integrations/issues",
|
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "1.2.0"
|
"version": "2.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,20 @@ from .const import (
|
|||||||
CONF_ALBUM_ID,
|
CONF_ALBUM_ID,
|
||||||
CONF_ALBUM_NAME,
|
CONF_ALBUM_NAME,
|
||||||
CONF_HUB_NAME,
|
CONF_HUB_NAME,
|
||||||
|
CONF_TELEGRAM_BOT_TOKEN,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_GET_RECENT_ASSETS,
|
SERVICE_GET_ASSETS,
|
||||||
SERVICE_REFRESH,
|
SERVICE_REFRESH,
|
||||||
|
SERVICE_SEND_TELEGRAM_NOTIFICATION,
|
||||||
)
|
)
|
||||||
from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
|
from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Telegram photo limits
|
||||||
|
TELEGRAM_MAX_PHOTO_SIZE = 10 * 1024 * 1024 # 10 MB - Telegram's max photo size
|
||||||
|
TELEGRAM_MAX_DIMENSION_SUM = 10000 # Maximum sum of width + height in pixels
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -63,6 +69,7 @@ async def async_setup_entry(
|
|||||||
coordinator = subentry_data.coordinator
|
coordinator = subentry_data.coordinator
|
||||||
|
|
||||||
entities: list[SensorEntity] = [
|
entities: list[SensorEntity] = [
|
||||||
|
ImmichAlbumIdSensor(coordinator, entry, subentry),
|
||||||
ImmichAlbumAssetCountSensor(coordinator, entry, subentry),
|
ImmichAlbumAssetCountSensor(coordinator, entry, subentry),
|
||||||
ImmichAlbumPhotoCountSensor(coordinator, entry, subentry),
|
ImmichAlbumPhotoCountSensor(coordinator, entry, subentry),
|
||||||
ImmichAlbumVideoCountSensor(coordinator, entry, subentry),
|
ImmichAlbumVideoCountSensor(coordinator, entry, subentry),
|
||||||
@@ -85,16 +92,43 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
platform.async_register_entity_service(
|
platform.async_register_entity_service(
|
||||||
SERVICE_GET_RECENT_ASSETS,
|
SERVICE_GET_ASSETS,
|
||||||
{
|
{
|
||||||
vol.Optional("count", default=10): vol.All(
|
vol.Optional("count", default=10): vol.All(
|
||||||
vol.Coerce(int), vol.Range(min=1, max=100)
|
vol.Coerce(int), vol.Range(min=1, max=100)
|
||||||
),
|
),
|
||||||
|
vol.Optional("filter", default="none"): vol.In(["none", "favorite", "rating"]),
|
||||||
|
vol.Optional("filter_min_rating", default=1): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=1, max=5)
|
||||||
|
),
|
||||||
|
vol.Optional("order", default="descending"): vol.In(["ascending", "descending", "random"]),
|
||||||
},
|
},
|
||||||
"async_get_recent_assets",
|
"async_get_assets",
|
||||||
supports_response=SupportsResponse.ONLY,
|
supports_response=SupportsResponse.ONLY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
platform.async_register_entity_service(
|
||||||
|
SERVICE_SEND_TELEGRAM_NOTIFICATION,
|
||||||
|
{
|
||||||
|
vol.Optional("bot_token"): str,
|
||||||
|
vol.Required("chat_id"): vol.Coerce(str),
|
||||||
|
vol.Optional("urls"): list,
|
||||||
|
vol.Optional("caption"): str,
|
||||||
|
vol.Optional("reply_to_message_id"): vol.Coerce(int),
|
||||||
|
vol.Optional("disable_web_page_preview"): bool,
|
||||||
|
vol.Optional("parse_mode", default="HTML"): str,
|
||||||
|
vol.Optional("max_group_size", default=10): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=2, max=10)
|
||||||
|
),
|
||||||
|
vol.Optional("chunk_delay", default=0): vol.All(
|
||||||
|
vol.Coerce(int), vol.Range(min=0, max=60000)
|
||||||
|
),
|
||||||
|
vol.Optional("wait_for_response", default=True): bool,
|
||||||
|
},
|
||||||
|
"async_send_telegram_notification",
|
||||||
|
supports_response=SupportsResponse.OPTIONAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], SensorEntity):
|
class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], SensorEntity):
|
||||||
"""Base sensor for Immich album."""
|
"""Base sensor for Immich album."""
|
||||||
@@ -122,13 +156,6 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
"""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."""
|
||||||
@@ -142,7 +169,6 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
name=self._album_name,
|
name=self._album_name,
|
||||||
manufacturer="Immich",
|
manufacturer="Immich",
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
via_device=(DOMAIN, self._entry.entry_id),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@@ -154,11 +180,819 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
|||||||
"""Refresh data for this album."""
|
"""Refresh data for this album."""
|
||||||
await self.coordinator.async_refresh_now()
|
await self.coordinator.async_refresh_now()
|
||||||
|
|
||||||
async def async_get_recent_assets(self, count: int = 10) -> ServiceResponse:
|
async def async_get_assets(
|
||||||
"""Get recent assets for this album."""
|
self,
|
||||||
assets = await self.coordinator.async_get_recent_assets(count)
|
limit: int = 10,
|
||||||
|
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,
|
||||||
|
) -> ServiceResponse:
|
||||||
|
"""Get assets for this album with optional filtering and ordering."""
|
||||||
|
assets = await self.coordinator.async_get_assets(
|
||||||
|
limit=limit,
|
||||||
|
favorite_only=favorite_only,
|
||||||
|
filter_min_rating=filter_min_rating,
|
||||||
|
order_by=order_by,
|
||||||
|
order=order,
|
||||||
|
asset_type=asset_type,
|
||||||
|
min_date=min_date,
|
||||||
|
max_date=max_date,
|
||||||
|
)
|
||||||
return {"assets": assets}
|
return {"assets": assets}
|
||||||
|
|
||||||
|
async def async_send_telegram_notification(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
urls: list[dict[str, str]] | None = None,
|
||||||
|
bot_token: str | None = None,
|
||||||
|
caption: str | None = None,
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
|
disable_web_page_preview: bool | None = None,
|
||||||
|
parse_mode: str = "HTML",
|
||||||
|
max_group_size: int = 10,
|
||||||
|
chunk_delay: int = 0,
|
||||||
|
wait_for_response: bool = True,
|
||||||
|
max_asset_data_size: int | None = None,
|
||||||
|
send_large_photos_as_documents: bool = False,
|
||||||
|
) -> ServiceResponse:
|
||||||
|
"""Send notification to Telegram.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Empty URLs: sends a simple text message
|
||||||
|
- Single photo: uses sendPhoto API
|
||||||
|
- Single video: uses sendVideo API
|
||||||
|
- Multiple items: uses sendMediaGroup API (splits into multiple groups if needed)
|
||||||
|
|
||||||
|
Each item in urls should be a dict with 'url' and 'type' (photo/video).
|
||||||
|
Downloads media and uploads to Telegram to bypass CORS restrictions.
|
||||||
|
|
||||||
|
If wait_for_response is False, the task will be executed in the background
|
||||||
|
and the service will return immediately.
|
||||||
|
"""
|
||||||
|
# If non-blocking mode, create a background task and return immediately
|
||||||
|
if not wait_for_response:
|
||||||
|
self.hass.async_create_task(
|
||||||
|
self._execute_telegram_notification(
|
||||||
|
chat_id=chat_id,
|
||||||
|
urls=urls,
|
||||||
|
bot_token=bot_token,
|
||||||
|
caption=caption,
|
||||||
|
reply_to_message_id=reply_to_message_id,
|
||||||
|
disable_web_page_preview=disable_web_page_preview,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
max_group_size=max_group_size,
|
||||||
|
chunk_delay=chunk_delay,
|
||||||
|
max_asset_data_size=max_asset_data_size,
|
||||||
|
send_large_photos_as_documents=send_large_photos_as_documents,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {"success": True, "status": "queued", "message": "Notification queued for background processing"}
|
||||||
|
|
||||||
|
# Blocking mode - execute and return result
|
||||||
|
return await self._execute_telegram_notification(
|
||||||
|
chat_id=chat_id,
|
||||||
|
urls=urls,
|
||||||
|
bot_token=bot_token,
|
||||||
|
caption=caption,
|
||||||
|
reply_to_message_id=reply_to_message_id,
|
||||||
|
disable_web_page_preview=disable_web_page_preview,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
max_group_size=max_group_size,
|
||||||
|
chunk_delay=chunk_delay,
|
||||||
|
max_asset_data_size=max_asset_data_size,
|
||||||
|
send_large_photos_as_documents=send_large_photos_as_documents,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _execute_telegram_notification(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
urls: list[dict[str, str]] | None = None,
|
||||||
|
bot_token: str | None = None,
|
||||||
|
caption: str | None = None,
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
|
disable_web_page_preview: bool | None = None,
|
||||||
|
parse_mode: str = "HTML",
|
||||||
|
max_group_size: int = 10,
|
||||||
|
chunk_delay: int = 0,
|
||||||
|
max_asset_data_size: int | None = None,
|
||||||
|
send_large_photos_as_documents: bool = False,
|
||||||
|
) -> ServiceResponse:
|
||||||
|
"""Execute the Telegram notification (internal method)."""
|
||||||
|
import json
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import FormData
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
# Get bot token from parameter or config
|
||||||
|
token = bot_token or self._entry.options.get(CONF_TELEGRAM_BOT_TOKEN)
|
||||||
|
if not token:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "No bot token provided. Set it in integration options or pass as parameter.",
|
||||||
|
}
|
||||||
|
|
||||||
|
session = async_get_clientsession(self.hass)
|
||||||
|
|
||||||
|
# Handle empty URLs - send simple text message
|
||||||
|
if not urls:
|
||||||
|
return await self._send_telegram_message(
|
||||||
|
session, token, chat_id, caption or "", reply_to_message_id, disable_web_page_preview, parse_mode
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle single photo
|
||||||
|
if len(urls) == 1 and urls[0].get("type", "photo") == "photo":
|
||||||
|
return await self._send_telegram_photo(
|
||||||
|
session, token, chat_id, urls[0].get("url"), caption, reply_to_message_id, parse_mode,
|
||||||
|
max_asset_data_size, send_large_photos_as_documents
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle single video
|
||||||
|
if len(urls) == 1 and urls[0].get("type") == "video":
|
||||||
|
return await self._send_telegram_video(
|
||||||
|
session, token, chat_id, urls[0].get("url"), caption, reply_to_message_id, parse_mode, max_asset_data_size
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle multiple items - send as media group(s)
|
||||||
|
return await self._send_telegram_media_group(
|
||||||
|
session, token, chat_id, urls, caption, reply_to_message_id, max_group_size, chunk_delay, parse_mode,
|
||||||
|
max_asset_data_size, send_large_photos_as_documents
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send_telegram_message(
|
||||||
|
self,
|
||||||
|
session: Any,
|
||||||
|
token: str,
|
||||||
|
chat_id: str,
|
||||||
|
text: str,
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
|
disable_web_page_preview: bool | None = None,
|
||||||
|
parse_mode: str = "HTML",
|
||||||
|
) -> ServiceResponse:
|
||||||
|
"""Send a simple text message to Telegram."""
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
telegram_url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text or "Notification from Home Assistant",
|
||||||
|
"parse_mode": parse_mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply_to_message_id:
|
||||||
|
payload["reply_to_message_id"] = reply_to_message_id
|
||||||
|
|
||||||
|
if disable_web_page_preview is not None:
|
||||||
|
payload["disable_web_page_preview"] = disable_web_page_preview
|
||||||
|
|
||||||
|
try:
|
||||||
|
_LOGGER.debug("Sending text message to Telegram")
|
||||||
|
async with session.post(telegram_url, json=payload) as response:
|
||||||
|
result = await response.json()
|
||||||
|
_LOGGER.debug("Telegram API response: status=%d, ok=%s", response.status, result.get("ok"))
|
||||||
|
if response.status == 200 and result.get("ok"):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message_id": result.get("result", {}).get("message_id"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
_LOGGER.error("Telegram API error: %s", result)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.get("description", "Unknown Telegram error"),
|
||||||
|
"error_code": result.get("error_code"),
|
||||||
|
}
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.error("Telegram message send failed: %s", err)
|
||||||
|
return {"success": False, "error": str(err)}
|
||||||
|
|
||||||
|
def _log_telegram_error(
|
||||||
|
self,
|
||||||
|
error_code: int | None,
|
||||||
|
description: str,
|
||||||
|
data: bytes | None = None,
|
||||||
|
media_type: str = "photo",
|
||||||
|
) -> None:
|
||||||
|
"""Log detailed Telegram API error with diagnostics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_code: Telegram error code
|
||||||
|
description: Error description from Telegram
|
||||||
|
data: Media data bytes (optional, for size diagnostics)
|
||||||
|
media_type: Type of media (photo/video)
|
||||||
|
"""
|
||||||
|
error_msg = f"Telegram API error ({error_code}): {description}"
|
||||||
|
|
||||||
|
# Add diagnostic information based on error type
|
||||||
|
if data:
|
||||||
|
error_msg += f" | Media size: {len(data)} bytes ({len(data) / (1024 * 1024):.2f} MB)"
|
||||||
|
|
||||||
|
# Check dimensions for photos
|
||||||
|
if media_type == "photo":
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(data))
|
||||||
|
width, height = img.size
|
||||||
|
dimension_sum = width + height
|
||||||
|
error_msg += f" | Dimensions: {width}x{height} (sum={dimension_sum})"
|
||||||
|
|
||||||
|
# Highlight limit violations
|
||||||
|
if len(data) > TELEGRAM_MAX_PHOTO_SIZE:
|
||||||
|
error_msg += f" | EXCEEDS size limit ({TELEGRAM_MAX_PHOTO_SIZE / (1024 * 1024):.0f} MB)"
|
||||||
|
if dimension_sum > TELEGRAM_MAX_DIMENSION_SUM:
|
||||||
|
error_msg += f" | EXCEEDS dimension limit ({TELEGRAM_MAX_DIMENSION_SUM})"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Provide suggestions based on error description
|
||||||
|
suggestions = []
|
||||||
|
if "dimension" in description.lower() or "PHOTO_INVALID_DIMENSIONS" in description:
|
||||||
|
suggestions.append("Photo dimensions too large - consider setting send_large_photos_as_documents=true")
|
||||||
|
elif "too large" in description.lower() or error_code == 413:
|
||||||
|
suggestions.append("File size too large - consider setting send_large_photos_as_documents=true or max_asset_data_size to skip large files")
|
||||||
|
elif "entity too large" in description.lower():
|
||||||
|
suggestions.append("Request entity too large - reduce max_group_size or set max_asset_data_size")
|
||||||
|
|
||||||
|
if suggestions:
|
||||||
|
error_msg += f" | Suggestions: {'; '.join(suggestions)}"
|
||||||
|
|
||||||
|
_LOGGER.error(error_msg)
|
||||||
|
|
||||||
|
def _check_telegram_photo_limits(
|
||||||
|
self,
|
||||||
|
data: bytes,
|
||||||
|
) -> tuple[bool, str | None, int | None, int | None]:
|
||||||
|
"""Check if photo data exceeds Telegram photo limits.
|
||||||
|
|
||||||
|
Telegram limits for photos:
|
||||||
|
- Max file size: 10 MB
|
||||||
|
- Max dimension sum: ~10,000 pixels (width + height)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (exceeds_limits, reason, width, height)
|
||||||
|
- exceeds_limits: True if photo exceeds limits
|
||||||
|
- reason: Human-readable reason (None if within limits)
|
||||||
|
- width: Image width in pixels (None if PIL not available)
|
||||||
|
- height: Image height in pixels (None if PIL not available)
|
||||||
|
"""
|
||||||
|
# Check file size
|
||||||
|
if len(data) > TELEGRAM_MAX_PHOTO_SIZE:
|
||||||
|
return True, f"size {len(data)} bytes exceeds {TELEGRAM_MAX_PHOTO_SIZE} bytes limit", None, None
|
||||||
|
|
||||||
|
# Try to check dimensions using PIL
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(data))
|
||||||
|
width, height = img.size
|
||||||
|
dimension_sum = width + height
|
||||||
|
|
||||||
|
if dimension_sum > TELEGRAM_MAX_DIMENSION_SUM:
|
||||||
|
return True, f"dimensions {width}x{height} (sum={dimension_sum}) exceed {TELEGRAM_MAX_DIMENSION_SUM} limit", width, height
|
||||||
|
|
||||||
|
return False, None, width, height
|
||||||
|
except ImportError:
|
||||||
|
# PIL not available, can't check dimensions
|
||||||
|
_LOGGER.debug("PIL not available, skipping dimension check")
|
||||||
|
return False, None, None, None
|
||||||
|
except Exception as e:
|
||||||
|
# Failed to check dimensions
|
||||||
|
_LOGGER.debug("Failed to check photo dimensions: %s", e)
|
||||||
|
return False, None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_telegram_photo(
|
||||||
|
self,
|
||||||
|
session: Any,
|
||||||
|
token: str,
|
||||||
|
chat_id: str,
|
||||||
|
url: str | None,
|
||||||
|
caption: str | None = None,
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
|
parse_mode: str = "HTML",
|
||||||
|
max_asset_data_size: int | None = None,
|
||||||
|
send_large_photos_as_documents: bool = False,
|
||||||
|
) -> ServiceResponse:
|
||||||
|
"""Send a single photo to Telegram."""
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import FormData
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return {"success": False, "error": "Missing 'url' for photo"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Download the photo
|
||||||
|
_LOGGER.debug("Downloading photo from %s", url[:80])
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Failed to download photo: HTTP {resp.status}",
|
||||||
|
}
|
||||||
|
data = await resp.read()
|
||||||
|
_LOGGER.debug("Downloaded photo: %d bytes", len(data))
|
||||||
|
|
||||||
|
# Check if photo exceeds max size limit (user-defined limit)
|
||||||
|
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Photo size (%d bytes) exceeds max_asset_data_size limit (%d bytes), skipping",
|
||||||
|
len(data), max_asset_data_size
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Photo size ({len(data)} bytes) exceeds max_asset_data_size limit ({max_asset_data_size} bytes)",
|
||||||
|
"skipped": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if photo exceeds Telegram's photo limits
|
||||||
|
exceeds_limits, reason, width, height = self._check_telegram_photo_limits(data)
|
||||||
|
if exceeds_limits:
|
||||||
|
if send_large_photos_as_documents:
|
||||||
|
# Send as document instead
|
||||||
|
_LOGGER.info("Photo %s, sending as document", reason)
|
||||||
|
return await self._send_telegram_document(
|
||||||
|
session, token, chat_id, data, "photo.jpg",
|
||||||
|
caption, reply_to_message_id, parse_mode
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Skip oversized photo
|
||||||
|
_LOGGER.warning("Photo %s, skipping (set send_large_photos_as_documents=true to send as document)", reason)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Photo {reason}",
|
||||||
|
"skipped": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build multipart form
|
||||||
|
form = FormData()
|
||||||
|
form.add_field("chat_id", chat_id)
|
||||||
|
form.add_field("photo", data, filename="photo.jpg", content_type="image/jpeg")
|
||||||
|
form.add_field("parse_mode", parse_mode)
|
||||||
|
|
||||||
|
if caption:
|
||||||
|
form.add_field("caption", caption)
|
||||||
|
|
||||||
|
if reply_to_message_id:
|
||||||
|
form.add_field("reply_to_message_id", str(reply_to_message_id))
|
||||||
|
|
||||||
|
# Send to Telegram
|
||||||
|
telegram_url = f"https://api.telegram.org/bot{token}/sendPhoto"
|
||||||
|
|
||||||
|
_LOGGER.debug("Uploading photo to Telegram")
|
||||||
|
async with session.post(telegram_url, data=form) as response:
|
||||||
|
result = await response.json()
|
||||||
|
_LOGGER.debug("Telegram API response: status=%d, ok=%s", response.status, result.get("ok"))
|
||||||
|
if response.status == 200 and result.get("ok"):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message_id": result.get("result", {}).get("message_id"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Log detailed error with diagnostics
|
||||||
|
self._log_telegram_error(
|
||||||
|
error_code=result.get("error_code"),
|
||||||
|
description=result.get("description", "Unknown Telegram error"),
|
||||||
|
data=data,
|
||||||
|
media_type="photo",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.get("description", "Unknown Telegram error"),
|
||||||
|
"error_code": result.get("error_code"),
|
||||||
|
}
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.error("Telegram photo upload failed: %s", err)
|
||||||
|
return {"success": False, "error": str(err)}
|
||||||
|
|
||||||
|
async def _send_telegram_video(
|
||||||
|
self,
|
||||||
|
session: Any,
|
||||||
|
token: str,
|
||||||
|
chat_id: str,
|
||||||
|
url: str | None,
|
||||||
|
caption: str | None = None,
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
|
parse_mode: str = "HTML",
|
||||||
|
max_asset_data_size: int | None = None,
|
||||||
|
) -> ServiceResponse:
|
||||||
|
"""Send a single video to Telegram."""
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import FormData
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return {"success": False, "error": "Missing 'url' for video"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Download the video
|
||||||
|
_LOGGER.debug("Downloading video from %s", url[:80])
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Failed to download video: HTTP {resp.status}",
|
||||||
|
}
|
||||||
|
data = await resp.read()
|
||||||
|
_LOGGER.debug("Downloaded video: %d bytes", len(data))
|
||||||
|
|
||||||
|
# Check if video exceeds max size limit
|
||||||
|
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Video size (%d bytes) exceeds max_asset_data_size limit (%d bytes), skipping",
|
||||||
|
len(data), max_asset_data_size
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Video size ({len(data)} bytes) exceeds max_asset_data_size limit ({max_asset_data_size} bytes)",
|
||||||
|
"skipped": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build multipart form
|
||||||
|
form = FormData()
|
||||||
|
form.add_field("chat_id", chat_id)
|
||||||
|
form.add_field("video", data, filename="video.mp4", content_type="video/mp4")
|
||||||
|
form.add_field("parse_mode", parse_mode)
|
||||||
|
|
||||||
|
if caption:
|
||||||
|
form.add_field("caption", caption)
|
||||||
|
|
||||||
|
if reply_to_message_id:
|
||||||
|
form.add_field("reply_to_message_id", str(reply_to_message_id))
|
||||||
|
|
||||||
|
# Send to Telegram
|
||||||
|
telegram_url = f"https://api.telegram.org/bot{token}/sendVideo"
|
||||||
|
|
||||||
|
_LOGGER.debug("Uploading video to Telegram")
|
||||||
|
async with session.post(telegram_url, data=form) as response:
|
||||||
|
result = await response.json()
|
||||||
|
_LOGGER.debug("Telegram API response: status=%d, ok=%s", response.status, result.get("ok"))
|
||||||
|
if response.status == 200 and result.get("ok"):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message_id": result.get("result", {}).get("message_id"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Log detailed error with diagnostics
|
||||||
|
self._log_telegram_error(
|
||||||
|
error_code=result.get("error_code"),
|
||||||
|
description=result.get("description", "Unknown Telegram error"),
|
||||||
|
data=data,
|
||||||
|
media_type="video",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.get("description", "Unknown Telegram error"),
|
||||||
|
"error_code": result.get("error_code"),
|
||||||
|
}
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.error("Telegram video upload failed: %s", err)
|
||||||
|
return {"success": False, "error": str(err)}
|
||||||
|
|
||||||
|
async def _send_telegram_document(
|
||||||
|
self,
|
||||||
|
session: Any,
|
||||||
|
token: str,
|
||||||
|
chat_id: str,
|
||||||
|
data: bytes,
|
||||||
|
filename: str = "photo.jpg",
|
||||||
|
caption: str | None = None,
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
|
parse_mode: str = "HTML",
|
||||||
|
) -> ServiceResponse:
|
||||||
|
"""Send a photo as a document to Telegram (for oversized photos)."""
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import FormData
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build multipart form
|
||||||
|
form = FormData()
|
||||||
|
form.add_field("chat_id", chat_id)
|
||||||
|
form.add_field("document", data, filename=filename, content_type="image/jpeg")
|
||||||
|
form.add_field("parse_mode", parse_mode)
|
||||||
|
|
||||||
|
if caption:
|
||||||
|
form.add_field("caption", caption)
|
||||||
|
|
||||||
|
if reply_to_message_id:
|
||||||
|
form.add_field("reply_to_message_id", str(reply_to_message_id))
|
||||||
|
|
||||||
|
# Send to Telegram
|
||||||
|
telegram_url = f"https://api.telegram.org/bot{token}/sendDocument"
|
||||||
|
|
||||||
|
_LOGGER.debug("Uploading oversized photo as document to Telegram (%d bytes)", len(data))
|
||||||
|
async with session.post(telegram_url, data=form) as response:
|
||||||
|
result = await response.json()
|
||||||
|
_LOGGER.debug("Telegram API response: status=%d, ok=%s", response.status, result.get("ok"))
|
||||||
|
if response.status == 200 and result.get("ok"):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message_id": result.get("result", {}).get("message_id"),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Log detailed error with diagnostics
|
||||||
|
self._log_telegram_error(
|
||||||
|
error_code=result.get("error_code"),
|
||||||
|
description=result.get("description", "Unknown Telegram error"),
|
||||||
|
data=data,
|
||||||
|
media_type="document",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.get("description", "Unknown Telegram error"),
|
||||||
|
"error_code": result.get("error_code"),
|
||||||
|
}
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.error("Telegram document upload failed: %s", err)
|
||||||
|
return {"success": False, "error": str(err)}
|
||||||
|
|
||||||
|
async def _send_telegram_media_group(
|
||||||
|
self,
|
||||||
|
session: Any,
|
||||||
|
token: str,
|
||||||
|
chat_id: str,
|
||||||
|
urls: list[dict[str, str]],
|
||||||
|
caption: str | None = None,
|
||||||
|
reply_to_message_id: int | None = None,
|
||||||
|
max_group_size: int = 10,
|
||||||
|
chunk_delay: int = 0,
|
||||||
|
parse_mode: str = "HTML",
|
||||||
|
max_asset_data_size: int | None = None,
|
||||||
|
send_large_photos_as_documents: bool = False,
|
||||||
|
) -> ServiceResponse:
|
||||||
|
"""Send media URLs to Telegram as media group(s).
|
||||||
|
|
||||||
|
If urls list exceeds max_group_size, splits into multiple media groups.
|
||||||
|
For chunks with single items, uses sendPhoto/sendVideo APIs.
|
||||||
|
Applies chunk_delay (in milliseconds) between groups if specified.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import FormData
|
||||||
|
|
||||||
|
# Split URLs into chunks based on max_group_size
|
||||||
|
chunks = [urls[i:i + max_group_size] for i in range(0, len(urls), max_group_size)]
|
||||||
|
all_message_ids = []
|
||||||
|
|
||||||
|
_LOGGER.debug("Sending %d media items in %d chunk(s) of max %d items (delay: %dms)",
|
||||||
|
len(urls), len(chunks), max_group_size, chunk_delay)
|
||||||
|
|
||||||
|
for chunk_idx, chunk in enumerate(chunks):
|
||||||
|
# Add delay before sending subsequent chunks
|
||||||
|
if chunk_idx > 0 and chunk_delay > 0:
|
||||||
|
delay_seconds = chunk_delay / 1000
|
||||||
|
_LOGGER.debug("Waiting %dms (%ss) before sending chunk %d/%d",
|
||||||
|
chunk_delay, delay_seconds, chunk_idx + 1, len(chunks))
|
||||||
|
await asyncio.sleep(delay_seconds)
|
||||||
|
|
||||||
|
# Optimize: Use single-item APIs for chunks with 1 item
|
||||||
|
if len(chunk) == 1:
|
||||||
|
item = chunk[0]
|
||||||
|
media_type = item.get("type", "photo")
|
||||||
|
url = item.get("url")
|
||||||
|
|
||||||
|
# Only apply caption and reply_to to the first chunk
|
||||||
|
chunk_caption = caption if chunk_idx == 0 else None
|
||||||
|
chunk_reply_to = reply_to_message_id if chunk_idx == 0 else None
|
||||||
|
|
||||||
|
if media_type == "photo":
|
||||||
|
_LOGGER.debug("Sending chunk %d/%d as single photo", chunk_idx + 1, len(chunks))
|
||||||
|
result = await self._send_telegram_photo(
|
||||||
|
session, token, chat_id, url, chunk_caption, chunk_reply_to, parse_mode,
|
||||||
|
max_asset_data_size, send_large_photos_as_documents
|
||||||
|
)
|
||||||
|
else: # video
|
||||||
|
_LOGGER.debug("Sending chunk %d/%d as single video", chunk_idx + 1, len(chunks))
|
||||||
|
result = await self._send_telegram_video(
|
||||||
|
session, token, chat_id, url, chunk_caption, chunk_reply_to, parse_mode, max_asset_data_size
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
result["failed_at_chunk"] = chunk_idx + 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
all_message_ids.append(result.get("message_id"))
|
||||||
|
continue
|
||||||
|
# Multi-item chunk: use sendMediaGroup
|
||||||
|
_LOGGER.debug("Sending chunk %d/%d as media group (%d items)", chunk_idx + 1, len(chunks), len(chunk))
|
||||||
|
|
||||||
|
# Download all media files for this chunk
|
||||||
|
media_files: list[tuple[str, bytes, str]] = [] # (type, data, filename)
|
||||||
|
oversized_photos: list[tuple[bytes, str | None]] = [] # For send_large_photos_as_documents=true
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
for i, item in enumerate(chunk):
|
||||||
|
url = item.get("url")
|
||||||
|
media_type = item.get("type", "photo")
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Missing 'url' in item {chunk_idx * max_group_size + i}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if media_type not in ("photo", "video"):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Invalid type '{media_type}' in item {chunk_idx * max_group_size + i}. Must be 'photo' or 'video'.",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
_LOGGER.debug("Downloading media %d from %s", chunk_idx * max_group_size + i, url[:80])
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Failed to download media {chunk_idx * max_group_size + i}: HTTP {resp.status}",
|
||||||
|
}
|
||||||
|
data = await resp.read()
|
||||||
|
_LOGGER.debug("Downloaded media %d: %d bytes", chunk_idx * max_group_size + i, len(data))
|
||||||
|
|
||||||
|
# Check if media exceeds max_asset_data_size limit (user-defined limit for skipping)
|
||||||
|
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Media %d size (%d bytes) exceeds max_asset_data_size limit (%d bytes), skipping",
|
||||||
|
chunk_idx * max_group_size + i, len(data), max_asset_data_size
|
||||||
|
)
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# For photos, check Telegram limits
|
||||||
|
if media_type == "photo":
|
||||||
|
exceeds_limits, reason, width, height = self._check_telegram_photo_limits(data)
|
||||||
|
if exceeds_limits:
|
||||||
|
if send_large_photos_as_documents:
|
||||||
|
# Separate this photo to send as document later
|
||||||
|
# Caption only on first item of first chunk
|
||||||
|
photo_caption = caption if chunk_idx == 0 and i == 0 and len(media_files) == 0 else None
|
||||||
|
oversized_photos.append((data, photo_caption))
|
||||||
|
_LOGGER.info("Photo %d %s, will send as document", i, reason)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Skip oversized photo
|
||||||
|
_LOGGER.warning("Photo %d %s, skipping (set send_large_photos_as_documents=true to send as document)", i, reason)
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
ext = "jpg" if media_type == "photo" else "mp4"
|
||||||
|
filename = f"media_{chunk_idx * max_group_size + i}.{ext}"
|
||||||
|
media_files.append((media_type, data, filename))
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"Failed to download media {chunk_idx * max_group_size + i}: {err}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Skip this chunk if all files were filtered out
|
||||||
|
if not media_files and not oversized_photos:
|
||||||
|
_LOGGER.info("Chunk %d/%d: all %d media items skipped",
|
||||||
|
chunk_idx + 1, len(chunks), len(chunk))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Send media group if we have normal-sized files
|
||||||
|
if media_files:
|
||||||
|
# Build multipart form
|
||||||
|
form = FormData()
|
||||||
|
form.add_field("chat_id", chat_id)
|
||||||
|
|
||||||
|
# Only use reply_to_message_id for the first chunk
|
||||||
|
if chunk_idx == 0 and reply_to_message_id:
|
||||||
|
form.add_field("reply_to_message_id", str(reply_to_message_id))
|
||||||
|
|
||||||
|
# Build media JSON with attach:// references
|
||||||
|
media_json = []
|
||||||
|
for i, (media_type, data, filename) in enumerate(media_files):
|
||||||
|
attach_name = f"file{i}"
|
||||||
|
media_item: dict[str, Any] = {
|
||||||
|
"type": media_type,
|
||||||
|
"media": f"attach://{attach_name}",
|
||||||
|
}
|
||||||
|
# Only add caption to the first item of the first chunk (if no oversized photos with caption)
|
||||||
|
if chunk_idx == 0 and i == 0 and caption and not oversized_photos:
|
||||||
|
media_item["caption"] = caption
|
||||||
|
media_item["parse_mode"] = parse_mode
|
||||||
|
media_json.append(media_item)
|
||||||
|
|
||||||
|
content_type = "image/jpeg" if media_type == "photo" else "video/mp4"
|
||||||
|
form.add_field(attach_name, data, filename=filename, content_type=content_type)
|
||||||
|
|
||||||
|
form.add_field("media", json.dumps(media_json))
|
||||||
|
|
||||||
|
# Send to Telegram
|
||||||
|
telegram_url = f"https://api.telegram.org/bot{token}/sendMediaGroup"
|
||||||
|
|
||||||
|
try:
|
||||||
|
_LOGGER.debug("Uploading media group chunk %d/%d (%d files) to Telegram",
|
||||||
|
chunk_idx + 1, len(chunks), len(media_files))
|
||||||
|
async with session.post(telegram_url, data=form) as response:
|
||||||
|
result = await response.json()
|
||||||
|
_LOGGER.debug("Telegram API response: status=%d, ok=%s", response.status, result.get("ok"))
|
||||||
|
if response.status == 200 and result.get("ok"):
|
||||||
|
chunk_message_ids = [
|
||||||
|
msg.get("message_id") for msg in result.get("result", [])
|
||||||
|
]
|
||||||
|
all_message_ids.extend(chunk_message_ids)
|
||||||
|
else:
|
||||||
|
# Log detailed error for media group with total size info
|
||||||
|
total_size = sum(len(d) for _, d, _ in media_files)
|
||||||
|
_LOGGER.error(
|
||||||
|
"Telegram API error for chunk %d/%d: %s | Media count: %d | Total size: %d bytes (%.2f MB)",
|
||||||
|
chunk_idx + 1, len(chunks),
|
||||||
|
result.get("description", "Unknown Telegram error"),
|
||||||
|
len(media_files),
|
||||||
|
total_size,
|
||||||
|
total_size / (1024 * 1024)
|
||||||
|
)
|
||||||
|
# Log detailed diagnostics for the first photo in the group
|
||||||
|
for media_type, data, _ in media_files:
|
||||||
|
if media_type == "photo":
|
||||||
|
self._log_telegram_error(
|
||||||
|
error_code=result.get("error_code"),
|
||||||
|
description=result.get("description", "Unknown Telegram error"),
|
||||||
|
data=data,
|
||||||
|
media_type="photo",
|
||||||
|
)
|
||||||
|
break # Only log details for first photo
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": result.get("description", "Unknown Telegram error"),
|
||||||
|
"error_code": result.get("error_code"),
|
||||||
|
"failed_at_chunk": chunk_idx + 1,
|
||||||
|
}
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.error("Telegram upload failed for chunk %d: %s", chunk_idx + 1, err)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(err),
|
||||||
|
"failed_at_chunk": chunk_idx + 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send oversized photos as documents
|
||||||
|
for i, (data, photo_caption) in enumerate(oversized_photos):
|
||||||
|
_LOGGER.debug("Sending oversized photo %d/%d as document", i + 1, len(oversized_photos))
|
||||||
|
result = await self._send_telegram_document(
|
||||||
|
session, token, chat_id, data, f"photo_{i}.jpg",
|
||||||
|
photo_caption, None, parse_mode
|
||||||
|
)
|
||||||
|
if result.get("success"):
|
||||||
|
all_message_ids.append(result.get("message_id"))
|
||||||
|
else:
|
||||||
|
_LOGGER.error("Failed to send oversized photo as document: %s", result.get("error"))
|
||||||
|
# Continue with other photos even if one fails
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message_ids": all_message_ids,
|
||||||
|
"chunks_sent": len(chunks),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ImmichAlbumIdSensor(ImmichAlbumBaseSensor):
|
||||||
|
"""Sensor exposing the Immich album ID."""
|
||||||
|
|
||||||
|
_attr_icon = "mdi:identifier"
|
||||||
|
_attr_translation_key = "album_id"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: ImmichAlbumWatcherCoordinator,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
subentry: ConfigSubentry,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the sensor."""
|
||||||
|
super().__init__(coordinator, entry, subentry)
|
||||||
|
self._attr_unique_id = f"{self._unique_id_prefix}_album_id"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> str | None:
|
||||||
|
"""Return the album ID."""
|
||||||
|
if self._album_data:
|
||||||
|
return self._album_data.id
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extra_state_attributes(self) -> dict[str, Any]:
|
||||||
|
"""Return extra state attributes."""
|
||||||
|
if not self._album_data:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
attrs: dict[str, Any] = {
|
||||||
|
"album_name": self._album_data.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Primary share URL (prefers public, falls back to protected)
|
||||||
|
share_url = self.coordinator.get_any_url()
|
||||||
|
if share_url:
|
||||||
|
attrs["share_url"] = share_url
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor):
|
class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor):
|
||||||
"""Sensor representing an Immich album asset count."""
|
"""Sensor representing an Immich album asset count."""
|
||||||
|
|||||||
@@ -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,3 +24,184 @@ get_recent_assets:
|
|||||||
min: 1
|
min: 1
|
||||||
max: 100
|
max: 100
|
||||||
mode: slider
|
mode: slider
|
||||||
|
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"
|
||||||
|
order:
|
||||||
|
name: Order
|
||||||
|
description: Sort direction.
|
||||||
|
required: false
|
||||||
|
default: "descending"
|
||||||
|
selector:
|
||||||
|
select:
|
||||||
|
options:
|
||||||
|
- label: "Ascending"
|
||||||
|
value: "ascending"
|
||||||
|
- label: "Descending"
|
||||||
|
value: "descending"
|
||||||
|
- label: "Random"
|
||||||
|
value: "random"
|
||||||
|
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:
|
||||||
|
|
||||||
|
send_telegram_notification:
|
||||||
|
name: Send Telegram Notification
|
||||||
|
description: Send a notification to Telegram (text, photo, video, or media group).
|
||||||
|
target:
|
||||||
|
entity:
|
||||||
|
integration: immich_album_watcher
|
||||||
|
domain: sensor
|
||||||
|
fields:
|
||||||
|
bot_token:
|
||||||
|
name: Bot Token
|
||||||
|
description: Telegram bot token. Uses configured token if not provided.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
chat_id:
|
||||||
|
name: Chat ID
|
||||||
|
description: Telegram chat ID to send to.
|
||||||
|
required: true
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
urls:
|
||||||
|
name: URLs
|
||||||
|
description: List of media URLs to send. Each item should have 'url' and 'type' (photo/video). If empty, sends a text message. Large lists are automatically split into multiple media groups.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
object:
|
||||||
|
caption:
|
||||||
|
name: Caption
|
||||||
|
description: Caption text. For media, applied to first item. For empty URLs, this is the message text.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
text:
|
||||||
|
multiline: true
|
||||||
|
reply_to_message_id:
|
||||||
|
name: Reply To Message ID
|
||||||
|
description: Message ID to reply to.
|
||||||
|
required: false
|
||||||
|
selector:
|
||||||
|
number:
|
||||||
|
mode: box
|
||||||
|
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:
|
||||||
|
|||||||
65
custom_components/immich_album_watcher/storage.py
Normal file
65
custom_components/immich_album_watcher/storage.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Storage helpers for Immich Album Watcher."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STORAGE_VERSION = 1
|
||||||
|
STORAGE_KEY_PREFIX = "immich_album_watcher"
|
||||||
|
|
||||||
|
|
||||||
|
class ImmichAlbumStorage:
|
||||||
|
"""Handles persistence of album state across restarts."""
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
|
||||||
|
"""Initialize the storage."""
|
||||||
|
self._store: Store[dict[str, Any]] = Store(
|
||||||
|
hass, STORAGE_VERSION, f"{STORAGE_KEY_PREFIX}.{entry_id}"
|
||||||
|
)
|
||||||
|
self._data: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
async def async_load(self) -> dict[str, Any]:
|
||||||
|
"""Load data from storage."""
|
||||||
|
self._data = await self._store.async_load() or {"albums": {}}
|
||||||
|
_LOGGER.debug("Loaded storage data with %d albums", len(self._data.get("albums", {})))
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
async def async_save_album_state(self, album_id: str, asset_ids: set[str]) -> None:
|
||||||
|
"""Save album asset IDs to storage."""
|
||||||
|
if self._data is None:
|
||||||
|
self._data = {"albums": {}}
|
||||||
|
|
||||||
|
self._data["albums"][album_id] = {
|
||||||
|
"asset_ids": list(asset_ids),
|
||||||
|
"last_updated": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
await self._store.async_save(self._data)
|
||||||
|
|
||||||
|
def get_album_asset_ids(self, album_id: str) -> set[str] | None:
|
||||||
|
"""Get persisted asset IDs for an album.
|
||||||
|
|
||||||
|
Returns None if no persisted state exists for the album.
|
||||||
|
"""
|
||||||
|
if self._data and "albums" in self._data:
|
||||||
|
album_data = self._data["albums"].get(album_id)
|
||||||
|
if album_data:
|
||||||
|
return set(album_data.get("asset_ids", []))
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def async_remove_album(self, album_id: str) -> None:
|
||||||
|
"""Remove an album from storage."""
|
||||||
|
if self._data and "albums" in self._data:
|
||||||
|
self._data["albums"].pop(album_id, None)
|
||||||
|
await self._store.async_save(self._data)
|
||||||
|
|
||||||
|
async def async_remove(self) -> None:
|
||||||
|
"""Remove all storage data."""
|
||||||
|
await self._store.async_remove()
|
||||||
|
self._data = None
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"entity": {
|
"entity": {
|
||||||
"sensor": {
|
"sensor": {
|
||||||
|
"album_id": {
|
||||||
|
"name": "{album_name}: Album ID"
|
||||||
|
},
|
||||||
"album_asset_count": {
|
"album_asset_count": {
|
||||||
"name": "{album_name}: Asset Count"
|
"name": "{album_name}: Asset Count"
|
||||||
},
|
},
|
||||||
@@ -68,13 +71,15 @@
|
|||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Immich Album Watcher Options",
|
"title": "Immich Album Watcher Options",
|
||||||
"description": "Configure which albums to monitor and how often to check for changes.",
|
"description": "Configure how often to check for changes and optional Telegram integration.",
|
||||||
"data": {
|
"data": {
|
||||||
"albums": "Albums to watch",
|
"albums": "Albums to watch",
|
||||||
"scan_interval": "Scan interval (seconds)"
|
"scan_interval": "Scan interval (seconds)",
|
||||||
|
"telegram_bot_token": "Telegram Bot Token"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"scan_interval": "How often to check for album changes (10-3600 seconds)"
|
"scan_interval": "How often to check for album changes (10-3600 seconds)",
|
||||||
|
"telegram_bot_token": "Bot token for sending media to Telegram (optional)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -105,7 +98,6 @@ class ImmichAlbumProtectedPasswordText(
|
|||||||
name=self._album_name,
|
name=self._album_name,
|
||||||
manufacturer="Immich",
|
manufacturer="Immich",
|
||||||
entry_type=DeviceEntryType.SERVICE,
|
entry_type=DeviceEntryType.SERVICE,
|
||||||
via_device=(DOMAIN, self._entry.entry_id),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -115,10 +118,12 @@
|
|||||||
"title": "Immich Album Watcher Options",
|
"title": "Immich Album Watcher Options",
|
||||||
"description": "Configure the polling interval for all albums.",
|
"description": "Configure the polling interval for all albums.",
|
||||||
"data": {
|
"data": {
|
||||||
"scan_interval": "Scan interval (seconds)"
|
"scan_interval": "Scan interval (seconds)",
|
||||||
|
"telegram_bot_token": "Telegram Bot Token"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"scan_interval": "How often to check for album changes (10-3600 seconds)"
|
"scan_interval": "How often to check for album changes (10-3600 seconds)",
|
||||||
|
"telegram_bot_token": "Bot token for sending notifications to Telegram"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,13 +133,95 @@
|
|||||||
"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)."
|
||||||
|
},
|
||||||
|
"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, or name)."
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"name": "Order",
|
||||||
|
"description": "Sort direction (ascending, descending, or random)."
|
||||||
|
},
|
||||||
|
"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)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"send_telegram_notification": {
|
||||||
|
"name": "Send Telegram Notification",
|
||||||
|
"description": "Send a notification to Telegram (text, photo, video, or media group).",
|
||||||
|
"fields": {
|
||||||
|
"bot_token": {
|
||||||
|
"name": "Bot Token",
|
||||||
|
"description": "Telegram bot token (optional if configured in integration options)."
|
||||||
|
},
|
||||||
|
"chat_id": {
|
||||||
|
"name": "Chat ID",
|
||||||
|
"description": "Telegram chat ID to send to."
|
||||||
|
},
|
||||||
|
"urls": {
|
||||||
|
"name": "URLs",
|
||||||
|
"description": "List of media URLs with type (photo/video). If empty, sends a text message. Large lists are automatically split into multiple media groups."
|
||||||
|
},
|
||||||
|
"caption": {
|
||||||
|
"name": "Caption",
|
||||||
|
"description": "Caption text. For media, applied to first item. For empty URLs, this is the message text."
|
||||||
|
},
|
||||||
|
"reply_to_message_id": {
|
||||||
|
"name": "Reply To",
|
||||||
|
"description": "Optional message ID to reply to."
|
||||||
|
},
|
||||||
|
"disable_web_page_preview": {
|
||||||
|
"name": "Disable Web Page Preview",
|
||||||
|
"description": "Disable link previews in text messages."
|
||||||
|
},
|
||||||
|
"parse_mode": {
|
||||||
|
"name": "Parse Mode",
|
||||||
|
"description": "How to parse the caption/text. Options are HTML, Markdown, MarkdownV2, or empty string for plain text."
|
||||||
|
},
|
||||||
|
"max_group_size": {
|
||||||
|
"name": "Max Group Size",
|
||||||
|
"description": "Maximum number of media items per media group (2-10). Large lists will be split into multiple groups."
|
||||||
|
},
|
||||||
|
"chunk_delay": {
|
||||||
|
"name": "Chunk Delay",
|
||||||
|
"description": "Delay in milliseconds between sending multiple media groups (0-60000). Useful for rate limiting."
|
||||||
|
},
|
||||||
|
"wait_for_response": {
|
||||||
|
"name": "Wait For Response",
|
||||||
|
"description": "Wait for Telegram to finish processing before returning. Set to false for fire-and-forget (automation continues immediately)."
|
||||||
|
},
|
||||||
|
"max_asset_data_size": {
|
||||||
|
"name": "Max Asset Data Size",
|
||||||
|
"description": "Maximum asset size in bytes. Assets exceeding this limit will be skipped. Leave empty for no limit."
|
||||||
|
},
|
||||||
|
"send_large_photos_as_documents": {
|
||||||
|
"name": "Send Large Photos As Documents",
|
||||||
|
"description": "How to handle photos exceeding Telegram's limits (10MB or 10000px dimension sum). If true, send as documents. If false, downsize to fit limits."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "Удалить защищённую ссылку"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -115,10 +118,12 @@
|
|||||||
"title": "Настройки Immich Album Watcher",
|
"title": "Настройки Immich Album Watcher",
|
||||||
"description": "Настройте интервал опроса для всех альбомов.",
|
"description": "Настройте интервал опроса для всех альбомов.",
|
||||||
"data": {
|
"data": {
|
||||||
"scan_interval": "Интервал сканирования (секунды)"
|
"scan_interval": "Интервал сканирования (секунды)",
|
||||||
|
"telegram_bot_token": "Токен Telegram бота"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)"
|
"scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)",
|
||||||
|
"telegram_bot_token": "Токен бота для отправки уведомлений в Telegram"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,13 +133,95 @@
|
|||||||
"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)."
|
||||||
|
},
|
||||||
|
"favorite_only": {
|
||||||
|
"name": "Только избранные",
|
||||||
|
"description": "Фильтр для отображения только избранных файлов."
|
||||||
|
},
|
||||||
|
"filter_min_rating": {
|
||||||
|
"name": "Минимальный рейтинг",
|
||||||
|
"description": "Минимальный рейтинг для файлов (1-5)."
|
||||||
|
},
|
||||||
|
"order_by": {
|
||||||
|
"name": "Сортировать по",
|
||||||
|
"description": "Поле для сортировки файлов (date - дата, rating - рейтинг, name - имя)."
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"name": "Порядок",
|
||||||
|
"description": "Направление сортировки (ascending - по возрастанию, descending - по убыванию, random - случайный)."
|
||||||
|
},
|
||||||
|
"asset_type": {
|
||||||
|
"name": "Тип файла",
|
||||||
|
"description": "Фильтровать файлы по типу (all - все, photo - только фото, video - только видео)."
|
||||||
|
},
|
||||||
|
"min_date": {
|
||||||
|
"name": "Минимальная дата",
|
||||||
|
"description": "Фильтровать файлы, созданные в эту дату или после (формат ISO 8601)."
|
||||||
|
},
|
||||||
|
"max_date": {
|
||||||
|
"name": "Максимальная дата",
|
||||||
|
"description": "Фильтровать файлы, созданные в эту дату или до (формат ISO 8601)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"send_telegram_notification": {
|
||||||
|
"name": "Отправить уведомление в Telegram",
|
||||||
|
"description": "Отправить уведомление в Telegram (текст, фото, видео или медиа-группу).",
|
||||||
|
"fields": {
|
||||||
|
"bot_token": {
|
||||||
|
"name": "Токен бота",
|
||||||
|
"description": "Токен Telegram бота (необязательно, если настроен в опциях интеграции)."
|
||||||
|
},
|
||||||
|
"chat_id": {
|
||||||
|
"name": "ID чата",
|
||||||
|
"description": "ID чата Telegram для отправки."
|
||||||
|
},
|
||||||
|
"urls": {
|
||||||
|
"name": "URL-адреса",
|
||||||
|
"description": "Список URL медиа-файлов с типом (photo/video). Если пусто, отправляет текстовое сообщение. Большие списки автоматически разделяются на несколько медиа-групп."
|
||||||
|
},
|
||||||
|
"caption": {
|
||||||
|
"name": "Подпись",
|
||||||
|
"description": "Текст подписи. Для медиа применяется к первому элементу. Для пустых URLs это текст сообщения."
|
||||||
|
},
|
||||||
|
"reply_to_message_id": {
|
||||||
|
"name": "Ответ на",
|
||||||
|
"description": "ID сообщения для ответа (необязательно)."
|
||||||
|
},
|
||||||
|
"disable_web_page_preview": {
|
||||||
|
"name": "Отключить предпросмотр ссылок",
|
||||||
|
"description": "Отключить предпросмотр ссылок в текстовых сообщениях."
|
||||||
|
},
|
||||||
|
"parse_mode": {
|
||||||
|
"name": "Режим парсинга",
|
||||||
|
"description": "Как парсить подпись/текст. Варианты: HTML, Markdown, MarkdownV2, или пустая строка для обычного текста."
|
||||||
|
},
|
||||||
|
"max_group_size": {
|
||||||
|
"name": "Макс. размер группы",
|
||||||
|
"description": "Максимальное количество медиа-файлов в одной группе (2-10). Большие списки будут разделены на несколько групп."
|
||||||
|
},
|
||||||
|
"chunk_delay": {
|
||||||
|
"name": "Задержка между группами",
|
||||||
|
"description": "Задержка в миллисекундах между отправкой нескольких медиа-групп (0-60000). Полезно для ограничения скорости."
|
||||||
|
},
|
||||||
|
"wait_for_response": {
|
||||||
|
"name": "Ждать ответа",
|
||||||
|
"description": "Ждать завершения отправки в Telegram перед возвратом. Установите false для фоновой отправки (автоматизация продолжается немедленно)."
|
||||||
|
},
|
||||||
|
"max_asset_data_size": {
|
||||||
|
"name": "Макс. размер ресурса",
|
||||||
|
"description": "Максимальный размер ресурса в байтах. Ресурсы, превышающие этот лимит, будут пропущены. Оставьте пустым для отсутствия ограничения."
|
||||||
|
},
|
||||||
|
"send_large_photos_as_documents": {
|
||||||
|
"name": "Большие фото как документы",
|
||||||
|
"description": "Как обрабатывать фото, превышающие лимиты Telegram (10МБ или сумма размеров 10000пкс). Если true, отправлять как документы. Если false, уменьшать для соответствия лимитам."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user