28 Commits

Author SHA1 Message Date
fde2d0ae31 Bump version to 2.7.1
All checks were successful
Validate / Hassfest (push) Successful in 4s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 02:51:22 +03:00
31663852f9 Fixed link to automation
All checks were successful
Validate / Hassfest (push) Successful in 6s
2026-02-03 02:50:19 +03:00
5cee3ccc79 Add chat_action parameter to send_telegram_notification service
All checks were successful
Validate / Hassfest (push) Successful in 4s
Shows typing/upload indicator while processing media. Supports:
typing, upload_photo, upload_video, upload_document actions.
Set to empty string to disable. Default: typing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 02:48:25 +03:00
3b133dc4bb Exclude archived assets from processing status check
All checks were successful
Validate / Hassfest (push) Successful in 4s
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:02:25 +03:00
a8ea9ab46a Rename on_this_day to memory_date with exclude-same-year behavior
All checks were successful
Validate / Hassfest (push) Successful in 2s
Renamed the date filter parameter and changed default behavior to match
Google Photos memories - now excludes assets from the same year as the
reference date, returning only photos from previous years on that day.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:24:08 +03:00
e88fd0fa3a Add get_assets filtering: offset, on_this_day, city, state, country
All checks were successful
Validate / Hassfest (push) Successful in 3s
- Add offset parameter for pagination support
- Add on_this_day parameter for memories filtering (match month and day)
- Add city, state, country parameters for geolocation filtering
- Update README with new parameters and examples

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 12:25:35 +03:00
3cf916dc77 Rename last_updated attribute to last_updated_at
All checks were successful
Validate / Hassfest (push) Successful in 3s
Renamed for consistency with created_at attribute naming.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 00:30:39 +03:00
df446390f2 Add album metadata attributes to Album ID sensor
All checks were successful
Validate / Hassfest (push) Successful in 4s
Add asset_count, last_updated, and created_at attributes to the
Album ID sensor for convenient access to album metadata.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 00:20:38 +03:00
1d61f05552 Track pending assets for delayed processing events
All checks were successful
Validate / Hassfest (push) Successful in 3s
- Add _pending_asset_ids to track assets detected but not yet processed
- Fire events when pending assets become processed (thumbhash available)
- Fixes issue where videos added during transcoding never triggered events
- Add debug logging for change detection and pending asset tracking
- Document external domain feature in README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 22:23:32 +03:00
38a2a6ad7a Add external domain support for URLs
All checks were successful
Validate / Hassfest (push) Successful in 4s
- Fetch externalDomain from Immich server config on startup
- Use external domain for user-facing URLs (share links, asset URLs)
- Keep internal connection URL for API calls
- Add get_internal_download_url() to convert external URLs back to
  internal for faster local network downloads (Telegram notifications)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:53:02 +03:00
0bb7e71a1e Fix video asset processing detection
All checks were successful
Validate / Hassfest (push) Successful in 3s
- Use thumbhash for all assets instead of encodedVideoPath for videos
  (encodedVideoPath is not exposed in Immich API response)
- Add isTrashed check to exclude trashed assets from events
- Simplify processing status logic for both photos and videos

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:36:21 +03:00
c29fc2fbcf Add Telegram file ID caching and reverse geocoding fields
All checks were successful
Validate / Hassfest (push) Successful in 3s
Implement caching for Telegram file_ids to avoid re-uploading the same media.
Cached IDs are reused for subsequent sends, improving performance significantly.
Added configurable cache TTL option (1-168 hours, default 48).

Also added city, state, and country fields from Immich reverse geocoding
to asset data in events and get_assets service.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 03:12:05 +03:00
011f105823 Add geolocation (latitude/longitude) to asset data
All checks were successful
Validate / Hassfest (push) Successful in 3s
Expose GPS coordinates from EXIF data in asset responses. The latitude
and longitude fields are included in get_assets service responses and
event data when available.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 02:29:56 +03:00
ee45fdc177 Fix the services API
All checks were successful
Validate / Hassfest (push) Successful in 3s
2026-02-01 02:22:52 +03:00
4b0f3b8b12 Enhance get_assets service with flexible filtering and sorting
All checks were successful
Validate / Hassfest (push) Successful in 5s
- Replace filter parameter with independent favorite_only boolean
- Add order_by parameter supporting date, rating, and name sorting
- Rename count to limit for clarity
- Add date range filtering with min_date and max_date parameters
- Add asset_type filtering for photos and videos
- Update README with language support section and fixed sensor list
- Add translations for all new parameters

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 01:39:04 +03:00
e5e45f0fbf Add asset preprocessing filter and enhance asset data
All checks were successful
Validate / Hassfest (push) Successful in 3s
Features:
- Filter unprocessed assets from events and get_assets service
  - Videos require completed transcoding (encodedVideoPath)
  - Photos require generated thumbnails (thumbhash)
- Add photo_url field for images (preview-sized thumbnail)
- Simplify asset attribute names (remove asset_ prefix)
- Prioritize user-added descriptions over EXIF descriptions

Documentation:
- Update README with new asset fields and preprocessing note
- Update services.yaml parameter descriptions

Version: 2.1.0

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 01:14:21 +03:00
8714685d5e Improve Telegram error handling and unify asset data structure
All checks were successful
Validate / Hassfest (push) Successful in 3s
- Remove photo downscaling logic in favor of cleaner error handling
- Add intelligent Telegram API error logging with diagnostics and suggestions
- Define Telegram photo limits as global constants (TELEGRAM_MAX_PHOTO_SIZE, TELEGRAM_MAX_DIMENSION_SUM)
- Add photo_url support for image assets (matching video_url for videos)
- Unify asset detail building with shared _build_asset_detail() helper method
- Enhance get_assets service to return complete asset data matching events
- Simplify attribute naming by removing redundant asset_ prefix from values

BREAKING CHANGE: Asset attribute keys changed from "asset_type", "asset_filename"
to simpler "type", "filename" for consistency and cleaner JSON responses

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 23:40:19 +03:00
bbcd97e1ac Expose favorite and asset rating to asset data
All checks were successful
Validate / Hassfest (push) Successful in 3s
2026-01-31 18:14:33 +03:00
04dd63825c Add intelligent handling for oversized photos in Telegram service
All checks were successful
Validate / Hassfest (push) Successful in 3s
Implements send_large_photos_as_documents parameter to handle photos
exceeding Telegram's limits (10MB file size or 10000px dimension sum).

Features:
- Automatic detection of oversized photos using file size and PIL-based
  dimension checking
- Two handling modes:
  * send_large_photos_as_documents=false (default): Intelligently
    downsizes photos using Lanczos resampling and progressive JPEG
    quality reduction to fit within Telegram limits
  * send_large_photos_as_documents=true: Sends oversized photos as
    documents to preserve original quality
- For media groups: separates oversized photos and sends them as
  documents after the main group, or downsizes them inline
- Maintains backward compatibility with existing max_asset_data_size
  parameter for hard size limits

This resolves PHOTO_INVALID_DIMENSIONS errors for large images like
26MP photos while giving users control over quality vs. file size.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 18:03:50 +03:00
71d3714f6a Add max_asset_data_size parameter to Telegram service
All checks were successful
Validate / Hassfest (push) Successful in 3s
Introduces optional max_asset_data_size parameter (in bytes) to filter
out oversized photos and videos from Telegram notifications. Assets
exceeding the limit are skipped with a warning, preventing
PHOTO_INVALID_DIMENSIONS errors for large images (e.g., 26MP photos).

Changes:
- Add max_asset_data_size parameter to service signature
- Implement size checking for single photos/videos
- Filter oversized assets in media groups
- Update services.yaml, translations, and documentation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 17:31:14 +03:00
459f5ef1e5 Bump version to 2.0.0 (major release)
All checks were successful
Validate / Hassfest (push) Successful in 3s
2026-01-31 17:07:40 +03:00
42b2d912c9 Add non-blocking mode support to Telegram notification service
All checks were successful
Validate / Hassfest (push) Successful in 3s
- Add `wait_for_response` parameter (default: true) for fire-and-forget operation
- Change supports_response to OPTIONAL to allow both modes
- Refactor execution logic into `_execute_telegram_notification` method
- Background tasks created with `hass.async_create_task` when wait_for_response=false
- Update documentation with non-blocking mode example and response behavior

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 15:53:35 +03:00
2007b020ba Add parse_mode to service call API
All checks were successful
Validate / Hassfest (push) Successful in 3s
2026-01-31 15:32:20 +03:00
2ae706d700 Enhance Telegram service with multi-format support and chunking
All checks were successful
Validate / Hassfest (push) Successful in 2s
Renamed send_telegram_media_group to send_telegram_notification with expanded capabilities:
- Text messages (when urls is empty)
- Single photo/video (uses sendPhoto/sendVideo APIs)
- Media groups (uses sendMediaGroup API)
- Automatic chunking for unlimited media URLs
- Smart optimization: single-item chunks use appropriate single-item APIs

New parameters:
- max_group_size (2-10, default 10): control items per media group
- chunk_delay (0-60000ms, default 0): delay between chunks for rate limiting
- disable_web_page_preview: disable link previews in text messages

The service now intelligently selects the most efficient Telegram API endpoint based on content type and chunk size, with comprehensive error handling and logging.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 14:33:18 +03:00
1cc5d7cc7d Remove album name from entity names
All checks were successful
Validate / Hassfest (push) Successful in 3s
2026-01-31 04:49:53 +03:00
5d878cfbd0 Add translation for telegram service
All checks were successful
Validate / Hassfest (push) Successful in 3s
2026-01-31 04:44:50 +03:00
c7ed037e2e Document Telegram integration and media group service
All checks were successful
Validate / Hassfest (push) Successful in 3s
- Add Telegram Bot Token to configuration options
- Document send_telegram_media_group service with examples
- Update get_recent_assets example with entity target

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 04:20:43 +03:00
d26e212c82 Add GitHub community files
All checks were successful
Validate / Hassfest (push) Successful in 3s
- CONTRIBUTING.md with development guidelines
- Issue templates for bugs and feature requests
- Pull request template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 04:16:17 +03:00
21 changed files with 2831 additions and 326 deletions

46
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View 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
View 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

View 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
View 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)

View File

@@ -3,6 +3,7 @@
## Version Management
Update the integration version in `custom_components/immich_album_watcher/manifest.json` only when changes are made to the **integration content** (files inside `custom_components/immich_album_watcher/`).
**IMPORTANT** ALWAYS ask for version bump before doing it.
Do NOT bump version for:
@@ -14,3 +15,18 @@ Use semantic versioning:
- **MAJOR** (x.0.0): Breaking changes
- **MINOR** (0.x.0): New features, backward compatible
- **PATCH** (0.0.x): Bug fixes, integration documentation updates
## Documentation Updates
**IMPORTANT**: Always keep the README.md synchronized with integration changes.
When modifying the integration interface, you MUST update the corresponding documentation:
- **Service parameters**: Update parameter tables and examples in README.md
- **New events**: Add event documentation with examples and field descriptions
- **New entities**: Document entity types, attributes, and usage
- **Configuration options**: Update configuration documentation
- **Translation files**: Add translations for new parameters/entities in `en.json` and `ru.json`
- **services.yaml**: Keep service definitions in sync with implementation
The README is the primary user-facing documentation and must accurately reflect the current state of the integration.

49
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,49 @@
# Contributing to Immich Album Watcher
Thank you for your interest in contributing to this Home Assistant integration!
## Getting Started
1. Fork the repository
2. Clone your fork locally
3. Create a new branch for your changes
## Development Setup
1. Set up a Home Assistant development environment
2. Copy the `custom_components/immich_album_watcher` folder to your HA config
3. Restart Home Assistant to load changes
## Code Style
- Follow [Home Assistant's development guidelines](https://developers.home-assistant.io/docs/development_guidelines)
- Use type hints for all function parameters and return values
- Keep code compatible with Python 3.11+
## Submitting Changes
1. Test your changes thoroughly
2. Update documentation if needed
3. Create a pull request with a clear description of changes
## Reporting Issues
When reporting bugs, please include:
- Home Assistant version
- Integration version
- Immich server version
- Relevant log entries
- Steps to reproduce
## Version Numbering
This project uses semantic versioning:
- **MAJOR** (x.0.0): Breaking changes
- **MINOR** (0.x.0): New features, backward compatible
- **PATCH** (0.0.x): Bug fixes
## Questions?
Open an issue for any questions about contributing.

449
README.md
View File

@@ -4,16 +4,21 @@
A Home Assistant custom integration that monitors [Immich](https://immich.app/) photo/video library albums for changes and exposes them as Home Assistant entities with event-firing capabilities.
> **Tip:** For the best experience, use this integration with the [Immich Album Watcher Blueprint](https://github.com/DolgolyovAlexei/haos-blueprints/tree/main/Common/Immich%20Album%20Watcher) to easily create automations for album change notifications.
## Features
- **Album Monitoring** - Watch selected Immich albums for asset additions and removals
- **Rich Sensor Data** - Multiple sensors per album:
- Album ID (with share URL attribute)
- Asset count (with detected people list)
- Photo count
- Video count
- Last updated timestamp
- Creation date
- Album ID (with album name and share URL attributes)
- Asset Count (total assets with detected people list)
- Photo Count (number of photos)
- Video Count (number of videos)
- Last Updated (last modification timestamp)
- Created (album creation date)
- Public URL (public share link)
- Protected URL (password-protected share link)
- Protected Password (password for protected link)
- **Camera Entity** - Album thumbnail displayed as a camera entity for dashboards
- **Binary Sensor** - "New Assets" indicator that turns on when assets are added
- **Face Recognition** - Detects and lists people recognized in album photos
@@ -31,12 +36,16 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
- Detected people in the asset
- **Services** - Custom service calls:
- `immich_album_watcher.refresh` - Force immediate data refresh
- `immich_album_watcher.get_recent_assets` - Get recent assets from an album
- `immich_album_watcher.get_assets` - Get assets from an album with filtering and ordering
- `immich_album_watcher.send_telegram_notification` - Send text, photo, video, or media group to Telegram
- **Share Link Management** - Button entities to create and delete share links:
- Create/delete public (unprotected) share links
- Create/delete password-protected share links
- Edit protected link passwords via Text entity
- **Configurable Polling** - Adjustable scan interval (10-3600 seconds)
- **Localization** - Available in multiple languages:
- English
- Russian (Русский)
## Installation
@@ -59,8 +68,6 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
3. Restart Home Assistant
4. Add the integration via **Settings****Devices & Services****Add Integration**
> **Tip:** For the best experience, use this integration with the [Immich Album Watcher Blueprint](https://github.com/DolgolyovAlexei/haos-blueprints/blob/main/Common/Immich%20Album%20Watcher.yaml) to easily create automations for album change notifications.
## Configuration
| Option | Description | Default |
@@ -69,12 +76,37 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
| API Key | Your Immich API key | Required |
| Albums | Albums to monitor | Required |
| Scan Interval | How often to check for changes (seconds) | 60 |
| Telegram Bot Token | Bot token for sending media to Telegram (optional) | - |
| Telegram Cache TTL | How long to cache uploaded file IDs (hours, 1-168) | 48 |
### External Domain Support
The integration supports connecting to a local Immich server while using an external domain for user-facing URLs. This is useful when:
- Your Home Assistant connects to Immich via local network (e.g., `http://192.168.1.100:2283`)
- But you want share links and asset URLs to use your public domain (e.g., `https://photos.example.com`)
**How it works:**
1. Configure "External domain" in Immich: **Administration → Settings → Server → External Domain**
2. The integration automatically fetches this setting on startup
3. All user-facing URLs (share links, asset URLs in events) use the external domain
4. API calls and file downloads still use the local connection URL for faster performance
**Example:**
- Server URL (in integration config): `http://192.168.1.100:2283`
- External Domain (in Immich settings): `https://photos.example.com`
- Share links in events: `https://photos.example.com/share/...`
- Telegram downloads: via `http://192.168.1.100:2283` (fast local network)
If no external domain is configured in Immich, all URLs will use the Server URL from the integration configuration.
## Entities Created (per album)
| Entity Type | Name | Description |
|-------------|------|-------------|
| Sensor | Album ID | Album identifier with `album_name` and `share_url` attributes |
| Sensor | Album ID | Album identifier with `album_name`, `asset_count`, `share_url`, `last_updated_at`, and `created_at` attributes |
| Sensor | Asset Count | Total number of assets (includes `people` list in attributes) |
| Sensor | Photo Count | Number of photos in the album |
| Sensor | Video Count | Number of videos in the album |
@@ -101,20 +133,325 @@ Force an immediate refresh of all album data:
service: immich_album_watcher.refresh
```
### Get Recent Assets
### Get Assets
Get the most recent assets from a specific album (returns response data):
Get assets from a specific album with optional filtering and ordering (returns response data). Only returns fully processed assets (videos with completed transcoding, photos with generated thumbnails).
```yaml
service: immich_album_watcher.get_recent_assets
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
album_id: "your-album-id-here"
count: 10
limit: 10 # Maximum number of assets (1-100)
offset: 0 # Number of assets to skip (for pagination)
favorite_only: false # true = favorites only, false = all assets
filter_min_rating: 4 # Min rating (1-5)
order_by: "date" # Options: "date", "rating", "name", "random"
order: "descending" # Options: "ascending", "descending"
asset_type: "all" # Options: "all", "photo", "video"
min_date: "2024-01-01" # Optional: assets created on or after this date
max_date: "2024-12-31" # Optional: assets created on or before this date
memory_date: "2024-02-14" # Optional: memories filter (excludes same year)
city: "Paris" # Optional: filter by city name
state: "California" # Optional: filter by state/region
country: "France" # Optional: filter by country
```
**Parameters:**
- `limit` (optional, default: 10): Maximum number of assets to return (1-100)
- `offset` (optional, default: 0): Number of assets to skip before returning results. Use with `limit` for pagination (e.g., `offset: 0, limit: 10` for first page, `offset: 10, limit: 10` for second page)
- `favorite_only` (optional, default: false): Filter to show only favorite assets
- `filter_min_rating` (optional, default: 1): Minimum rating for assets (1-5 stars). Applied independently of `favorite_only`
- `order_by` (optional, default: "date"): Field to sort assets by
- `"date"`: Sort by creation date
- `"rating"`: Sort by rating (assets without rating are placed last)
- `"name"`: Sort by filename
- `"random"`: Random order (ignores `order`)
- `order` (optional, default: "descending"): Sort direction
- `"ascending"`: Ascending order
- `"descending"`: Descending order
- `asset_type` (optional, default: "all"): Filter by asset type
- `"all"`: No type filtering, return both photos and videos
- `"photo"`: Return only photos
- `"video"`: Return only videos
- `min_date` (optional): Filter assets created on or after this date. Use ISO 8601 format (e.g., `"2024-01-01"` or `"2024-01-01T10:30:00"`)
- `max_date` (optional): Filter assets created on or before this date. Use ISO 8601 format (e.g., `"2024-12-31"` or `"2024-12-31T23:59:59"`)
- `memory_date` (optional): Filter assets by matching month and day, excluding the same year (memories filter like Google Photos). Provide a date in ISO 8601 format (e.g., `"2024-02-14"`) to get all assets taken on February 14th from previous years
- `city` (optional): Filter assets by city name (case-insensitive substring match). Based on reverse geocoded location from asset GPS data
- `state` (optional): Filter assets by state/region name (case-insensitive substring match). Based on reverse geocoded location from asset GPS data
- `country` (optional): Filter assets by country name (case-insensitive substring match). Based on reverse geocoded location from asset GPS data
**Examples:**
Get 5 most recent favorite assets:
```yaml
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
limit: 5
favorite_only: true
order_by: "date"
order: "descending"
```
Get 10 random assets rated 3 stars or higher:
```yaml
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
limit: 10
filter_min_rating: 3
order_by: "random"
```
Get 20 most recent photos only:
```yaml
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
limit: 20
asset_type: "photo"
order_by: "date"
order: "descending"
```
Get top 10 highest rated favorite videos:
```yaml
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
limit: 10
favorite_only: true
asset_type: "video"
order_by: "rating"
order: "descending"
```
Get photos sorted alphabetically by name:
```yaml
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
limit: 20
asset_type: "photo"
order_by: "name"
order: "ascending"
```
Get photos from a specific date range:
```yaml
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
limit: 50
asset_type: "photo"
min_date: "2024-06-01"
max_date: "2024-06-30"
order_by: "date"
order: "descending"
```
Get "On This Day" memories (photos from today's date in previous years):
```yaml
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
limit: 20
memory_date: "{{ now().strftime('%Y-%m-%d') }}"
order_by: "date"
order: "ascending"
```
Paginate through all assets (first page):
```yaml
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
limit: 10
offset: 0
order_by: "date"
order: "descending"
```
Paginate through all assets (second page):
```yaml
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
limit: 10
offset: 10
order_by: "date"
order: "descending"
```
Get photos taken in a specific city:
```yaml
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
limit: 50
city: "Paris"
asset_type: "photo"
order_by: "date"
order: "descending"
```
Get all assets from a specific country:
```yaml
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
limit: 100
country: "Japan"
order_by: "date"
order: "ascending"
```
### Send Telegram Notification
Send notifications to Telegram. Supports multiple formats:
- **Text message** - When `urls` is empty or not provided
- **Single photo** - When `urls` contains one photo
- **Single video** - When `urls` contains one video
- **Media group** - When `urls` contains multiple items
The service downloads media from Immich and uploads it to Telegram, bypassing any CORS restrictions. Large lists of media are automatically split into multiple media groups based on the `max_group_size` parameter (default: 10 items per group).
**File ID Caching:** When media is uploaded to Telegram, the service caches the returned `file_id`. Subsequent sends of the same media will use the cached `file_id` instead of re-uploading, significantly improving performance. The cache TTL is configurable in hub options (default: 48 hours, range: 1-168 hours). The cache is persistent across Home Assistant restarts and is stored per album.
**Examples:**
Text message:
```yaml
service: immich_album_watcher.send_telegram_notification
target:
entity_id: sensor.album_name_asset_limit
data:
chat_id: "-1001234567890"
caption: "Check out the new album!"
disable_web_page_preview: true
```
Single photo:
```yaml
service: immich_album_watcher.send_telegram_notification
target:
entity_id: sensor.album_name_asset_limit
data:
chat_id: "-1001234567890"
urls:
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
type: photo
caption: "Beautiful sunset!"
```
Media group:
```yaml
service: immich_album_watcher.send_telegram_notification
target:
entity_id: sensor.album_name_asset_limit
data:
chat_id: "-1001234567890"
urls:
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
type: photo
- url: "https://immich.example.com/api/assets/zzz/video/playback?key=yyy"
type: video
caption: "New photos from the album!"
reply_to_message_id: 123
```
HTML formatting:
```yaml
service: immich_album_watcher.send_telegram_notification
target:
entity_id: sensor.album_name_asset_limit
data:
chat_id: "-1001234567890"
caption: |
<b>Album Updated!</b>
New photos by <i>{{ trigger.event.data.added_assets[0].owner }}</i>
<a href="https://immich.example.com/album">View Album</a>
parse_mode: "HTML" # Default, can be omitted
```
Non-blocking mode (fire-and-forget):
```yaml
service: immich_album_watcher.send_telegram_notification
target:
entity_id: sensor.album_name_asset_limit
data:
chat_id: "-1001234567890"
urls:
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
type: photo
caption: "Quick notification"
wait_for_response: false # Automation continues immediately
```
| Field | Description | Required |
|-------|-------------|----------|
| `chat_id` | Telegram chat ID to send to | Yes |
| `urls` | List of media items with `url` and `type` (photo/video). Empty for text message. | No |
| `bot_token` | Telegram bot token (uses configured token if not provided) | No |
| `caption` | For media: caption applied to first item. For text: the message text. Supports HTML formatting by default. | No |
| `reply_to_message_id` | Message ID to reply to | No |
| `disable_web_page_preview` | Disable link previews in text messages | No |
| `parse_mode` | How to parse caption/text. Options: `HTML`, `Markdown`, `MarkdownV2`, or empty string for plain text. Default: `HTML` | No |
| `max_group_size` | Maximum media items per group (2-10). Large lists split into multiple groups. Default: 10 | No |
| `chunk_delay` | Delay in milliseconds between sending multiple groups (0-60000). Useful for rate limiting. Default: 0 | No |
| `wait_for_response` | Wait for Telegram to finish processing. Set to `false` for fire-and-forget (automation continues immediately). Default: `true` | No |
| `max_asset_data_size` | Maximum asset size in bytes. Assets exceeding this limit will be skipped. Default: no limit | No |
| `send_large_photos_as_documents` | Handle photos exceeding Telegram limits (10MB or 10000px dimension sum). If `true`, send as documents. If `false`, skip oversized photos. Default: `false` | No |
| `chat_action` | Chat action to display while processing media (`typing`, `upload_photo`, `upload_video`, `upload_document`). Set to empty string to disable. Default: `typing` | No |
The service returns a response with `success` status and `message_id` (single message), `message_ids` (media group), or `groups_sent` (number of groups when split). When `wait_for_response` is `false`, the service returns immediately with `{"success": true, "status": "queued"}` while processing continues in the background.
## Events
Use these events in your automations:
The integration fires multiple event types that you can use in your automations:
### Available Events
| Event Type | Description | When Fired |
|------------|-------------|------------|
| `immich_album_watcher_album_changed` | General album change event | Fired for any album change |
| `immich_album_watcher_assets_added` | Assets were added to the album | When new photos/videos are added |
| `immich_album_watcher_assets_removed` | Assets were removed from the album | When photos/videos are removed |
| `immich_album_watcher_album_renamed` | Album name was changed | When the album is renamed |
| `immich_album_watcher_album_deleted` | Album was deleted | When the album is deleted from Immich |
| `immich_album_watcher_album_sharing_changed` | Album sharing status changed | When album is shared or unshared |
### Example Usage
```yaml
automation:
@@ -126,22 +463,48 @@ automation:
- service: notify.mobile_app
data:
title: "New Photos"
message: "{{ trigger.event.data.added_count }} new photos in {{ trigger.event.data.album_name }}"
message: "{{ trigger.event.data.added_limit }} new photos in {{ trigger.event.data.album_name }}"
- alias: "Album renamed"
trigger:
- platform: event
event_type: immich_album_watcher_album_renamed
action:
- service: notify.mobile_app
data:
title: "Album Renamed"
message: "Album '{{ trigger.event.data.old_name }}' renamed to '{{ trigger.event.data.new_name }}'"
- alias: "Album deleted"
trigger:
- platform: event
event_type: immich_album_watcher_album_deleted
action:
- service: notify.mobile_app
data:
title: "Album Deleted"
message: "Album '{{ trigger.event.data.album_name }}' was deleted"
```
### Event Data
| Field | Description |
|-------|-------------|
| `album_id` | Album ID |
| `album_name` | Album name |
| `album_url` | Public URL to view the album (only present if album has a shared link) |
| `change_type` | Type of change (assets_added, assets_removed, changed) |
| `added_count` | Number of assets added |
| `removed_count` | Number of assets removed |
| `added_assets` | List of added assets with details (see below) |
| `removed_assets` | List of removed asset IDs |
| `people` | List of all people detected in the album |
| Field | Description | Available In |
|-------|-------------|--------------|
| `hub_name` | Hub name configured in integration | All events |
| `album_id` | Album ID | All events |
| `album_name` | Current album name | All events |
| `album_url` | Public URL to view the album (only present if album has a shared link) | All events except `album_deleted` |
| `change_type` | Type of change (assets_added, assets_removed, album_renamed, album_sharing_changed, changed) | All events except `album_deleted` |
| `shared` | Current sharing status of the album | All events except `album_deleted` |
| `added_limit` | Number of assets added | `album_changed`, `assets_added` |
| `removed_limit` | Number of assets removed | `album_changed`, `assets_removed` |
| `added_assets` | List of added assets with details (see below) | `album_changed`, `assets_added` |
| `removed_assets` | List of removed asset IDs | `album_changed`, `assets_removed` |
| `people` | List of all people detected in the album | All events except `album_deleted` |
| `old_name` | Previous album name | `album_renamed` |
| `new_name` | New album name | `album_renamed` |
| `old_shared` | Previous sharing status | `album_sharing_changed` |
| `new_shared` | New sharing status | `album_sharing_changed` |
### Added Assets Fields
@@ -150,15 +513,27 @@ Each item in the `added_assets` list contains the following fields:
| Field | Description |
|-------|-------------|
| `id` | Unique asset ID |
| `asset_type` | Type of asset (`IMAGE` or `VIDEO`) |
| `asset_filename` | Original filename of the asset |
| `asset_created` | Date/time when the asset was originally created |
| `asset_owner` | Display name of the user who owns the asset |
| `asset_owner_id` | Unique ID of the user who owns the asset |
| `asset_description` | Description/caption of the asset (from EXIF data) |
| `asset_url` | Public URL to view the asset (only present if album has a shared link) |
| `type` | Type of asset (`IMAGE` or `VIDEO`) |
| `filename` | Original filename of the asset |
| `created_at` | Date/time when the asset was originally created |
| `owner` | Display name of the user who owns the asset |
| `owner_id` | Unique ID of the user who owns the asset |
| `description` | Description/caption of the asset (from EXIF data) |
| `is_favorite` | Whether the asset is marked as favorite (`true` or `false`) |
| `rating` | User rating of the asset (1-5 stars, or `null` if not rated) |
| `latitude` | GPS latitude coordinate (or `null` if no geolocation) |
| `longitude` | GPS longitude coordinate (or `null` if no geolocation) |
| `city` | City name from reverse geocoding (or `null` if unavailable) |
| `state` | State/region name from reverse geocoding (or `null` if unavailable) |
| `country` | Country name from reverse geocoding (or `null` if unavailable) |
| `url` | Public URL to view the asset (only present if album has a shared link) |
| `download_url` | Direct download URL for the original file (if shared link exists) |
| `playback_url` | Video playback URL (for VIDEO assets only, if shared link exists) |
| `photo_url` | Photo preview URL (for IMAGE assets only, if shared link exists) |
| `people` | List of people detected in this specific asset |
> **Note:** Assets are only included in events and service responses when they are fully processed by Immich. For videos, this means transcoding must be complete (with `encodedVideoPath`). For photos, thumbnail generation must be complete (with `thumbhash`). This ensures that all media URLs are valid and accessible. Unprocessed assets are silently ignored until their processing completes.
Example accessing asset owner in an automation:
```yaml
@@ -172,8 +547,8 @@ automation:
data:
title: "New Photos"
message: >
{{ trigger.event.data.added_assets[0].asset_owner }} added
{{ trigger.event.data.added_count }} photos to {{ trigger.event.data.album_name }}
{{ trigger.event.data.added_assets[0].owner }} added
{{ trigger.event.data.added_limit }} photos to {{ trigger.event.data.album_name }}
```
## Requirements

View File

@@ -15,12 +15,14 @@ from .const import (
CONF_HUB_NAME,
CONF_IMMICH_URL,
CONF_SCAN_INTERVAL,
CONF_TELEGRAM_CACHE_TTL,
DEFAULT_SCAN_INTERVAL,
DEFAULT_TELEGRAM_CACHE_TTL,
DOMAIN,
PLATFORMS,
)
from .coordinator import ImmichAlbumWatcherCoordinator
from .storage import ImmichAlbumStorage
from .storage import ImmichAlbumStorage, TelegramFileCache
_LOGGER = logging.getLogger(__name__)
@@ -33,6 +35,7 @@ class ImmichHubData:
url: str
api_key: str
scan_interval: int
telegram_cache_ttl: int
@dataclass
@@ -55,6 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo
url = entry.data[CONF_IMMICH_URL]
api_key = entry.data[CONF_API_KEY]
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
telegram_cache_ttl = entry.options.get(CONF_TELEGRAM_CACHE_TTL, DEFAULT_TELEGRAM_CACHE_TTL)
# Store hub data
entry.runtime_data = ImmichHubData(
@@ -62,6 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo
url=url,
api_key=api_key,
scan_interval=scan_interval,
telegram_cache_ttl=telegram_cache_ttl,
)
# Create storage for persisting album state across restarts
@@ -107,6 +112,12 @@ async def _async_setup_subentry_coordinator(
_LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id)
# Create and load Telegram file cache for this album
# TTL is in hours from config, convert to seconds
cache_ttl_seconds = hub_data.telegram_cache_ttl * 60 * 60
telegram_cache = TelegramFileCache(hass, album_id, ttl_seconds=cache_ttl_seconds)
await telegram_cache.async_load()
# Create coordinator for this album
coordinator = ImmichAlbumWatcherCoordinator(
hass,
@@ -117,6 +128,7 @@ async def _async_setup_subentry_coordinator(
scan_interval=hub_data.scan_interval,
hub_name=hub_data.name,
storage=storage,
telegram_cache=telegram_cache,
)
# Load persisted state before first refresh to detect changes during downtime

View File

@@ -82,13 +82,6 @@ class ImmichAlbumNewAssetsSensor(
"""Get the album data from coordinator."""
return self.coordinator.data
@property
def translation_placeholders(self) -> dict[str, str]:
"""Return translation placeholders."""
if self._album_data:
return {"album_name": self._album_data.name}
return {"album_name": self._album_name}
@property
def is_on(self) -> bool | None:
"""Return true if new assets were recently added."""

View File

@@ -83,13 +83,6 @@ class ImmichCreateShareLinkButton(
"""Get the album data from coordinator."""
return self.coordinator.data
@property
def translation_placeholders(self) -> dict[str, str]:
"""Return translation placeholders."""
if self._album_data:
return {"album_name": self._album_data.name}
return {"album_name": self._album_name}
@property
def available(self) -> bool:
"""Return if entity is available.
@@ -173,13 +166,6 @@ class ImmichDeleteShareLinkButton(
"""Get the album data from coordinator."""
return self.coordinator.data
@property
def translation_placeholders(self) -> dict[str, str]:
"""Return translation placeholders."""
if self._album_data:
return {"album_name": self._album_data.name}
return {"album_name": self._album_name}
@property
def available(self) -> bool:
"""Return if entity is available.
@@ -270,13 +256,6 @@ class ImmichCreateProtectedLinkButton(
"""Get the album data from coordinator."""
return self.coordinator.data
@property
def translation_placeholders(self) -> dict[str, str]:
"""Return translation placeholders."""
if self._album_data:
return {"album_name": self._album_data.name}
return {"album_name": self._album_name}
@property
def available(self) -> bool:
"""Return if entity is available.
@@ -364,13 +343,6 @@ class ImmichDeleteProtectedLinkButton(
"""Get the album data from coordinator."""
return self.coordinator.data
@property
def translation_placeholders(self) -> dict[str, str]:
"""Return translation placeholders."""
if self._album_data:
return {"album_name": self._album_data.name}
return {"album_name": self._album_name}
@property
def available(self) -> bool:
"""Return if entity is available.

View File

@@ -78,13 +78,6 @@ class ImmichAlbumThumbnailCamera(
"""Get the album data from coordinator."""
return self.coordinator.data
@property
def translation_placeholders(self) -> dict[str, str]:
"""Return translation placeholders."""
if self._album_data:
return {"album_name": self._album_data.name}
return {"album_name": self._album_name}
@property
def available(self) -> bool:
"""Return if entity is available."""

View File

@@ -27,7 +27,9 @@ from .const import (
CONF_IMMICH_URL,
CONF_SCAN_INTERVAL,
CONF_TELEGRAM_BOT_TOKEN,
CONF_TELEGRAM_CACHE_TTL,
DEFAULT_SCAN_INTERVAL,
DEFAULT_TELEGRAM_CACHE_TTL,
DOMAIN,
SUBENTRY_TYPE_ALBUM,
)
@@ -252,6 +254,9 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
CONF_TELEGRAM_BOT_TOKEN: user_input.get(
CONF_TELEGRAM_BOT_TOKEN, ""
),
CONF_TELEGRAM_CACHE_TTL: user_input.get(
CONF_TELEGRAM_CACHE_TTL, DEFAULT_TELEGRAM_CACHE_TTL
),
},
)
@@ -261,6 +266,9 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
current_bot_token = self._config_entry.options.get(
CONF_TELEGRAM_BOT_TOKEN, ""
)
current_cache_ttl = self._config_entry.options.get(
CONF_TELEGRAM_CACHE_TTL, DEFAULT_TELEGRAM_CACHE_TTL
)
return self.async_show_form(
step_id="init",
@@ -272,6 +280,9 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
vol.Optional(
CONF_TELEGRAM_BOT_TOKEN, default=current_bot_token
): str,
vol.Optional(
CONF_TELEGRAM_CACHE_TTL, default=current_cache_ttl
): vol.All(vol.Coerce(int), vol.Range(min=1, max=168)),
}
),
)

View File

@@ -14,12 +14,14 @@ CONF_ALBUM_ID: Final = "album_id"
CONF_ALBUM_NAME: Final = "album_name"
CONF_SCAN_INTERVAL: Final = "scan_interval"
CONF_TELEGRAM_BOT_TOKEN: Final = "telegram_bot_token"
CONF_TELEGRAM_CACHE_TTL: Final = "telegram_cache_ttl"
# Subentry type
SUBENTRY_TYPE_ALBUM: Final = "album"
# Defaults
DEFAULT_SCAN_INTERVAL: Final = 60 # seconds
DEFAULT_TELEGRAM_CACHE_TTL: Final = 48 # hours
NEW_ASSETS_RESET_DELAY: Final = 300 # 5 minutes
DEFAULT_SHARE_PASSWORD: Final = "immich123"
@@ -27,6 +29,9 @@ DEFAULT_SHARE_PASSWORD: Final = "immich123"
EVENT_ALBUM_CHANGED: Final = f"{DOMAIN}_album_changed"
EVENT_ASSETS_ADDED: Final = f"{DOMAIN}_assets_added"
EVENT_ASSETS_REMOVED: Final = f"{DOMAIN}_assets_removed"
EVENT_ALBUM_RENAMED: Final = f"{DOMAIN}_album_renamed"
EVENT_ALBUM_DELETED: Final = f"{DOMAIN}_album_deleted"
EVENT_ALBUM_SHARING_CHANGED: Final = f"{DOMAIN}_album_sharing_changed"
# Attributes
ATTR_HUB_NAME: Final = "hub_name"
@@ -44,21 +49,32 @@ ATTR_REMOVED_COUNT: Final = "removed_count"
ATTR_ADDED_ASSETS: Final = "added_assets"
ATTR_REMOVED_ASSETS: Final = "removed_assets"
ATTR_CHANGE_TYPE: Final = "change_type"
ATTR_LAST_UPDATED: Final = "last_updated"
ATTR_LAST_UPDATED: Final = "last_updated_at"
ATTR_CREATED_AT: Final = "created_at"
ATTR_THUMBNAIL_URL: Final = "thumbnail_url"
ATTR_SHARED: Final = "shared"
ATTR_OWNER: Final = "owner"
ATTR_PEOPLE: Final = "people"
ATTR_ASSET_TYPE: Final = "asset_type"
ATTR_ASSET_FILENAME: Final = "asset_filename"
ATTR_ASSET_CREATED: Final = "asset_created"
ATTR_ASSET_OWNER: Final = "asset_owner"
ATTR_ASSET_OWNER_ID: Final = "asset_owner_id"
ATTR_ASSET_URL: Final = "asset_url"
ATTR_ASSET_DOWNLOAD_URL: Final = "asset_download_url"
ATTR_ASSET_PLAYBACK_URL: Final = "asset_playback_url"
ATTR_ASSET_DESCRIPTION: Final = "asset_description"
ATTR_OLD_NAME: Final = "old_name"
ATTR_NEW_NAME: Final = "new_name"
ATTR_OLD_SHARED: Final = "old_shared"
ATTR_NEW_SHARED: Final = "new_shared"
ATTR_ASSET_TYPE: Final = "type"
ATTR_ASSET_FILENAME: Final = "filename"
ATTR_ASSET_CREATED: Final = "created_at"
ATTR_ASSET_OWNER: Final = "owner"
ATTR_ASSET_OWNER_ID: Final = "owner_id"
ATTR_ASSET_URL: Final = "url"
ATTR_ASSET_DOWNLOAD_URL: Final = "download_url"
ATTR_ASSET_PLAYBACK_URL: Final = "playback_url"
ATTR_ASSET_DESCRIPTION: Final = "description"
ATTR_ASSET_IS_FAVORITE: Final = "is_favorite"
ATTR_ASSET_RATING: Final = "rating"
ATTR_ASSET_LATITUDE: Final = "latitude"
ATTR_ASSET_LONGITUDE: Final = "longitude"
ATTR_ASSET_CITY: Final = "city"
ATTR_ASSET_STATE: Final = "state"
ATTR_ASSET_COUNTRY: Final = "country"
# Asset types
ASSET_TYPE_IMAGE: Final = "IMAGE"
@@ -69,5 +85,5 @@ PLATFORMS: Final = ["sensor", "binary_sensor", "camera", "text", "button"]
# Services
SERVICE_REFRESH: Final = "refresh"
SERVICE_GET_RECENT_ASSETS: Final = "get_recent_assets"
SERVICE_SEND_TELEGRAM_MEDIA_GROUP: Final = "send_telegram_media_group"
SERVICE_GET_ASSETS: Final = "get_assets"
SERVICE_SEND_TELEGRAM_NOTIFICATION: Final = "send_telegram_notification"

View File

@@ -8,7 +8,7 @@ from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .storage import ImmichAlbumStorage
from .storage import ImmichAlbumStorage, TelegramFileCache
import aiohttp
@@ -28,20 +28,36 @@ from .const import (
ATTR_ASSET_DESCRIPTION,
ATTR_ASSET_DOWNLOAD_URL,
ATTR_ASSET_FILENAME,
ATTR_ASSET_IS_FAVORITE,
ATTR_ASSET_LATITUDE,
ATTR_ASSET_LONGITUDE,
ATTR_ASSET_CITY,
ATTR_ASSET_STATE,
ATTR_ASSET_COUNTRY,
ATTR_ASSET_OWNER,
ATTR_ASSET_OWNER_ID,
ATTR_ASSET_PLAYBACK_URL,
ATTR_ASSET_RATING,
ATTR_ASSET_TYPE,
ATTR_ASSET_URL,
ATTR_ASSET_PLAYBACK_URL,
ATTR_CHANGE_TYPE,
ATTR_HUB_NAME,
ATTR_PEOPLE,
ATTR_REMOVED_ASSETS,
ATTR_REMOVED_COUNT,
ATTR_OLD_NAME,
ATTR_NEW_NAME,
ATTR_OLD_SHARED,
ATTR_NEW_SHARED,
ATTR_SHARED,
ATTR_THUMBNAIL_URL,
DOMAIN,
EVENT_ALBUM_CHANGED,
EVENT_ASSETS_ADDED,
EVENT_ASSETS_REMOVED,
EVENT_ALBUM_RENAMED,
EVENT_ALBUM_DELETED,
EVENT_ALBUM_SHARING_CHANGED,
)
_LOGGER = logging.getLogger(__name__)
@@ -107,6 +123,14 @@ class AssetInfo:
owner_name: str = ""
description: str = ""
people: list[str] = field(default_factory=list)
is_favorite: bool = False
rating: int | None = None
latitude: float | None = None
longitude: float | None = None
city: str | None = None
state: str | None = None
country: str | None = None
is_processed: bool = True # Whether asset is fully processed by Immich
@classmethod
def from_api_response(
@@ -122,23 +146,105 @@ class AssetInfo:
if users_cache and owner_id:
owner_name = users_cache.get(owner_id, "")
# Get description from exifInfo if available
description = ""
# Get description - prioritize user-added description over EXIF description
description = data.get("description", "") or ""
exif_info = data.get("exifInfo")
if exif_info:
if not description and exif_info:
# Fall back to EXIF description if no user description
description = exif_info.get("description", "") or ""
# Get favorites and rating
is_favorite = data.get("isFavorite", False)
rating = exif_info.get("rating") if exif_info else None
# Get geolocation
latitude = exif_info.get("latitude") if exif_info else None
longitude = exif_info.get("longitude") if exif_info else None
# Get reverse geocoded location
city = exif_info.get("city") if exif_info else None
state = exif_info.get("state") if exif_info else None
country = exif_info.get("country") if exif_info else None
# Check if asset is fully processed by Immich
asset_type = data.get("type", ASSET_TYPE_IMAGE)
is_processed = cls._check_processing_status(data, asset_type)
return cls(
id=data["id"],
type=data.get("type", ASSET_TYPE_IMAGE),
type=asset_type,
filename=data.get("originalFileName", ""),
created_at=data.get("fileCreatedAt", ""),
owner_id=owner_id,
owner_name=owner_name,
description=description,
people=people,
is_favorite=is_favorite,
rating=rating,
latitude=latitude,
longitude=longitude,
city=city,
state=state,
country=country,
is_processed=is_processed,
)
@staticmethod
def _check_processing_status(data: dict[str, Any], _asset_type: str) -> bool:
"""Check if asset has been fully processed by Immich.
For all assets: Check if thumbnails have been generated (thumbhash exists).
Immich generates thumbnails for both photos and videos regardless of
whether video transcoding is needed.
Args:
data: Asset data from API response
_asset_type: Asset type (IMAGE or VIDEO) - unused but kept for API stability
Returns:
True if asset is fully processed and not trashed/offline/archived, False otherwise
"""
asset_id = data.get("id", "unknown")
asset_type = data.get("type", "unknown")
is_offline = data.get("isOffline", False)
is_trashed = data.get("isTrashed", False)
is_archived = data.get("isArchived", False)
thumbhash = data.get("thumbhash")
_LOGGER.debug(
"Asset %s (%s): isOffline=%s, isTrashed=%s, isArchived=%s, thumbhash=%s",
asset_id,
asset_type,
is_offline,
is_trashed,
is_archived,
bool(thumbhash),
)
# Exclude offline assets
if is_offline:
_LOGGER.debug("Asset %s excluded: offline", asset_id)
return False
# Exclude trashed assets
if is_trashed:
_LOGGER.debug("Asset %s excluded: trashed", asset_id)
return False
# Exclude archived assets
if is_archived:
_LOGGER.debug("Asset %s excluded: archived", asset_id)
return False
# Check if thumbnails have been generated
# This works for both photos and videos - Immich always generates thumbnails
# Note: The API doesn't expose video transcoding status (encodedVideoPath),
# but thumbhash is sufficient since Immich generates thumbnails for all assets
is_processed = bool(thumbhash)
if not is_processed:
_LOGGER.debug("Asset %s excluded: no thumbhash", asset_id)
return is_processed
@dataclass
class AlbumData:
@@ -210,6 +316,10 @@ class AlbumChange:
removed_count: int = 0
added_assets: list[AssetInfo] = field(default_factory=list)
removed_asset_ids: list[str] = field(default_factory=list)
old_name: str | None = None
new_name: str | None = None
old_shared: bool | None = None
new_shared: bool | None = None
class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
@@ -225,6 +335,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
scan_interval: int,
hub_name: str = "Immich",
storage: ImmichAlbumStorage | None = None,
telegram_cache: TelegramFileCache | None = None,
) -> None:
"""Initialize the coordinator."""
super().__init__(
@@ -244,13 +355,45 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
self._users_cache: dict[str, str] = {} # user_id -> name
self._shared_links: list[SharedLinkInfo] = []
self._storage = storage
self._telegram_cache = telegram_cache
self._persisted_asset_ids: set[str] | None = None
self._external_domain: str | None = None # Fetched from server config
self._pending_asset_ids: set[str] = set() # Assets detected but not yet processed
@property
def immich_url(self) -> str:
"""Return the Immich URL."""
"""Return the Immich URL (for API calls)."""
return self._url
@property
def external_url(self) -> str:
"""Return the external URL for links.
Uses externalDomain from Immich server config if set,
otherwise falls back to the connection URL.
"""
if self._external_domain:
return self._external_domain.rstrip("/")
return self._url
def get_internal_download_url(self, url: str) -> str:
"""Convert an external URL to internal URL for faster downloads.
If the URL starts with the external domain, replace it with the
internal connection URL to download via local network.
Args:
url: The URL to convert (may be external or internal)
Returns:
URL using internal connection for downloads
"""
if self._external_domain:
external = self._external_domain.rstrip("/")
if url.startswith(external):
return url.replace(external, self._url, 1)
return url
@property
def api_key(self) -> str:
"""Return the API key."""
@@ -266,6 +409,11 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
"""Return the album name."""
return self._album_name
@property
def telegram_cache(self) -> TelegramFileCache | None:
"""Return the Telegram file cache."""
return self._telegram_cache
def update_scan_interval(self, scan_interval: int) -> None:
"""Update the scan interval."""
self.update_interval = timedelta(seconds=scan_interval)
@@ -291,33 +439,138 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
self._album_name,
)
async def async_get_recent_assets(self, count: int = 10) -> list[dict[str, Any]]:
"""Get recent assets from the album."""
async def async_get_assets(
self,
limit: int = 10,
offset: int = 0,
favorite_only: bool = False,
filter_min_rating: int = 1,
order_by: str = "date",
order: str = "descending",
asset_type: str = "all",
min_date: str | None = None,
max_date: str | None = None,
memory_date: str | None = None,
city: str | None = None,
state: str | None = None,
country: str | None = None,
) -> list[dict[str, Any]]:
"""Get assets from the album with optional filtering and ordering.
Args:
limit: Maximum number of assets to return (1-100)
offset: Number of assets to skip before returning results (for pagination)
favorite_only: Filter to show only favorite assets
filter_min_rating: Minimum rating for assets (1-5)
order_by: Field to sort by - 'date', 'rating', or 'name'
order: Sort direction - 'ascending', 'descending', or 'random'
asset_type: Asset type filter - 'all', 'photo', or 'video'
min_date: Filter assets created on or after this date (ISO 8601 format)
max_date: Filter assets created on or before this date (ISO 8601 format)
memory_date: Filter assets by matching month and day, excluding the same year (memories filter)
city: Filter assets by city (case-insensitive substring match)
state: Filter assets by state/region (case-insensitive substring match)
country: Filter assets by country (case-insensitive substring match)
Returns:
List of asset data dictionaries
"""
if self.data is None:
return []
# Sort assets by created_at descending
sorted_assets = sorted(
self.data.assets.values(),
key=lambda a: a.created_at,
reverse=True,
)[:count]
# Start with all processed assets only
assets = [a for a in self.data.assets.values() if a.is_processed]
# Apply favorite filter
if favorite_only:
assets = [a for a in assets if a.is_favorite]
# Apply rating filter
if filter_min_rating > 1:
assets = [a for a in assets if a.rating is not None and a.rating >= filter_min_rating]
# Apply asset type filtering
if asset_type == "photo":
assets = [a for a in assets if a.type == ASSET_TYPE_IMAGE]
elif asset_type == "video":
assets = [a for a in assets if a.type == ASSET_TYPE_VIDEO]
# Apply date filtering
if min_date:
assets = [a for a in assets if a.created_at >= min_date]
if max_date:
assets = [a for a in assets if a.created_at <= max_date]
# Apply memory date filtering (match month and day, exclude same year)
if memory_date:
try:
# Parse the reference date (supports ISO 8601 format)
ref_date = datetime.fromisoformat(memory_date.replace("Z", "+00:00"))
ref_year = ref_date.year
ref_month = ref_date.month
ref_day = ref_date.day
def matches_memory(asset: AssetInfo) -> bool:
"""Check if asset matches memory criteria (same month/day, different year)."""
try:
asset_date = datetime.fromisoformat(
asset.created_at.replace("Z", "+00:00")
)
# Match month and day, but exclude same year (true memories behavior)
return (
asset_date.month == ref_month
and asset_date.day == ref_day
and asset_date.year != ref_year
)
except (ValueError, AttributeError):
return False
assets = [a for a in assets if matches_memory(a)]
except ValueError:
_LOGGER.warning("Invalid memory_date format: %s", memory_date)
# Apply geolocation filtering (case-insensitive substring match)
if city:
city_lower = city.lower()
assets = [a for a in assets if a.city and city_lower in a.city.lower()]
if state:
state_lower = state.lower()
assets = [a for a in assets if a.state and state_lower in a.state.lower()]
if country:
country_lower = country.lower()
assets = [a for a in assets if a.country and country_lower in a.country.lower()]
# Apply ordering
if order_by == "random":
import random
random.shuffle(assets)
elif order_by == "rating":
# Sort by rating, putting None values last
assets = sorted(
assets,
key=lambda a: (a.rating is None, a.rating if a.rating is not None else 0),
reverse=(order == "descending")
)
elif order_by == "name":
assets = sorted(
assets,
key=lambda a: a.filename.lower(),
reverse=(order == "descending")
)
else: # date (default)
assets = sorted(
assets,
key=lambda a: a.created_at,
reverse=(order == "descending")
)
# Apply offset and limit for pagination
assets = assets[offset : offset + limit]
# Build result with all available asset data (matching event data)
result = []
for asset in sorted_assets:
asset_data = {
"id": asset.id,
"type": asset.type,
"filename": asset.filename,
"created_at": asset.created_at,
"description": asset.description,
"people": asset.people,
"thumbnail_url": f"{self._url}/api/assets/{asset.id}/thumbnail",
}
if asset.type == ASSET_TYPE_VIDEO:
video_url = self._get_asset_video_url(asset.id)
if video_url:
asset_data["video_url"] = video_url
for asset in assets:
asset_data = self._build_asset_detail(asset, include_thumbnail=True)
result.append(asset_data)
return result
@@ -368,6 +621,36 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
return self._users_cache
async def _async_fetch_server_config(self) -> None:
"""Fetch server config from Immich to get external domain."""
if self._session is None:
self._session = async_get_clientsession(self.hass)
headers = {"x-api-key": self._api_key}
try:
async with self._session.get(
f"{self._url}/api/server/config",
headers=headers,
) as response:
if response.status == 200:
data = await response.json()
external_domain = data.get("externalDomain", "") or ""
self._external_domain = external_domain
if external_domain:
_LOGGER.debug(
"Using external domain from Immich: %s", external_domain
)
else:
_LOGGER.debug(
"No external domain configured in Immich, using connection URL"
)
else:
_LOGGER.warning(
"Failed to fetch server config: HTTP %s", response.status
)
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch server config: %s", err)
async def _async_fetch_shared_links(self) -> list[SharedLinkInfo]:
"""Fetch shared links for this album from Immich."""
if self._session is None:
@@ -411,29 +694,29 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
"""Get the public URL if album has an accessible shared link."""
accessible_links = self._get_accessible_links()
if accessible_links:
return f"{self._url}/share/{accessible_links[0].key}"
return f"{self.external_url}/share/{accessible_links[0].key}"
return None
def get_any_url(self) -> str | None:
"""Get any non-expired URL (prefers accessible, falls back to protected)."""
accessible_links = self._get_accessible_links()
if accessible_links:
return f"{self._url}/share/{accessible_links[0].key}"
return f"{self.external_url}/share/{accessible_links[0].key}"
non_expired = [link for link in self._shared_links if not link.is_expired]
if non_expired:
return f"{self._url}/share/{non_expired[0].key}"
return f"{self.external_url}/share/{non_expired[0].key}"
return None
def get_protected_url(self) -> str | None:
"""Get a protected URL if any password-protected link exists."""
protected_links = self._get_protected_links()
if protected_links:
return f"{self._url}/share/{protected_links[0].key}"
return f"{self.external_url}/share/{protected_links[0].key}"
return None
def get_protected_urls(self) -> list[str]:
"""Get all password-protected URLs."""
return [f"{self._url}/share/{link.key}" for link in self._get_protected_links()]
return [f"{self.external_url}/share/{link.key}" for link in self._get_protected_links()]
def get_protected_password(self) -> str | None:
"""Get the password for the first protected link."""
@@ -444,13 +727,13 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
def get_public_urls(self) -> list[str]:
"""Get all accessible public URLs."""
return [f"{self._url}/share/{link.key}" for link in self._get_accessible_links()]
return [f"{self.external_url}/share/{link.key}" for link in self._get_accessible_links()]
def get_shared_links_info(self) -> list[dict[str, Any]]:
"""Get detailed info about all shared links."""
return [
{
"url": f"{self._url}/share/{link.key}",
"url": f"{self.external_url}/share/{link.key}",
"has_password": link.has_password,
"is_expired": link.is_expired,
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
@@ -463,37 +746,108 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
"""Get the public viewer URL for an asset (web page)."""
accessible_links = self._get_accessible_links()
if accessible_links:
return f"{self._url}/share/{accessible_links[0].key}/photos/{asset_id}"
return f"{self.external_url}/share/{accessible_links[0].key}/photos/{asset_id}"
non_expired = [link for link in self._shared_links if not link.is_expired]
if non_expired:
return f"{self._url}/share/{non_expired[0].key}/photos/{asset_id}"
return f"{self.external_url}/share/{non_expired[0].key}/photos/{asset_id}"
return None
def _get_asset_download_url(self, asset_id: str) -> str | None:
"""Get the direct download URL for an asset (media file)."""
accessible_links = self._get_accessible_links()
if accessible_links:
return f"{self._url}/api/assets/{asset_id}/original?key={accessible_links[0].key}"
return f"{self.external_url}/api/assets/{asset_id}/original?key={accessible_links[0].key}"
non_expired = [link for link in self._shared_links if not link.is_expired]
if non_expired:
return f"{self._url}/api/assets/{asset_id}/original?key={non_expired[0].key}"
return f"{self.external_url}/api/assets/{asset_id}/original?key={non_expired[0].key}"
return None
def _get_asset_video_url(self, asset_id: str) -> str | None:
"""Get the transcoded video playback URL for a video asset."""
accessible_links = self._get_accessible_links()
if accessible_links:
return f"{self._url}/api/assets/{asset_id}/video/playback?key={accessible_links[0].key}"
return f"{self.external_url}/api/assets/{asset_id}/video/playback?key={accessible_links[0].key}"
non_expired = [link for link in self._shared_links if not link.is_expired]
if non_expired:
return f"{self._url}/api/assets/{asset_id}/video/playback?key={non_expired[0].key}"
return f"{self.external_url}/api/assets/{asset_id}/video/playback?key={non_expired[0].key}"
return None
def _get_asset_photo_url(self, asset_id: str) -> str | None:
"""Get the preview-sized thumbnail URL for a photo asset."""
accessible_links = self._get_accessible_links()
if accessible_links:
return f"{self.external_url}/api/assets/{asset_id}/thumbnail?size=preview&key={accessible_links[0].key}"
non_expired = [link for link in self._shared_links if not link.is_expired]
if non_expired:
return f"{self.external_url}/api/assets/{asset_id}/thumbnail?size=preview&key={non_expired[0].key}"
return None
def _build_asset_detail(
self, asset: AssetInfo, include_thumbnail: bool = True
) -> dict[str, Any]:
"""Build asset detail dictionary with all available data.
Args:
asset: AssetInfo object
include_thumbnail: If True, include thumbnail_url
Returns:
Dictionary with asset details (using ATTR_* constants for consistency)
"""
# Base asset data
asset_detail = {
"id": asset.id,
ATTR_ASSET_TYPE: asset.type,
ATTR_ASSET_FILENAME: asset.filename,
ATTR_ASSET_CREATED: asset.created_at,
ATTR_ASSET_OWNER: asset.owner_name,
ATTR_ASSET_OWNER_ID: asset.owner_id,
ATTR_ASSET_DESCRIPTION: asset.description,
ATTR_PEOPLE: asset.people,
ATTR_ASSET_IS_FAVORITE: asset.is_favorite,
ATTR_ASSET_RATING: asset.rating,
ATTR_ASSET_LATITUDE: asset.latitude,
ATTR_ASSET_LONGITUDE: asset.longitude,
ATTR_ASSET_CITY: asset.city,
ATTR_ASSET_STATE: asset.state,
ATTR_ASSET_COUNTRY: asset.country,
}
# Add thumbnail URL if requested
if include_thumbnail:
asset_detail[ATTR_THUMBNAIL_URL] = f"{self.external_url}/api/assets/{asset.id}/thumbnail"
# Add public viewer URL (web page)
asset_url = self._get_asset_public_url(asset.id)
if asset_url:
asset_detail[ATTR_ASSET_URL] = asset_url
# Add download URL (direct media file)
asset_download_url = self._get_asset_download_url(asset.id)
if asset_download_url:
asset_detail[ATTR_ASSET_DOWNLOAD_URL] = asset_download_url
# Add type-specific URLs
if asset.type == ASSET_TYPE_VIDEO:
asset_video_url = self._get_asset_video_url(asset.id)
if asset_video_url:
asset_detail[ATTR_ASSET_PLAYBACK_URL] = asset_video_url
elif asset.type == ASSET_TYPE_IMAGE:
asset_photo_url = self._get_asset_photo_url(asset.id)
if asset_photo_url:
asset_detail["photo_url"] = asset_photo_url # TODO: Add ATTR_ASSET_PHOTO_URL constant
return asset_detail
async def _async_update_data(self) -> AlbumData | None:
"""Fetch data from Immich API."""
if self._session is None:
self._session = async_get_clientsession(self.hass)
# Fetch server config to get external domain (once)
if self._external_domain is None:
await self._async_fetch_server_config()
# Fetch users to resolve owner names
if not self._users_cache:
await self._async_fetch_users()
@@ -510,6 +864,15 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
) as response:
if response.status == 404:
_LOGGER.warning("Album %s not found", self._album_id)
# Fire album_deleted event if we had previous state (album was deleted)
if self._previous_state:
event_data = {
ATTR_HUB_NAME: self._hub_name,
ATTR_ALBUM_ID: self._album_id,
ATTR_ALBUM_NAME: self._previous_state.name,
}
self.hass.bus.async_fire(EVENT_ALBUM_DELETED, event_data)
_LOGGER.info("Album '%s' was deleted", self._previous_state.name)
return None
if response.status != 200:
raise UpdateFailed(
@@ -538,11 +901,16 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
elif removed_ids and not added_ids:
change_type = "assets_removed"
added_assets = [
album.assets[aid]
for aid in added_ids
if aid in album.assets
]
added_assets = []
for aid in added_ids:
if aid not in album.assets:
continue
asset = album.assets[aid]
if asset.is_processed:
added_assets.append(asset)
else:
# Track unprocessed assets for later
self._pending_asset_ids.add(aid)
change = AlbumChange(
album_id=album.id,
@@ -599,53 +967,96 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
added_ids = new_state.asset_ids - old_state.asset_ids
removed_ids = old_state.asset_ids - new_state.asset_ids
if not added_ids and not removed_ids:
_LOGGER.debug(
"Change detection: added_ids=%d, removed_ids=%d, pending=%d",
len(added_ids),
len(removed_ids),
len(self._pending_asset_ids),
)
# Track new unprocessed assets and collect processed ones
added_assets = []
for aid in added_ids:
if aid not in new_state.assets:
_LOGGER.debug("Asset %s: not in assets dict", aid)
continue
asset = new_state.assets[aid]
_LOGGER.debug(
"New asset %s (%s): is_processed=%s, filename=%s",
aid,
asset.type,
asset.is_processed,
asset.filename,
)
if asset.is_processed:
added_assets.append(asset)
else:
# Track unprocessed assets for later
self._pending_asset_ids.add(aid)
_LOGGER.debug("Asset %s added to pending (not yet processed)", aid)
# Check if any pending assets are now processed
newly_processed = []
for aid in list(self._pending_asset_ids):
if aid not in new_state.assets:
# Asset was removed, no longer pending
self._pending_asset_ids.discard(aid)
continue
asset = new_state.assets[aid]
if asset.is_processed:
_LOGGER.debug(
"Pending asset %s (%s) is now processed: filename=%s",
aid,
asset.type,
asset.filename,
)
newly_processed.append(asset)
self._pending_asset_ids.discard(aid)
# Include newly processed pending assets
added_assets.extend(newly_processed)
# Detect metadata changes
name_changed = old_state.name != new_state.name
sharing_changed = old_state.shared != new_state.shared
# Return None only if nothing changed at all
if not added_assets and not removed_ids and not name_changed and not sharing_changed:
return None
# Determine primary change type (use added_assets not added_ids)
change_type = "changed"
if added_ids and not removed_ids:
if name_changed and not added_assets and not removed_ids and not sharing_changed:
change_type = "album_renamed"
elif sharing_changed and not added_assets and not removed_ids and not name_changed:
change_type = "album_sharing_changed"
elif added_assets and not removed_ids and not name_changed and not sharing_changed:
change_type = "assets_added"
elif removed_ids and not added_ids:
elif removed_ids and not added_assets and not name_changed and not sharing_changed:
change_type = "assets_removed"
added_assets = [
new_state.assets[aid] for aid in added_ids if aid in new_state.assets
]
return AlbumChange(
album_id=new_state.id,
album_name=new_state.name,
change_type=change_type,
added_count=len(added_ids),
added_count=len(added_assets), # Count only processed assets
removed_count=len(removed_ids),
added_assets=added_assets,
removed_asset_ids=list(removed_ids),
old_name=old_state.name if name_changed else None,
new_name=new_state.name if name_changed else None,
old_shared=old_state.shared if sharing_changed else None,
new_shared=new_state.shared if sharing_changed else None,
)
def _fire_events(self, change: AlbumChange, album: AlbumData) -> None:
"""Fire Home Assistant events for album changes."""
added_assets_detail = []
for asset in change.added_assets:
asset_detail = {
"id": asset.id,
ATTR_ASSET_TYPE: asset.type,
ATTR_ASSET_FILENAME: asset.filename,
ATTR_ASSET_CREATED: asset.created_at,
ATTR_ASSET_OWNER: asset.owner_name,
ATTR_ASSET_OWNER_ID: asset.owner_id,
ATTR_ASSET_DESCRIPTION: asset.description,
ATTR_PEOPLE: asset.people,
}
asset_url = self._get_asset_public_url(asset.id)
if asset_url:
asset_detail[ATTR_ASSET_URL] = asset_url
asset_download_url = self._get_asset_download_url(asset.id)
if asset_download_url:
asset_detail[ATTR_ASSET_DOWNLOAD_URL] = asset_download_url
if asset.type == ASSET_TYPE_VIDEO:
asset_video_url = self._get_asset_video_url(asset.id)
if asset_video_url:
asset_detail[ATTR_ASSET_PLAYBACK_URL] = asset_video_url
# Only include fully processed assets
if not asset.is_processed:
continue
asset_detail = self._build_asset_detail(asset, include_thumbnail=False)
added_assets_detail.append(asset_detail)
event_data = {
@@ -658,8 +1069,18 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
ATTR_ADDED_ASSETS: added_assets_detail,
ATTR_REMOVED_ASSETS: change.removed_asset_ids,
ATTR_PEOPLE: list(album.people),
ATTR_SHARED: album.shared,
}
# Add metadata change attributes if applicable
if change.old_name is not None:
event_data[ATTR_OLD_NAME] = change.old_name
event_data[ATTR_NEW_NAME] = change.new_name
if change.old_shared is not None:
event_data[ATTR_OLD_SHARED] = change.old_shared
event_data[ATTR_NEW_SHARED] = change.new_shared
album_url = self.get_any_url()
if album_url:
event_data[ATTR_ALBUM_URL] = album_url
@@ -679,6 +1100,24 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
if change.removed_count > 0:
self.hass.bus.async_fire(EVENT_ASSETS_REMOVED, event_data)
# Fire specific events for metadata changes
if change.old_name is not None:
self.hass.bus.async_fire(EVENT_ALBUM_RENAMED, event_data)
_LOGGER.info(
"Album renamed: '%s' -> '%s'",
change.old_name,
change.new_name,
)
if change.old_shared is not None:
self.hass.bus.async_fire(EVENT_ALBUM_SHARING_CHANGED, event_data)
_LOGGER.info(
"Album '%s' sharing changed: %s -> %s",
change.album_name,
change.old_shared,
change.new_shared,
)
def get_protected_link_id(self) -> str | None:
"""Get the ID of the first protected link."""
protected_links = self._get_protected_links()

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
"requirements": [],
"version": "1.4.0"
"version": "2.7.1"
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,17 +6,17 @@ refresh:
integration: immich_album_watcher
domain: sensor
get_recent_assets:
name: Get Recent Assets
description: Get the most recent assets from the targeted album.
get_assets:
name: Get Assets
description: Get assets from the targeted album with optional filtering and ordering.
target:
entity:
integration: immich_album_watcher
domain: sensor
fields:
count:
name: Count
description: Number of recent assets to return (1-100).
limit:
name: Limit
description: Maximum number of assets to return (1-100).
required: false
default: 10
selector:
@@ -24,10 +24,114 @@ get_recent_assets:
min: 1
max: 100
mode: slider
offset:
name: Offset
description: Number of assets to skip before returning results (for pagination). Use with limit to fetch assets in pages.
required: false
default: 0
selector:
number:
min: 0
mode: box
favorite_only:
name: Favorite Only
description: Filter to show only favorite assets.
required: false
default: false
selector:
boolean:
filter_min_rating:
name: Minimum Rating
description: Minimum rating for assets (1-5). Set to filter by rating.
required: false
default: 1
selector:
number:
min: 1
max: 5
mode: slider
order_by:
name: Order By
description: Field to sort assets by.
required: false
default: "date"
selector:
select:
options:
- label: "Date"
value: "date"
- label: "Rating"
value: "rating"
- label: "Name"
value: "name"
- label: "Random"
value: "random"
order:
name: Order
description: Sort direction.
required: false
default: "descending"
selector:
select:
options:
- label: "Ascending"
value: "ascending"
- label: "Descending"
value: "descending"
asset_type:
name: Asset Type
description: Filter assets by type (all, photo, or video).
required: false
default: "all"
selector:
select:
options:
- label: "All (no type filtering)"
value: "all"
- label: "Photos only"
value: "photo"
- label: "Videos only"
value: "video"
min_date:
name: Minimum Date
description: Filter assets created on or after this date (ISO 8601 format, e.g., 2024-01-01 or 2024-01-01T10:30:00).
required: false
selector:
text:
max_date:
name: Maximum Date
description: Filter assets created on or before this date (ISO 8601 format, e.g., 2024-12-31 or 2024-12-31T23:59:59).
required: false
selector:
text:
memory_date:
name: Memory Date
description: Filter assets by matching month and day, excluding the same year (memories filter like Google Photos). Provide a date in ISO 8601 format (e.g., 2024-02-14) to get assets from February 14th of previous years.
required: false
selector:
text:
city:
name: City
description: Filter assets by city name (case-insensitive substring match). Based on reverse geocoded location from asset GPS data.
required: false
selector:
text:
state:
name: State
description: Filter assets by state/region name (case-insensitive substring match). Based on reverse geocoded location from asset GPS data.
required: false
selector:
text:
country:
name: Country
description: Filter assets by country name (case-insensitive substring match). Based on reverse geocoded location from asset GPS data.
required: false
selector:
text:
send_telegram_media_group:
name: Send Telegram Media Group
description: Send specified media URLs to a Telegram chat as a media group.
send_telegram_notification:
name: Send Telegram Notification
description: Send a notification to Telegram (text, photo, video, or media group).
target:
entity:
integration: immich_album_watcher
@@ -47,13 +151,13 @@ send_telegram_media_group:
text:
urls:
name: URLs
description: List of media URLs to send (max 10). Each item should have 'url' and 'type' (photo/video).
required: true
description: List of media URLs to send. Each item should have 'url' and 'type' (photo/video). If empty, sends a text message. Large lists are automatically split into multiple media groups.
required: false
selector:
object:
caption:
name: Caption
description: Optional caption for the media group (applied to first item).
description: Caption text. For media, applied to first item. For empty URLs, this is the message text.
required: false
selector:
text:
@@ -65,3 +169,90 @@ send_telegram_media_group:
selector:
number:
mode: box
disable_web_page_preview:
name: Disable Web Page Preview
description: Disable link previews in text messages.
required: false
selector:
boolean:
parse_mode:
name: Parse Mode
description: How to parse the caption/text. Options are "HTML", "Markdown", "MarkdownV2", or empty string for plain text.
required: false
default: "HTML"
selector:
select:
options:
- label: "HTML"
value: "HTML"
- label: "Markdown"
value: "Markdown"
- label: "MarkdownV2"
value: "MarkdownV2"
- label: "Plain Text"
value: ""
max_group_size:
name: Max Group Size
description: Maximum number of media items per media group (2-10). Large lists will be split into multiple groups.
required: false
default: 10
selector:
number:
min: 2
max: 10
mode: slider
chunk_delay:
name: Chunk Delay
description: Delay in milliseconds between sending multiple media groups (0-60000). Useful for rate limiting.
required: false
default: 0
selector:
number:
min: 0
max: 60000
step: 100
unit_of_measurement: "ms"
mode: slider
wait_for_response:
name: Wait For Response
description: Wait for Telegram to finish processing before returning. Set to false for fire-and-forget (automation continues immediately).
required: false
default: true
selector:
boolean:
max_asset_data_size:
name: Max Asset Data Size
description: Maximum asset size in bytes. Assets exceeding this limit will be skipped. Leave empty for no limit.
required: false
selector:
number:
min: 1
max: 52428800
step: 1048576
unit_of_measurement: "bytes"
mode: box
send_large_photos_as_documents:
name: Send Large Photos As Documents
description: How to handle photos exceeding Telegram's limits (10MB or 10000px dimension sum). If true, send as documents. If false, skip oversized photos.
required: false
default: false
selector:
boolean:
chat_action:
name: Chat Action
description: Chat action to display while processing (typing, upload_photo, upload_video, upload_document). Set to empty to disable.
required: false
default: "typing"
selector:
select:
options:
- label: "Typing"
value: "typing"
- label: "Uploading Photo"
value: "upload_photo"
- label: "Uploading Video"
value: "upload_video"
- label: "Uploading Document"
value: "upload_document"
- label: "Disabled"
value: ""

View File

@@ -14,6 +14,9 @@ _LOGGER = logging.getLogger(__name__)
STORAGE_VERSION = 1
STORAGE_KEY_PREFIX = "immich_album_watcher"
# Default TTL for Telegram file_id cache (48 hours in seconds)
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
class ImmichAlbumStorage:
"""Handles persistence of album state across restarts."""
@@ -63,3 +66,116 @@ class ImmichAlbumStorage:
"""Remove all storage data."""
await self._store.async_remove()
self._data = None
class TelegramFileCache:
"""Cache for Telegram file_ids to avoid re-uploading media.
When a file is uploaded to Telegram, it returns a file_id that can be reused
to send the same file without re-uploading. This cache stores these file_ids
keyed by the source URL.
"""
def __init__(
self,
hass: HomeAssistant,
album_id: str,
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
) -> None:
"""Initialize the Telegram file cache.
Args:
hass: Home Assistant instance
album_id: Album ID for scoping the cache
ttl_seconds: Time-to-live for cache entries in seconds (default: 48 hours)
"""
self._store: Store[dict[str, Any]] = Store(
hass, STORAGE_VERSION, f"{STORAGE_KEY_PREFIX}.telegram_cache.{album_id}"
)
self._data: dict[str, Any] | None = None
self._ttl_seconds = ttl_seconds
async def async_load(self) -> None:
"""Load cache data from storage."""
self._data = await self._store.async_load() or {"files": {}}
# Clean up expired entries on load
await self._cleanup_expired()
_LOGGER.debug(
"Loaded Telegram file cache with %d entries",
len(self._data.get("files", {})),
)
async def _cleanup_expired(self) -> None:
"""Remove expired cache entries."""
if not self._data or "files" not in self._data:
return
now = datetime.now(timezone.utc)
expired_keys = []
for url, entry in self._data["files"].items():
cached_at_str = entry.get("cached_at")
if cached_at_str:
cached_at = datetime.fromisoformat(cached_at_str)
age_seconds = (now - cached_at).total_seconds()
if age_seconds > self._ttl_seconds:
expired_keys.append(url)
if expired_keys:
for key in expired_keys:
del self._data["files"][key]
await self._store.async_save(self._data)
_LOGGER.debug("Cleaned up %d expired Telegram cache entries", len(expired_keys))
def get(self, url: str) -> dict[str, Any] | None:
"""Get cached file_id for a URL.
Args:
url: The source URL of the media
Returns:
Dict with 'file_id' and 'type' if cached and not expired, None otherwise
"""
if not self._data or "files" not in self._data:
return None
entry = self._data["files"].get(url)
if not entry:
return None
# Check if expired
cached_at_str = entry.get("cached_at")
if cached_at_str:
cached_at = datetime.fromisoformat(cached_at_str)
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
if age_seconds > self._ttl_seconds:
return None
return {
"file_id": entry.get("file_id"),
"type": entry.get("type"),
}
async def async_set(self, url: str, file_id: str, media_type: str) -> None:
"""Store a file_id for a URL.
Args:
url: The source URL of the media
file_id: The Telegram file_id
media_type: The type of media ('photo', 'video', 'document')
"""
if self._data is None:
self._data = {"files": {}}
self._data["files"][url] = {
"file_id": file_id,
"type": media_type,
"cached_at": datetime.now(timezone.utc).isoformat(),
}
await self._store.async_save(self._data)
_LOGGER.debug("Cached Telegram file_id for URL (type: %s)", media_type)
async def async_remove(self) -> None:
"""Remove all cache data."""
await self._store.async_remove()
self._data = None

View File

@@ -79,13 +79,6 @@ class ImmichAlbumProtectedPasswordText(
"""Get the album data from coordinator."""
return self.coordinator.data
@property
def translation_placeholders(self) -> dict[str, str]:
"""Return translation placeholders."""
if self._album_data:
return {"album_name": self._album_data.name}
return {"album_name": self._album_name}
@property
def available(self) -> bool:
"""Return if entity is available.

View File

@@ -1,58 +1,61 @@
{
"entity": {
"sensor": {
"album_id": {
"name": "Album ID"
},
"album_asset_count": {
"name": "{album_name}: Asset Count"
"name": "Asset Count"
},
"album_photo_count": {
"name": "{album_name}: Photo Count"
"name": "Photo Count"
},
"album_video_count": {
"name": "{album_name}: Video Count"
"name": "Video Count"
},
"album_last_updated": {
"name": "{album_name}: Last Updated"
"name": "Last Updated"
},
"album_created": {
"name": "{album_name}: Created"
"name": "Created"
},
"album_public_url": {
"name": "{album_name}: Public URL"
"name": "Public URL"
},
"album_protected_url": {
"name": "{album_name}: Protected URL"
"name": "Protected URL"
},
"album_protected_password": {
"name": "{album_name}: Protected Password"
"name": "Protected Password"
}
},
"binary_sensor": {
"album_new_assets": {
"name": "{album_name}: New Assets"
"name": "New Assets"
}
},
"camera": {
"album_thumbnail": {
"name": "{album_name}: Thumbnail"
"name": "Thumbnail"
}
},
"text": {
"album_protected_password_edit": {
"name": "{album_name}: Share Password"
"name": "Share Password"
}
},
"button": {
"create_share_link": {
"name": "{album_name}: Create Share Link"
"name": "Create Share Link"
},
"delete_share_link": {
"name": "{album_name}: Delete Share Link"
"name": "Delete Share Link"
},
"create_protected_link": {
"name": "{album_name}: Create Protected Link"
"name": "Create Protected Link"
},
"delete_protected_link": {
"name": "{album_name}: Delete Protected Link"
"name": "Delete Protected Link"
}
}
},
@@ -113,12 +116,16 @@
"step": {
"init": {
"title": "Immich Album Watcher Options",
"description": "Configure the polling interval for all albums.",
"description": "Configure the polling interval and Telegram settings for all albums.",
"data": {
"scan_interval": "Scan interval (seconds)"
"scan_interval": "Scan interval (seconds)",
"telegram_bot_token": "Telegram Bot Token",
"telegram_cache_ttl": "Telegram Cache TTL (hours)"
},
"data_description": {
"scan_interval": "How often to check for album changes (10-3600 seconds)"
"scan_interval": "How often to check for album changes (10-3600 seconds)",
"telegram_bot_token": "Bot token for sending notifications to Telegram",
"telegram_cache_ttl": "How long to cache uploaded file IDs to avoid re-uploading (1-168 hours, default: 48)"
}
}
}
@@ -128,13 +135,119 @@
"name": "Refresh",
"description": "Force an immediate refresh of album data from Immich."
},
"get_recent_assets": {
"name": "Get Recent Assets",
"description": "Get the most recent assets from the targeted album.",
"get_assets": {
"name": "Get Assets",
"description": "Get assets from the targeted album with optional filtering and ordering.",
"fields": {
"count": {
"name": "Count",
"description": "Number of recent assets to return (1-100)."
"limit": {
"name": "Limit",
"description": "Maximum number of assets to return (1-100)."
},
"offset": {
"name": "Offset",
"description": "Number of assets to skip (for pagination)."
},
"favorite_only": {
"name": "Favorite Only",
"description": "Filter to show only favorite assets."
},
"filter_min_rating": {
"name": "Minimum Rating",
"description": "Minimum rating for assets (1-5)."
},
"order_by": {
"name": "Order By",
"description": "Field to sort assets by (date, rating, name, or random)."
},
"order": {
"name": "Order",
"description": "Sort direction (ascending or descending)."
},
"asset_type": {
"name": "Asset Type",
"description": "Filter assets by type (all, photo, or video)."
},
"min_date": {
"name": "Minimum Date",
"description": "Filter assets created on or after this date (ISO 8601 format)."
},
"max_date": {
"name": "Maximum Date",
"description": "Filter assets created on or before this date (ISO 8601 format)."
},
"memory_date": {
"name": "Memory Date",
"description": "Filter assets by matching month and day, excluding the same year (memories filter)."
},
"city": {
"name": "City",
"description": "Filter assets by city name (case-insensitive)."
},
"state": {
"name": "State",
"description": "Filter assets by state/region name (case-insensitive)."
},
"country": {
"name": "Country",
"description": "Filter assets by country name (case-insensitive)."
}
}
},
"send_telegram_notification": {
"name": "Send Telegram Notification",
"description": "Send a notification to Telegram (text, photo, video, or media group).",
"fields": {
"bot_token": {
"name": "Bot Token",
"description": "Telegram bot token (optional if configured in integration options)."
},
"chat_id": {
"name": "Chat ID",
"description": "Telegram chat ID to send to."
},
"urls": {
"name": "URLs",
"description": "List of media URLs with type (photo/video). If empty, sends a text message. Large lists are automatically split into multiple media groups."
},
"caption": {
"name": "Caption",
"description": "Caption text. For media, applied to first item. For empty URLs, this is the message text."
},
"reply_to_message_id": {
"name": "Reply To",
"description": "Optional message ID to reply to."
},
"disable_web_page_preview": {
"name": "Disable Web Page Preview",
"description": "Disable link previews in text messages."
},
"parse_mode": {
"name": "Parse Mode",
"description": "How to parse the caption/text. Options are HTML, Markdown, MarkdownV2, or empty string for plain text."
},
"max_group_size": {
"name": "Max Group Size",
"description": "Maximum number of media items per media group (2-10). Large lists will be split into multiple groups."
},
"chunk_delay": {
"name": "Chunk Delay",
"description": "Delay in milliseconds between sending multiple media groups (0-60000). Useful for rate limiting."
},
"wait_for_response": {
"name": "Wait For Response",
"description": "Wait for Telegram to finish processing before returning. Set to false for fire-and-forget (automation continues immediately)."
},
"max_asset_data_size": {
"name": "Max Asset Data Size",
"description": "Maximum asset size in bytes. Assets exceeding this limit will be skipped. Leave empty for no limit."
},
"send_large_photos_as_documents": {
"name": "Send Large Photos As Documents",
"description": "How to handle photos exceeding Telegram's limits (10MB or 10000px dimension sum). If true, send as documents. If false, downsize to fit limits."
},
"chat_action": {
"name": "Chat Action",
"description": "Chat action to display while processing (typing, upload_photo, upload_video, upload_document). Set to empty to disable."
}
}
}

View File

@@ -1,58 +1,61 @@
{
"entity": {
"sensor": {
"album_id": {
"name": "ID альбома"
},
"album_asset_count": {
"name": "{album_name}: Число файлов"
"name": "Число файлов"
},
"album_photo_count": {
"name": "{album_name}: Число фото"
"name": "Число фото"
},
"album_video_count": {
"name": "{album_name}: Число видео"
"name": "Число видео"
},
"album_last_updated": {
"name": "{album_name}: Последнее обновление"
"name": "Последнее обновление"
},
"album_created": {
"name": "{album_name}: Дата создания"
"name": "Дата создания"
},
"album_public_url": {
"name": "{album_name}: Публичная ссылка"
"name": "Публичная ссылка"
},
"album_protected_url": {
"name": "{album_name}: Защищённая ссылка"
"name": "Защищённая ссылка"
},
"album_protected_password": {
"name": "{album_name}: Пароль ссылки"
"name": "Пароль ссылки"
}
},
"binary_sensor": {
"album_new_assets": {
"name": "{album_name}: Новые файлы"
"name": "Новые файлы"
}
},
"camera": {
"album_thumbnail": {
"name": "{album_name}: Превью"
"name": "Превью"
}
},
"text": {
"album_protected_password_edit": {
"name": "{album_name}: Пароль ссылки"
"name": "Пароль ссылки"
}
},
"button": {
"create_share_link": {
"name": "{album_name}: Создать ссылку"
"name": "Создать ссылку"
},
"delete_share_link": {
"name": "{album_name}: Удалить ссылку"
"name": "Удалить ссылку"
},
"create_protected_link": {
"name": "{album_name}: Создать защищённую ссылку"
"name": "Создать защищённую ссылку"
},
"delete_protected_link": {
"name": "{album_name}: Удалить защищённую ссылку"
"name": "Удалить защищённую ссылку"
}
}
},
@@ -113,12 +116,16 @@
"step": {
"init": {
"title": "Настройки Immich Album Watcher",
"description": "Настройте интервал опроса для всех альбомов.",
"description": "Настройте интервал опроса и параметры Telegram для всех альбомов.",
"data": {
"scan_interval": "Интервал сканирования (секунды)"
"scan_interval": "Интервал сканирования (секунды)",
"telegram_bot_token": "Токен Telegram бота",
"telegram_cache_ttl": "Время жизни кэша Telegram (часы)"
},
"data_description": {
"scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)"
"scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)",
"telegram_bot_token": "Токен бота для отправки уведомлений в Telegram",
"telegram_cache_ttl": "Сколько хранить ID загруженных файлов для повторной отправки без загрузки (1-168 часов, по умолчанию: 48)"
}
}
}
@@ -128,13 +135,119 @@
"name": "Обновить",
"description": "Принудительно обновить данные альбома из Immich."
},
"get_recent_assets": {
"name": "Получить последние файлы",
"description": "Получить последние файлы из выбранного альбома.",
"get_assets": {
"name": "Получить файлы",
"description": "Получить файлы из выбранного альбома с возможностью фильтрации и сортировки.",
"fields": {
"count": {
"name": "Количество",
"description": "Количество возвращаемых файлов (1-100)."
"limit": {
"name": "Лимит",
"description": "Максимальное количество возвращаемых файлов (1-100)."
},
"offset": {
"name": "Смещение",
"description": "Количество файлов для пропуска (для пагинации)."
},
"favorite_only": {
"name": "Только избранные",
"description": "Фильтр для отображения только избранных файлов."
},
"filter_min_rating": {
"name": "Минимальный рейтинг",
"description": "Минимальный рейтинг для файлов (1-5)."
},
"order_by": {
"name": "Сортировать по",
"description": "Поле для сортировки файлов (date - дата, rating - рейтинг, name - имя, random - случайный)."
},
"order": {
"name": "Порядок",
"description": "Направление сортировки (ascending - по возрастанию, descending - по убыванию)."
},
"asset_type": {
"name": "Тип файла",
"description": "Фильтровать файлы по типу (all - все, photo - только фото, video - только видео)."
},
"min_date": {
"name": "Минимальная дата",
"description": "Фильтровать файлы, созданные в эту дату или после (формат ISO 8601)."
},
"max_date": {
"name": "Максимальная дата",
"description": "Фильтровать файлы, созданные в эту дату или до (формат ISO 8601)."
},
"memory_date": {
"name": "Дата воспоминания",
"description": "Фильтр по совпадению месяца и дня, исключая тот же год (воспоминания)."
},
"city": {
"name": "Город",
"description": "Фильтр по названию города (без учёта регистра)."
},
"state": {
"name": "Регион",
"description": "Фильтр по названию региона/области (без учёта регистра)."
},
"country": {
"name": "Страна",
"description": "Фильтр по названию страны (без учёта регистра)."
}
}
},
"send_telegram_notification": {
"name": "Отправить уведомление в Telegram",
"description": "Отправить уведомление в Telegram (текст, фото, видео или медиа-группу).",
"fields": {
"bot_token": {
"name": "Токен бота",
"description": "Токен Telegram бота (необязательно, если настроен в опциях интеграции)."
},
"chat_id": {
"name": "ID чата",
"description": "ID чата Telegram для отправки."
},
"urls": {
"name": "URL-адреса",
"description": "Список URL медиа-файлов с типом (photo/video). Если пусто, отправляет текстовое сообщение. Большие списки автоматически разделяются на несколько медиа-групп."
},
"caption": {
"name": "Подпись",
"description": "Текст подписи. Для медиа применяется к первому элементу. Для пустых URLs это текст сообщения."
},
"reply_to_message_id": {
"name": "Ответ на",
"description": "ID сообщения для ответа (необязательно)."
},
"disable_web_page_preview": {
"name": "Отключить предпросмотр ссылок",
"description": "Отключить предпросмотр ссылок в текстовых сообщениях."
},
"parse_mode": {
"name": "Режим парсинга",
"description": "Как парсить подпись/текст. Варианты: HTML, Markdown, MarkdownV2, или пустая строка для обычного текста."
},
"max_group_size": {
"name": "Макс. размер группы",
"description": "Максимальное количество медиа-файлов в одной группе (2-10). Большие списки будут разделены на несколько групп."
},
"chunk_delay": {
"name": "Задержка между группами",
"description": "Задержка в миллисекундах между отправкой нескольких медиа-групп (0-60000). Полезно для ограничения скорости."
},
"wait_for_response": {
"name": "Ждать ответа",
"description": "Ждать завершения отправки в Telegram перед возвратом. Установите false для фоновой отправки (автоматизация продолжается немедленно)."
},
"max_asset_data_size": {
"name": "Макс. размер ресурса",
"description": "Максимальный размер ресурса в байтах. Ресурсы, превышающие этот лимит, будут пропущены. Оставьте пустым для отсутствия ограничения."
},
"send_large_photos_as_documents": {
"name": "Большие фото как документы",
"description": "Как обрабатывать фото, превышающие лимиты Telegram (10МБ или сумма размеров 10000пкс). Если true, отправлять как документы. Если false, уменьшать для соответствия лимитам."
},
"chat_action": {
"name": "Действие в чате",
"description": "Действие для отображения во время обработки (typing, upload_photo, upload_video, upload_document). Оставьте пустым для отключения."
}
}
}