55 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
02c0535f50 Add telegram media sender as service. Also fixed the via_device warnings that would break in HA 2025.12.0.
All checks were successful
Validate / Hassfest (push) Successful in 3s
2026-01-31 04:07:13 +03:00
c570e157be Skip the validation step on Gitea
All checks were successful
Validate / Hassfest (push) Successful in 4s
2026-01-31 01:59:05 +03:00
56d249b598 No luck, let's run it at least on GitHub
Some checks failed
Validate / Hassfest (push) Failing after 4s
2026-01-31 01:54:20 +03:00
ebed587f6f Let's try this actions one more time
Some checks failed
Validate / Hassfest (push) Failing after 1m4s
2026-01-31 01:53:00 +03:00
a89d45268d Attemp to also support Gitea for actions
Some checks failed
Validate / Hassfest (push) Failing after 1m25s
2026-01-31 01:50:02 +03:00
950fe0fd91 Add hassfest validation action
Some checks failed
Validate / Hassfest (push) Failing after 19s
2026-01-31 01:39:28 +03:00
91c30e086d Actualize entities and asset fields info in README.md 2026-01-30 23:45:34 +03:00
6f39a8175d Update root README.md 2026-01-30 23:41:52 +03:00
e6619cb1c5 Album ID sensor now also exposes album name 2026-01-30 15:48:16 +03:00
eedc7792c8 Add album id sensor that has primary share link url attribute 2026-01-30 15:41:58 +03:00
3a0573e432 Add persistent storage 2026-01-30 15:30:05 +03:00
7c53110c07 Update README.md as we only have one integration now 2026-01-30 14:54:03 +03:00
03430df5fb Add license file and improve CLAUDE.md context file 2026-01-30 14:50:01 +03:00
2ca26e178a Add CLAUDE.md with versioning rules 2026-01-30 14:45:46 +03:00
847c39eaa8 Add a note about handy blueprint 2026-01-30 14:42:17 +03:00
9013c5e0c3 Update README to use github remote url 2026-01-30 14:40:50 +03:00
557ec91f05 Update remote url for HACS config and README 2026-01-30 14:12:46 +03:00
436139ede9 Prepare the integration for HACS installation 2026-01-30 14:11:28 +03:00
f622a25484 Add playback url 2026-01-30 05:23:15 +03:00
a4738dfd80 Add support for asset download url 2026-01-30 05:00:18 +03:00
5d2f4c7edf Add link generation buttons 2026-01-30 03:45:06 +03:00
82f293d0df Add hub support 2026-01-30 02:57:45 +03:00
60573374a4 Implement hub and subenty approach based on telegram bot integration implementation 2026-01-30 02:39:59 +03:00
c8bd475a52 Add entity picker support 2026-01-30 02:05:10 +03:00
17c1792637 Remove repository structure from README 2026-01-30 01:36:30 +03:00
c49392af51 Add support for asset_description assert metadata 2026-01-30 01:33:22 +03:00
69b344142f Allow to track protected links and change protected link password (if available) 2026-01-30 01:21:09 +03:00
35 changed files with 5647 additions and 1596 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)

17
.github/workflows/validate.yaml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Validate
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
hassfest:
name: Hassfest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: home-assistant/actions/hassfest@master
if: github.server_url == 'https://github.com'

32
CLAUDE.md Normal file
View File

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

49
CONTRIBUTING.md Normal file
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.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Alexei Dolgolyov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

594
README.md
View File

@@ -1,54 +1,576 @@
# HAOS Integrations # Immich Album Watcher
A collection of custom integrations for Home Assistant. <img src="custom_components/immich_album_watcher/icon.png" alt="Immich" width="64" height="64">
## Repository Structure 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.
haos-integrations/
├── immich_album_watcher/ # Immich Album Watcher integration
│ ├── __init__.py
│ ├── binary_sensor.py
│ ├── camera.py
│ ├── config_flow.py
│ ├── const.py
│ ├── coordinator.py
│ ├── manifest.json
│ ├── sensor.py
│ ├── services.yaml
│ ├── strings.json
│ ├── icon.png
│ ├── README.md
│ └── translations/
│ ├── en.json
│ └── ru.json
├── .gitignore
├── LICENSE
└── README.md
```
## Available Integrations ## Features
| Integration | Description | Documentation | - **Album Monitoring** - Watch selected Immich albums for asset additions and removals
|-------------|-------------|---------------| - **Rich Sensor Data** - Multiple sensors per album:
| [Immich Album Watcher](immich_album_watcher/) | Monitor Immich albums for changes with sensors, events, and face recognition | [README](immich_album_watcher/README.md) | - Album ID (with album name and share URL attributes)
- Asset Count (total assets with detected people list)
- Photo Count (number of photos)
- Video Count (number of videos)
- Last Updated (last modification timestamp)
- Created (album creation date)
- Public URL (public share link)
- Protected URL (password-protected share link)
- Protected Password (password for protected link)
- **Camera Entity** - Album thumbnail displayed as a camera entity for dashboards
- **Binary Sensor** - "New Assets" indicator that turns on when assets are added
- **Face Recognition** - Detects and lists people recognized in album photos
- **Event Firing** - Fires Home Assistant events when albums change:
- `immich_album_watcher_album_changed` - General album changes
- `immich_album_watcher_assets_added` - When new assets are added
- `immich_album_watcher_assets_removed` - When assets are removed
- **Enhanced Event Data** - Events include detailed asset info:
- Asset type (photo/video)
- Filename
- Creation date
- Asset owner (who uploaded the asset)
- Asset description/caption
- Public URL (if album has a shared link)
- Detected people in the asset
- **Services** - Custom service calls:
- `immich_album_watcher.refresh` - Force immediate data refresh
- `immich_album_watcher.get_assets` - Get assets from an album with filtering and ordering
- `immich_album_watcher.send_telegram_notification` - Send text, photo, video, or media group to Telegram
- **Share Link Management** - Button entities to create and delete share links:
- Create/delete public (unprotected) share links
- Create/delete password-protected share links
- Edit protected link passwords via Text entity
- **Configurable Polling** - Adjustable scan interval (10-3600 seconds)
- **Localization** - Available in multiple languages:
- English
- Russian (Русский)
## Installation ## Installation
### HACS Installation (Recommended)
1. Open HACS in Home Assistant
2. Click on the three dots in the top right corner
3. Select **Custom repositories**
4. Add this repository URL: `https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher`
5. Select **Integration** as the category
6. Click **Add**
7. Search for "Immich Album Watcher" in HACS and install it
8. Restart Home Assistant
9. Add the integration via **Settings****Devices & Services****Add Integration**
### Manual Installation ### Manual Installation
1. Download or clone this repository 1. Download or clone this repository
2. Copy the desired integration folder (e.g., `immich_album_watcher`) to your Home Assistant `custom_components` directory 2. Copy the `custom_components/immich_album_watcher` folder to your Home Assistant `config/custom_components` directory
3. Restart Home Assistant 3. Restart Home Assistant
4. Add the integration via **Settings****Devices & Services****Add Integration** 4. Add the integration via **Settings****Devices & Services****Add Integration**
### HACS Installation ## Configuration
1. Open HACS in Home Assistant | Option | Description | Default |
2. Go to **Integrations****Custom repositories** |--------|-------------|---------|
3. Add this repository URL | Server URL | Your Immich server URL (e.g., `https://immich.example.com`) | Required |
4. Install the desired integration | API Key | Your Immich API key | Required |
5. Restart Home Assistant | 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`, `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 |
| Sensor | Last Updated | When the album was last modified |
| Sensor | Created | When the album was created |
| Sensor | Public URL | Public share link URL (accessible links without password) |
| Sensor | Protected URL | Password-protected share link URL (if any exist) |
| Sensor | Protected Password | Password for the protected share link (read-only) |
| Binary Sensor | New Assets | On when new assets were recently added |
| Camera | Thumbnail | Album cover image |
| Text | Protected Password | Editable password for the protected share link |
| Button | Create Share Link | Creates an unprotected public share link |
| Button | Delete Share Link | Deletes the unprotected public share link |
| Button | Create Protected Link | Creates a password-protected share link |
| Button | Delete Protected Link | Deletes the password-protected share link |
## Services
### Refresh
Force an immediate refresh of all album data:
```yaml
service: immich_album_watcher.refresh
```
### Get Assets
Get assets from a specific album with optional filtering and ordering (returns response data). Only returns fully processed assets (videos with completed transcoding, photos with generated thumbnails).
```yaml
service: immich_album_watcher.get_assets
target:
entity_id: sensor.album_name_asset_limit
data:
limit: 10 # Maximum number of assets (1-100)
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
The integration fires multiple event types that you can use in your automations:
### Available Events
| Event Type | Description | When Fired |
|------------|-------------|------------|
| `immich_album_watcher_album_changed` | General album change event | Fired for any album change |
| `immich_album_watcher_assets_added` | Assets were added to the album | When new photos/videos are added |
| `immich_album_watcher_assets_removed` | Assets were removed from the album | When photos/videos are removed |
| `immich_album_watcher_album_renamed` | Album name was changed | When the album is renamed |
| `immich_album_watcher_album_deleted` | Album was deleted | When the album is deleted from Immich |
| `immich_album_watcher_album_sharing_changed` | Album sharing status changed | When album is shared or unshared |
### Example Usage
```yaml
automation:
- alias: "New photos added to album"
trigger:
- platform: event
event_type: immich_album_watcher_assets_added
action:
- service: notify.mobile_app
data:
title: "New Photos"
message: "{{ trigger.event.data.added_limit }} new photos in {{ trigger.event.data.album_name }}"
- alias: "Album renamed"
trigger:
- platform: event
event_type: immich_album_watcher_album_renamed
action:
- service: notify.mobile_app
data:
title: "Album Renamed"
message: "Album '{{ trigger.event.data.old_name }}' renamed to '{{ trigger.event.data.new_name }}'"
- alias: "Album deleted"
trigger:
- platform: event
event_type: immich_album_watcher_album_deleted
action:
- service: notify.mobile_app
data:
title: "Album Deleted"
message: "Album '{{ trigger.event.data.album_name }}' was deleted"
```
### Event Data
| Field | Description | Available In |
|-------|-------------|--------------|
| `hub_name` | Hub name configured in integration | All events |
| `album_id` | Album ID | All events |
| `album_name` | Current album name | All events |
| `album_url` | Public URL to view the album (only present if album has a shared link) | All events except `album_deleted` |
| `change_type` | Type of change (assets_added, assets_removed, album_renamed, album_sharing_changed, changed) | All events except `album_deleted` |
| `shared` | Current sharing status of the album | All events except `album_deleted` |
| `added_limit` | Number of assets added | `album_changed`, `assets_added` |
| `removed_limit` | Number of assets removed | `album_changed`, `assets_removed` |
| `added_assets` | List of added assets with details (see below) | `album_changed`, `assets_added` |
| `removed_assets` | List of removed asset IDs | `album_changed`, `assets_removed` |
| `people` | List of all people detected in the album | All events except `album_deleted` |
| `old_name` | Previous album name | `album_renamed` |
| `new_name` | New album name | `album_renamed` |
| `old_shared` | Previous sharing status | `album_sharing_changed` |
| `new_shared` | New sharing status | `album_sharing_changed` |
### Added Assets Fields
Each item in the `added_assets` list contains the following fields:
| Field | Description |
|-------|-------------|
| `id` | Unique asset ID |
| `type` | Type of asset (`IMAGE` or `VIDEO`) |
| `filename` | Original filename of the asset |
| `created_at` | Date/time when the asset was originally created |
| `owner` | Display name of the user who owns the asset |
| `owner_id` | Unique ID of the user who owns the asset |
| `description` | Description/caption of the asset (from EXIF data) |
| `is_favorite` | Whether the asset is marked as favorite (`true` or `false`) |
| `rating` | User rating of the asset (1-5 stars, or `null` if not rated) |
| `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
automation:
- alias: "Notify when someone adds photos"
trigger:
- platform: event
event_type: immich_album_watcher_assets_added
action:
- service: notify.mobile_app
data:
title: "New Photos"
message: >
{{ trigger.event.data.added_assets[0].owner }} added
{{ trigger.event.data.added_limit }} photos to {{ trigger.event.data.album_name }}
```
## Requirements
- Home Assistant 2024.1.0 or newer
- Immich server with API access
- Valid Immich API key with the following permissions:
### Required API Permissions
| Permission | Required | Description |
| ---------- | -------- | ----------- |
| `album.read` | Yes | Read album data and asset lists |
| `asset.read` | Yes | Read asset details (type, filename, creation date) |
| `user.read` | Yes | Resolve asset owner names |
| `person.read` | Yes | Read face recognition / people data |
| `sharedLink.read` | Yes | Read shared links for public/protected URL sensors |
| `sharedLink.create` | Optional | Create share links via the Button entities |
| `sharedLink.edit` | Optional | Edit shared link passwords via the Text entity |
| `sharedLink.delete` | Optional | Delete share links via the Button entities |
> **Note:** Without optional permissions, the corresponding entities will be unavailable or non-functional.
## Contributing ## Contributing

View File

@@ -0,0 +1,193 @@
"""Immich Album Watcher integration for Home Assistant."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant
from .const import (
CONF_ALBUM_ID,
CONF_ALBUM_NAME,
CONF_API_KEY,
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, TelegramFileCache
_LOGGER = logging.getLogger(__name__)
@dataclass
class ImmichHubData:
"""Data for the Immich hub."""
name: str
url: str
api_key: str
scan_interval: int
telegram_cache_ttl: int
@dataclass
class ImmichAlbumRuntimeData:
"""Runtime data for an album subentry."""
coordinator: ImmichAlbumWatcherCoordinator
album_id: str
album_name: str
type ImmichConfigEntry = ConfigEntry[ImmichHubData]
async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool:
"""Set up Immich Album Watcher hub from a config entry."""
hass.data.setdefault(DOMAIN, {})
hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
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(
name=hub_name,
url=url,
api_key=api_key,
scan_interval=scan_interval,
telegram_cache_ttl=telegram_cache_ttl,
)
# Create storage for persisting album state across restarts
storage = ImmichAlbumStorage(hass, entry.entry_id)
await storage.async_load()
# Store hub reference
hass.data[DOMAIN][entry.entry_id] = {
"hub": entry.runtime_data,
"subentries": {},
"storage": storage,
}
# Track loaded subentries to detect changes
hass.data[DOMAIN][entry.entry_id]["loaded_subentries"] = set(entry.subentries.keys())
# Set up coordinators for all subentries (albums)
for subentry_id, subentry in entry.subentries.items():
await _async_setup_subentry_coordinator(hass, entry, subentry)
# Forward platform setup once - platforms will iterate through subentries
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register update listener for options and subentry changes
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
_LOGGER.info(
"Immich Album Watcher hub set up successfully with %d albums",
len(entry.subentries),
)
return True
async def _async_setup_subentry_coordinator(
hass: HomeAssistant, entry: ImmichConfigEntry, subentry: ConfigSubentry
) -> None:
"""Set up coordinator for an album subentry."""
hub_data: ImmichHubData = entry.runtime_data
album_id = subentry.data[CONF_ALBUM_ID]
album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
storage: ImmichAlbumStorage = hass.data[DOMAIN][entry.entry_id]["storage"]
_LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id)
# 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,
url=hub_data.url,
api_key=hub_data.api_key,
album_id=album_id,
album_name=album_name,
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
await coordinator.async_load_persisted_state()
# Fetch initial data
await coordinator.async_config_entry_first_refresh()
# Store subentry runtime data
subentry_data = ImmichAlbumRuntimeData(
coordinator=coordinator,
album_id=album_id,
album_name=album_name,
)
hass.data[DOMAIN][entry.entry_id]["subentries"][subentry.subentry_id] = subentry_data
_LOGGER.info("Coordinator for album '%s' set up successfully", album_name)
async def _async_update_listener(
hass: HomeAssistant, entry: ImmichConfigEntry
) -> None:
"""Handle config entry updates (options or subentry changes)."""
entry_data = hass.data[DOMAIN][entry.entry_id]
loaded_subentries = entry_data.get("loaded_subentries", set())
current_subentries = set(entry.subentries.keys())
# Check if subentries changed
if loaded_subentries != current_subentries:
_LOGGER.info(
"Subentries changed (loaded: %d, current: %d), reloading entry",
len(loaded_subentries),
len(current_subentries),
)
await hass.config_entries.async_reload(entry.entry_id)
return
# Handle options-only update (scan interval change)
new_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
# Update hub data
entry.runtime_data.scan_interval = new_interval
# Update all subentry coordinators
subentries_data = entry_data["subentries"]
for subentry_data in subentries_data.values():
subentry_data.coordinator.update_scan_interval(new_interval)
_LOGGER.info("Updated scan interval to %d seconds", new_interval)
async def async_unload_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool:
"""Unload a config entry."""
# Unload all platforms
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
# Clean up hub data
hass.data[DOMAIN].pop(entry.entry_id, None)
_LOGGER.info("Immich Album Watcher hub unloaded")
return unload_ok

View File

@@ -9,18 +9,20 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import ( from .const import (
ATTR_ADDED_COUNT,
ATTR_ALBUM_ID, ATTR_ALBUM_ID,
ATTR_ALBUM_NAME, ATTR_ALBUM_NAME,
CONF_ALBUMS, CONF_ALBUM_ID,
CONF_ALBUM_NAME,
CONF_HUB_NAME,
DOMAIN, DOMAIN,
NEW_ASSETS_RESET_DELAY, NEW_ASSETS_RESET_DELAY,
) )
@@ -35,15 +37,19 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Immich Album Watcher binary sensors from a config entry.""" """Set up Immich Album Watcher binary sensors from a config entry."""
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] # Iterate through all album subentries
album_ids = entry.options.get(CONF_ALBUMS, []) for subentry_id, subentry in entry.subentries.items():
subentry_data = hass.data[DOMAIN][entry.entry_id]["subentries"].get(subentry_id)
if not subentry_data:
_LOGGER.error("Subentry data not found for %s", subentry_id)
continue
entities = [ coordinator = subentry_data.coordinator
ImmichAlbumNewAssetsSensor(coordinator, entry, album_id)
for album_id in album_ids
]
async_add_entities(entities) async_add_entities(
[ImmichAlbumNewAssetsSensor(coordinator, entry, subentry)],
config_subentry_id=subentry_id,
)
class ImmichAlbumNewAssetsSensor( class ImmichAlbumNewAssetsSensor(
@@ -59,28 +65,22 @@ class ImmichAlbumNewAssetsSensor(
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the binary sensor.""" """Initialize the binary sensor."""
super().__init__(coordinator) super().__init__(coordinator)
self._album_id = album_id
self._entry = entry self._entry = entry
self._attr_unique_id = f"{entry.entry_id}_{album_id}_new_assets" self._subentry = subentry
self._reset_unsub = None self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
self._attr_unique_id = f"{unique_id_prefix}_new_assets"
@property @property
def _album_data(self) -> AlbumData | None: def _album_data(self) -> AlbumData | None:
"""Get the album data from coordinator.""" """Get the album data from coordinator."""
if self.coordinator.data is None: return self.coordinator.data
return None
return self.coordinator.data.get(self._album_id)
@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": f"Album {self._album_id[:8]}"}
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
@@ -96,7 +96,7 @@ class ImmichAlbumNewAssetsSensor(
elapsed = datetime.now() - self._album_data.last_change_time elapsed = datetime.now() - self._album_data.last_change_time
if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY): if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY):
# Auto-reset the flag # Auto-reset the flag
self.coordinator.clear_new_assets_flag(self._album_id) self.coordinator.clear_new_assets_flag()
return False return False
return True return True
@@ -124,12 +124,12 @@ class ImmichAlbumNewAssetsSensor(
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return device info.""" """Return device info - one device per album."""
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)}, identifiers={(DOMAIN, self._subentry.subentry_id)},
name="Immich Album Watcher", name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type="service", entry_type=DeviceEntryType.SERVICE,
) )
@callback @callback
@@ -139,5 +139,5 @@ class ImmichAlbumNewAssetsSensor(
async def async_turn_off(self, **kwargs) -> None: async def async_turn_off(self, **kwargs) -> None:
"""Turn off the sensor (clear new assets flag).""" """Turn off the sensor (clear new assets flag)."""
self.coordinator.clear_new_assets_flag(self._album_id) self.coordinator.clear_new_assets_flag()
self.async_write_ha_state() self.async_write_ha_state()

View File

@@ -0,0 +1,405 @@
"""Button platform for Immich Album Watcher."""
from __future__ import annotations
import logging
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import (
ATTR_ALBUM_ID,
ATTR_ALBUM_PROTECTED_URL,
CONF_ALBUM_ID,
CONF_ALBUM_NAME,
CONF_HUB_NAME,
DEFAULT_SHARE_PASSWORD,
DOMAIN,
)
from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Immich Album Watcher button entities from a config entry."""
# Iterate through all album subentries
for subentry_id, subentry in entry.subentries.items():
subentry_data = hass.data[DOMAIN][entry.entry_id]["subentries"].get(subentry_id)
if not subentry_data:
_LOGGER.error("Subentry data not found for %s", subentry_id)
continue
coordinator = subentry_data.coordinator
async_add_entities(
[
ImmichCreateShareLinkButton(coordinator, entry, subentry),
ImmichDeleteShareLinkButton(coordinator, entry, subentry),
ImmichCreateProtectedLinkButton(coordinator, entry, subentry),
ImmichDeleteProtectedLinkButton(coordinator, entry, subentry),
],
config_subentry_id=subentry_id,
)
class ImmichCreateShareLinkButton(
CoordinatorEntity[ImmichAlbumWatcherCoordinator], ButtonEntity
):
"""Button entity for creating an unprotected share link."""
_attr_has_entity_name = True
_attr_icon = "mdi:link-plus"
_attr_translation_key = "create_share_link"
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the button entity."""
super().__init__(coordinator)
self._entry = entry
self._subentry = subentry
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
self._attr_unique_id = f"{unique_id_prefix}_create_share_link"
@property
def _album_data(self) -> AlbumData | None:
"""Get the album data from coordinator."""
return self.coordinator.data
@property
def available(self) -> bool:
"""Return if entity is available.
Only available when there is no unprotected link.
"""
if not self.coordinator.last_update_success or self._album_data is None:
return False
# Only available if there's no unprotected link yet
return not self.coordinator.has_unprotected_link()
@property
def device_info(self) -> DeviceInfo:
"""Return device info - one device per album."""
return DeviceInfo(
identifiers={(DOMAIN, self._subentry.subentry_id)},
name=self._album_name,
manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE,
)
@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return extra state attributes."""
if not self._album_data:
return {}
return {
ATTR_ALBUM_ID: self._album_data.id,
}
async def async_press(self) -> None:
"""Handle button press - create share link."""
if self.coordinator.has_unprotected_link():
_LOGGER.warning(
"Album %s already has an unprotected share link",
self._album_name,
)
return
success = await self.coordinator.async_create_shared_link()
if success:
await self.coordinator.async_refresh()
else:
_LOGGER.error("Failed to create share link for album %s", self._album_id)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.async_write_ha_state()
class ImmichDeleteShareLinkButton(
CoordinatorEntity[ImmichAlbumWatcherCoordinator], ButtonEntity
):
"""Button entity for deleting an unprotected share link."""
_attr_has_entity_name = True
_attr_icon = "mdi:link-off"
_attr_translation_key = "delete_share_link"
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the button entity."""
super().__init__(coordinator)
self._entry = entry
self._subentry = subentry
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
self._attr_unique_id = f"{unique_id_prefix}_delete_share_link"
@property
def _album_data(self) -> AlbumData | None:
"""Get the album data from coordinator."""
return self.coordinator.data
@property
def available(self) -> bool:
"""Return if entity is available.
Only available when there is an unprotected link.
"""
if not self.coordinator.last_update_success or self._album_data is None:
return False
# Only available if there's an unprotected link to delete
return self.coordinator.has_unprotected_link()
@property
def device_info(self) -> DeviceInfo:
"""Return device info - one device per album."""
return DeviceInfo(
identifiers={(DOMAIN, self._subentry.subentry_id)},
name=self._album_name,
manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE,
)
@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return extra state attributes."""
if not self._album_data:
return {}
attrs = {
ATTR_ALBUM_ID: self._album_data.id,
}
public_url = self.coordinator.get_public_url()
if public_url:
attrs[ATTR_ALBUM_PROTECTED_URL] = public_url
return attrs
async def async_press(self) -> None:
"""Handle button press - delete share link."""
link_id = self.coordinator.get_unprotected_link_id()
if not link_id:
_LOGGER.warning(
"No unprotected share link found for album %s",
self._album_name,
)
return
success = await self.coordinator.async_delete_shared_link(link_id)
if success:
await self.coordinator.async_refresh()
else:
_LOGGER.error("Failed to delete share link for album %s", self._album_id)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.async_write_ha_state()
class ImmichCreateProtectedLinkButton(
CoordinatorEntity[ImmichAlbumWatcherCoordinator], ButtonEntity
):
"""Button entity for creating a protected (password) share link."""
_attr_has_entity_name = True
_attr_icon = "mdi:link-lock"
_attr_translation_key = "create_protected_link"
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the button entity."""
super().__init__(coordinator)
self._entry = entry
self._subentry = subentry
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
self._attr_unique_id = f"{unique_id_prefix}_create_protected_link"
@property
def _album_data(self) -> AlbumData | None:
"""Get the album data from coordinator."""
return self.coordinator.data
@property
def available(self) -> bool:
"""Return if entity is available.
Only available when there is no protected link.
"""
if not self.coordinator.last_update_success or self._album_data is None:
return False
# Only available if there's no protected link yet
return not self.coordinator.has_protected_link()
@property
def device_info(self) -> DeviceInfo:
"""Return device info - one device per album."""
return DeviceInfo(
identifiers={(DOMAIN, self._subentry.subentry_id)},
name=self._album_name,
manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE,
)
@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return extra state attributes."""
if not self._album_data:
return {}
return {
ATTR_ALBUM_ID: self._album_data.id,
}
async def async_press(self) -> None:
"""Handle button press - create protected share link."""
if self.coordinator.has_protected_link():
_LOGGER.warning(
"Album %s already has a protected share link",
self._album_name,
)
return
success = await self.coordinator.async_create_shared_link(
password=DEFAULT_SHARE_PASSWORD
)
if success:
await self.coordinator.async_refresh()
else:
_LOGGER.error(
"Failed to create protected share link for album %s", self._album_id
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.async_write_ha_state()
class ImmichDeleteProtectedLinkButton(
CoordinatorEntity[ImmichAlbumWatcherCoordinator], ButtonEntity
):
"""Button entity for deleting a protected (password) share link."""
_attr_has_entity_name = True
_attr_icon = "mdi:link-off"
_attr_translation_key = "delete_protected_link"
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the button entity."""
super().__init__(coordinator)
self._entry = entry
self._subentry = subentry
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
self._attr_unique_id = f"{unique_id_prefix}_delete_protected_link"
@property
def _album_data(self) -> AlbumData | None:
"""Get the album data from coordinator."""
return self.coordinator.data
@property
def available(self) -> bool:
"""Return if entity is available.
Only available when there is a protected link.
"""
if not self.coordinator.last_update_success or self._album_data is None:
return False
# Only available if there's a protected link to delete
return self.coordinator.has_protected_link()
@property
def device_info(self) -> DeviceInfo:
"""Return device info - one device per album."""
return DeviceInfo(
identifiers={(DOMAIN, self._subentry.subentry_id)},
name=self._album_name,
manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE,
)
@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return extra state attributes."""
if not self._album_data:
return {}
attrs = {
ATTR_ALBUM_ID: self._album_data.id,
}
protected_url = self.coordinator.get_protected_url()
if protected_url:
attrs[ATTR_ALBUM_PROTECTED_URL] = protected_url
return attrs
async def async_press(self) -> None:
"""Handle button press - delete protected share link."""
link_id = self.coordinator.get_protected_link_id()
if not link_id:
_LOGGER.warning(
"No protected share link found for album %s",
self._album_name,
)
return
success = await self.coordinator.async_delete_shared_link(link_id)
if success:
await self.coordinator.async_refresh()
else:
_LOGGER.error(
"Failed to delete protected share link for album %s", self._album_id
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.async_write_ha_state()

View File

@@ -8,14 +8,16 @@ from datetime import timedelta
import aiohttp import aiohttp
from homeassistant.components.camera import Camera from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import CONF_ALBUMS, DOMAIN from .const import CONF_ALBUM_ID, CONF_ALBUM_NAME, CONF_HUB_NAME, DOMAIN
from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -29,15 +31,19 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Immich Album Watcher cameras from a config entry.""" """Set up Immich Album Watcher cameras from a config entry."""
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] # Iterate through all album subentries
album_ids = entry.options.get(CONF_ALBUMS, []) for subentry_id, subentry in entry.subentries.items():
subentry_data = hass.data[DOMAIN][entry.entry_id]["subentries"].get(subentry_id)
if not subentry_data:
_LOGGER.error("Subentry data not found for %s", subentry_id)
continue
entities = [ coordinator = subentry_data.coordinator
ImmichAlbumThumbnailCamera(coordinator, entry, album_id)
for album_id in album_ids
]
async_add_entities(entities) async_add_entities(
[ImmichAlbumThumbnailCamera(coordinator, entry, subentry)],
config_subentry_id=subentry_id,
)
class ImmichAlbumThumbnailCamera( class ImmichAlbumThumbnailCamera(
@@ -52,30 +58,25 @@ class ImmichAlbumThumbnailCamera(
self, self,
coordinator: ImmichAlbumWatcherCoordinator, coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry, entry: ConfigEntry,
album_id: str, subentry: ConfigSubentry,
) -> None: ) -> None:
"""Initialize the camera.""" """Initialize the camera."""
CoordinatorEntity.__init__(self, coordinator) CoordinatorEntity.__init__(self, coordinator)
Camera.__init__(self) Camera.__init__(self)
self._album_id = album_id
self._entry = entry self._entry = entry
self._attr_unique_id = f"{entry.entry_id}_{album_id}_thumbnail" self._subentry = subentry
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
self._attr_unique_id = f"{unique_id_prefix}_thumbnail"
self._cached_image: bytes | None = None self._cached_image: bytes | None = None
self._last_thumbnail_id: str | None = None self._last_thumbnail_id: str | None = None
@property @property
def _album_data(self) -> AlbumData | None: def _album_data(self) -> AlbumData | None:
"""Get the album data from coordinator.""" """Get the album data from coordinator."""
if self.coordinator.data is None: return self.coordinator.data
return None
return self.coordinator.data.get(self._album_id)
@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": f"Album {self._album_id[:8]}"}
@property @property
def available(self) -> bool: def available(self) -> bool:
@@ -88,12 +89,12 @@ class ImmichAlbumThumbnailCamera(
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return device info.""" """Return device info - one device per album."""
return DeviceInfo( return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)}, identifiers={(DOMAIN, self._subentry.subentry_id)},
name="Immich Album Watcher", name=self._album_name,
manufacturer="Immich", manufacturer="Immich",
entry_type="service", entry_type=DeviceEntryType.SERVICE,
) )
@property @property

View File

@@ -8,19 +8,30 @@ from typing import Any
import aiohttp import aiohttp
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
OptionsFlow,
SubentryFlowResult,
)
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from .const import ( from .const import (
CONF_ALBUMS, CONF_ALBUM_ID,
CONF_ALBUM_NAME,
CONF_API_KEY, CONF_API_KEY,
CONF_HUB_NAME,
CONF_IMMICH_URL, CONF_IMMICH_URL,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
CONF_TELEGRAM_BOT_TOKEN,
CONF_TELEGRAM_CACHE_TTL,
DEFAULT_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL,
DEFAULT_TELEGRAM_CACHE_TTL,
DOMAIN, DOMAIN,
SUBENTRY_TYPE_ALBUM,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -57,13 +68,12 @@ async def fetch_albums(
class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN): class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Immich Album Watcher.""" """Handle a config flow for Immich Album Watcher."""
VERSION = 1 VERSION = 2
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize the config flow.""" """Initialize the config flow."""
self._url: str | None = None self._url: str | None = None
self._api_key: str | None = None self._api_key: str | None = None
self._albums: list[dict[str, Any]] = []
@staticmethod @staticmethod
@callback @callback
@@ -71,13 +81,22 @@ class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return ImmichAlbumWatcherOptionsFlow(config_entry) return ImmichAlbumWatcherOptionsFlow(config_entry)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return supported subentry types."""
return {SUBENTRY_TYPE_ALBUM: ImmichAlbumSubentryFlowHandler}
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> ConfigFlowResult:
"""Handle the initial step - connection details.""" """Handle the initial step - connection details."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
hub_name = user_input[CONF_HUB_NAME].strip()
self._url = user_input[CONF_IMMICH_URL].rstrip("/") self._url = user_input[CONF_IMMICH_URL].rstrip("/")
self._api_key = user_input[CONF_API_KEY] self._api_key = user_input[CONF_API_KEY]
@@ -85,12 +104,22 @@ class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN):
try: try:
await validate_connection(session, self._url, self._api_key) await validate_connection(session, self._url, self._api_key)
self._albums = await fetch_albums(session, self._url, self._api_key)
if not self._albums: # Set unique ID based on URL
errors["base"] = "no_albums" await self.async_set_unique_id(self._url)
else: self._abort_if_unique_id_configured()
return await self.async_step_albums()
return self.async_create_entry(
title=hub_name,
data={
CONF_HUB_NAME: hub_name,
CONF_IMMICH_URL: self._url,
CONF_API_KEY: self._api_key,
},
options={
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
},
)
except InvalidAuth: except InvalidAuth:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
@@ -106,6 +135,7 @@ class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", step_id="user",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_HUB_NAME, default="Immich"): str,
vol.Required(CONF_IMMICH_URL): str, vol.Required(CONF_IMMICH_URL): str,
vol.Required(CONF_API_KEY): str, vol.Required(CONF_API_KEY): str,
} }
@@ -116,45 +146,87 @@ class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN):
}, },
) )
async def async_step_albums(
class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow for adding albums."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
super().__init__()
self._albums: list[dict[str, Any]] = []
async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> SubentryFlowResult:
"""Handle album selection step.""" """Handle album selection."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
# Get parent config entry data
config_entry = self._get_entry()
url = config_entry.data[CONF_IMMICH_URL]
api_key = config_entry.data[CONF_API_KEY]
# Fetch available albums
session = async_get_clientsession(self.hass)
try:
self._albums = await fetch_albums(session, url, api_key)
except Exception:
_LOGGER.exception("Failed to fetch albums")
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({}),
errors=errors,
)
if not self._albums:
return self.async_abort(reason="no_albums")
if user_input is not None: if user_input is not None:
selected_albums = user_input.get(CONF_ALBUMS, []) album_id = user_input[CONF_ALBUM_ID]
if not selected_albums: # Check if album is already configured
errors["base"] = "no_albums_selected" for subentry in config_entry.subentries.values():
else: if subentry.data.get(CONF_ALBUM_ID) == album_id:
# Create unique ID based on URL return self.async_abort(reason="album_already_configured")
await self.async_set_unique_id(self._url)
self._abort_if_unique_id_configured()
return self.async_create_entry( # Find album name
title="Immich Album Watcher", album_name = "Unknown Album"
data={ for album in self._albums:
CONF_IMMICH_URL: self._url, if album["id"] == album_id:
CONF_API_KEY: self._api_key, album_name = album.get("albumName", "Unnamed")
}, break
options={
CONF_ALBUMS: selected_albums,
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
},
)
# Build album selection list return self.async_create_entry(
title=album_name,
data={
CONF_ALBUM_ID: album_id,
CONF_ALBUM_NAME: album_name,
},
)
# Get already configured album IDs
configured_albums = set()
for subentry in config_entry.subentries.values():
if aid := subentry.data.get(CONF_ALBUM_ID):
configured_albums.add(aid)
# Build album selection list (excluding already configured)
album_options = { album_options = {
album["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)" album["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)"
for album in self._albums for album in self._albums
if album["id"] not in configured_albums
} }
if not album_options:
return self.async_abort(reason="all_albums_configured")
return self.async_show_form( return self.async_show_form(
step_id="albums", step_id="user",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_ALBUMS): cv.multi_select(album_options), vol.Required(CONF_ALBUM_ID): vol.In(album_options),
} }
), ),
errors=errors, errors=errors,
@@ -167,60 +239,52 @@ class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow.""" """Initialize options flow."""
self._config_entry = config_entry self._config_entry = config_entry
self._albums: list[dict[str, Any]] = []
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> ConfigFlowResult:
"""Manage the options.""" """Manage the options."""
errors: dict[str, str] = {} if user_input is not None:
# Fetch current albums from Immich
session = async_get_clientsession(self.hass)
url = self._config_entry.data[CONF_IMMICH_URL]
api_key = self._config_entry.data[CONF_API_KEY]
try:
self._albums = await fetch_albums(session, url, api_key)
except Exception:
_LOGGER.exception("Failed to fetch albums")
errors["base"] = "cannot_connect"
if user_input is not None and not errors:
return self.async_create_entry( return self.async_create_entry(
title="", title="",
data={ data={
CONF_ALBUMS: user_input.get(CONF_ALBUMS, []),
CONF_SCAN_INTERVAL: user_input.get( CONF_SCAN_INTERVAL: user_input.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
), ),
CONF_TELEGRAM_BOT_TOKEN: user_input.get(
CONF_TELEGRAM_BOT_TOKEN, ""
),
CONF_TELEGRAM_CACHE_TTL: user_input.get(
CONF_TELEGRAM_CACHE_TTL, DEFAULT_TELEGRAM_CACHE_TTL
),
}, },
) )
# Build album selection list
album_options = {
album["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)"
for album in self._albums
}
current_albums = self._config_entry.options.get(CONF_ALBUMS, [])
current_interval = self._config_entry.options.get( current_interval = self._config_entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
) )
current_bot_token = self._config_entry.options.get(
CONF_TELEGRAM_BOT_TOKEN, ""
)
current_cache_ttl = self._config_entry.options.get(
CONF_TELEGRAM_CACHE_TTL, DEFAULT_TELEGRAM_CACHE_TTL
)
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_ALBUMS, default=current_albums): cv.multi_select(
album_options
),
vol.Required( vol.Required(
CONF_SCAN_INTERVAL, default=current_interval CONF_SCAN_INTERVAL, default=current_interval
): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), ): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)),
vol.Optional(
CONF_TELEGRAM_BOT_TOKEN, default=current_bot_token
): str,
vol.Optional(
CONF_TELEGRAM_CACHE_TTL, default=current_cache_ttl
): vol.All(vol.Coerce(int), vol.Range(min=1, max=168)),
} }
), ),
errors=errors,
) )

View File

@@ -0,0 +1,89 @@
"""Constants for the Immich Album Watcher integration."""
from datetime import timedelta
from typing import Final
DOMAIN: Final = "immich_album_watcher"
# Configuration keys
CONF_HUB_NAME: Final = "hub_name"
CONF_IMMICH_URL: Final = "immich_url"
CONF_API_KEY: Final = "api_key"
CONF_ALBUMS: Final = "albums"
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"
# Events
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"
ATTR_ALBUM_ID: Final = "album_id"
ATTR_ALBUM_NAME: Final = "album_name"
ATTR_ALBUM_URL: Final = "album_url"
ATTR_ALBUM_URLS: Final = "album_urls"
ATTR_ALBUM_PROTECTED_URL: Final = "album_protected_url"
ATTR_ALBUM_PROTECTED_PASSWORD: Final = "album_protected_password"
ATTR_ASSET_COUNT: Final = "asset_count"
ATTR_PHOTO_COUNT: Final = "photo_count"
ATTR_VIDEO_COUNT: Final = "video_count"
ATTR_ADDED_COUNT: Final = "added_count"
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_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_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"
ASSET_TYPE_VIDEO: Final = "VIDEO"
# Platforms
PLATFORMS: Final = ["sensor", "binary_sensor", "camera", "text", "button"]
# Services
SERVICE_REFRESH: Final = "refresh"
SERVICE_GET_ASSETS: Final = "get_assets"
SERVICE_SEND_TELEGRAM_NOTIFICATION: Final = "send_telegram_notification"

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -0,0 +1,12 @@
{
"domain": "immich_album_watcher",
"name": "Immich Album Watcher",
"codeowners": ["@alexei.dolgolyov"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
"requirements": [],
"version": "2.7.1"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,258 @@
refresh:
name: Refresh
description: Force an immediate refresh of album data from Immich.
target:
entity:
integration: immich_album_watcher
domain: sensor
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:
limit:
name: Limit
description: Maximum number of assets to return (1-100).
required: false
default: 10
selector:
number:
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_notification:
name: Send Telegram Notification
description: Send a notification to Telegram (text, photo, video, or media group).
target:
entity:
integration: immich_album_watcher
domain: sensor
fields:
bot_token:
name: Bot Token
description: Telegram bot token. Uses configured token if not provided.
required: false
selector:
text:
chat_id:
name: Chat ID
description: Telegram chat ID to send to.
required: true
selector:
text:
urls:
name: URLs
description: List of media URLs to send. Each item should have 'url' and 'type' (photo/video). If empty, sends a text message. Large lists are automatically split into multiple media groups.
required: false
selector:
object:
caption:
name: Caption
description: Caption text. For media, applied to first item. For empty URLs, this is the message text.
required: false
selector:
text:
multiline: true
reply_to_message_id:
name: Reply To Message ID
description: Message ID to reply to.
required: false
selector:
number:
mode: box
disable_web_page_preview:
name: Disable Web Page Preview
description: Disable link previews in text messages.
required: false
selector:
boolean:
parse_mode:
name: Parse Mode
description: How to parse the caption/text. Options are "HTML", "Markdown", "MarkdownV2", or empty string for plain text.
required: false
default: "HTML"
selector:
select:
options:
- label: "HTML"
value: "HTML"
- label: "Markdown"
value: "Markdown"
- label: "MarkdownV2"
value: "MarkdownV2"
- label: "Plain Text"
value: ""
max_group_size:
name: Max Group Size
description: Maximum number of media items per media group (2-10). Large lists will be split into multiple groups.
required: false
default: 10
selector:
number:
min: 2
max: 10
mode: slider
chunk_delay:
name: Chunk Delay
description: Delay in milliseconds between sending multiple media groups (0-60000). Useful for rate limiting.
required: false
default: 0
selector:
number:
min: 0
max: 60000
step: 100
unit_of_measurement: "ms"
mode: slider
wait_for_response:
name: Wait For Response
description: Wait for Telegram to finish processing before returning. Set to false for fire-and-forget (automation continues immediately).
required: false
default: true
selector:
boolean:
max_asset_data_size:
name: Max Asset Data Size
description: Maximum asset size in bytes. Assets exceeding this limit will be skipped. Leave empty for no limit.
required: false
selector:
number:
min: 1
max: 52428800
step: 1048576
unit_of_measurement: "bytes"
mode: box
send_large_photos_as_documents:
name: Send Large Photos As Documents
description: How to handle photos exceeding Telegram's limits (10MB or 10000px dimension sum). If true, send as documents. If false, skip oversized photos.
required: false
default: false
selector:
boolean:
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

@@ -0,0 +1,181 @@
"""Storage helpers for Immich Album Watcher."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION = 1
STORAGE_KEY_PREFIX = "immich_album_watcher"
# 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."""
def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
"""Initialize the storage."""
self._store: Store[dict[str, Any]] = Store(
hass, STORAGE_VERSION, f"{STORAGE_KEY_PREFIX}.{entry_id}"
)
self._data: dict[str, Any] | None = None
async def async_load(self) -> dict[str, Any]:
"""Load data from storage."""
self._data = await self._store.async_load() or {"albums": {}}
_LOGGER.debug("Loaded storage data with %d albums", len(self._data.get("albums", {})))
return self._data
async def async_save_album_state(self, album_id: str, asset_ids: set[str]) -> None:
"""Save album asset IDs to storage."""
if self._data is None:
self._data = {"albums": {}}
self._data["albums"][album_id] = {
"asset_ids": list(asset_ids),
"last_updated": datetime.now(timezone.utc).isoformat(),
}
await self._store.async_save(self._data)
def get_album_asset_ids(self, album_id: str) -> set[str] | None:
"""Get persisted asset IDs for an album.
Returns None if no persisted state exists for the album.
"""
if self._data and "albums" in self._data:
album_data = self._data["albums"].get(album_id)
if album_data:
return set(album_data.get("asset_ids", []))
return None
async def async_remove_album(self, album_id: str) -> None:
"""Remove an album from storage."""
if self._data and "albums" in self._data:
self._data["albums"].pop(album_id, None)
await self._store.async_save(self._data)
async def async_remove(self) -> None:
"""Remove all storage data."""
await self._store.async_remove()
self._data = None
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

@@ -1,6 +1,9 @@
{ {
"entity": { "entity": {
"sensor": { "sensor": {
"album_id": {
"name": "{album_name}: Album ID"
},
"album_asset_count": { "album_asset_count": {
"name": "{album_name}: Asset Count" "name": "{album_name}: Asset Count"
}, },
@@ -68,13 +71,15 @@
"step": { "step": {
"init": { "init": {
"title": "Immich Album Watcher Options", "title": "Immich Album Watcher Options",
"description": "Configure which albums to monitor and how often to check for changes.", "description": "Configure how often to check for changes and optional Telegram integration.",
"data": { "data": {
"albums": "Albums to watch", "albums": "Albums to watch",
"scan_interval": "Scan interval (seconds)" "scan_interval": "Scan interval (seconds)",
"telegram_bot_token": "Telegram Bot Token"
}, },
"data_description": { "data_description": {
"scan_interval": "How often to check for album changes (10-3600 seconds)" "scan_interval": "How often to check for album changes (10-3600 seconds)",
"telegram_bot_token": "Bot token for sending media to Telegram (optional)"
} }
} }
}, },

View File

@@ -0,0 +1,152 @@
"""Text platform for Immich Album Watcher."""
from __future__ import annotations
import logging
from homeassistant.components.text import TextEntity, TextMode
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import (
ATTR_ALBUM_ID,
ATTR_ALBUM_PROTECTED_URL,
CONF_ALBUM_ID,
CONF_ALBUM_NAME,
CONF_HUB_NAME,
DOMAIN,
)
from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Immich Album Watcher text entities from a config entry."""
# Iterate through all album subentries
for subentry_id, subentry in entry.subentries.items():
subentry_data = hass.data[DOMAIN][entry.entry_id]["subentries"].get(subentry_id)
if not subentry_data:
_LOGGER.error("Subentry data not found for %s", subentry_id)
continue
coordinator = subentry_data.coordinator
async_add_entities(
[ImmichAlbumProtectedPasswordText(coordinator, entry, subentry)],
config_subentry_id=subentry_id,
)
class ImmichAlbumProtectedPasswordText(
CoordinatorEntity[ImmichAlbumWatcherCoordinator], TextEntity
):
"""Text entity for editing an Immich album's protected link password."""
_attr_has_entity_name = True
_attr_icon = "mdi:key-variant"
_attr_translation_key = "album_protected_password_edit"
_attr_mode = TextMode.PASSWORD
_attr_native_max = 100
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the text entity."""
super().__init__(coordinator)
self._entry = entry
self._subentry = subentry
self._album_id = subentry.data[CONF_ALBUM_ID]
self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich")
unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}")
self._attr_unique_id = f"{unique_id_prefix}_protected_password_edit"
@property
def _album_data(self) -> AlbumData | None:
"""Get the album data from coordinator."""
return self.coordinator.data
@property
def available(self) -> bool:
"""Return if entity is available.
Only available when the album has a protected shared link.
"""
if not self.coordinator.last_update_success or self._album_data is None:
return False
# Only available if there's a protected link to edit
return self.coordinator.get_protected_link_id() is not None
@property
def device_info(self) -> DeviceInfo:
"""Return device info - one device per album."""
return DeviceInfo(
identifiers={(DOMAIN, self._subentry.subentry_id)},
name=self._album_name,
manufacturer="Immich",
entry_type=DeviceEntryType.SERVICE,
)
@property
def native_value(self) -> str | None:
"""Return the current password value."""
if self._album_data:
return self.coordinator.get_protected_password()
return None
@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return extra state attributes."""
if not self._album_data:
return {}
attrs = {
ATTR_ALBUM_ID: self._album_data.id,
}
protected_url = self.coordinator.get_protected_url()
if protected_url:
attrs[ATTR_ALBUM_PROTECTED_URL] = protected_url
return attrs
async def async_set_value(self, value: str) -> None:
"""Set the password for the protected shared link."""
link_id = self.coordinator.get_protected_link_id()
if not link_id:
_LOGGER.error(
"Cannot set password: no protected link found for album %s",
self._album_id,
)
return
# Empty string means remove password
password = value if value else None
success = await self.coordinator.async_set_shared_link_password(
link_id, password
)
if success:
# Trigger a coordinator update to refresh the state
await self.coordinator.async_request_refresh()
else:
_LOGGER.error("Failed to update password for album %s", self._album_id)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.async_write_ha_state()

View File

@@ -0,0 +1,255 @@
{
"entity": {
"sensor": {
"album_id": {
"name": "Album ID"
},
"album_asset_count": {
"name": "Asset Count"
},
"album_photo_count": {
"name": "Photo Count"
},
"album_video_count": {
"name": "Video Count"
},
"album_last_updated": {
"name": "Last Updated"
},
"album_created": {
"name": "Created"
},
"album_public_url": {
"name": "Public URL"
},
"album_protected_url": {
"name": "Protected URL"
},
"album_protected_password": {
"name": "Protected Password"
}
},
"binary_sensor": {
"album_new_assets": {
"name": "New Assets"
}
},
"camera": {
"album_thumbnail": {
"name": "Thumbnail"
}
},
"text": {
"album_protected_password_edit": {
"name": "Share Password"
}
},
"button": {
"create_share_link": {
"name": "Create Share Link"
},
"delete_share_link": {
"name": "Delete Share Link"
},
"create_protected_link": {
"name": "Create Protected Link"
},
"delete_protected_link": {
"name": "Delete Protected Link"
}
}
},
"config": {
"step": {
"user": {
"title": "Connect to Immich",
"description": "Enter your Immich server details. You can get an API key from Immich → User Settings → API Keys.",
"data": {
"hub_name": "Hub Name",
"immich_url": "Immich URL",
"api_key": "API Key"
},
"data_description": {
"hub_name": "A name for this Immich server (used in entity IDs)",
"immich_url": "The URL of your Immich server (e.g., http://192.168.1.100:2283)",
"api_key": "Your Immich API key"
}
}
},
"error": {
"cannot_connect": "Failed to connect to Immich server",
"invalid_auth": "Invalid API key",
"no_albums": "No albums found on the server",
"unknown": "Unexpected error occurred"
},
"abort": {
"already_configured": "This Immich server is already configured"
}
},
"config_subentries": {
"album": {
"initiate_flow": {
"user": "Add Album"
},
"entry_type": "Album",
"step": {
"user": {
"title": "Add Album to Watch",
"description": "Select an album from your Immich server to monitor for changes.",
"data": {
"album_id": "Album"
}
}
},
"error": {
"cannot_connect": "Failed to connect to Immich server"
},
"abort": {
"parent_not_found": "Hub configuration not found",
"no_albums": "No albums found on the server",
"all_albums_configured": "All albums are already configured",
"album_already_configured": "This album is already being watched"
}
}
},
"options": {
"step": {
"init": {
"title": "Immich Album Watcher Options",
"description": "Configure the polling interval and Telegram settings for all albums.",
"data": {
"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)",
"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)"
}
}
}
},
"services": {
"refresh": {
"name": "Refresh",
"description": "Force an immediate refresh of album data from Immich."
},
"get_assets": {
"name": "Get Assets",
"description": "Get assets from the targeted album with optional filtering and ordering.",
"fields": {
"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

@@ -0,0 +1,255 @@
{
"entity": {
"sensor": {
"album_id": {
"name": "ID альбома"
},
"album_asset_count": {
"name": "Число файлов"
},
"album_photo_count": {
"name": "Число фото"
},
"album_video_count": {
"name": "Число видео"
},
"album_last_updated": {
"name": "Последнее обновление"
},
"album_created": {
"name": "Дата создания"
},
"album_public_url": {
"name": "Публичная ссылка"
},
"album_protected_url": {
"name": "Защищённая ссылка"
},
"album_protected_password": {
"name": "Пароль ссылки"
}
},
"binary_sensor": {
"album_new_assets": {
"name": "Новые файлы"
}
},
"camera": {
"album_thumbnail": {
"name": "Превью"
}
},
"text": {
"album_protected_password_edit": {
"name": "Пароль ссылки"
}
},
"button": {
"create_share_link": {
"name": "Создать ссылку"
},
"delete_share_link": {
"name": "Удалить ссылку"
},
"create_protected_link": {
"name": "Создать защищённую ссылку"
},
"delete_protected_link": {
"name": "Удалить защищённую ссылку"
}
}
},
"config": {
"step": {
"user": {
"title": "Подключение к Immich",
"description": "Введите данные вашего сервера Immich. API-ключ можно получить в Immich → Настройки пользователя → API-ключи.",
"data": {
"hub_name": "Название хаба",
"immich_url": "URL Immich",
"api_key": "API-ключ"
},
"data_description": {
"hub_name": "Название для этого сервера Immich (используется в ID сущностей)",
"immich_url": "URL вашего сервера Immich (например, http://192.168.1.100:2283)",
"api_key": "Ваш API-ключ Immich"
}
}
},
"error": {
"cannot_connect": "Не удалось подключиться к серверу Immich",
"invalid_auth": "Неверный API-ключ",
"no_albums": "На сервере не найдено альбомов",
"unknown": "Произошла непредвиденная ошибка"
},
"abort": {
"already_configured": "Этот сервер Immich уже настроен"
}
},
"config_subentries": {
"album": {
"initiate_flow": {
"user": "Добавить альбом"
},
"entry_type": "Альбом",
"step": {
"user": {
"title": "Добавить альбом для отслеживания",
"description": "Выберите альбом с вашего сервера Immich для отслеживания изменений.",
"data": {
"album_id": "Альбом"
}
}
},
"error": {
"cannot_connect": "Не удалось подключиться к серверу Immich"
},
"abort": {
"parent_not_found": "Конфигурация хаба не найдена",
"no_albums": "На сервере не найдено альбомов",
"all_albums_configured": "Все альбомы уже настроены",
"album_already_configured": "Этот альбом уже отслеживается"
}
}
},
"options": {
"step": {
"init": {
"title": "Настройки Immich Album Watcher",
"description": "Настройте интервал опроса и параметры Telegram для всех альбомов.",
"data": {
"scan_interval": "Интервал сканирования (секунды)",
"telegram_bot_token": "Токен Telegram бота",
"telegram_cache_ttl": "Время жизни кэша Telegram (часы)"
},
"data_description": {
"scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)",
"telegram_bot_token": "Токен бота для отправки уведомлений в Telegram",
"telegram_cache_ttl": "Сколько хранить ID загруженных файлов для повторной отправки без загрузки (1-168 часов, по умолчанию: 48)"
}
}
}
},
"services": {
"refresh": {
"name": "Обновить",
"description": "Принудительно обновить данные альбома из Immich."
},
"get_assets": {
"name": "Получить файлы",
"description": "Получить файлы из выбранного альбома с возможностью фильтрации и сортировки.",
"fields": {
"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). Оставьте пустым для отключения."
}
}
}
}
}

6
hacs.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "Immich Album Watcher",
"homeassistant": "2024.1.0",
"render_readme": true,
"content_in_root": false
}

View File

@@ -1,160 +0,0 @@
# Immich Album Watcher
<img src="icon.png" alt="Immich" width="64" height="64">
A Home Assistant custom integration that monitors [Immich](https://immich.app/) photo/video library albums for changes and exposes them as Home Assistant entities with event-firing capabilities.
## Features
- **Album Monitoring** - Watch selected Immich albums for asset additions and removals
- **Rich Sensor Data** - Multiple sensors per album:
- Asset count (total)
- Photo count
- Video count
- People count (detected faces)
- Last updated timestamp
- Creation date
- **Camera Entity** - Album thumbnail displayed as a camera entity for dashboards
- **Binary Sensor** - "New Assets" indicator that turns on when assets are added
- **Face Recognition** - Detects and lists people recognized in album photos
- **Event Firing** - Fires Home Assistant events when albums change:
- `immich_album_watcher_album_changed` - General album changes
- `immich_album_watcher_assets_added` - When new assets are added
- `immich_album_watcher_assets_removed` - When assets are removed
- **Enhanced Event Data** - Events include detailed asset info:
- Asset type (photo/video)
- Filename
- Creation date
- Asset owner (who uploaded the asset)
- Public URL (if album has a shared link)
- Detected people in the asset
- **Services** - Custom service calls:
- `immich_album_watcher.refresh` - Force immediate data refresh
- `immich_album_watcher.get_recent_assets` - Get recent assets from an album
- **Configurable Polling** - Adjustable scan interval (10-3600 seconds)
## Entities Created (per album)
| Entity Type | Name | Description |
|-------------|------|-------------|
| Sensor | Asset Count | Total number of assets in the album |
| Sensor | Photo Count | Number of photos in the album |
| Sensor | Video Count | Number of videos in the album |
| Sensor | People Count | Number of unique people detected |
| Sensor | Last Updated | When the album was last modified |
| Sensor | Created | When the album was created |
| Sensor | Public URL | Public share link URL (if album is shared) |
| Binary Sensor | New Assets | On when new assets were recently added |
| Camera | Thumbnail | Album cover image |
## Installation
1. Copy the `immich_album_watcher` folder to your Home Assistant `custom_components` directory
2. Restart Home Assistant
3. Go to **Settings****Devices & Services****Add Integration**
4. Search for "Immich Album Watcher"
5. Enter your Immich server URL and API key
6. Select the albums you want to monitor
## Configuration
| Option | Description | Default |
|--------|-------------|---------|
| Server URL | Your Immich server URL (e.g., `https://immich.example.com`) | Required |
| API Key | Your Immich API key | Required |
| Albums | Albums to monitor | Required |
| Scan Interval | How often to check for changes (seconds) | 60 |
## Services
### Refresh
Force an immediate refresh of all album data:
```yaml
service: immich_album_watcher.refresh
```
### Get Recent Assets
Get the most recent assets from a specific album (returns response data):
```yaml
service: immich_album_watcher.get_recent_assets
data:
album_id: "your-album-id-here"
count: 10
```
## Events
Use these events in your automations:
```yaml
automation:
- alias: "New photos added to album"
trigger:
- platform: event
event_type: immich_album_watcher_assets_added
action:
- service: notify.mobile_app
data:
title: "New Photos"
message: "{{ trigger.event.data.added_count }} new photos in {{ trigger.event.data.album_name }}"
```
### Event Data
| Field | Description |
|-------|-------------|
| `album_id` | Album ID |
| `album_name` | Album name |
| `album_url` | Public URL to view the album (only present if album has a shared link) |
| `change_type` | Type of change (assets_added, assets_removed, changed) |
| `added_count` | Number of assets added |
| `removed_count` | Number of assets removed |
| `added_assets` | List of added assets with details (see below) |
| `removed_assets` | List of removed asset IDs |
| `people` | List of all people detected in the album |
### Added Assets Fields
Each item in the `added_assets` list contains the following fields:
| Field | Description |
|-------|-------------|
| `id` | Unique asset ID |
| `asset_type` | Type of asset (`IMAGE` or `VIDEO`) |
| `asset_filename` | Original filename of the asset |
| `asset_created` | Date/time when the asset was originally created |
| `asset_owner` | Display name of the user who owns the asset |
| `asset_owner_id` | Unique ID of the user who owns the asset |
| `asset_url` | Public URL to view the asset (only present if album has a shared link) |
| `people` | List of people detected in this specific asset |
Example accessing asset owner in an automation:
```yaml
automation:
- alias: "Notify when someone adds photos"
trigger:
- platform: event
event_type: immich_album_watcher_assets_added
action:
- service: notify.mobile_app
data:
title: "New Photos"
message: >
{{ trigger.event.data.added_assets[0].asset_owner }} added
{{ trigger.event.data.added_count }} photos to {{ trigger.event.data.album_name }}
```
## Requirements
- Home Assistant 2024.1.0 or newer
- Immich server with API access
- Valid Immich API key with `album.read` and `asset.read` permissions
## License
MIT License - see the [LICENSE](../LICENSE) file for details.

View File

@@ -1,145 +0,0 @@
"""Immich Album Watcher integration for Home Assistant."""
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_ALBUM_ID,
CONF_ALBUMS,
CONF_API_KEY,
CONF_IMMICH_URL,
CONF_SCAN_INTERVAL,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
PLATFORMS,
SERVICE_GET_RECENT_ASSETS,
SERVICE_REFRESH,
)
from .coordinator import ImmichAlbumWatcherCoordinator
_LOGGER = logging.getLogger(__name__)
# Service schemas
SERVICE_REFRESH_SCHEMA = vol.Schema({})
SERVICE_GET_RECENT_ASSETS_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ALBUM_ID): cv.string,
vol.Optional("count", default=10): vol.All(
vol.Coerce(int), vol.Range(min=1, max=100)
),
}
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Immich Album Watcher from a config entry."""
hass.data.setdefault(DOMAIN, {})
url = entry.data[CONF_IMMICH_URL]
api_key = entry.data[CONF_API_KEY]
album_ids = entry.options.get(CONF_ALBUMS, [])
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
coordinator = ImmichAlbumWatcherCoordinator(
hass,
url=url,
api_key=api_key,
album_ids=album_ids,
scan_interval=scan_interval,
)
# Fetch initial data
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = coordinator
# Set up platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register update listener for options changes
entry.async_on_unload(entry.add_update_listener(async_update_options))
# Register services (only once)
await async_setup_services(hass)
_LOGGER.info(
"Immich Album Watcher set up successfully, watching %d albums",
len(album_ids),
)
return True
async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for Immich Album Watcher."""
if hass.services.has_service(DOMAIN, SERVICE_REFRESH):
return # Services already registered
async def handle_refresh(call: ServiceCall) -> None:
"""Handle the refresh service call."""
for coordinator in hass.data[DOMAIN].values():
if isinstance(coordinator, ImmichAlbumWatcherCoordinator):
await coordinator.async_refresh_now()
async def handle_get_recent_assets(call: ServiceCall) -> ServiceResponse:
"""Handle the get_recent_assets service call."""
album_id = call.data[ATTR_ALBUM_ID]
count = call.data.get("count", 10)
for coordinator in hass.data[DOMAIN].values():
if isinstance(coordinator, ImmichAlbumWatcherCoordinator):
if coordinator.data and album_id in coordinator.data:
assets = await coordinator.async_get_recent_assets(album_id, count)
return {"assets": assets}
return {"assets": [], "error": f"Album {album_id} not found"}
hass.services.async_register(
DOMAIN,
SERVICE_REFRESH,
handle_refresh,
schema=SERVICE_REFRESH_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_GET_RECENT_ASSETS,
handle_get_recent_assets,
schema=SERVICE_GET_RECENT_ASSETS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id]
album_ids = entry.options.get(CONF_ALBUMS, [])
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
coordinator.update_config(album_ids, scan_interval)
# Reload the entry to update sensors
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
# Unregister services if no more entries
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, SERVICE_REFRESH)
hass.services.async_remove(DOMAIN, SERVICE_GET_RECENT_ASSETS)
return unload_ok

View File

@@ -1,57 +0,0 @@
"""Constants for the Immich Album Watcher integration."""
from datetime import timedelta
from typing import Final
DOMAIN: Final = "immich_album_watcher"
# Configuration keys
CONF_IMMICH_URL: Final = "immich_url"
CONF_API_KEY: Final = "api_key"
CONF_ALBUMS: Final = "albums"
CONF_SCAN_INTERVAL: Final = "scan_interval"
# Defaults
DEFAULT_SCAN_INTERVAL: Final = 60 # seconds
NEW_ASSETS_RESET_DELAY: Final = 300 # 5 minutes
# Events
EVENT_ALBUM_CHANGED: Final = f"{DOMAIN}_album_changed"
EVENT_ASSETS_ADDED: Final = f"{DOMAIN}_assets_added"
EVENT_ASSETS_REMOVED: Final = f"{DOMAIN}_assets_removed"
# Attributes
ATTR_ALBUM_ID: Final = "album_id"
ATTR_ALBUM_NAME: Final = "album_name"
ATTR_ALBUM_URL: Final = "album_url"
ATTR_ASSET_COUNT: Final = "asset_count"
ATTR_PHOTO_COUNT: Final = "photo_count"
ATTR_VIDEO_COUNT: Final = "video_count"
ATTR_ADDED_COUNT: Final = "added_count"
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_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"
# Asset types
ASSET_TYPE_IMAGE: Final = "IMAGE"
ASSET_TYPE_VIDEO: Final = "VIDEO"
# Platforms
PLATFORMS: Final = ["sensor", "binary_sensor", "camera"]
# Services
SERVICE_REFRESH: Final = "refresh"
SERVICE_GET_RECENT_ASSETS: Final = "get_recent_assets"

View File

@@ -1,480 +0,0 @@
"""Data coordinator for Immich Album Watcher."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
ASSET_TYPE_IMAGE,
ASSET_TYPE_VIDEO,
ATTR_ADDED_ASSETS,
ATTR_ADDED_COUNT,
ATTR_ALBUM_ID,
ATTR_ALBUM_NAME,
ATTR_ALBUM_URL,
ATTR_ASSET_CREATED,
ATTR_ASSET_FILENAME,
ATTR_ASSET_OWNER,
ATTR_ASSET_OWNER_ID,
ATTR_ASSET_TYPE,
ATTR_ASSET_URL,
ATTR_CHANGE_TYPE,
ATTR_PEOPLE,
ATTR_REMOVED_ASSETS,
ATTR_REMOVED_COUNT,
DOMAIN,
EVENT_ALBUM_CHANGED,
EVENT_ASSETS_ADDED,
EVENT_ASSETS_REMOVED,
)
_LOGGER = logging.getLogger(__name__)
@dataclass
class AssetInfo:
"""Data class for asset information."""
id: str
type: str # IMAGE or VIDEO
filename: str
created_at: str
owner_id: str = ""
owner_name: str = ""
people: list[str] = field(default_factory=list)
@classmethod
def from_api_response(
cls, data: dict[str, Any], users_cache: dict[str, str] | None = None
) -> AssetInfo:
"""Create AssetInfo from API response."""
people = []
if "people" in data:
people = [p.get("name", "") for p in data["people"] if p.get("name")]
owner_id = data.get("ownerId", "")
owner_name = ""
if users_cache and owner_id:
owner_name = users_cache.get(owner_id, "")
return cls(
id=data["id"],
type=data.get("type", ASSET_TYPE_IMAGE),
filename=data.get("originalFileName", ""),
created_at=data.get("fileCreatedAt", ""),
owner_id=owner_id,
owner_name=owner_name,
people=people,
)
@dataclass
class AlbumData:
"""Data class for album information."""
id: str
name: str
asset_count: int
photo_count: int
video_count: int
created_at: str
updated_at: str
shared: bool
owner: str
thumbnail_asset_id: str | None
asset_ids: set[str] = field(default_factory=set)
assets: dict[str, AssetInfo] = field(default_factory=dict)
people: set[str] = field(default_factory=set)
has_new_assets: bool = False
last_change_time: datetime | None = None
@classmethod
def from_api_response(
cls, data: dict[str, Any], users_cache: dict[str, str] | None = None
) -> AlbumData:
"""Create AlbumData from API response."""
assets_data = data.get("assets", [])
asset_ids = set()
assets = {}
people = set()
photo_count = 0
video_count = 0
for asset_data in assets_data:
asset = AssetInfo.from_api_response(asset_data, users_cache)
asset_ids.add(asset.id)
assets[asset.id] = asset
people.update(asset.people)
if asset.type == ASSET_TYPE_IMAGE:
photo_count += 1
elif asset.type == ASSET_TYPE_VIDEO:
video_count += 1
return cls(
id=data["id"],
name=data.get("albumName", "Unnamed"),
asset_count=data.get("assetCount", len(asset_ids)),
photo_count=photo_count,
video_count=video_count,
created_at=data.get("createdAt", ""),
updated_at=data.get("updatedAt", ""),
shared=data.get("shared", False),
owner=data.get("owner", {}).get("name", "Unknown"),
thumbnail_asset_id=data.get("albumThumbnailAssetId"),
asset_ids=asset_ids,
assets=assets,
people=people,
)
@dataclass
class AlbumChange:
"""Data class for album changes."""
album_id: str
album_name: str
change_type: str
added_count: int = 0
removed_count: int = 0
added_assets: list[AssetInfo] = field(default_factory=list)
removed_asset_ids: list[str] = field(default_factory=list)
class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]):
"""Coordinator for fetching Immich album data."""
def __init__(
self,
hass: HomeAssistant,
url: str,
api_key: str,
album_ids: list[str],
scan_interval: int,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=scan_interval),
)
self._url = url.rstrip("/")
self._api_key = api_key
self._album_ids = album_ids
self._previous_states: dict[str, AlbumData] = {}
self._session: aiohttp.ClientSession | None = None
self._people_cache: dict[str, str] = {} # person_id -> name
self._users_cache: dict[str, str] = {} # user_id -> name
self._shared_links_cache: dict[str, str] = {} # album_id -> share_key
@property
def immich_url(self) -> str:
"""Return the Immich URL."""
return self._url
@property
def api_key(self) -> str:
"""Return the API key."""
return self._api_key
def update_config(self, album_ids: list[str], scan_interval: int) -> None:
"""Update configuration."""
self._album_ids = album_ids
self.update_interval = timedelta(seconds=scan_interval)
async def async_refresh_now(self) -> None:
"""Force an immediate refresh."""
await self.async_request_refresh()
async def async_get_recent_assets(
self, album_id: str, count: int = 10
) -> list[dict[str, Any]]:
"""Get recent assets from an album."""
if self.data is None or album_id not in self.data:
return []
album = self.data[album_id]
# Sort assets by created_at descending
sorted_assets = sorted(
album.assets.values(),
key=lambda a: a.created_at,
reverse=True,
)[:count]
return [
{
"id": asset.id,
"type": asset.type,
"filename": asset.filename,
"created_at": asset.created_at,
"people": asset.people,
"thumbnail_url": f"{self._url}/api/assets/{asset.id}/thumbnail",
}
for asset in sorted_assets
]
async def async_fetch_people(self) -> dict[str, str]:
"""Fetch all people from Immich."""
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/people",
headers=headers,
) as response:
if response.status == 200:
data = await response.json()
people_list = data.get("people", data) if isinstance(data, dict) else data
self._people_cache = {
p["id"]: p.get("name", "")
for p in people_list
if p.get("name")
}
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch people: %s", err)
return self._people_cache
async def _async_fetch_users(self) -> dict[str, str]:
"""Fetch all users from Immich and cache them."""
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/users",
headers=headers,
) as response:
if response.status == 200:
data = await response.json()
self._users_cache = {
u["id"]: u.get("name", u.get("email", "Unknown"))
for u in data
if u.get("id")
}
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch users: %s", err)
return self._users_cache
async def _async_fetch_shared_links(self) -> dict[str, str]:
"""Fetch shared links from Immich and cache album_id -> share_key mapping."""
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/shared-links",
headers=headers,
) as response:
if response.status == 200:
data = await response.json()
_LOGGER.debug("Fetched %d shared links from Immich", len(data))
self._shared_links_cache.clear()
for link in data:
# Only process album-type shared links
link_type = link.get("type", "")
album = link.get("album")
key = link.get("key")
_LOGGER.debug(
"Shared link: type=%s, key=%s, album_id=%s",
link_type,
key[:8] if key else None,
album.get("id") if album else None,
)
if album and key:
album_id = album.get("id")
if album_id:
self._shared_links_cache[album_id] = key
_LOGGER.debug(
"Cached %d album shared links", len(self._shared_links_cache)
)
else:
_LOGGER.warning(
"Failed to fetch shared links: HTTP %s", response.status
)
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to fetch shared links: %s", err)
return self._shared_links_cache
def get_album_public_url(self, album_id: str) -> str | None:
"""Get the public URL for an album if it has a shared link."""
share_key = self._shared_links_cache.get(album_id)
if share_key:
return f"{self._url}/share/{share_key}"
return None
def _get_asset_public_url(self, album_id: str, asset_id: str) -> str | None:
"""Get the public URL for an asset if the album has a shared link."""
share_key = self._shared_links_cache.get(album_id)
if share_key:
return f"{self._url}/share/{share_key}/photos/{asset_id}"
return None
async def _async_update_data(self) -> dict[str, AlbumData]:
"""Fetch data from Immich API."""
if self._session is None:
self._session = async_get_clientsession(self.hass)
# Fetch users to resolve owner names
if not self._users_cache:
await self._async_fetch_users()
# Fetch shared links to resolve public URLs (refresh each time as links can change)
await self._async_fetch_shared_links()
headers = {"x-api-key": self._api_key}
albums_data: dict[str, AlbumData] = {}
for album_id in self._album_ids:
try:
async with self._session.get(
f"{self._url}/api/albums/{album_id}",
headers=headers,
) as response:
if response.status == 404:
_LOGGER.warning("Album %s not found, skipping", album_id)
continue
if response.status != 200:
raise UpdateFailed(
f"Error fetching album {album_id}: HTTP {response.status}"
)
data = await response.json()
album = AlbumData.from_api_response(data, self._users_cache)
# Detect changes and update flags
if album_id in self._previous_states:
change = self._detect_change(
self._previous_states[album_id], album
)
if change:
album.has_new_assets = change.added_count > 0
album.last_change_time = datetime.now()
self._fire_events(change, album)
else:
# First run, no changes
album.has_new_assets = False
# Preserve has_new_assets from previous state if still within window
if album_id in self._previous_states:
prev = self._previous_states[album_id]
if prev.has_new_assets and prev.last_change_time:
# Keep the flag if change was recent
album.last_change_time = prev.last_change_time
if not album.has_new_assets:
album.has_new_assets = prev.has_new_assets
albums_data[album_id] = album
except aiohttp.ClientError as err:
raise UpdateFailed(f"Error communicating with Immich: {err}") from err
# Update previous states
self._previous_states = albums_data.copy()
return albums_data
def _detect_change(
self, old_state: AlbumData, new_state: AlbumData
) -> AlbumChange | None:
"""Detect changes between two album states."""
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:
return None
change_type = "changed"
if added_ids and not removed_ids:
change_type = "assets_added"
elif removed_ids and not added_ids:
change_type = "assets_removed"
# Get full asset info for added assets
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),
removed_count=len(removed_ids),
added_assets=added_assets,
removed_asset_ids=list(removed_ids),
)
def _fire_events(self, change: AlbumChange, album: AlbumData) -> None:
"""Fire Home Assistant events for album changes."""
# Build detailed asset info for events
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_PEOPLE: asset.people,
}
# Add public URL if album has a shared link
asset_url = self._get_asset_public_url(change.album_id, asset.id)
if asset_url:
asset_detail[ATTR_ASSET_URL] = asset_url
added_assets_detail.append(asset_detail)
event_data = {
ATTR_ALBUM_ID: change.album_id,
ATTR_ALBUM_NAME: change.album_name,
ATTR_CHANGE_TYPE: change.change_type,
ATTR_ADDED_COUNT: change.added_count,
ATTR_REMOVED_COUNT: change.removed_count,
ATTR_ADDED_ASSETS: added_assets_detail,
ATTR_REMOVED_ASSETS: change.removed_asset_ids,
ATTR_PEOPLE: list(album.people),
}
# Add album public URL if it has a shared link
album_url = self.get_album_public_url(change.album_id)
if album_url:
event_data[ATTR_ALBUM_URL] = album_url
# Fire general change event
self.hass.bus.async_fire(EVENT_ALBUM_CHANGED, event_data)
_LOGGER.info(
"Album '%s' changed: +%d -%d assets",
change.album_name,
change.added_count,
change.removed_count,
)
# Fire specific events
if change.added_count > 0:
self.hass.bus.async_fire(EVENT_ASSETS_ADDED, event_data)
if change.removed_count > 0:
self.hass.bus.async_fire(EVENT_ASSETS_REMOVED, event_data)
def clear_new_assets_flag(self, album_id: str) -> None:
"""Clear the new assets flag for an album."""
if self.data and album_id in self.data:
self.data[album_id].has_new_assets = False
self.data[album_id].last_change_time = None

View File

@@ -1,12 +0,0 @@
{
"domain": "immich_album_watcher",
"name": "Immich Album Watcher",
"codeowners": [],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/your-repo/immich-album-watcher",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/your-repo/immich-album-watcher/issues",
"requirements": [],
"version": "1.0.0"
}

View File

@@ -1,342 +0,0 @@
"""Sensor platform for Immich Album Watcher."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_ALBUM_ID,
ATTR_ALBUM_URL,
ATTR_ASSET_COUNT,
ATTR_CREATED_AT,
ATTR_LAST_UPDATED,
ATTR_OWNER,
ATTR_PEOPLE,
ATTR_PHOTO_COUNT,
ATTR_SHARED,
ATTR_THUMBNAIL_URL,
ATTR_VIDEO_COUNT,
CONF_ALBUMS,
DOMAIN,
)
from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Immich Album Watcher sensors from a config entry."""
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id]
album_ids = entry.options.get(CONF_ALBUMS, [])
entities: list[SensorEntity] = []
for album_id in album_ids:
entities.append(ImmichAlbumAssetCountSensor(coordinator, entry, album_id))
entities.append(ImmichAlbumPhotoCountSensor(coordinator, entry, album_id))
entities.append(ImmichAlbumVideoCountSensor(coordinator, entry, album_id))
entities.append(ImmichAlbumLastUpdatedSensor(coordinator, entry, album_id))
entities.append(ImmichAlbumCreatedSensor(coordinator, entry, album_id))
entities.append(ImmichAlbumPeopleSensor(coordinator, entry, album_id))
entities.append(ImmichAlbumPublicUrlSensor(coordinator, entry, album_id))
async_add_entities(entities)
class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], SensorEntity):
"""Base sensor for Immich album."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
album_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._album_id = album_id
self._entry = entry
@property
def _album_data(self) -> AlbumData | None:
"""Get the album data from coordinator."""
if self.coordinator.data is None:
return None
return self.coordinator.data.get(self._album_id)
@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": f"Album {self._album_id[:8]}"}
@property
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.last_update_success and self._album_data is not None
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
return DeviceInfo(
identifiers={(DOMAIN, self._entry.entry_id)},
name="Immich Album Watcher",
manufacturer="Immich",
entry_type="service",
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.async_write_ha_state()
class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor):
"""Sensor representing an Immich album asset count."""
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:image-album"
_attr_translation_key = "album_asset_count"
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
album_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, entry, album_id)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_asset_count"
@property
def native_value(self) -> int | None:
"""Return the state of the sensor (asset count)."""
if self._album_data:
return self._album_data.asset_count
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes."""
if not self._album_data:
return {}
attrs = {
ATTR_ALBUM_ID: self._album_data.id,
ATTR_ASSET_COUNT: self._album_data.asset_count,
ATTR_PHOTO_COUNT: self._album_data.photo_count,
ATTR_VIDEO_COUNT: self._album_data.video_count,
ATTR_LAST_UPDATED: self._album_data.updated_at,
ATTR_CREATED_AT: self._album_data.created_at,
ATTR_SHARED: self._album_data.shared,
ATTR_OWNER: self._album_data.owner,
ATTR_PEOPLE: list(self._album_data.people),
}
# Add thumbnail URL if available
if self._album_data.thumbnail_asset_id:
attrs[ATTR_THUMBNAIL_URL] = (
f"{self.coordinator.immich_url}/api/assets/"
f"{self._album_data.thumbnail_asset_id}/thumbnail"
)
return attrs
class ImmichAlbumPhotoCountSensor(ImmichAlbumBaseSensor):
"""Sensor representing an Immich album photo count."""
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:image"
_attr_translation_key = "album_photo_count"
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
album_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, entry, album_id)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_photo_count"
@property
def native_value(self) -> int | None:
"""Return the state of the sensor (photo count)."""
if self._album_data:
return self._album_data.photo_count
return None
class ImmichAlbumVideoCountSensor(ImmichAlbumBaseSensor):
"""Sensor representing an Immich album video count."""
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:video"
_attr_translation_key = "album_video_count"
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
album_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, entry, album_id)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_video_count"
@property
def native_value(self) -> int | None:
"""Return the state of the sensor (video count)."""
if self._album_data:
return self._album_data.video_count
return None
class ImmichAlbumLastUpdatedSensor(ImmichAlbumBaseSensor):
"""Sensor representing an Immich album last updated time."""
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_icon = "mdi:clock-outline"
_attr_translation_key = "album_last_updated"
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
album_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, entry, album_id)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_last_updated"
@property
def native_value(self) -> datetime | None:
"""Return the state of the sensor (last updated datetime)."""
if self._album_data and self._album_data.updated_at:
try:
return datetime.fromisoformat(
self._album_data.updated_at.replace("Z", "+00:00")
)
except ValueError:
return None
return None
class ImmichAlbumCreatedSensor(ImmichAlbumBaseSensor):
"""Sensor representing an Immich album creation date."""
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_icon = "mdi:calendar-plus"
_attr_translation_key = "album_created"
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
album_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, entry, album_id)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_created"
@property
def native_value(self) -> datetime | None:
"""Return the state of the sensor (creation datetime)."""
if self._album_data and self._album_data.created_at:
try:
return datetime.fromisoformat(
self._album_data.created_at.replace("Z", "+00:00")
)
except ValueError:
return None
return None
class ImmichAlbumPeopleSensor(ImmichAlbumBaseSensor):
"""Sensor representing people detected in an Immich album."""
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_icon = "mdi:account-group"
_attr_translation_key = "album_people_count"
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
album_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, entry, album_id)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_people_count"
@property
def native_value(self) -> int | None:
"""Return the state of the sensor (number of unique people)."""
if self._album_data:
return len(self._album_data.people)
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes."""
if not self._album_data:
return {}
return {
ATTR_PEOPLE: list(self._album_data.people),
}
class ImmichAlbumPublicUrlSensor(ImmichAlbumBaseSensor):
"""Sensor representing an Immich album public URL."""
_attr_icon = "mdi:link-variant"
_attr_translation_key = "album_public_url"
def __init__(
self,
coordinator: ImmichAlbumWatcherCoordinator,
entry: ConfigEntry,
album_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, entry, album_id)
self._attr_unique_id = f"{entry.entry_id}_{album_id}_public_url"
@property
def native_value(self) -> str | None:
"""Return the state of the sensor (public URL)."""
if self._album_data:
return self.coordinator.get_album_public_url(self._album_id)
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes."""
if not self._album_data:
return {}
return {
ATTR_ALBUM_ID: self._album_data.id,
ATTR_SHARED: self._album_data.shared,
}

View File

@@ -1,24 +0,0 @@
refresh:
name: Refresh
description: Force an immediate refresh of all album data from Immich.
get_recent_assets:
name: Get Recent Assets
description: Get the most recent assets from a specific album.
fields:
album_id:
name: Album ID
description: The ID of the album to get recent assets from.
required: true
selector:
text:
count:
name: Count
description: Number of recent assets to return (1-100).
required: false
default: 10
selector:
number:
min: 1
max: 100
mode: slider

View File

@@ -1,108 +0,0 @@
{
"entity": {
"sensor": {
"album_asset_count": {
"name": "{album_name}: Asset Count"
},
"album_photo_count": {
"name": "{album_name}: Photo Count"
},
"album_video_count": {
"name": "{album_name}: Video Count"
},
"album_last_updated": {
"name": "{album_name}: Last Updated"
},
"album_created": {
"name": "{album_name}: Created"
},
"album_people_count": {
"name": "{album_name}: People Count"
},
"album_public_url": {
"name": "{album_name}: Public URL"
}
},
"binary_sensor": {
"album_new_assets": {
"name": "{album_name}: New Assets"
}
},
"camera": {
"album_thumbnail": {
"name": "{album_name}: Thumbnail"
}
}
},
"config": {
"step": {
"user": {
"title": "Connect to Immich",
"description": "Enter your Immich server details. You can get an API key from Immich → User Settings → API Keys.",
"data": {
"immich_url": "Immich URL",
"api_key": "API Key"
},
"data_description": {
"immich_url": "The URL of your Immich server (e.g., http://192.168.1.100:2283)",
"api_key": "Your Immich API key"
}
},
"albums": {
"title": "Select Albums",
"description": "Choose which albums to monitor for changes.",
"data": {
"albums": "Albums to watch"
}
}
},
"error": {
"cannot_connect": "Failed to connect to Immich server",
"invalid_auth": "Invalid API key",
"no_albums": "No albums found on the server",
"no_albums_selected": "Please select at least one album",
"unknown": "Unexpected error occurred"
},
"abort": {
"already_configured": "This Immich server is already configured"
}
},
"options": {
"step": {
"init": {
"title": "Immich Album Watcher Options",
"description": "Configure which albums to monitor and how often to check for changes.",
"data": {
"albums": "Albums to watch",
"scan_interval": "Scan interval (seconds)"
},
"data_description": {
"scan_interval": "How often to check for album changes (10-3600 seconds)"
}
}
},
"error": {
"cannot_connect": "Failed to connect to Immich server"
}
},
"services": {
"refresh": {
"name": "Refresh",
"description": "Force an immediate refresh of all album data from Immich."
},
"get_recent_assets": {
"name": "Get Recent Assets",
"description": "Get the most recent assets from a specific album.",
"fields": {
"album_id": {
"name": "Album ID",
"description": "The ID of the album to get recent assets from."
},
"count": {
"name": "Count",
"description": "Number of recent assets to return (1-100)."
}
}
}
}
}

View File

@@ -1,108 +0,0 @@
{
"entity": {
"sensor": {
"album_asset_count": {
"name": "{album_name}: Число файлов"
},
"album_photo_count": {
"name": "{album_name}: Число фото"
},
"album_video_count": {
"name": "{album_name}: Число видео"
},
"album_last_updated": {
"name": "{album_name}: Последнее обновление"
},
"album_created": {
"name": "{album_name}: Дата создания"
},
"album_people_count": {
"name": "{album_name}: Число людей"
},
"album_public_url": {
"name": "{album_name}: Публичная ссылка"
}
},
"binary_sensor": {
"album_new_assets": {
"name": "{album_name}: Новые файлы"
}
},
"camera": {
"album_thumbnail": {
"name": "{album_name}: Превью"
}
}
},
"config": {
"step": {
"user": {
"title": "Подключение к Immich",
"description": "Введите данные вашего сервера Immich. API-ключ можно получить в Immich → Настройки пользователя → API-ключи.",
"data": {
"immich_url": "URL Immich",
"api_key": "API-ключ"
},
"data_description": {
"immich_url": "URL вашего сервера Immich (например, http://192.168.1.100:2283)",
"api_key": "Ваш API-ключ Immich"
}
},
"albums": {
"title": "Выбор альбомов",
"description": "Выберите альбомы для отслеживания изменений.",
"data": {
"albums": "Альбомы для отслеживания"
}
}
},
"error": {
"cannot_connect": "Не удалось подключиться к серверу Immich",
"invalid_auth": "Неверный API-ключ",
"no_albums": "На сервере не найдено альбомов",
"no_albums_selected": "Пожалуйста, выберите хотя бы один альбом",
"unknown": "Произошла непредвиденная ошибка"
},
"abort": {
"already_configured": "Этот сервер Immich уже настроен"
}
},
"options": {
"step": {
"init": {
"title": "Настройки Immich Album Watcher",
"description": "Настройте отслеживаемые альбомы и частоту проверки изменений.",
"data": {
"albums": "Альбомы для отслеживания",
"scan_interval": "Интервал сканирования (секунды)"
},
"data_description": {
"scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)"
}
}
},
"error": {
"cannot_connect": "Не удалось подключиться к серверу Immich"
}
},
"services": {
"refresh": {
"name": "Обновить",
"description": "Принудительно обновить данные всех альбомов из Immich."
},
"get_recent_assets": {
"name": "Получить последние файлы",
"description": "Получить последние файлы из указанного альбома.",
"fields": {
"album_id": {
"name": "ID альбома",
"description": "ID альбома для получения последних файлов."
},
"count": {
"name": "Количество",
"description": "Количество возвращаемых файлов (1-100)."
}
}
}
}
}