Compare commits
117 Commits
9a768b24f8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ad8ddaa25 | |||
| ff43e006d8 | |||
| 0a94f2bc88 | |||
| 482f54d620 | |||
| e6ff0a423a | |||
| ffce3ee337 | |||
| c5a3521b14 | |||
| 4babaddd87 | |||
| 510463cba6 | |||
| b87b5b2c87 | |||
| 3c893d6dbf | |||
| afb8be8101 | |||
| 59108a834c | |||
| 31873a8ffd | |||
| ce21733ae6 | |||
| 68b104ed40 | |||
| 6076e6d8ca | |||
| 5870ebd216 | |||
| aab29e253f | |||
| 693c157c31 | |||
| 0bb4d8a949 | |||
| bc8fda5984 | |||
| 381de98c40 | |||
| a04d5618d0 | |||
| fa829da8b7 | |||
| a85c557a20 | |||
| 69299c055f | |||
| 7ef9cb4326 | |||
| 7c8f0f4432 | |||
| 5a0b0b78f6 | |||
| af9bfb7b22 | |||
| 4b01a4b371 | |||
| cf987cbfb4 | |||
| 5dee7c55ca | |||
| ca6a9c8830 | |||
| 7b7ef5fec1 | |||
| 0200b9929f | |||
| 431069fbdb | |||
| 5192483fff | |||
| b708b14f32 | |||
| 90b4713d5c | |||
| fd1ad91fbe | |||
| 42063b7bf6 | |||
| 89cb2bbb70 | |||
| 2aa9b8939d | |||
| 1ad9b8af1d | |||
| 3a516d6d58 | |||
| 62bf15dce3 | |||
| 88ffd5d077 | |||
| 43f83acda9 | |||
| ab1c7ac0db | |||
| 2b487707ce | |||
| 87ce1bc5ec | |||
| 58b2281dc6 | |||
| b107cfe67f | |||
| d0783d0b6a | |||
| 71b79cd919 | |||
| 678e8a6e62 | |||
| dd7032b411 | |||
| 65ca81a3f3 | |||
| 3ba33a36cf | |||
| 6ca3cae5df | |||
| fde2d0ae31 | |||
| 31663852f9 | |||
| 5cee3ccc79 | |||
| 3b133dc4bb | |||
| a8ea9ab46a | |||
| e88fd0fa3a | |||
| 3cf916dc77 | |||
| df446390f2 | |||
| 1d61f05552 | |||
| 38a2a6ad7a | |||
| 0bb7e71a1e | |||
| c29fc2fbcf | |||
| 011f105823 | |||
| ee45fdc177 | |||
| 4b0f3b8b12 | |||
| e5e45f0fbf | |||
| 8714685d5e | |||
| bbcd97e1ac | |||
| 04dd63825c | |||
| 71d3714f6a | |||
| 459f5ef1e5 | |||
| 42b2d912c9 | |||
| 2007b020ba | |||
| 2ae706d700 | |||
| 1cc5d7cc7d | |||
| 5d878cfbd0 | |||
| c7ed037e2e | |||
| d26e212c82 | |||
| 02c0535f50 | |||
| c570e157be | |||
| 56d249b598 | |||
| ebed587f6f | |||
| a89d45268d | |||
| 950fe0fd91 | |||
| 91c30e086d | |||
| 6f39a8175d | |||
| e6619cb1c5 | |||
| eedc7792c8 | |||
| 3a0573e432 | |||
| 7c53110c07 | |||
| 03430df5fb | |||
| 2ca26e178a | |||
| 847c39eaa8 | |||
| 9013c5e0c3 | |||
| 557ec91f05 | |||
| 436139ede9 | |||
| f622a25484 | |||
| a4738dfd80 | |||
| 5d2f4c7edf | |||
| 82f293d0df | |||
| 60573374a4 | |||
| c8bd475a52 | |||
| 17c1792637 | |||
| c49392af51 | |||
| 69b344142f |
46
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
46
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug or unexpected behavior
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Describe the Bug
|
||||
|
||||
A clear description of what the bug is.
|
||||
|
||||
## Environment
|
||||
|
||||
- **Home Assistant version:**
|
||||
- **Integration version:**
|
||||
- **Immich version:**
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
What you expected to happen.
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
What actually happened.
|
||||
|
||||
## Logs
|
||||
|
||||
<details>
|
||||
<summary>Relevant log entries</summary>
|
||||
|
||||
```
|
||||
Paste logs here
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Additional Context
|
||||
|
||||
Any other context about the problem.
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Home Assistant Community
|
||||
url: https://community.home-assistant.io/
|
||||
about: Ask questions about Home Assistant
|
||||
- name: Immich Documentation
|
||||
url: https://immich.app/docs
|
||||
about: Immich official documentation
|
||||
27
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
27
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature or enhancement
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Feature Description
|
||||
|
||||
A clear description of what you would like to see added.
|
||||
|
||||
## Use Case
|
||||
|
||||
Describe the problem this feature would solve or the use case it enables.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
If you have ideas on how to implement this, describe them here.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
Any alternative solutions or features you've considered.
|
||||
|
||||
## Additional Context
|
||||
|
||||
Any other context, screenshots, or examples.
|
||||
20
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
20
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
## Description
|
||||
|
||||
Brief description of the changes.
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Documentation update
|
||||
- [ ] Other (describe):
|
||||
|
||||
## Testing
|
||||
|
||||
Describe how you tested these changes.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] Changes have been tested locally
|
||||
- [ ] Documentation updated (if applicable)
|
||||
17
.github/workflows/validate.yaml
vendored
Normal file
17
.github/workflows/validate.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
hassfest:
|
||||
name: Hassfest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: home-assistant/actions/hassfest@master
|
||||
if: github.server_url == 'https://github.com'
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -44,3 +44,5 @@ htmlcov/
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
__pycache__/
|
||||
test-data/
|
||||
|
||||
63
CLAUDE.md
Normal file
63
CLAUDE.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 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.
|
||||
|
||||
## Development Servers
|
||||
|
||||
**IMPORTANT**: When the user requests it OR when backend code changes are made (files in `packages/server/`), you MUST restart the standalone server using this one-liner:
|
||||
```bash
|
||||
PID=$(netstat -ano 2>/dev/null | grep ':8420.*LISTENING' | awk '{print $5}' | head -1) && [ -n "$PID" ] && taskkill //F //PID $PID 2>/dev/null; sleep 1 && cd packages/server && pip install -e . 2>&1 | tail -1 && cd ../.. && IMMICH_WATCHER_DATA_DIR=./test-data IMMICH_WATCHER_SECRET_KEY=test-secret-key-minimum-32chars nohup python -m uvicorn immich_watcher_server.main:app --host 0.0.0.0 --port 8420 > /dev/null 2>&1 & sleep 3 && curl -s http://localhost:8420/api/health
|
||||
```
|
||||
|
||||
**IMPORTANT**: Overlays (modals, dropdowns, pickers) MUST use `position: fixed` with inline styles and `z-index: 9999`. Tailwind CSS v4 `fixed`/`absolute` classes do NOT work reliably inside flex/overflow containers in this project. Always calculate position from `getBoundingClientRect()` for dropdowns, or use `top:0;left:0;right:0;bottom:0` for full-screen backdrops.
|
||||
|
||||
**IMPORTANT**: When the user requests it, restart the frontend dev server using this one-liner:
|
||||
```bash
|
||||
PID=$(netstat -ano 2>/dev/null | grep ':5173.*LISTENING' | awk '{print $5}' | head -1) && [ -n "$PID" ] && taskkill //F //PID $PID 2>/dev/null; sleep 1 && cd frontend && npx vite dev --port 5173 --host > /dev/null 2>&1 & sleep 4 && curl -s -o /dev/null -w "Frontend: %{http_code}" http://localhost:5173/
|
||||
```
|
||||
|
||||
## Frontend Architecture Notes
|
||||
|
||||
- **i18n**: Uses `$state` rune in `.svelte.ts` file (`lib/i18n/index.svelte.ts` or `index.ts` with auto-detect). Locale auto-detects from localStorage at module load time. `t()` is reactive via `$state`. `setLocale()` updates immediately without page reload.
|
||||
- **Svelte 5 runes**: `$state` only works in `.svelte` and `.svelte.ts` files. Regular `.ts` files cannot use runes -- use plain variables instead.
|
||||
- **Static adapter**: Frontend uses `@sveltejs/adapter-static` with SPA fallback. API calls proxied via Vite dev server config.
|
||||
- **Auth flow**: After login/setup, use `window.location.href = '/'` (hard redirect), NOT `goto('/')` (races with layout auth check).
|
||||
- **Tailwind CSS v4**: Uses `@theme` directive in `app.css` for CSS variables. Grid/flex classes work but `fixed`/`absolute` positioning requires inline styles in overlay components.
|
||||
|
||||
## Backend Architecture Notes
|
||||
|
||||
- **SQLAlchemy async + aiohttp**: Cannot nest `async with aiohttp.ClientSession()` inside a route that has an active SQLAlchemy async session -- greenlet context breaks. Eagerly load all DB data before entering aiohttp context, or use `check_tracker_with_session()` pattern.
|
||||
- **Jinja2 SandboxedEnvironment**: All template rendering MUST use `from jinja2.sandbox import SandboxedEnvironment` (not `jinja2.sandbox.SandboxedEnvironment` -- dotted access doesn't work).
|
||||
- **System-owned entities**: `user_id=0` means system-owned (e.g. default templates). Access checks must allow `user_id == 0` in `_get()` helpers.
|
||||
- **Default templates**: Stored as `.jinja2` files in `packages/server/src/immich_watcher_server/templates/{en,ru}/`. Loaded by `load_default_templates(locale)` and seeded to DB on first startup if no templates exist.
|
||||
- **FastAPI route ordering**: Static path routes (e.g. `/variables`) MUST be registered BEFORE parameterized routes (e.g. `/{config_id}`) to avoid path conflicts.
|
||||
- **`__pycache__`**: Add to `.gitignore`. Never commit.
|
||||
49
CONTRIBUTING.md
Normal file
49
CONTRIBUTING.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Contributing to Immich Album Watcher
|
||||
|
||||
Thank you for your interest in contributing to this Home Assistant integration!
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork locally
|
||||
3. Create a new branch for your changes
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Set up a Home Assistant development environment
|
||||
2. Copy the `custom_components/immich_album_watcher` folder to your HA config
|
||||
3. Restart Home Assistant to load changes
|
||||
|
||||
## Code Style
|
||||
|
||||
- Follow [Home Assistant's development guidelines](https://developers.home-assistant.io/docs/development_guidelines)
|
||||
- Use type hints for all function parameters and return values
|
||||
- Keep code compatible with Python 3.11+
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
1. Test your changes thoroughly
|
||||
2. Update documentation if needed
|
||||
3. Create a pull request with a clear description of changes
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
When reporting bugs, please include:
|
||||
|
||||
- Home Assistant version
|
||||
- Integration version
|
||||
- Immich server version
|
||||
- Relevant log entries
|
||||
- Steps to reproduce
|
||||
|
||||
## Version Numbering
|
||||
|
||||
This project uses semantic versioning:
|
||||
|
||||
- **MAJOR** (x.0.0): Breaking changes
|
||||
- **MINOR** (0.x.0): New features, backward compatible
|
||||
- **PATCH** (0.0.x): Bug fixes
|
||||
|
||||
## Questions?
|
||||
|
||||
Open an issue for any questions about contributing.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Alexei Dolgolyov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
638
README.md
638
README.md
@@ -1,54 +1,620 @@
|
||||
# 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.
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
> **Tip:** For the best experience, use this integration with the [Immich Album Watcher Blueprint](https://git.dolgolyov-family.by/alexei.dolgolyov/haos-blueprints/src/branch/main/Common/Immich%20Album%20Watcher) to easily create automations for album change notifications.
|
||||
|
||||
## Available Integrations
|
||||
## Features
|
||||
|
||||
| Integration | Description | Documentation |
|
||||
|-------------|-------------|---------------|
|
||||
| [Immich Album Watcher](immich_album_watcher/) | Monitor Immich albums for changes with sensors, events, and face recognition | [README](immich_album_watcher/README.md) |
|
||||
- **Album Monitoring** - Watch selected Immich albums for asset additions and removals
|
||||
- **Rich Sensor Data** - Multiple sensors per album:
|
||||
- Album ID (with album name and share URL attributes)
|
||||
- Asset Count (total assets with detected people list)
|
||||
- Photo Count (number of photos)
|
||||
- Video Count (number of videos)
|
||||
- Last Updated (last modification timestamp)
|
||||
- Created (album creation date)
|
||||
- Public URL (public share link)
|
||||
- Protected URL (password-protected share link)
|
||||
- Protected Password (password for protected link)
|
||||
- **Camera Entity** - Album thumbnail displayed as a camera entity for dashboards
|
||||
- **Binary Sensor** - "New Assets" indicator that turns on when assets are added
|
||||
- **Face Recognition** - Detects and lists people recognized in album photos
|
||||
- **Event Firing** - Fires Home Assistant events when albums change:
|
||||
- `immich_album_watcher_album_changed` - General album changes
|
||||
- `immich_album_watcher_assets_added` - When new assets are added
|
||||
- `immich_album_watcher_assets_removed` - When assets are removed
|
||||
- **Enhanced Event Data** - Events include detailed asset info:
|
||||
- Asset type (photo/video)
|
||||
- Filename
|
||||
- Creation date
|
||||
- Asset owner (who uploaded the asset)
|
||||
- Asset description/caption
|
||||
- Public URL (if album has a shared link)
|
||||
- Detected people in the asset
|
||||
- **Services** - Custom service calls:
|
||||
- `immich_album_watcher.refresh` - Force immediate data refresh
|
||||
- `immich_album_watcher.get_assets` - Get assets from an album with filtering and ordering
|
||||
- `immich_album_watcher.send_telegram_notification` - Send text, photo, video, document, 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
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
4. Add the integration via **Settings** → **Devices & Services** → **Add Integration**
|
||||
|
||||
### HACS Installation
|
||||
## Configuration
|
||||
|
||||
1. Open HACS in Home Assistant
|
||||
2. Go to **Integrations** → **Custom repositories**
|
||||
3. Add this repository URL
|
||||
4. Install the desired integration
|
||||
5. Restart Home Assistant
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| Server URL | Your Immich server URL (e.g., `https://immich.example.com`) | Required |
|
||||
| API Key | Your Immich API key | Required |
|
||||
| Albums | Albums to monitor | Required |
|
||||
| Scan Interval | How often to check for changes (seconds) | 60 |
|
||||
| Telegram Bot Token | Bot token for sending media to Telegram (optional) | - |
|
||||
| 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 `assets` is empty or not provided
|
||||
- **Single document** - When `assets` contains one document (default type)
|
||||
- **Single photo** - When `assets` contains one photo (`type: photo`)
|
||||
- **Single video** - When `assets` contains one video (`type: video`)
|
||||
- **Media group** - When `assets` contains multiple photos/videos (documents are sent separately)
|
||||
|
||||
The service downloads media from Immich and uploads it to Telegram, bypassing any CORS restrictions. Large lists of photos and videos are automatically split into multiple media groups based on the `max_group_size` parameter (default: 10 items per group). Documents cannot be grouped and are sent individually.
|
||||
|
||||
**File ID Caching:** When media is uploaded to Telegram, the service caches the returned `file_id`. Subsequent sends of the same media will use the cached `file_id` instead of re-uploading, significantly improving performance. The cache TTL is configurable in hub options (default: 48 hours, range: 1-168 hours). The cache is persistent across Home Assistant restarts and is shared across all albums in the hub.
|
||||
|
||||
**Dual Cache System:** The integration maintains two separate caches for optimal performance:
|
||||
|
||||
- **Asset ID Cache** - For Immich assets with extractable asset IDs (UUIDs). The same asset accessed via different URL types (thumbnail, original, video playback, share links) shares the same cache entry.
|
||||
- **URL Cache** - For non-Immich URLs or URLs without extractable asset IDs. Also used when a custom `cache_key` is provided.
|
||||
|
||||
**Smart Cache Keys:** The service automatically extracts asset IDs from Immich URLs. Supported URL patterns:
|
||||
|
||||
- `/api/assets/{asset_id}/original`
|
||||
- `/api/assets/{asset_id}/thumbnail`
|
||||
- `/api/assets/{asset_id}/video/playback`
|
||||
- `/share/{key}/photos/{asset_id}`
|
||||
|
||||
You can provide a custom `cache_key` per asset to override this behavior (stored in URL cache).
|
||||
|
||||
**Examples:**
|
||||
|
||||
Text message:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
caption: "Check out the new album!"
|
||||
disable_web_page_preview: true
|
||||
```
|
||||
|
||||
Single document (default):
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
assets:
|
||||
- url: "https://immich.example.com/api/assets/xxx/original?key=yyy"
|
||||
content_type: "image/heic" # Optional: explicit MIME type
|
||||
caption: "Original file"
|
||||
```
|
||||
|
||||
Single photo:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
assets:
|
||||
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||
type: photo
|
||||
caption: "Beautiful sunset!"
|
||||
```
|
||||
|
||||
Media group:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
assets:
|
||||
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||
type: photo
|
||||
- url: "https://immich.example.com/api/assets/zzz/video/playback?key=yyy"
|
||||
type: video
|
||||
caption: "New photos from the album!"
|
||||
reply_to_message_id: 123
|
||||
```
|
||||
|
||||
HTML formatting:
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
caption: |
|
||||
<b>Album Updated!</b>
|
||||
New photos by <i>{{ trigger.event.data.added_assets[0].owner }}</i>
|
||||
<a href="https://immich.example.com/album">View Album</a>
|
||||
parse_mode: "HTML" # Default, can be omitted
|
||||
```
|
||||
|
||||
Non-blocking mode (fire-and-forget):
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
assets:
|
||||
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||
type: photo
|
||||
caption: "Quick notification"
|
||||
wait_for_response: false # Automation continues immediately
|
||||
```
|
||||
|
||||
Using custom cache_key (useful when same media has different URLs):
|
||||
|
||||
```yaml
|
||||
service: immich_album_watcher.send_telegram_notification
|
||||
target:
|
||||
entity_id: sensor.album_name_asset_limit
|
||||
data:
|
||||
chat_id: "-1001234567890"
|
||||
assets:
|
||||
- url: "https://immich.example.com/api/assets/xxx/thumbnail?key=yyy"
|
||||
type: photo
|
||||
cache_key: "asset_xxx" # Custom key for caching instead of URL
|
||||
caption: "Photo with custom cache key"
|
||||
```
|
||||
|
||||
| Field | Description | Required |
|
||||
|-------|-------------|----------|
|
||||
| `chat_id` | Telegram chat ID to send to | Yes |
|
||||
| `assets` | List of media items with `url`, optional `type` (document/photo/video, default: document), optional `content_type` (MIME type, e.g., `image/jpeg`), and optional `cache_key` (custom key for caching). Empty for text message. Photos and videos can be grouped; documents are sent separately. | No |
|
||||
| `bot_token` | Telegram bot token (uses configured token if not provided) | No |
|
||||
| `caption` | For media: caption applied to first item. For text: the message text. Supports HTML formatting by default. | No |
|
||||
| `reply_to_message_id` | Message ID to reply to | No |
|
||||
| `disable_web_page_preview` | Disable link previews in text messages | No |
|
||||
| `parse_mode` | How to parse caption/text. Options: `HTML`, `Markdown`, `MarkdownV2`, or empty string for plain text. Default: `HTML` | No |
|
||||
| `max_group_size` | Maximum media items per group (2-10). Large lists split into multiple groups. Default: 10 | No |
|
||||
| `chunk_delay` | Delay in milliseconds between sending multiple groups (0-60000). Useful for rate limiting. Default: 0 | No |
|
||||
| `wait_for_response` | Wait for Telegram to finish processing. Set to `false` for fire-and-forget (automation continues immediately). Default: `true` | No |
|
||||
| `max_asset_data_size` | Maximum asset size in bytes. Assets exceeding this limit will be skipped. Default: no limit | No |
|
||||
| `send_large_photos_as_documents` | Handle photos exceeding Telegram limits (10MB or 10000px dimension sum). If `true`, send as documents. If `false`, skip oversized photos. Default: `false` | No |
|
||||
| `chat_action` | Chat action to display while processing media (`typing`, `upload_photo`, `upload_video`, `upload_document`). Set to empty string to disable. Default: `typing` | No |
|
||||
|
||||
The service returns a response with `success` status and `message_id` (single message), `message_ids` (media group), or `groups_sent` (number of groups when split). When `wait_for_response` is `false`, the service returns immediately with `{"success": true, "status": "queued"}` while processing continues in the background.
|
||||
|
||||
## Events
|
||||
|
||||
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
|
||||
|
||||
|
||||
445
custom_components/immich_album_watcher/__init__.py
Normal file
445
custom_components/immich_album_watcher/__init__.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""Immich Album Watcher integration for Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time as dt_time
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.event import async_track_time_change
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_ALBUM_ID,
|
||||
CONF_ALBUM_NAME,
|
||||
CONF_API_KEY,
|
||||
CONF_HUB_NAME,
|
||||
CONF_IMMICH_URL,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_SERVER_API_KEY,
|
||||
CONF_SERVER_URL,
|
||||
CONF_TELEGRAM_CACHE_TTL,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_TELEGRAM_CACHE_TTL,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import ImmichAlbumWatcherCoordinator
|
||||
from .storage import (
|
||||
ImmichAlbumStorage,
|
||||
NotificationQueue,
|
||||
TelegramFileCache,
|
||||
create_notification_queue,
|
||||
create_telegram_cache,
|
||||
)
|
||||
|
||||
_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()
|
||||
|
||||
# Create and load Telegram file caches once per hub (shared across all albums)
|
||||
# TTL is in hours from config, convert to seconds
|
||||
cache_ttl_seconds = telegram_cache_ttl * 60 * 60
|
||||
# URL-based cache for non-Immich URLs or URLs without extractable asset IDs
|
||||
telegram_cache = create_telegram_cache(hass, entry.entry_id, ttl_seconds=cache_ttl_seconds)
|
||||
await telegram_cache.async_load()
|
||||
# Asset ID-based cache for Immich URLs — uses thumbhash validation instead of TTL
|
||||
telegram_asset_cache = create_telegram_cache(
|
||||
hass, f"{entry.entry_id}_assets", use_thumbhash=True
|
||||
)
|
||||
await telegram_asset_cache.async_load()
|
||||
|
||||
# Create notification queue for quiet hours
|
||||
notification_queue = create_notification_queue(hass, entry.entry_id)
|
||||
await notification_queue.async_load()
|
||||
|
||||
# Create optional server sync client
|
||||
server_url = entry.options.get(CONF_SERVER_URL, "")
|
||||
server_api_key = entry.options.get(CONF_SERVER_API_KEY, "")
|
||||
sync_client = None
|
||||
if server_url and server_api_key:
|
||||
from .sync import ServerSyncClient
|
||||
sync_client = ServerSyncClient(hass, server_url, server_api_key)
|
||||
_LOGGER.info("Server sync enabled: %s", server_url)
|
||||
|
||||
# Store hub reference
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
"hub": entry.runtime_data,
|
||||
"subentries": {},
|
||||
"storage": storage,
|
||||
"telegram_cache": telegram_cache,
|
||||
"telegram_asset_cache": telegram_asset_cache,
|
||||
"notification_queue": notification_queue,
|
||||
"sync_client": sync_client,
|
||||
"quiet_hours_unsubs": {}, # keyed by "HH:MM" end time
|
||||
}
|
||||
|
||||
# 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)
|
||||
|
||||
# Check if there are queued notifications from before restart
|
||||
if notification_queue.has_pending():
|
||||
_register_queue_timers(hass, entry)
|
||||
# Process any items whose quiet hours have already ended
|
||||
hass.async_create_task(_process_ready_notifications(hass, entry))
|
||||
|
||||
# Register update listener for options and subentry changes
|
||||
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"]
|
||||
telegram_cache: TelegramFileCache = hass.data[DOMAIN][entry.entry_id]["telegram_cache"]
|
||||
telegram_asset_cache: TelegramFileCache = hass.data[DOMAIN][entry.entry_id]["telegram_asset_cache"]
|
||||
|
||||
_LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id)
|
||||
|
||||
# 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,
|
||||
telegram_asset_cache=telegram_asset_cache,
|
||||
sync_client=hass.data[DOMAIN][entry.entry_id].get("sync_client"),
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
def _is_quiet_hours(start_str: str, end_str: str) -> bool:
|
||||
"""Check if current time is within quiet hours."""
|
||||
if not start_str or not end_str:
|
||||
return False
|
||||
|
||||
try:
|
||||
now = dt_util.now().time()
|
||||
start_time = dt_time.fromisoformat(start_str)
|
||||
end_time = dt_time.fromisoformat(end_str)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if start_time <= end_time:
|
||||
return start_time <= now < end_time
|
||||
else:
|
||||
# Crosses midnight (e.g., 22:00 - 08:00)
|
||||
return now >= start_time or now < end_time
|
||||
|
||||
|
||||
def _register_queue_timers(hass: HomeAssistant, entry: ImmichConfigEntry) -> None:
|
||||
"""Register timers for each unique quiet_hours_end in the queue."""
|
||||
entry_data = hass.data[DOMAIN][entry.entry_id]
|
||||
queue: NotificationQueue = entry_data["notification_queue"]
|
||||
unsubs: dict[str, list] = entry_data["quiet_hours_unsubs"]
|
||||
|
||||
# Collect unique end times from queued items
|
||||
end_times: set[str] = set()
|
||||
for item in queue.get_all():
|
||||
end_str = item.get("params", {}).get("quiet_hours_end", "")
|
||||
if end_str:
|
||||
end_times.add(end_str)
|
||||
|
||||
for end_str in end_times:
|
||||
if end_str in unsubs:
|
||||
continue # Timer already registered for this end time
|
||||
|
||||
try:
|
||||
end_time = dt_time.fromisoformat(end_str)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Invalid quiet hours end time in queue: %s", end_str)
|
||||
continue
|
||||
|
||||
async def _on_quiet_hours_end(_now: datetime, _end_str: str = end_str) -> None:
|
||||
"""Handle quiet hours end — process matching queued notifications."""
|
||||
_LOGGER.info("Quiet hours ended (%s), processing queued notifications", _end_str)
|
||||
await _process_notifications_for_end_time(hass, entry, _end_str)
|
||||
|
||||
unsub = async_track_time_change(
|
||||
hass, _on_quiet_hours_end, hour=end_time.hour, minute=end_time.minute, second=0
|
||||
)
|
||||
unsubs[end_str] = unsub
|
||||
entry.async_on_unload(unsub)
|
||||
|
||||
_LOGGER.debug("Registered quiet hours timer for %s", end_str)
|
||||
|
||||
|
||||
def _unregister_queue_timer(hass: HomeAssistant, entry: ImmichConfigEntry, end_str: str) -> None:
|
||||
"""Unregister a quiet hours timer if no more items need it."""
|
||||
entry_data = hass.data[DOMAIN][entry.entry_id]
|
||||
queue: NotificationQueue = entry_data["notification_queue"]
|
||||
unsubs: dict[str, list] = entry_data["quiet_hours_unsubs"]
|
||||
|
||||
# Check if any remaining items still use this end time
|
||||
for item in queue.get_all():
|
||||
if item.get("params", {}).get("quiet_hours_end", "") == end_str:
|
||||
return # Still needed
|
||||
|
||||
unsub = unsubs.pop(end_str, None)
|
||||
if unsub:
|
||||
unsub()
|
||||
_LOGGER.debug("Unregistered quiet hours timer for %s (no more items)", end_str)
|
||||
|
||||
|
||||
async def _process_ready_notifications(
|
||||
hass: HomeAssistant, entry: ImmichConfigEntry
|
||||
) -> None:
|
||||
"""Process queued notifications whose quiet hours have already ended."""
|
||||
entry_data = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if not entry_data:
|
||||
return
|
||||
|
||||
queue: NotificationQueue = entry_data["notification_queue"]
|
||||
items = queue.get_all()
|
||||
if not items:
|
||||
return
|
||||
|
||||
# Find items whose quiet hours have ended
|
||||
ready_indices = []
|
||||
for i, item in enumerate(items):
|
||||
params = item.get("params", {})
|
||||
start_str = params.get("quiet_hours_start", "")
|
||||
end_str = params.get("quiet_hours_end", "")
|
||||
if not _is_quiet_hours(start_str, end_str):
|
||||
ready_indices.append(i)
|
||||
|
||||
if not ready_indices:
|
||||
return
|
||||
|
||||
_LOGGER.info("Found %d queued notifications ready to send (quiet hours ended)", len(ready_indices))
|
||||
await _send_queued_items(hass, entry, ready_indices)
|
||||
|
||||
|
||||
async def _process_notifications_for_end_time(
|
||||
hass: HomeAssistant, entry: ImmichConfigEntry, end_str: str
|
||||
) -> None:
|
||||
"""Process queued notifications matching a specific quiet_hours_end time."""
|
||||
entry_data = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if not entry_data:
|
||||
return
|
||||
|
||||
queue: NotificationQueue = entry_data["notification_queue"]
|
||||
items = queue.get_all()
|
||||
if not items:
|
||||
return
|
||||
|
||||
# Find items matching this end time that are no longer in quiet hours
|
||||
matching_indices = []
|
||||
for i, item in enumerate(items):
|
||||
params = item.get("params", {})
|
||||
if params.get("quiet_hours_end", "") == end_str:
|
||||
start_str = params.get("quiet_hours_start", "")
|
||||
if not _is_quiet_hours(start_str, end_str):
|
||||
matching_indices.append(i)
|
||||
|
||||
if not matching_indices:
|
||||
return
|
||||
|
||||
_LOGGER.info("Processing %d queued notifications for quiet hours end %s", len(matching_indices), end_str)
|
||||
await _send_queued_items(hass, entry, matching_indices)
|
||||
|
||||
# Clean up timer if no more items need it
|
||||
_unregister_queue_timer(hass, entry, end_str)
|
||||
|
||||
|
||||
async def _send_queued_items(
|
||||
hass: HomeAssistant, entry: ImmichConfigEntry, indices: list[int]
|
||||
) -> None:
|
||||
"""Send specific queued notifications by index and remove them from the queue."""
|
||||
import asyncio
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
entry_data = hass.data[DOMAIN].get(entry.entry_id)
|
||||
if not entry_data:
|
||||
return
|
||||
|
||||
queue: NotificationQueue = entry_data["notification_queue"]
|
||||
|
||||
# Find a fallback sensor entity
|
||||
ent_reg = er.async_get(hass)
|
||||
fallback_entity_id = None
|
||||
for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
|
||||
if ent.domain == "sensor":
|
||||
fallback_entity_id = ent.entity_id
|
||||
break
|
||||
|
||||
if not fallback_entity_id:
|
||||
_LOGGER.warning("No sensor entity found to process notification queue")
|
||||
return
|
||||
|
||||
items = queue.get_all()
|
||||
sent_count = 0
|
||||
sent_indices = []
|
||||
for i in indices:
|
||||
if i >= len(items):
|
||||
continue
|
||||
params = dict(items[i].get("params", {}))
|
||||
try:
|
||||
target_entity_id = params.pop("entity_id", None) or fallback_entity_id
|
||||
# Remove quiet hours params so the replay doesn't re-queue
|
||||
params.pop("quiet_hours_start", None)
|
||||
params.pop("quiet_hours_end", None)
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"send_telegram_notification",
|
||||
params,
|
||||
target={"entity_id": target_entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
sent_count += 1
|
||||
sent_indices.append(i)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to send queued notification %d", i + 1)
|
||||
|
||||
# Small delay between notifications to avoid rate limiting
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Only remove successfully sent items (in reverse order to preserve indices)
|
||||
if sent_indices:
|
||||
await queue.async_remove_indices(sorted(sent_indices, reverse=True))
|
||||
_LOGGER.info("Sent %d/%d queued notifications", sent_count, len(indices))
|
||||
|
||||
|
||||
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
|
||||
new_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
|
||||
# Update hub data
|
||||
entry.runtime_data.scan_interval = new_interval
|
||||
|
||||
# Rebuild sync client if server URL/key changed
|
||||
server_url = entry.options.get(CONF_SERVER_URL, "")
|
||||
server_api_key = entry.options.get(CONF_SERVER_API_KEY, "")
|
||||
sync_client = None
|
||||
if server_url and server_api_key:
|
||||
from .sync import ServerSyncClient
|
||||
sync_client = ServerSyncClient(hass, server_url, server_api_key)
|
||||
entry_data["sync_client"] = sync_client
|
||||
|
||||
# Update all subentry coordinators
|
||||
subentries_data = entry_data["subentries"]
|
||||
for subentry_data in subentries_data.values():
|
||||
subentry_data.coordinator.update_scan_interval(new_interval)
|
||||
subentry_data.coordinator.update_sync_client(sync_client)
|
||||
|
||||
_LOGGER.info("Updated hub options (scan_interval=%d)", new_interval)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
# Cancel all quiet hours timers
|
||||
entry_data = hass.data[DOMAIN].get(entry.entry_id, {})
|
||||
for unsub in entry_data.get("quiet_hours_unsubs", {}).values():
|
||||
unsub()
|
||||
|
||||
# Unload all platforms
|
||||
unload_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
|
||||
@@ -3,24 +3,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
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.event import async_track_time_interval
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import (
|
||||
ATTR_ADDED_COUNT,
|
||||
ATTR_ALBUM_ID,
|
||||
ATTR_ALBUM_NAME,
|
||||
CONF_ALBUMS,
|
||||
CONF_ALBUM_ID,
|
||||
CONF_ALBUM_NAME,
|
||||
CONF_HUB_NAME,
|
||||
DOMAIN,
|
||||
NEW_ASSETS_RESET_DELAY,
|
||||
)
|
||||
@@ -35,15 +39,19 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Immich Album Watcher binary sensors from a config entry."""
|
||||
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
album_ids = entry.options.get(CONF_ALBUMS, [])
|
||||
# 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
|
||||
|
||||
entities = [
|
||||
ImmichAlbumNewAssetsSensor(coordinator, entry, album_id)
|
||||
for album_id in album_ids
|
||||
]
|
||||
coordinator = subentry_data.coordinator
|
||||
|
||||
async_add_entities(entities)
|
||||
async_add_entities(
|
||||
[ImmichAlbumNewAssetsSensor(coordinator, entry, subentry)],
|
||||
config_subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class ImmichAlbumNewAssetsSensor(
|
||||
@@ -59,28 +67,22 @@ class ImmichAlbumNewAssetsSensor(
|
||||
self,
|
||||
coordinator: ImmichAlbumWatcherCoordinator,
|
||||
entry: ConfigEntry,
|
||||
album_id: str,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._album_id = album_id
|
||||
self._entry = entry
|
||||
self._attr_unique_id = f"{entry.entry_id}_{album_id}_new_assets"
|
||||
self._reset_unsub = None
|
||||
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}_{self._album_id}")
|
||||
self._attr_unique_id = f"{unique_id_prefix}_new_assets"
|
||||
|
||||
@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]}"}
|
||||
return self.coordinator.data
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
@@ -93,10 +95,10 @@ class ImmichAlbumNewAssetsSensor(
|
||||
|
||||
# Check if we're still within the reset window
|
||||
if self._album_data.last_change_time:
|
||||
elapsed = datetime.now() - self._album_data.last_change_time
|
||||
elapsed = dt_util.now() - self._album_data.last_change_time
|
||||
if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY):
|
||||
# Auto-reset the flag
|
||||
self.coordinator.clear_new_assets_flag(self._album_id)
|
||||
self.coordinator.clear_new_assets_flag()
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -124,12 +126,12 @@ class ImmichAlbumNewAssetsSensor(
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device info."""
|
||||
"""Return device info - one device per album."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||
name="Immich Album Watcher",
|
||||
identifiers={(DOMAIN, self._subentry.subentry_id)},
|
||||
name=self._album_name,
|
||||
manufacturer="Immich",
|
||||
entry_type="service",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -139,5 +141,5 @@ class ImmichAlbumNewAssetsSensor(
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
"""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()
|
||||
405
custom_components/immich_album_watcher/button.py
Normal file
405
custom_components/immich_album_watcher/button.py
Normal 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}_{self._album_id}")
|
||||
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}_{self._album_id}")
|
||||
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}_{self._album_id}")
|
||||
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}_{self._album_id}")
|
||||
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()
|
||||
@@ -8,19 +8,21 @@ from datetime import timedelta
|
||||
import aiohttp
|
||||
|
||||
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.helpers.aiohttp_client import async_get_clientsession
|
||||
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 CONF_ALBUMS, DOMAIN
|
||||
from .const import CONF_ALBUM_ID, CONF_ALBUM_NAME, CONF_HUB_NAME, DOMAIN
|
||||
from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
_THUMBNAIL_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -29,15 +31,19 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Immich Album Watcher cameras from a config entry."""
|
||||
coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
album_ids = entry.options.get(CONF_ALBUMS, [])
|
||||
# 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
|
||||
|
||||
entities = [
|
||||
ImmichAlbumThumbnailCamera(coordinator, entry, album_id)
|
||||
for album_id in album_ids
|
||||
]
|
||||
coordinator = subentry_data.coordinator
|
||||
|
||||
async_add_entities(entities)
|
||||
async_add_entities(
|
||||
[ImmichAlbumThumbnailCamera(coordinator, entry, subentry)],
|
||||
config_subentry_id=subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class ImmichAlbumThumbnailCamera(
|
||||
@@ -52,30 +58,25 @@ class ImmichAlbumThumbnailCamera(
|
||||
self,
|
||||
coordinator: ImmichAlbumWatcherCoordinator,
|
||||
entry: ConfigEntry,
|
||||
album_id: str,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
"""Initialize the camera."""
|
||||
CoordinatorEntity.__init__(self, coordinator)
|
||||
Camera.__init__(self)
|
||||
self._album_id = album_id
|
||||
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}_{self._album_id}")
|
||||
self._attr_unique_id = f"{unique_id_prefix}_thumbnail"
|
||||
self._cached_image: bytes | None = None
|
||||
self._last_thumbnail_id: str | None = None
|
||||
|
||||
@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]}"}
|
||||
return self.coordinator.data
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -88,12 +89,12 @@ class ImmichAlbumThumbnailCamera(
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device info."""
|
||||
"""Return device info - one device per album."""
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self._entry.entry_id)},
|
||||
name="Immich Album Watcher",
|
||||
identifiers={(DOMAIN, self._subentry.subentry_id)},
|
||||
name=self._album_name,
|
||||
manufacturer="Immich",
|
||||
entry_type="service",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -130,7 +131,7 @@ class ImmichAlbumThumbnailCamera(
|
||||
)
|
||||
|
||||
try:
|
||||
async with session.get(thumbnail_url, headers=headers) as response:
|
||||
async with session.get(thumbnail_url, headers=headers, timeout=_THUMBNAIL_TIMEOUT) as response:
|
||||
if response.status == 200:
|
||||
self._cached_image = await response.read()
|
||||
self._last_thumbnail_id = self._album_data.thumbnail_asset_id
|
||||
340
custom_components/immich_album_watcher/config_flow.py
Normal file
340
custom_components/immich_album_watcher/config_flow.py
Normal file
@@ -0,0 +1,340 @@
|
||||
"""Config flow for Immich Album Watcher integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
OptionsFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_ALBUM_ID,
|
||||
CONF_ALBUM_NAME,
|
||||
CONF_API_KEY,
|
||||
CONF_HUB_NAME,
|
||||
CONF_IMMICH_URL,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_SERVER_API_KEY,
|
||||
CONF_SERVER_URL,
|
||||
CONF_TELEGRAM_BOT_TOKEN,
|
||||
CONF_TELEGRAM_CACHE_TTL,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_TELEGRAM_CACHE_TTL,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_ALBUM,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_CONNECT_TIMEOUT = aiohttp.ClientTimeout(total=10)
|
||||
|
||||
|
||||
async def validate_connection(
|
||||
session: aiohttp.ClientSession, url: str, api_key: str
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the Immich connection and return server info."""
|
||||
headers = {"x-api-key": api_key}
|
||||
async with session.get(
|
||||
f"{url.rstrip('/')}/api/server/ping", headers=headers, timeout=_CONNECT_TIMEOUT
|
||||
) as response:
|
||||
if response.status == 401:
|
||||
raise InvalidAuth
|
||||
if response.status != 200:
|
||||
raise CannotConnect
|
||||
return await response.json()
|
||||
|
||||
|
||||
async def fetch_albums(
|
||||
session: aiohttp.ClientSession, url: str, api_key: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch all albums from Immich."""
|
||||
headers = {"x-api-key": api_key}
|
||||
async with session.get(
|
||||
f"{url.rstrip('/')}/api/albums", headers=headers
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise CannotConnect
|
||||
return await response.json()
|
||||
|
||||
|
||||
class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Immich Album Watcher."""
|
||||
|
||||
VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._url: str | None = None
|
||||
self._api_key: str | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
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(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step - connection details."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
hub_name = user_input[CONF_HUB_NAME].strip()
|
||||
self._url = user_input[CONF_IMMICH_URL].rstrip("/")
|
||||
self._api_key = user_input[CONF_API_KEY]
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
try:
|
||||
await validate_connection(session, self._url, self._api_key)
|
||||
|
||||
# Set unique ID based on URL
|
||||
await self.async_set_unique_id(self._url)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
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:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except aiohttp.ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HUB_NAME, default="Immich"): str,
|
||||
vol.Required(CONF_IMMICH_URL): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"docs_url": "https://immich.app/docs/features/command-line-interface#obtain-the-api-key"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle album selection."""
|
||||
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]
|
||||
|
||||
if user_input is not None and self._albums:
|
||||
album_id = user_input[CONF_ALBUM_ID]
|
||||
|
||||
# Check if album is already configured
|
||||
for subentry in config_entry.subentries.values():
|
||||
if subentry.data.get(CONF_ALBUM_ID) == album_id:
|
||||
return self.async_abort(reason="album_already_configured")
|
||||
|
||||
# Find album name
|
||||
album_name = "Unknown Album"
|
||||
for album in self._albums:
|
||||
if album["id"] == album_id:
|
||||
album_name = album.get("albumName", "Unnamed")
|
||||
break
|
||||
|
||||
return self.async_create_entry(
|
||||
title=album_name,
|
||||
data={
|
||||
CONF_ALBUM_ID: album_id,
|
||||
CONF_ALBUM_NAME: album_name,
|
||||
},
|
||||
)
|
||||
|
||||
# Fetch available albums (only when displaying the form)
|
||||
if not self._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")
|
||||
|
||||
# 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["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)"
|
||||
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(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUM_ID): vol.In(album_options),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
|
||||
"""Handle options flow for Immich Album Watcher."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self._config_entry = config_entry
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
# Validate server connection if URL is provided
|
||||
server_url = user_input.get(CONF_SERVER_URL, "").strip()
|
||||
server_api_key = user_input.get(CONF_SERVER_API_KEY, "").strip()
|
||||
if bool(server_url) != bool(server_api_key):
|
||||
errors["base"] = "server_partial_config"
|
||||
elif server_url and server_api_key:
|
||||
try:
|
||||
session = async_get_clientsession(self.hass)
|
||||
async with session.get(
|
||||
f"{server_url.rstrip('/')}/api/health"
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
errors["base"] = "server_connect_failed"
|
||||
except Exception:
|
||||
errors["base"] = "server_connect_failed"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={
|
||||
CONF_SCAN_INTERVAL: user_input.get(
|
||||
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
|
||||
),
|
||||
CONF_SERVER_URL: server_url,
|
||||
CONF_SERVER_API_KEY: server_api_key,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self._build_options_schema(),
|
||||
)
|
||||
|
||||
def _build_options_schema(self) -> vol.Schema:
|
||||
"""Build the options form schema."""
|
||||
current_interval = self._config_entry.options.get(
|
||||
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
|
||||
)
|
||||
current_server_url = self._config_entry.options.get(
|
||||
CONF_SERVER_URL, ""
|
||||
)
|
||||
current_server_api_key = self._config_entry.options.get(
|
||||
CONF_SERVER_API_KEY, ""
|
||||
)
|
||||
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_SCAN_INTERVAL, default=current_interval
|
||||
): 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)),
|
||||
vol.Optional(
|
||||
CONF_SERVER_URL, default=current_server_url,
|
||||
description={"suggested_value": current_server_url},
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_SERVER_API_KEY, default=current_server_api_key,
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(Exception):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(Exception):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
89
custom_components/immich_album_watcher/const.py
Normal file
89
custom_components/immich_album_watcher/const.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Constants for the Immich Album Watcher integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
# Re-export shared constants from core library
|
||||
from immich_watcher_core.constants import ( # noqa: F401
|
||||
ASSET_TYPE_IMAGE,
|
||||
ASSET_TYPE_VIDEO,
|
||||
ATTR_ADDED_ASSETS,
|
||||
ATTR_ADDED_COUNT,
|
||||
ATTR_ALBUM_ID,
|
||||
ATTR_ALBUM_NAME,
|
||||
ATTR_ALBUM_PROTECTED_PASSWORD,
|
||||
ATTR_ALBUM_PROTECTED_URL,
|
||||
ATTR_ALBUM_URL,
|
||||
ATTR_ALBUM_URLS,
|
||||
ATTR_ASSET_CITY,
|
||||
ATTR_ASSET_COUNT,
|
||||
ATTR_ASSET_COUNTRY,
|
||||
ATTR_ASSET_CREATED,
|
||||
ATTR_ASSET_DESCRIPTION,
|
||||
ATTR_ASSET_DOWNLOAD_URL,
|
||||
ATTR_ASSET_FILENAME,
|
||||
ATTR_ASSET_IS_FAVORITE,
|
||||
ATTR_ASSET_LATITUDE,
|
||||
ATTR_ASSET_LONGITUDE,
|
||||
ATTR_ASSET_OWNER,
|
||||
ATTR_ASSET_OWNER_ID,
|
||||
ATTR_ASSET_PLAYBACK_URL,
|
||||
ATTR_ASSET_RATING,
|
||||
ATTR_ASSET_STATE,
|
||||
ATTR_ASSET_TYPE,
|
||||
ATTR_ASSET_URL,
|
||||
ATTR_CHANGE_TYPE,
|
||||
ATTR_CREATED_AT,
|
||||
ATTR_HUB_NAME,
|
||||
ATTR_LAST_UPDATED,
|
||||
ATTR_NEW_NAME,
|
||||
ATTR_NEW_SHARED,
|
||||
ATTR_OLD_NAME,
|
||||
ATTR_OLD_SHARED,
|
||||
ATTR_OWNER,
|
||||
ATTR_PEOPLE,
|
||||
ATTR_PHOTO_COUNT,
|
||||
ATTR_REMOVED_ASSETS,
|
||||
ATTR_REMOVED_COUNT,
|
||||
ATTR_SHARED,
|
||||
ATTR_THUMBNAIL_URL,
|
||||
ATTR_VIDEO_COUNT,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_SHARE_PASSWORD,
|
||||
DEFAULT_TELEGRAM_CACHE_TTL,
|
||||
NEW_ASSETS_RESET_DELAY,
|
||||
)
|
||||
|
||||
# HA-specific constants
|
||||
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"
|
||||
CONF_SERVER_URL: Final = "server_url"
|
||||
CONF_SERVER_API_KEY: Final = "server_api_key"
|
||||
|
||||
# Subentry type
|
||||
SUBENTRY_TYPE_ALBUM: Final = "album"
|
||||
|
||||
# HA event names (prefixed with domain)
|
||||
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"
|
||||
|
||||
# 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"
|
||||
555
custom_components/immich_album_watcher/coordinator.py
Normal file
555
custom_components/immich_album_watcher/coordinator.py
Normal file
@@ -0,0 +1,555 @@
|
||||
"""Data coordinator for Immich Album Watcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .storage import ImmichAlbumStorage
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
# Import models and utilities from core library
|
||||
from immich_watcher_core.models import (
|
||||
AlbumChange,
|
||||
AlbumData,
|
||||
AssetInfo,
|
||||
SharedLinkInfo,
|
||||
)
|
||||
from immich_watcher_core.immich_client import ImmichClient, ImmichApiError
|
||||
from immich_watcher_core.change_detector import detect_album_changes
|
||||
from immich_watcher_core.asset_utils import (
|
||||
build_asset_detail,
|
||||
filter_assets,
|
||||
get_any_url,
|
||||
get_protected_links,
|
||||
get_protected_password,
|
||||
get_protected_url,
|
||||
get_protected_urls,
|
||||
get_public_url,
|
||||
get_public_urls,
|
||||
get_accessible_links,
|
||||
sort_assets,
|
||||
)
|
||||
from immich_watcher_core.telegram.cache import TelegramFileCache
|
||||
|
||||
from .const import (
|
||||
ATTR_ADDED_ASSETS,
|
||||
ATTR_ADDED_COUNT,
|
||||
ATTR_ALBUM_ID,
|
||||
ATTR_ALBUM_NAME,
|
||||
ATTR_ALBUM_URL,
|
||||
ATTR_CHANGE_TYPE,
|
||||
ATTR_HUB_NAME,
|
||||
ATTR_NEW_NAME,
|
||||
ATTR_NEW_SHARED,
|
||||
ATTR_OLD_NAME,
|
||||
ATTR_OLD_SHARED,
|
||||
ATTR_PEOPLE,
|
||||
ATTR_REMOVED_ASSETS,
|
||||
ATTR_REMOVED_COUNT,
|
||||
ATTR_SHARED,
|
||||
DOMAIN,
|
||||
EVENT_ALBUM_CHANGED,
|
||||
EVENT_ALBUM_DELETED,
|
||||
EVENT_ALBUM_RENAMED,
|
||||
EVENT_ALBUM_SHARING_CHANGED,
|
||||
EVENT_ASSETS_ADDED,
|
||||
EVENT_ASSETS_REMOVED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Re-export models for backward compatibility with other integration files
|
||||
__all__ = [
|
||||
"AlbumChange",
|
||||
"AlbumData",
|
||||
"AssetInfo",
|
||||
"SharedLinkInfo",
|
||||
"ImmichAlbumWatcherCoordinator",
|
||||
]
|
||||
|
||||
|
||||
class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
"""Coordinator for fetching Immich album data.
|
||||
|
||||
Delegates to ImmichClient from core library for API calls and uses
|
||||
core's change detection, filtering, and URL utilities.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
url: str,
|
||||
api_key: str,
|
||||
album_id: str,
|
||||
album_name: str,
|
||||
scan_interval: int,
|
||||
hub_name: str = "Immich",
|
||||
storage: ImmichAlbumStorage | None = None,
|
||||
telegram_cache: TelegramFileCache | None = None,
|
||||
telegram_asset_cache: TelegramFileCache | None = None,
|
||||
sync_client: Any | None = None,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=f"{DOMAIN}_{album_id}",
|
||||
update_interval=timedelta(seconds=scan_interval),
|
||||
)
|
||||
self._album_id = album_id
|
||||
self._album_name = album_name
|
||||
self._hub_name = hub_name
|
||||
self._previous_state: AlbumData | None = None
|
||||
self._storage = storage
|
||||
self._telegram_cache = telegram_cache
|
||||
self._telegram_asset_cache = telegram_asset_cache
|
||||
self._sync_client = sync_client
|
||||
self._persisted_asset_ids: set[str] | None = None
|
||||
self._pending_asset_ids: set[str] = set()
|
||||
|
||||
# Core library client (session injected lazily)
|
||||
self._client: ImmichClient | None = None
|
||||
self._url = url
|
||||
self._api_key = api_key
|
||||
|
||||
# Caches managed by the client
|
||||
self._users_cache: dict[str, str] = {}
|
||||
self._users_cache_time: float = 0
|
||||
self._shared_links: list[SharedLinkInfo] = []
|
||||
self._shared_links_dirty = True
|
||||
self._server_config_fetched = False
|
||||
|
||||
def _get_client(self) -> ImmichClient:
|
||||
"""Get or create the Immich client (lazy init with HA session)."""
|
||||
if self._client is None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
self._client = ImmichClient(session, self._url, self._api_key)
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def immich_url(self) -> str:
|
||||
"""Return the Immich URL (for API calls)."""
|
||||
return self._url
|
||||
|
||||
@property
|
||||
def external_url(self) -> str:
|
||||
"""Return the external URL for links."""
|
||||
return self._get_client().external_url
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
"""Return the API key."""
|
||||
return self._api_key
|
||||
|
||||
@property
|
||||
def album_id(self) -> str:
|
||||
"""Return the album ID."""
|
||||
return self._album_id
|
||||
|
||||
@property
|
||||
def album_name(self) -> str:
|
||||
"""Return the album name."""
|
||||
return self._album_name
|
||||
|
||||
@property
|
||||
def telegram_cache(self) -> TelegramFileCache | None:
|
||||
"""Return the Telegram file cache (URL-based)."""
|
||||
return self._telegram_cache
|
||||
|
||||
@property
|
||||
def telegram_asset_cache(self) -> TelegramFileCache | None:
|
||||
"""Return the Telegram asset cache (asset ID-based)."""
|
||||
return self._telegram_asset_cache
|
||||
|
||||
def get_internal_download_url(self, url: str) -> str:
|
||||
"""Convert an external URL to internal URL for faster downloads."""
|
||||
return self._get_client().get_internal_download_url(url)
|
||||
|
||||
def get_asset_thumbhash(self, asset_id: str) -> str | None:
|
||||
"""Get the current thumbhash for an asset from coordinator data."""
|
||||
if self.data and asset_id in self.data.assets:
|
||||
return self.data.assets[asset_id].thumbhash
|
||||
return None
|
||||
|
||||
def update_scan_interval(self, scan_interval: int) -> None:
|
||||
"""Update the scan interval."""
|
||||
self.update_interval = timedelta(seconds=scan_interval)
|
||||
|
||||
def update_sync_client(self, sync_client: Any | None) -> None:
|
||||
"""Update the server sync client."""
|
||||
self._sync_client = sync_client
|
||||
|
||||
async def async_refresh_now(self) -> None:
|
||||
"""Force an immediate refresh."""
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def async_load_persisted_state(self) -> None:
|
||||
"""Load persisted asset IDs from storage."""
|
||||
if self._storage:
|
||||
self._persisted_asset_ids = self._storage.get_album_asset_ids(
|
||||
self._album_id
|
||||
)
|
||||
if self._persisted_asset_ids is not None:
|
||||
_LOGGER.debug(
|
||||
"Loaded %d persisted asset IDs for album '%s'",
|
||||
len(self._persisted_asset_ids),
|
||||
self._album_name,
|
||||
)
|
||||
|
||||
async def async_get_assets(
|
||||
self,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
favorite_only: bool = False,
|
||||
filter_min_rating: int = 1,
|
||||
order_by: str = "date",
|
||||
order: str = "descending",
|
||||
asset_type: str = "all",
|
||||
min_date: str | None = None,
|
||||
max_date: str | None = None,
|
||||
memory_date: str | None = None,
|
||||
city: str | None = None,
|
||||
state: str | None = None,
|
||||
country: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get assets from the album with optional filtering and ordering."""
|
||||
if self.data is None:
|
||||
return []
|
||||
|
||||
# Use core library for filtering and sorting
|
||||
assets = list(self.data.assets.values())
|
||||
assets = filter_assets(
|
||||
assets,
|
||||
favorite_only=favorite_only,
|
||||
min_rating=filter_min_rating,
|
||||
asset_type=asset_type,
|
||||
min_date=min_date,
|
||||
max_date=max_date,
|
||||
memory_date=memory_date,
|
||||
city=city,
|
||||
state=state,
|
||||
country=country,
|
||||
processed_only=True,
|
||||
)
|
||||
assets = sort_assets(assets, order_by=order_by, order=order)
|
||||
|
||||
# Apply offset and limit for pagination
|
||||
assets = assets[offset : offset + limit]
|
||||
|
||||
# Build result using core utility
|
||||
return [
|
||||
build_asset_detail(
|
||||
asset, self.external_url, self._shared_links, include_thumbnail=True
|
||||
)
|
||||
for asset in assets
|
||||
]
|
||||
|
||||
async def async_fetch_people(self) -> dict[str, str]:
|
||||
"""Fetch all people from Immich."""
|
||||
client = self._get_client()
|
||||
result = await client.get_people()
|
||||
return result
|
||||
|
||||
# --- Shared link URL helpers (delegate to core asset_utils) ---
|
||||
|
||||
def get_public_url(self) -> str | None:
|
||||
"""Get the public URL if album has an accessible shared link."""
|
||||
return get_public_url(self.external_url, self._shared_links)
|
||||
|
||||
def get_any_url(self) -> str | None:
|
||||
"""Get any non-expired URL (prefers accessible, falls back to protected)."""
|
||||
return get_any_url(self.external_url, self._shared_links)
|
||||
|
||||
def get_protected_url(self) -> str | None:
|
||||
"""Get a protected URL if any password-protected link exists."""
|
||||
return get_protected_url(self.external_url, self._shared_links)
|
||||
|
||||
def get_protected_urls(self) -> list[str]:
|
||||
"""Get all password-protected URLs."""
|
||||
return get_protected_urls(self.external_url, self._shared_links)
|
||||
|
||||
def get_protected_password(self) -> str | None:
|
||||
"""Get the password for the first protected link."""
|
||||
return get_protected_password(self._shared_links)
|
||||
|
||||
def get_public_urls(self) -> list[str]:
|
||||
"""Get all accessible public URLs."""
|
||||
return get_public_urls(self.external_url, self._shared_links)
|
||||
|
||||
def get_shared_links_info(self) -> list[dict[str, Any]]:
|
||||
"""Get detailed info about all shared links."""
|
||||
return [
|
||||
{
|
||||
"url": f"{self.external_url}/share/{link.key}",
|
||||
"has_password": link.has_password,
|
||||
"is_expired": link.is_expired,
|
||||
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
||||
"is_accessible": link.is_accessible,
|
||||
}
|
||||
for link in self._shared_links
|
||||
]
|
||||
|
||||
def get_protected_link_id(self) -> str | None:
|
||||
"""Get the ID of the first protected link."""
|
||||
protected = get_protected_links(self._shared_links)
|
||||
if protected:
|
||||
return protected[0].id
|
||||
return None
|
||||
|
||||
def has_unprotected_link(self) -> bool:
|
||||
"""Check if album has an unprotected (accessible) shared link."""
|
||||
return len(get_accessible_links(self._shared_links)) > 0
|
||||
|
||||
def has_protected_link(self) -> bool:
|
||||
"""Check if album has a protected (password) shared link."""
|
||||
return len(get_protected_links(self._shared_links)) > 0
|
||||
|
||||
def get_unprotected_link_id(self) -> str | None:
|
||||
"""Get the ID of the first unprotected link."""
|
||||
accessible = get_accessible_links(self._shared_links)
|
||||
if accessible:
|
||||
return accessible[0].id
|
||||
return None
|
||||
|
||||
# --- Shared link management (delegate to core client) ---
|
||||
|
||||
async def async_create_shared_link(self, password: str | None = None) -> bool:
|
||||
"""Create a new shared link for the album."""
|
||||
client = self._get_client()
|
||||
result = await client.create_shared_link(self._album_id, password)
|
||||
if result:
|
||||
self._shared_links = await client.get_shared_links(self._album_id)
|
||||
self._shared_links_dirty = False
|
||||
return result
|
||||
|
||||
async def async_delete_shared_link(self, link_id: str) -> bool:
|
||||
"""Delete a shared link."""
|
||||
client = self._get_client()
|
||||
result = await client.delete_shared_link(link_id)
|
||||
if result:
|
||||
self._shared_links = await client.get_shared_links(self._album_id)
|
||||
self._shared_links_dirty = False
|
||||
return result
|
||||
|
||||
async def async_set_shared_link_password(
|
||||
self, link_id: str, password: str | None
|
||||
) -> bool:
|
||||
"""Update the password for a shared link."""
|
||||
client = self._get_client()
|
||||
result = await client.set_shared_link_password(link_id, password)
|
||||
if result:
|
||||
self._shared_links = await client.get_shared_links(self._album_id)
|
||||
self._shared_links_dirty = False
|
||||
return result
|
||||
|
||||
def clear_new_assets_flag(self) -> None:
|
||||
"""Clear the new assets flag."""
|
||||
if self.data:
|
||||
self.data.has_new_assets = False
|
||||
self.data.last_change_time = None
|
||||
|
||||
# --- Data update (main polling loop) ---
|
||||
|
||||
async def _async_update_data(self) -> AlbumData | None:
|
||||
"""Fetch data from Immich API."""
|
||||
client = self._get_client()
|
||||
|
||||
# Fetch server config to get external domain (once)
|
||||
if not self._server_config_fetched:
|
||||
await client.get_server_config()
|
||||
self._server_config_fetched = True
|
||||
|
||||
# Fetch users to resolve owner names (refresh every hour)
|
||||
import time
|
||||
if not self._users_cache or (time.monotonic() - self._users_cache_time > 3600):
|
||||
self._users_cache = await client.get_users()
|
||||
self._users_cache_time = time.monotonic()
|
||||
|
||||
# Fetch shared links only when needed (on first load or after mutations)
|
||||
if self._shared_links_dirty:
|
||||
self._shared_links = await client.get_shared_links(self._album_id)
|
||||
self._shared_links_dirty = False
|
||||
|
||||
try:
|
||||
album = await client.get_album(self._album_id, self._users_cache)
|
||||
except ImmichApiError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
|
||||
if album is None:
|
||||
# Album was deleted (404)
|
||||
if self._previous_state:
|
||||
event_data = {
|
||||
ATTR_HUB_NAME: self._hub_name,
|
||||
ATTR_ALBUM_ID: self._album_id,
|
||||
ATTR_ALBUM_NAME: self._previous_state.name,
|
||||
}
|
||||
self.hass.bus.async_fire(EVENT_ALBUM_DELETED, event_data)
|
||||
_LOGGER.info("Album '%s' was deleted", self._previous_state.name)
|
||||
return None
|
||||
|
||||
# Detect changes using core library
|
||||
if self._previous_state:
|
||||
change, self._pending_asset_ids = detect_album_changes(
|
||||
self._previous_state, album, self._pending_asset_ids
|
||||
)
|
||||
if change:
|
||||
album.has_new_assets = change.added_count > 0
|
||||
album.last_change_time = dt_util.now()
|
||||
self._fire_events(change, album)
|
||||
elif self._persisted_asset_ids is not None:
|
||||
# First refresh after restart - compare with persisted state
|
||||
added_ids = album.asset_ids - self._persisted_asset_ids
|
||||
removed_ids = self._persisted_asset_ids - album.asset_ids
|
||||
|
||||
if added_ids or removed_ids:
|
||||
change_type = "changed"
|
||||
if added_ids and not removed_ids:
|
||||
change_type = "assets_added"
|
||||
elif removed_ids and not added_ids:
|
||||
change_type = "assets_removed"
|
||||
|
||||
added_assets = []
|
||||
for aid in added_ids:
|
||||
if aid not in album.assets:
|
||||
continue
|
||||
asset = album.assets[aid]
|
||||
if asset.is_processed:
|
||||
added_assets.append(asset)
|
||||
else:
|
||||
self._pending_asset_ids.add(aid)
|
||||
|
||||
change = AlbumChange(
|
||||
album_id=album.id,
|
||||
album_name=album.name,
|
||||
change_type=change_type,
|
||||
added_count=len(added_ids),
|
||||
removed_count=len(removed_ids),
|
||||
added_assets=added_assets,
|
||||
removed_asset_ids=list(removed_ids),
|
||||
)
|
||||
album.has_new_assets = change.added_count > 0
|
||||
album.last_change_time = dt_util.now()
|
||||
self._fire_events(change, album)
|
||||
_LOGGER.info(
|
||||
"Detected changes during downtime for album '%s': +%d -%d",
|
||||
album.name,
|
||||
len(added_ids),
|
||||
len(removed_ids),
|
||||
)
|
||||
else:
|
||||
album.has_new_assets = False
|
||||
|
||||
# Clear persisted state after first comparison
|
||||
self._persisted_asset_ids = None
|
||||
else:
|
||||
album.has_new_assets = False
|
||||
|
||||
# Preserve has_new_assets from previous state if still within window
|
||||
if self._previous_state:
|
||||
prev = self._previous_state
|
||||
if prev.has_new_assets and prev.last_change_time:
|
||||
album.last_change_time = prev.last_change_time
|
||||
if not album.has_new_assets:
|
||||
album.has_new_assets = prev.has_new_assets
|
||||
|
||||
# Update previous state
|
||||
self._previous_state = album
|
||||
|
||||
# Persist current state for recovery after restart
|
||||
if self._storage:
|
||||
await self._storage.async_save_album_state(
|
||||
self._album_id, album.asset_ids
|
||||
)
|
||||
|
||||
return album
|
||||
|
||||
def _fire_events(self, change: AlbumChange, album: AlbumData) -> None:
|
||||
"""Fire Home Assistant events for album changes."""
|
||||
added_assets_detail = []
|
||||
for asset in change.added_assets:
|
||||
if not asset.is_processed:
|
||||
continue
|
||||
asset_detail = build_asset_detail(
|
||||
asset, self.external_url, self._shared_links, include_thumbnail=False
|
||||
)
|
||||
added_assets_detail.append(asset_detail)
|
||||
|
||||
event_data = {
|
||||
ATTR_HUB_NAME: self._hub_name,
|
||||
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),
|
||||
ATTR_SHARED: album.shared,
|
||||
}
|
||||
|
||||
if change.old_name is not None:
|
||||
event_data[ATTR_OLD_NAME] = change.old_name
|
||||
event_data[ATTR_NEW_NAME] = change.new_name
|
||||
|
||||
if change.old_shared is not None:
|
||||
event_data[ATTR_OLD_SHARED] = change.old_shared
|
||||
event_data[ATTR_NEW_SHARED] = change.new_shared
|
||||
|
||||
album_url = self.get_any_url()
|
||||
if album_url:
|
||||
event_data[ATTR_ALBUM_URL] = album_url
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
if change.old_name is not None:
|
||||
self.hass.bus.async_fire(EVENT_ALBUM_RENAMED, event_data)
|
||||
_LOGGER.info(
|
||||
"Album renamed: '%s' -> '%s'",
|
||||
change.old_name,
|
||||
change.new_name,
|
||||
)
|
||||
|
||||
if change.old_shared is not None:
|
||||
self.hass.bus.async_fire(EVENT_ALBUM_SHARING_CHANGED, event_data)
|
||||
_LOGGER.info(
|
||||
"Album '%s' sharing changed: %s -> %s",
|
||||
change.album_name,
|
||||
change.old_shared,
|
||||
change.new_shared,
|
||||
)
|
||||
|
||||
# Report event to standalone server (fire-and-forget)
|
||||
if self._sync_client:
|
||||
self.hass.async_create_task(
|
||||
self._sync_client.async_report_event(
|
||||
tracker_name=self._hub_name,
|
||||
event_type=change.change_type,
|
||||
album_id=change.album_id,
|
||||
album_name=change.album_name,
|
||||
details={
|
||||
"added_count": change.added_count,
|
||||
"removed_count": change.removed_count,
|
||||
},
|
||||
),
|
||||
name=f"immich_sync_report_{change.album_id}",
|
||||
)
|
||||
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
12
custom_components/immich_album_watcher/manifest.json
Normal file
12
custom_components/immich_album_watcher/manifest.json
Normal 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": "local_polling",
|
||||
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
|
||||
"requirements": ["immich-watcher-core==0.1.0"],
|
||||
"version": "2.8.0"
|
||||
}
|
||||
609
custom_components/immich_album_watcher/sensor.py
Normal file
609
custom_components/immich_album_watcher/sensor.py
Normal file
@@ -0,0 +1,609 @@
|
||||
"""Sensor platform for Immich Album Watcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse, callback
|
||||
from homeassistant.helpers import entity_platform
|
||||
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_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from immich_watcher_core.models import AlbumData
|
||||
from immich_watcher_core.telegram.client import TelegramClient
|
||||
|
||||
from .const import (
|
||||
ATTR_ALBUM_ID,
|
||||
ATTR_ALBUM_NAME,
|
||||
ATTR_ALBUM_PROTECTED_URL,
|
||||
ATTR_ALBUM_URLS,
|
||||
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_ALBUM_ID,
|
||||
CONF_ALBUM_NAME,
|
||||
CONF_HUB_NAME,
|
||||
CONF_TELEGRAM_BOT_TOKEN,
|
||||
DOMAIN,
|
||||
SERVICE_GET_ASSETS,
|
||||
SERVICE_REFRESH,
|
||||
SERVICE_SEND_TELEGRAM_NOTIFICATION,
|
||||
)
|
||||
from .coordinator import ImmichAlbumWatcherCoordinator
|
||||
from .storage import NotificationQueue
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up sensor entities for all album subentries."""
|
||||
entry_data = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
for subentry_id, subentry in entry.subentries.items():
|
||||
subentry_data = entry_data["subentries"].get(subentry_id)
|
||||
if not subentry_data:
|
||||
continue
|
||||
|
||||
coordinator = subentry_data.coordinator
|
||||
|
||||
entities = [
|
||||
ImmichAlbumIdSensor(coordinator, entry, subentry),
|
||||
ImmichAlbumAssetCountSensor(coordinator, entry, subentry),
|
||||
ImmichAlbumPhotoCountSensor(coordinator, entry, subentry),
|
||||
ImmichAlbumVideoCountSensor(coordinator, entry, subentry),
|
||||
ImmichAlbumLastUpdatedSensor(coordinator, entry, subentry),
|
||||
ImmichAlbumCreatedSensor(coordinator, entry, subentry),
|
||||
ImmichAlbumPublicUrlSensor(coordinator, entry, subentry),
|
||||
ImmichAlbumProtectedUrlSensor(coordinator, entry, subentry),
|
||||
ImmichAlbumProtectedPasswordSensor(coordinator, entry, subentry),
|
||||
]
|
||||
|
||||
async_add_entities(entities, config_subentry_id=subentry_id)
|
||||
|
||||
# Register entity services
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_REFRESH,
|
||||
{},
|
||||
"async_refresh_album",
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_GET_ASSETS,
|
||||
{
|
||||
vol.Optional("limit", default=10): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1, max=100)
|
||||
),
|
||||
vol.Optional("offset", default=0): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0)
|
||||
),
|
||||
vol.Optional("favorite_only", default=False): bool,
|
||||
vol.Optional("filter_min_rating", default=1): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1, max=5)
|
||||
),
|
||||
vol.Optional("order_by", default="date"): vol.In(
|
||||
["date", "rating", "name", "random"]
|
||||
),
|
||||
vol.Optional("order", default="descending"): vol.In(
|
||||
["ascending", "descending"]
|
||||
),
|
||||
vol.Optional("asset_type", default="all"): vol.In(["all", "photo", "video"]),
|
||||
vol.Optional("min_date"): str,
|
||||
vol.Optional("max_date"): str,
|
||||
vol.Optional("memory_date"): str,
|
||||
vol.Optional("city"): str,
|
||||
vol.Optional("state"): str,
|
||||
vol.Optional("country"): str,
|
||||
},
|
||||
"async_get_assets",
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SEND_TELEGRAM_NOTIFICATION,
|
||||
{
|
||||
vol.Optional("bot_token"): str,
|
||||
vol.Required("chat_id"): vol.Coerce(str),
|
||||
vol.Optional("assets"): list,
|
||||
vol.Optional("caption"): str,
|
||||
vol.Optional("reply_to_message_id"): vol.Coerce(int),
|
||||
vol.Optional("disable_web_page_preview"): bool,
|
||||
vol.Optional("parse_mode", default="HTML"): str,
|
||||
vol.Optional("max_group_size", default=10): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=2, max=10)
|
||||
),
|
||||
vol.Optional("chunk_delay", default=0): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=60000)
|
||||
),
|
||||
vol.Optional("wait_for_response", default=True): bool,
|
||||
vol.Optional("max_asset_data_size"): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1, max=52428800)
|
||||
),
|
||||
vol.Optional("send_large_photos_as_documents", default=False): bool,
|
||||
vol.Optional("chat_action", default="typing"): vol.Any(
|
||||
None, vol.In(["", "typing", "upload_photo", "upload_video", "upload_document"])
|
||||
),
|
||||
vol.Optional("quiet_hours_start"): vol.Match(r"^\d{2}:\d{2}$"),
|
||||
vol.Optional("quiet_hours_end"): vol.Match(r"^\d{2}:\d{2}$"),
|
||||
},
|
||||
"async_send_telegram_notification",
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
|
||||
|
||||
class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], SensorEntity):
|
||||
"""Base sensor for Immich album."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ImmichAlbumWatcherCoordinator,
|
||||
entry: ConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
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")
|
||||
self._unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}")
|
||||
|
||||
@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."""
|
||||
return self.coordinator.last_update_success and self._album_data 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,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_refresh_album(self) -> None:
|
||||
"""Refresh data for this album."""
|
||||
await self.coordinator.async_refresh_now()
|
||||
|
||||
async def async_get_assets(
|
||||
self,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
favorite_only: bool = False,
|
||||
filter_min_rating: int = 1,
|
||||
order_by: str = "date",
|
||||
order: str = "descending",
|
||||
asset_type: str = "all",
|
||||
min_date: str | None = None,
|
||||
max_date: str | None = None,
|
||||
memory_date: str | None = None,
|
||||
city: str | None = None,
|
||||
state: str | None = None,
|
||||
country: str | None = None,
|
||||
) -> ServiceResponse:
|
||||
"""Get assets for this album with optional filtering and ordering."""
|
||||
assets = await self.coordinator.async_get_assets(
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
favorite_only=favorite_only,
|
||||
filter_min_rating=filter_min_rating,
|
||||
order_by=order_by,
|
||||
order=order,
|
||||
asset_type=asset_type,
|
||||
min_date=min_date,
|
||||
max_date=max_date,
|
||||
memory_date=memory_date,
|
||||
city=city,
|
||||
state=state,
|
||||
country=country,
|
||||
)
|
||||
return {"assets": assets}
|
||||
|
||||
async def async_send_telegram_notification(
|
||||
self,
|
||||
chat_id: str,
|
||||
assets: list[dict[str, str]] | None = None,
|
||||
bot_token: str | None = None,
|
||||
caption: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
disable_web_page_preview: bool | None = None,
|
||||
parse_mode: str = "HTML",
|
||||
max_group_size: int = 10,
|
||||
chunk_delay: int = 0,
|
||||
wait_for_response: bool = True,
|
||||
max_asset_data_size: int | None = None,
|
||||
send_large_photos_as_documents: bool = False,
|
||||
chat_action: str | None = "typing",
|
||||
quiet_hours_start: str | None = None,
|
||||
quiet_hours_end: str | None = None,
|
||||
) -> ServiceResponse:
|
||||
"""Send notification to Telegram."""
|
||||
# Check quiet hours — queue notification if active
|
||||
from . import _is_quiet_hours
|
||||
if _is_quiet_hours(quiet_hours_start, quiet_hours_end):
|
||||
from . import _register_queue_timers
|
||||
queue: NotificationQueue = self.hass.data[DOMAIN][self._entry.entry_id]["notification_queue"]
|
||||
await queue.async_enqueue({
|
||||
"entity_id": self.entity_id,
|
||||
"chat_id": chat_id,
|
||||
"assets": assets,
|
||||
"bot_token": bot_token,
|
||||
"caption": caption,
|
||||
"reply_to_message_id": reply_to_message_id,
|
||||
"disable_web_page_preview": disable_web_page_preview,
|
||||
"parse_mode": parse_mode,
|
||||
"max_group_size": max_group_size,
|
||||
"chunk_delay": chunk_delay,
|
||||
"max_asset_data_size": max_asset_data_size,
|
||||
"send_large_photos_as_documents": send_large_photos_as_documents,
|
||||
"chat_action": chat_action,
|
||||
"quiet_hours_start": quiet_hours_start,
|
||||
"quiet_hours_end": quiet_hours_end,
|
||||
})
|
||||
_register_queue_timers(self.hass, self._entry)
|
||||
return {"success": True, "status": "queued_quiet_hours"}
|
||||
|
||||
# If non-blocking mode, create a background task and return immediately
|
||||
if not wait_for_response:
|
||||
self.hass.async_create_task(
|
||||
self._execute_telegram_notification(
|
||||
chat_id=chat_id,
|
||||
assets=assets,
|
||||
bot_token=bot_token,
|
||||
caption=caption,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
parse_mode=parse_mode,
|
||||
max_group_size=max_group_size,
|
||||
chunk_delay=chunk_delay,
|
||||
max_asset_data_size=max_asset_data_size,
|
||||
send_large_photos_as_documents=send_large_photos_as_documents,
|
||||
chat_action=chat_action,
|
||||
)
|
||||
)
|
||||
return {"success": True, "status": "queued", "message": "Notification queued for background processing"}
|
||||
|
||||
return await self._execute_telegram_notification(
|
||||
chat_id=chat_id,
|
||||
assets=assets,
|
||||
bot_token=bot_token,
|
||||
caption=caption,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
parse_mode=parse_mode,
|
||||
max_group_size=max_group_size,
|
||||
chunk_delay=chunk_delay,
|
||||
max_asset_data_size=max_asset_data_size,
|
||||
send_large_photos_as_documents=send_large_photos_as_documents,
|
||||
chat_action=chat_action,
|
||||
)
|
||||
|
||||
async def _execute_telegram_notification(
|
||||
self,
|
||||
chat_id: str,
|
||||
assets: list[dict[str, str]] | None = None,
|
||||
bot_token: str | None = None,
|
||||
caption: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
disable_web_page_preview: bool | None = None,
|
||||
parse_mode: str = "HTML",
|
||||
max_group_size: int = 10,
|
||||
chunk_delay: int = 0,
|
||||
max_asset_data_size: int | None = None,
|
||||
send_large_photos_as_documents: bool = False,
|
||||
chat_action: str | None = "typing",
|
||||
) -> ServiceResponse:
|
||||
"""Execute the Telegram notification using core TelegramClient."""
|
||||
# Get bot token from parameter or config
|
||||
token = bot_token or self._entry.options.get(CONF_TELEGRAM_BOT_TOKEN)
|
||||
if not token:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "No bot token provided. Set it in integration options or pass as parameter.",
|
||||
}
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
# Create core TelegramClient with HA-managed session and coordinator caches
|
||||
telegram = TelegramClient(
|
||||
session,
|
||||
token,
|
||||
url_cache=self.coordinator.telegram_cache,
|
||||
asset_cache=self.coordinator.telegram_asset_cache,
|
||||
url_resolver=self.coordinator.get_internal_download_url,
|
||||
thumbhash_resolver=self.coordinator.get_asset_thumbhash,
|
||||
)
|
||||
|
||||
return await telegram.send_notification(
|
||||
chat_id=chat_id,
|
||||
assets=assets,
|
||||
caption=caption,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
parse_mode=parse_mode,
|
||||
max_group_size=max_group_size,
|
||||
chunk_delay=chunk_delay,
|
||||
max_asset_data_size=max_asset_data_size,
|
||||
send_large_photos_as_documents=send_large_photos_as_documents,
|
||||
chat_action=chat_action,
|
||||
)
|
||||
|
||||
|
||||
class ImmichAlbumIdSensor(ImmichAlbumBaseSensor):
|
||||
"""Sensor exposing the Immich album ID."""
|
||||
|
||||
_attr_icon = "mdi:identifier"
|
||||
_attr_translation_key = "album_id"
|
||||
|
||||
def __init__(self, coordinator, entry, subentry):
|
||||
super().__init__(coordinator, entry, subentry)
|
||||
self._attr_unique_id = f"{self._unique_id_prefix}_album_id"
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
if self._album_data:
|
||||
return self._album_data.id
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
if not self._album_data:
|
||||
return {}
|
||||
attrs: dict[str, Any] = {
|
||||
ATTR_ALBUM_NAME: self._album_data.name,
|
||||
ATTR_ASSET_COUNT: self._album_data.asset_count,
|
||||
ATTR_LAST_UPDATED: self._album_data.updated_at,
|
||||
ATTR_CREATED_AT: self._album_data.created_at,
|
||||
}
|
||||
share_url = self.coordinator.get_any_url()
|
||||
if share_url:
|
||||
attrs["share_url"] = share_url
|
||||
return attrs
|
||||
|
||||
|
||||
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, entry, subentry):
|
||||
super().__init__(coordinator, entry, subentry)
|
||||
self._attr_unique_id = f"{self._unique_id_prefix}_asset_count"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
if self._album_data:
|
||||
return self._album_data.asset_count
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
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),
|
||||
}
|
||||
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, entry, subentry):
|
||||
super().__init__(coordinator, entry, subentry)
|
||||
self._attr_unique_id = f"{self._unique_id_prefix}_photo_count"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
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, entry, subentry):
|
||||
super().__init__(coordinator, entry, subentry)
|
||||
self._attr_unique_id = f"{self._unique_id_prefix}_video_count"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
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, entry, subentry):
|
||||
super().__init__(coordinator, entry, subentry)
|
||||
self._attr_unique_id = f"{self._unique_id_prefix}_last_updated"
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | None:
|
||||
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, entry, subentry):
|
||||
super().__init__(coordinator, entry, subentry)
|
||||
self._attr_unique_id = f"{self._unique_id_prefix}_created"
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | None:
|
||||
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 ImmichAlbumPublicUrlSensor(ImmichAlbumBaseSensor):
|
||||
"""Sensor representing an Immich album public URL."""
|
||||
|
||||
_attr_icon = "mdi:link-variant"
|
||||
_attr_translation_key = "album_public_url"
|
||||
|
||||
def __init__(self, coordinator, entry, subentry):
|
||||
super().__init__(coordinator, entry, subentry)
|
||||
self._attr_unique_id = f"{self._unique_id_prefix}_public_url"
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
if self._album_data:
|
||||
return self.coordinator.get_public_url()
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
if not self._album_data:
|
||||
return {}
|
||||
attrs = {
|
||||
ATTR_ALBUM_ID: self._album_data.id,
|
||||
ATTR_SHARED: self._album_data.shared,
|
||||
}
|
||||
all_urls = self.coordinator.get_public_urls()
|
||||
if len(all_urls) > 1:
|
||||
attrs[ATTR_ALBUM_URLS] = all_urls
|
||||
links_info = self.coordinator.get_shared_links_info()
|
||||
if links_info:
|
||||
attrs["shared_links"] = links_info
|
||||
return attrs
|
||||
|
||||
|
||||
class ImmichAlbumProtectedUrlSensor(ImmichAlbumBaseSensor):
|
||||
"""Sensor representing an Immich album password-protected URL."""
|
||||
|
||||
_attr_icon = "mdi:link-lock"
|
||||
_attr_translation_key = "album_protected_url"
|
||||
|
||||
def __init__(self, coordinator, entry, subentry):
|
||||
super().__init__(coordinator, entry, subentry)
|
||||
self._attr_unique_id = f"{self._unique_id_prefix}_protected_url"
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
if self._album_data:
|
||||
return self.coordinator.get_protected_url()
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
if not self._album_data:
|
||||
return {}
|
||||
attrs = {ATTR_ALBUM_ID: self._album_data.id}
|
||||
all_urls = self.coordinator.get_protected_urls()
|
||||
if len(all_urls) > 1:
|
||||
attrs["protected_urls"] = all_urls
|
||||
return attrs
|
||||
|
||||
|
||||
class ImmichAlbumProtectedPasswordSensor(ImmichAlbumBaseSensor):
|
||||
"""Sensor representing an Immich album protected link password."""
|
||||
|
||||
_attr_icon = "mdi:key"
|
||||
_attr_translation_key = "album_protected_password"
|
||||
|
||||
def __init__(self, coordinator, entry, subentry):
|
||||
super().__init__(coordinator, entry, subentry)
|
||||
self._attr_unique_id = f"{self._unique_id_prefix}_protected_password"
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
if self._album_data:
|
||||
return self.coordinator.get_protected_password()
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
if not self._album_data:
|
||||
return {}
|
||||
return {
|
||||
ATTR_ALBUM_ID: self._album_data.id,
|
||||
ATTR_ALBUM_PROTECTED_URL: self.coordinator.get_protected_url(),
|
||||
}
|
||||
270
custom_components/immich_album_watcher/services.yaml
Normal file
270
custom_components/immich_album_watcher/services.yaml
Normal file
@@ -0,0 +1,270 @@
|
||||
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, document, 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:
|
||||
assets:
|
||||
name: Assets
|
||||
description: "List of media assets to send. Each item should have 'url', optional 'type' (document/photo/video, default: document), optional 'content_type' (MIME type, e.g., 'image/jpeg'), and optional 'cache_key' (custom key for caching instead of URL). If empty, sends a text message. Photos and videos can be grouped; documents are sent separately."
|
||||
required: 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: ""
|
||||
quiet_hours_start:
|
||||
name: Quiet Hours Start
|
||||
description: "Start time for quiet hours (HH:MM format, e.g. 22:00). When set along with quiet_hours_end, notifications during this period are queued and sent when quiet hours end. Omit to send immediately."
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
quiet_hours_end:
|
||||
name: Quiet Hours End
|
||||
description: "End time for quiet hours (HH:MM format, e.g. 08:00). Queued notifications will be sent at this time."
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
141
custom_components/immich_album_watcher/storage.py
Normal file
141
custom_components/immich_album_watcher/storage.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""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
|
||||
|
||||
from immich_watcher_core.notifications.queue import (
|
||||
NotificationQueue as CoreNotificationQueue,
|
||||
)
|
||||
from immich_watcher_core.telegram.cache import TelegramFileCache as CoreTelegramFileCache
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY_PREFIX = "immich_album_watcher"
|
||||
|
||||
|
||||
class HAStorageBackend:
|
||||
"""Home Assistant storage backend adapter.
|
||||
|
||||
Wraps homeassistant.helpers.storage.Store to satisfy the
|
||||
StorageBackend protocol from immich_watcher_core.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, key: str) -> None:
|
||||
"""Initialize with HA store.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
key: Storage key (e.g. "immich_album_watcher.telegram_cache.xxx")
|
||||
"""
|
||||
self._store: Store[dict[str, Any]] = Store(hass, STORAGE_VERSION, key)
|
||||
|
||||
async def load(self) -> dict[str, Any] | None:
|
||||
"""Load data from HA storage."""
|
||||
return await self._store.async_load()
|
||||
|
||||
async def save(self, data: dict[str, Any]) -> None:
|
||||
"""Save data to HA storage."""
|
||||
await self._store.async_save(data)
|
||||
|
||||
async def remove(self) -> None:
|
||||
"""Remove all stored data."""
|
||||
await self._store.async_remove()
|
||||
|
||||
|
||||
class ImmichAlbumStorage:
|
||||
"""Handles persistence of album state across restarts.
|
||||
|
||||
This remains HA-native as it manages HA-specific album tracking state.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Convenience factory functions for creating core classes with HA backends
|
||||
|
||||
|
||||
def create_telegram_cache(
|
||||
hass: HomeAssistant,
|
||||
entry_id: str,
|
||||
ttl_seconds: int = 48 * 60 * 60,
|
||||
use_thumbhash: bool = False,
|
||||
) -> CoreTelegramFileCache:
|
||||
"""Create a TelegramFileCache with HA storage backend.
|
||||
|
||||
Args:
|
||||
hass: Home Assistant instance
|
||||
entry_id: Config entry ID for scoping
|
||||
ttl_seconds: TTL for cache entries (TTL mode only)
|
||||
use_thumbhash: Use thumbhash validation instead of TTL
|
||||
"""
|
||||
suffix = f"_assets" if use_thumbhash else ""
|
||||
backend = HAStorageBackend(
|
||||
hass, f"{STORAGE_KEY_PREFIX}.telegram_cache.{entry_id}{suffix}"
|
||||
)
|
||||
return CoreTelegramFileCache(backend, ttl_seconds=ttl_seconds, use_thumbhash=use_thumbhash)
|
||||
|
||||
|
||||
def create_notification_queue(
|
||||
hass: HomeAssistant, entry_id: str
|
||||
) -> CoreNotificationQueue:
|
||||
"""Create a NotificationQueue with HA storage backend."""
|
||||
backend = HAStorageBackend(
|
||||
hass, f"{STORAGE_KEY_PREFIX}.notification_queue.{entry_id}"
|
||||
)
|
||||
return CoreNotificationQueue(backend)
|
||||
|
||||
|
||||
# Re-export core types for backward compatibility
|
||||
TelegramFileCache = CoreTelegramFileCache
|
||||
NotificationQueue = CoreNotificationQueue
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"album_id": {
|
||||
"name": "{album_name}: Album ID"
|
||||
},
|
||||
"album_asset_count": {
|
||||
"name": "{album_name}: Asset Count"
|
||||
},
|
||||
@@ -68,13 +71,15 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"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": {
|
||||
"albums": "Albums to watch",
|
||||
"scan_interval": "Scan interval (seconds)"
|
||||
"scan_interval": "Scan interval (seconds)",
|
||||
"telegram_bot_token": "Telegram Bot Token"
|
||||
},
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
},
|
||||
123
custom_components/immich_album_watcher/sync.py
Normal file
123
custom_components/immich_album_watcher/sync.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Optional sync with the standalone Immich Watcher server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServerSyncClient:
|
||||
"""Client for communicating with the standalone Immich Watcher server.
|
||||
|
||||
All methods are safe to call even if the server is unreachable --
|
||||
they log warnings and return empty/default values. The HA integration
|
||||
must never break due to server connectivity issues.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, server_url: str, api_key: str) -> None:
|
||||
self._hass = hass
|
||||
self._base_url = server_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"X-API-Key": self._api_key, "Content-Type": "application/json"}
|
||||
|
||||
async def async_get_trackers(self) -> list[dict[str, Any]]:
|
||||
"""Fetch tracker configurations from the server.
|
||||
|
||||
Returns empty list on any error.
|
||||
"""
|
||||
try:
|
||||
session = async_get_clientsession(self._hass)
|
||||
async with session.get(
|
||||
f"{self._base_url}/api/sync/trackers",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
_LOGGER.warning(
|
||||
"Server sync: failed to fetch trackers (HTTP %d)", response.status
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Server sync: connection failed: %s", err)
|
||||
return []
|
||||
|
||||
async def async_render_template(
|
||||
self, template_id: int, context: dict[str, Any]
|
||||
) -> str | None:
|
||||
"""Render a server-managed template with context.
|
||||
|
||||
Returns None on any error.
|
||||
"""
|
||||
try:
|
||||
session = async_get_clientsession(self._hass)
|
||||
async with session.post(
|
||||
f"{self._base_url}/api/sync/templates/{template_id}/render",
|
||||
headers=self._headers,
|
||||
json={"context": context},
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data.get("rendered")
|
||||
_LOGGER.warning(
|
||||
"Server sync: template render failed (HTTP %d)", response.status
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Server sync: template render connection failed: %s", err)
|
||||
return None
|
||||
|
||||
async def async_report_event(
|
||||
self,
|
||||
tracker_name: str,
|
||||
event_type: str,
|
||||
album_id: str,
|
||||
album_name: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Report a detected event to the server for logging.
|
||||
|
||||
Returns True if successfully reported, False on any error.
|
||||
Fire-and-forget -- failures are logged but don't affect HA operation.
|
||||
"""
|
||||
try:
|
||||
session = async_get_clientsession(self._hass)
|
||||
payload = {
|
||||
"tracker_name": tracker_name,
|
||||
"event_type": event_type,
|
||||
"album_id": album_id,
|
||||
"album_name": album_name,
|
||||
"details": details or {},
|
||||
}
|
||||
async with session.post(
|
||||
f"{self._base_url}/api/sync/events",
|
||||
headers=self._headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
_LOGGER.debug("Server sync: event reported for album '%s'", album_name)
|
||||
return True
|
||||
_LOGGER.debug(
|
||||
"Server sync: event report failed (HTTP %d)", response.status
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.debug("Server sync: event report connection failed: %s", err)
|
||||
return False
|
||||
|
||||
async def async_check_connection(self) -> bool:
|
||||
"""Check if the server is reachable."""
|
||||
try:
|
||||
session = async_get_clientsession(self._hass)
|
||||
async with session.get(
|
||||
f"{self._base_url}/api/health",
|
||||
) as response:
|
||||
return response.status == 200
|
||||
except aiohttp.ClientError:
|
||||
return False
|
||||
152
custom_components/immich_album_watcher/text.py
Normal file
152
custom_components/immich_album_watcher/text.py
Normal 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}_{self._album_id}")
|
||||
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()
|
||||
269
custom_components/immich_album_watcher/translations/en.json
Normal file
269
custom_components/immich_album_watcher/translations/en.json
Normal file
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"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",
|
||||
"server_connect_failed": "Failed to connect to Immich Watcher server",
|
||||
"server_partial_config": "Both server URL and API key are required (or leave both empty to disable sync)"
|
||||
},
|
||||
"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)",
|
||||
"server_url": "Watcher Server URL (optional)",
|
||||
"server_api_key": "Watcher Server API Key (optional)"
|
||||
},
|
||||
"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)",
|
||||
"server_url": "URL of the standalone Immich Watcher server for config sync and event reporting (leave empty to disable)",
|
||||
"server_api_key": "API key (JWT access token) for authenticating with the Watcher server"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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, document, or media group).",
|
||||
"fields": {
|
||||
"bot_token": {
|
||||
"name": "Bot Token",
|
||||
"description": "Telegram bot token (optional if configured in integration options)."
|
||||
},
|
||||
"chat_id": {
|
||||
"name": "Chat ID",
|
||||
"description": "Telegram chat ID to send to."
|
||||
},
|
||||
"assets": {
|
||||
"name": "Assets",
|
||||
"description": "List of media assets with 'url', optional 'type' (document/photo/video, default: document), optional 'content_type' (MIME type), and optional 'cache_key' (custom key for caching instead of URL). If empty, sends a text message. Photos and videos can be grouped; documents are sent separately."
|
||||
},
|
||||
"caption": {
|
||||
"name": "Caption",
|
||||
"description": "Caption text. For media, applied to first item. For empty URLs, this is the message text."
|
||||
},
|
||||
"reply_to_message_id": {
|
||||
"name": "Reply To",
|
||||
"description": "Optional message ID to reply to."
|
||||
},
|
||||
"disable_web_page_preview": {
|
||||
"name": "Disable Web Page Preview",
|
||||
"description": "Disable link previews in text messages."
|
||||
},
|
||||
"parse_mode": {
|
||||
"name": "Parse Mode",
|
||||
"description": "How to parse the caption/text. Options are HTML, Markdown, MarkdownV2, or empty string for plain text."
|
||||
},
|
||||
"max_group_size": {
|
||||
"name": "Max Group Size",
|
||||
"description": "Maximum number of media items per media group (2-10). Large lists will be split into multiple groups."
|
||||
},
|
||||
"chunk_delay": {
|
||||
"name": "Chunk Delay",
|
||||
"description": "Delay in milliseconds between sending multiple media groups (0-60000). Useful for rate limiting."
|
||||
},
|
||||
"wait_for_response": {
|
||||
"name": "Wait For Response",
|
||||
"description": "Wait for Telegram to finish processing before returning. Set to false for fire-and-forget (automation continues immediately)."
|
||||
},
|
||||
"max_asset_data_size": {
|
||||
"name": "Max Asset Data Size",
|
||||
"description": "Maximum asset size in bytes. Assets exceeding this limit will be skipped. Leave empty for no limit."
|
||||
},
|
||||
"send_large_photos_as_documents": {
|
||||
"name": "Send Large Photos As Documents",
|
||||
"description": "How to handle photos exceeding Telegram's limits (10MB or 10000px dimension sum). If true, send as documents. If false, skip oversized photos."
|
||||
},
|
||||
"chat_action": {
|
||||
"name": "Chat Action",
|
||||
"description": "Chat action to display while processing (typing, upload_photo, upload_video, upload_document). Set to empty to disable."
|
||||
},
|
||||
"quiet_hours_start": {
|
||||
"name": "Quiet Hours Start",
|
||||
"description": "Start time for quiet hours (HH:MM format, e.g. 22:00). Notifications during this period are queued and sent when quiet hours end. Omit to send immediately."
|
||||
},
|
||||
"quiet_hours_end": {
|
||||
"name": "Quiet Hours End",
|
||||
"description": "End time for quiet hours (HH:MM format, e.g. 08:00). Queued notifications will be sent at this time."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
267
custom_components/immich_album_watcher/translations/ru.json
Normal file
267
custom_components/immich_album_watcher/translations/ru.json
Normal file
@@ -0,0 +1,267 @@
|
||||
{
|
||||
"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 (часы)",
|
||||
"server_url": "URL сервера Watcher (необязательно)",
|
||||
"server_api_key": "API ключ сервера Watcher (необязательно)"
|
||||
},
|
||||
"data_description": {
|
||||
"scan_interval": "Как часто проверять изменения в альбомах (10-3600 секунд)",
|
||||
"telegram_bot_token": "Токен бота для отправки уведомлений в Telegram",
|
||||
"telegram_cache_ttl": "Сколько хранить ID загруженных файлов для повторной отправки без загрузки (1-168 часов, по умолчанию: 48)",
|
||||
"server_url": "URL автономного сервера Immich Watcher для синхронизации конфигурации и отчётов о событиях (оставьте пустым для отключения)",
|
||||
"server_api_key": "API ключ (JWT токен) для аутентификации на сервере Watcher"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 для отправки."
|
||||
},
|
||||
"assets": {
|
||||
"name": "Ресурсы",
|
||||
"description": "Список медиа-ресурсов с 'url', опциональным 'type' (document/photo/video, по умолчанию document), опциональным 'content_type' (MIME-тип) и опциональным 'cache_key' (свой ключ кэширования вместо URL). Если пусто, отправляет текстовое сообщение. Фото и видео группируются; документы отправляются отдельно."
|
||||
},
|
||||
"caption": {
|
||||
"name": "Подпись",
|
||||
"description": "Текст подписи. Для медиа применяется к первому элементу. Для пустых URLs это текст сообщения."
|
||||
},
|
||||
"reply_to_message_id": {
|
||||
"name": "Ответ на",
|
||||
"description": "ID сообщения для ответа (необязательно)."
|
||||
},
|
||||
"disable_web_page_preview": {
|
||||
"name": "Отключить предпросмотр ссылок",
|
||||
"description": "Отключить предпросмотр ссылок в текстовых сообщениях."
|
||||
},
|
||||
"parse_mode": {
|
||||
"name": "Режим парсинга",
|
||||
"description": "Как парсить подпись/текст. Варианты: HTML, Markdown, MarkdownV2, или пустая строка для обычного текста."
|
||||
},
|
||||
"max_group_size": {
|
||||
"name": "Макс. размер группы",
|
||||
"description": "Максимальное количество медиа-файлов в одной группе (2-10). Большие списки будут разделены на несколько групп."
|
||||
},
|
||||
"chunk_delay": {
|
||||
"name": "Задержка между группами",
|
||||
"description": "Задержка в миллисекундах между отправкой нескольких медиа-групп (0-60000). Полезно для ограничения скорости."
|
||||
},
|
||||
"wait_for_response": {
|
||||
"name": "Ждать ответа",
|
||||
"description": "Ждать завершения отправки в Telegram перед возвратом. Установите false для фоновой отправки (автоматизация продолжается немедленно)."
|
||||
},
|
||||
"max_asset_data_size": {
|
||||
"name": "Макс. размер ресурса",
|
||||
"description": "Максимальный размер ресурса в байтах. Ресурсы, превышающие этот лимит, будут пропущены. Оставьте пустым для отсутствия ограничения."
|
||||
},
|
||||
"send_large_photos_as_documents": {
|
||||
"name": "Большие фото как документы",
|
||||
"description": "Как обрабатывать фото, превышающие лимиты Telegram (10МБ или сумма размеров 10000пкс). Если true, отправлять как документы. Если false, пропускать."
|
||||
},
|
||||
"chat_action": {
|
||||
"name": "Действие в чате",
|
||||
"description": "Действие для отображения во время обработки (typing, upload_photo, upload_video, upload_document). Оставьте пустым для отключения."
|
||||
},
|
||||
"quiet_hours_start": {
|
||||
"name": "Начало тихих часов",
|
||||
"description": "Время начала тихих часов (формат ЧЧ:ММ, например 22:00). Уведомления в этот период ставятся в очередь и отправляются по окончании. Не указывайте для немедленной отправки."
|
||||
},
|
||||
"quiet_hours_end": {
|
||||
"name": "Конец тихих часов",
|
||||
"description": "Время окончания тихих часов (формат ЧЧ:ММ, например 08:00). Уведомления из очереди будут отправлены в это время."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
42
frontend/README.md
Normal file
42
frontend/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.12.8 create --template minimal --types ts --no-install frontend
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
4003
frontend/package-lock.json
generated
Normal file
4003
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"bits-ui": "^2.16.3",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/language": "^6.12.2",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"codemirror": "^6.0.2"
|
||||
}
|
||||
}
|
||||
188
frontend/src/app.css
Normal file
188
frontend/src/app.css
Normal file
@@ -0,0 +1,188 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-background: #f8f9fb;
|
||||
--color-foreground: #1a1a2e;
|
||||
--color-muted: #eef0f4;
|
||||
--color-muted-foreground: #6b7280;
|
||||
--color-border: #e2e4ea;
|
||||
--color-primary: #0d9488;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-accent: #eef0f4;
|
||||
--color-accent-foreground: #1a1a2e;
|
||||
--color-destructive: #ef4444;
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #1a1a2e;
|
||||
--color-success-bg: #ecfdf5;
|
||||
--color-success-fg: #059669;
|
||||
--color-warning-bg: #fffbeb;
|
||||
--color-warning-fg: #d97706;
|
||||
--color-error-bg: #fef2f2;
|
||||
--color-error-fg: #dc2626;
|
||||
--color-glow: rgba(13, 148, 136, 0.15);
|
||||
--color-glow-strong: rgba(13, 148, 136, 0.3);
|
||||
--color-sidebar: #ffffff;
|
||||
--color-sidebar-active: rgba(13, 148, 136, 0.08);
|
||||
--font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
/* Dark theme overrides */
|
||||
[data-theme="dark"] {
|
||||
--color-background: #0c0e14;
|
||||
--color-foreground: #e4e6ed;
|
||||
--color-muted: #1a1d28;
|
||||
--color-muted-foreground: #8b8fa4;
|
||||
--color-border: #252836;
|
||||
--color-primary: #14b8a6;
|
||||
--color-primary-foreground: #0c0e14;
|
||||
--color-accent: #1a1d28;
|
||||
--color-accent-foreground: #e4e6ed;
|
||||
--color-destructive: #f87171;
|
||||
--color-card: #13151e;
|
||||
--color-card-foreground: #e4e6ed;
|
||||
--color-success-bg: #052e16;
|
||||
--color-success-fg: #34d399;
|
||||
--color-warning-bg: #422006;
|
||||
--color-warning-fg: #fbbf24;
|
||||
--color-error-bg: #450a0a;
|
||||
--color-error-fg: #f87171;
|
||||
--color-glow: rgba(20, 184, 166, 0.12);
|
||||
--color-glow-strong: rgba(20, 184, 166, 0.25);
|
||||
--color-sidebar: #10121a;
|
||||
--color-sidebar-active: rgba(20, 184, 166, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Subtle background pattern */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
opacity: 0.4;
|
||||
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
|
||||
background-size: 32px 32px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Form controls */
|
||||
input, select, textarea {
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--color-background);
|
||||
border-color: var(--color-border);
|
||||
font-family: var(--font-sans);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow), 0 0 12px var(--color-glow);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* Override browser autofill styles in dark mode */
|
||||
[data-theme="dark"] input:-webkit-autofill,
|
||||
[data-theme="dark"] input:-webkit-autofill:hover,
|
||||
[data-theme="dark"] input:-webkit-autofill:focus,
|
||||
[data-theme="dark"] select:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0 1000px #13151e inset !important;
|
||||
-webkit-text-fill-color: #e4e6ed !important;
|
||||
caret-color: #e4e6ed;
|
||||
}
|
||||
|
||||
/* Color scheme for native controls */
|
||||
[data-theme="dark"] { color-scheme: dark; }
|
||||
[data-theme="light"] { color-scheme: light; }
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--color-muted-foreground); }
|
||||
|
||||
/* Stagger animation utility */
|
||||
@keyframes fadeSlideIn {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% { box-shadow: 0 0 4px var(--color-glow); }
|
||||
50% { box-shadow: 0 0 16px var(--color-glow-strong); }
|
||||
}
|
||||
|
||||
@keyframes countUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
.animate-fade-slide-in {
|
||||
animation: fadeSlideIn 0.4s ease-out both;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
background: linear-gradient(90deg, var(--color-muted) 25%, var(--color-border) 50%, var(--color-muted) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulseGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-count-up {
|
||||
animation: countUp 0.5s ease-out both;
|
||||
}
|
||||
|
||||
/* Stagger children utility — add .stagger-children to parent */
|
||||
.stagger-children > * {
|
||||
animation: fadeSlideIn 0.4s ease-out both;
|
||||
}
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 60ms; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 120ms; }
|
||||
.stagger-children > *:nth-child(4) { animation-delay: 180ms; }
|
||||
.stagger-children > *:nth-child(5) { animation-delay: 240ms; }
|
||||
.stagger-children > *:nth-child(6) { animation-delay: 300ms; }
|
||||
.stagger-children > *:nth-child(7) { animation-delay: 360ms; }
|
||||
.stagger-children > *:nth-child(8) { animation-delay: 420ms; }
|
||||
.stagger-children > *:nth-child(9) { animation-delay: 480ms; }
|
||||
.stagger-children > *:nth-child(10) { animation-delay: 540ms; }
|
||||
|
||||
/* Mono text utility */
|
||||
.font-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
13
frontend/src/app.d.ts
vendored
Normal file
13
frontend/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
16
frontend/src/app.html
Normal file
16
frontend/src/app.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
<title>Immich Watcher</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
88
frontend/src/lib/api.ts
Normal file
88
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* API client with JWT auth for the Immich Watcher backend.
|
||||
*/
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
export function setTokens(access: string, refresh: string) {
|
||||
localStorage.setItem('access_token', access);
|
||||
localStorage.setItem('refresh_token', refresh);
|
||||
}
|
||||
|
||||
export function clearTokens() {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
async function refreshAccessToken(): Promise<boolean> {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (!refreshToken) return false;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTokens(data.access_token, data.refresh_token);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function api<T = any>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>)
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
|
||||
// Try token refresh on 401
|
||||
if (res.status === 401 && token) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${getToken()}`;
|
||||
res = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
64
frontend/src/lib/auth.svelte.ts
Normal file
64
frontend/src/lib/auth.svelte.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Reactive auth state using Svelte 5 runes.
|
||||
*/
|
||||
|
||||
import { api, setTokens, clearTokens, isAuthenticated } from './api';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
let user = $state<User | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
export function getAuth() {
|
||||
return {
|
||||
get user() { return user; },
|
||||
get loading() { return loading; },
|
||||
get isAdmin() { return user?.role === 'admin'; },
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadUser() {
|
||||
if (!isAuthenticated()) {
|
||||
user = null;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
user = await api<User>('/auth/me');
|
||||
} catch {
|
||||
user = null;
|
||||
clearTokens();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const data = await api<{ access_token: string; refresh_token: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
setTokens(data.access_token, data.refresh_token);
|
||||
await loadUser();
|
||||
}
|
||||
|
||||
export async function setup(username: string, password: string) {
|
||||
const data = await api<{ access_token: string; refresh_token: string }>('/auth/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
setTokens(data.access_token, data.refresh_token);
|
||||
await loadUser();
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearTokens();
|
||||
user = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
27
frontend/src/lib/components/Card.svelte
Normal file
27
frontend/src/lib/components/Card.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
let { children, class: className = '', hover = false } = $props<{
|
||||
children: import('svelte').Snippet;
|
||||
class?: string;
|
||||
hover?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="card-component {hover ? 'card-hover' : ''} {className}"
|
||||
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.75rem; padding: 1.25rem;"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card-component {
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 4px 16px var(--color-glow), 0 0 0 1px var(--color-glow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
71
frontend/src/lib/components/ConfirmModal.svelte
Normal file
71
frontend/src/lib/components/ConfirmModal.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import Modal from './Modal.svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let { open = false, title = '', message = '', onconfirm, oncancel } = $props<{
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
onconfirm: () => void;
|
||||
oncancel: () => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<Modal {open} title={title || t('common.confirm')} onclose={oncancel}>
|
||||
<div class="flex items-start gap-3 mb-5">
|
||||
<div class="flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0"
|
||||
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlertCircle" size={20} />
|
||||
</div>
|
||||
<p class="text-sm mt-1.5" style="color: var(--color-muted-foreground);">{message}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button onclick={oncancel}
|
||||
class="confirm-btn-cancel">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onclick={onconfirm}
|
||||
class="confirm-btn-delete">
|
||||
<MdiIcon name="mdiDelete" size={15} />
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.confirm-btn-cancel {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.confirm-btn-cancel:hover {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.confirm-btn-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: var(--color-destructive);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.confirm-btn-delete:hover {
|
||||
box-shadow: 0 0 16px rgba(239, 68, 68, 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
44
frontend/src/lib/components/Hint.svelte
Normal file
44
frontend/src/lib/components/Hint.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
let { text = '' } = $props<{ text: string }>();
|
||||
let visible = $state(false);
|
||||
let tooltipStyle = $state('');
|
||||
let btnEl: HTMLButtonElement;
|
||||
|
||||
function show() {
|
||||
if (!btnEl) return;
|
||||
visible = true;
|
||||
const rect = btnEl.getBoundingClientRect();
|
||||
const tooltipWidth = 272;
|
||||
let left = rect.left + rect.width / 2 - tooltipWidth / 2;
|
||||
if (left < 8) left = 8;
|
||||
if (left + tooltipWidth > window.innerWidth - 8) left = window.innerWidth - tooltipWidth - 8;
|
||||
tooltipStyle = `position:fixed; z-index:99999; bottom:${window.innerHeight - rect.top + 8}px; left:${left}px; width:${tooltipWidth}px;`;
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button type="button" bind:this={btnEl}
|
||||
class="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full text-[9px] font-bold leading-none
|
||||
border border-[var(--color-border)] bg-[var(--color-muted)] text-[var(--color-muted-foreground)]
|
||||
hover:bg-[var(--color-border)] hover:text-[var(--color-foreground)]
|
||||
transition-colors cursor-help align-middle ml-2 flex-shrink-0"
|
||||
onmouseenter={show}
|
||||
onmouseleave={hide}
|
||||
onfocus={show}
|
||||
onblur={hide}
|
||||
aria-label={text}
|
||||
tabindex="0"
|
||||
>?</button>
|
||||
|
||||
{#if visible}
|
||||
<div role="tooltip" style={tooltipStyle}
|
||||
class="px-3 py-2.5 rounded-lg text-xs
|
||||
bg-[var(--color-card)] text-[var(--color-foreground)]
|
||||
border border-[var(--color-border)]
|
||||
shadow-xl whitespace-normal leading-relaxed pointer-events-none">
|
||||
{text}
|
||||
</div>
|
||||
{/if}
|
||||
65
frontend/src/lib/components/IconButton.svelte
Normal file
65
frontend/src/lib/components/IconButton.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
|
||||
let { icon, title = '', onclick, disabled = false, variant = 'default', size = 16, class: className = '' } = $props<{
|
||||
icon: string;
|
||||
title?: string;
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
variant?: 'default' | 'danger' | 'success';
|
||||
size?: number;
|
||||
class?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<button type="button" {title} {onclick} {disabled}
|
||||
class="icon-btn icon-btn-{variant} {className}"
|
||||
>
|
||||
<MdiIcon name={icon} {size} />
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.icon-btn-default {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.icon-btn-default:hover {
|
||||
color: var(--color-foreground);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.icon-btn-danger {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.icon-btn-danger:hover {
|
||||
color: var(--color-destructive);
|
||||
background: var(--color-error-bg);
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.icon-btn-success {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.icon-btn-success:hover {
|
||||
color: var(--color-success-fg);
|
||||
background: var(--color-success-bg);
|
||||
box-shadow: 0 0 8px rgba(5, 150, 105, 0.15);
|
||||
}
|
||||
</style>
|
||||
98
frontend/src/lib/components/IconPicker.svelte
Normal file
98
frontend/src/lib/components/IconPicker.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import * as mdi from '@mdi/js';
|
||||
|
||||
let { value = '', onselect } = $props<{
|
||||
value: string;
|
||||
onselect: (icon: string) => void;
|
||||
}>();
|
||||
|
||||
let open = $state(false);
|
||||
let search = $state('');
|
||||
let buttonEl: HTMLButtonElement;
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
const allIcons = Object.keys(mdi).filter(k => k.startsWith('mdi') && k !== 'default');
|
||||
|
||||
const popular = [
|
||||
'mdiServer', 'mdiCamera', 'mdiImage', 'mdiVideo', 'mdiBell', 'mdiSend',
|
||||
'mdiRobot', 'mdiHome', 'mdiStar', 'mdiHeart', 'mdiAccount', 'mdiFolder',
|
||||
'mdiFolderImage', 'mdiAlbum', 'mdiImageMultiple', 'mdiCloudUpload',
|
||||
'mdiEye', 'mdiCog', 'mdiTelegram', 'mdiWebhook', 'mdiMessageText',
|
||||
'mdiCalendar', 'mdiClock', 'mdiMapMarker', 'mdiTag', 'mdiFilter',
|
||||
'mdiSort', 'mdiMagnify', 'mdiPencil', 'mdiDelete', 'mdiPlus',
|
||||
'mdiCheck', 'mdiClose', 'mdiAlert', 'mdiInformation', 'mdiShield',
|
||||
'mdiLink', 'mdiDownload', 'mdiUpload', 'mdiRefresh', 'mdiPlay',
|
||||
'mdiPause', 'mdiStop', 'mdiSkipNext', 'mdiMusic', 'mdiMovie',
|
||||
'mdiFileDocument', 'mdiEmail', 'mdiPhone', 'mdiChat', 'mdiShare',
|
||||
];
|
||||
|
||||
function filtered(): string[] {
|
||||
if (!search) return popular.filter(p => allIcons.includes(p));
|
||||
const q = search.toLowerCase();
|
||||
return allIcons.filter(k => k.toLowerCase().includes(q)).slice(0, 60);
|
||||
}
|
||||
|
||||
function getPath(iconName: string): string {
|
||||
return (mdi as any)[iconName] || '';
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
if (!open && buttonEl) {
|
||||
const rect = buttonEl.getBoundingClientRect();
|
||||
dropdownStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; left:${rect.left}px;`;
|
||||
}
|
||||
open = !open;
|
||||
if (!open) search = '';
|
||||
}
|
||||
|
||||
function select(iconName: string) {
|
||||
onselect(iconName);
|
||||
open = false;
|
||||
search = '';
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open) {
|
||||
open = false;
|
||||
search = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
||||
|
||||
<div class="inline-block">
|
||||
<button type="button" bind:this={buttonEl} onclick={toggleOpen}
|
||||
class="flex items-center justify-center gap-1 px-2 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] hover:bg-[var(--color-muted)] transition-colors">
|
||||
{#if value && getPath(value)}
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getPath(value)} /></svg>
|
||||
{:else}
|
||||
<span class="text-[var(--color-muted-foreground)] text-xs">Icon</span>
|
||||
{/if}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">▼</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div style="position:fixed; top:0; left:0; right:0; bottom:0; z-index:9998;"
|
||||
role="presentation"
|
||||
onclick={() => { open = false; search = ''; }}></div>
|
||||
|
||||
<div style="{dropdownStyle} width: 20rem;"
|
||||
class="bg-[var(--color-card)] border border-[var(--color-border)] rounded-lg shadow-lg p-3">
|
||||
<input type="text" bind:value={search} placeholder="Search icons..."
|
||||
class="w-full px-2 py-1 mb-2 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
<div style="display: grid; grid-template-columns: repeat(8, 1fr); gap: 0.25rem; max-height: 14rem; overflow-y: auto; overflow-x: hidden;">
|
||||
<button type="button" onclick={() => select('')}
|
||||
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] text-xs text-[var(--color-muted-foreground)]"
|
||||
title="No icon">✕</button>
|
||||
{#each filtered() as iconName}
|
||||
<button type="button" onclick={() => select(iconName)}
|
||||
class="flex items-center justify-center aspect-square rounded hover:bg-[var(--color-muted)] {value === iconName ? 'bg-[var(--color-accent)]' : ''}"
|
||||
title={iconName.replace('mdi', '')}>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d={getPath(iconName)} /></svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
128
frontend/src/lib/components/JinjaEditor.svelte
Normal file
128
frontend/src/lib/components/JinjaEditor.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { EditorView, Decoration, placeholder as cmPlaceholder, type DecorationSet } from '@codemirror/view';
|
||||
import { EditorState, StateField, StateEffect } from '@codemirror/state';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { getTheme } from '$lib/theme.svelte';
|
||||
|
||||
let { value = '', onchange, rows = 6, placeholder = '', errorLine = null } = $props<{
|
||||
value: string;
|
||||
onchange: (val: string) => void;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
errorLine?: number | null;
|
||||
}>();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let view: EditorView;
|
||||
const theme = getTheme();
|
||||
|
||||
// Error line highlight effect and field
|
||||
const setErrorLine = StateEffect.define<number | null>();
|
||||
const errorLineField = StateField.define<DecorationSet>({
|
||||
create() { return Decoration.none; },
|
||||
update(decorations, tr) {
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(setErrorLine)) {
|
||||
if (e.value === null) return Decoration.none;
|
||||
const lineNum = e.value;
|
||||
if (lineNum < 1 || lineNum > tr.state.doc.lines) return Decoration.none;
|
||||
const line = tr.state.doc.line(lineNum);
|
||||
return Decoration.set([
|
||||
Decoration.line({ class: 'cm-error-line' }).range(line.from),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return decorations;
|
||||
},
|
||||
provide: f => EditorView.decorations.from(f),
|
||||
});
|
||||
|
||||
// Simple Jinja2 stream parser for syntax highlighting
|
||||
const jinjaLang = StreamLanguage.define({
|
||||
token(stream) {
|
||||
// Jinja2 comment {# ... #}
|
||||
if (stream.match('{#')) {
|
||||
stream.skipTo('#}') && stream.match('#}');
|
||||
return 'comment';
|
||||
}
|
||||
// Jinja2 expression {{ ... }}
|
||||
if (stream.match('{{')) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match('}}')) return 'variableName';
|
||||
stream.next();
|
||||
}
|
||||
return 'variableName';
|
||||
}
|
||||
// Jinja2 statement {% ... %}
|
||||
if (stream.match('{%')) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.match('%}')) return 'keyword';
|
||||
stream.next();
|
||||
}
|
||||
return 'keyword';
|
||||
}
|
||||
// Regular text
|
||||
while (stream.next()) {
|
||||
if (stream.peek() === '{') break;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const extensions = [
|
||||
jinjaLang,
|
||||
errorLineField,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onchange(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.theme({
|
||||
'&': { fontSize: '13px', fontFamily: "'Consolas', 'Monaco', 'Courier New', monospace" },
|
||||
'.cm-content': { minHeight: `${rows * 1.5}em`, padding: '8px' },
|
||||
'.cm-editor': { borderRadius: '0.375rem', border: '1px solid var(--color-border)' },
|
||||
'.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '0px' },
|
||||
'.cm-error-line': { backgroundColor: 'rgba(239, 68, 68, 0.2)', outline: '1px solid rgba(239, 68, 68, 0.4)' },
|
||||
// Jinja2 syntax colors
|
||||
'.ͼc': { color: '#e879f9' }, // keyword ({% %}) - purple
|
||||
'.ͼd': { color: '#38bdf8' }, // variableName ({{ }}) - blue
|
||||
'.ͼ5': { color: '#6b7280' }, // comment ({# #}) - gray
|
||||
}),
|
||||
];
|
||||
|
||||
if (theme.isDark) {
|
||||
extensions.push(oneDark);
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
extensions.push(cmPlaceholder(placeholder));
|
||||
}
|
||||
|
||||
view = new EditorView({
|
||||
state: EditorState.create({ doc: value, extensions }),
|
||||
parent: container,
|
||||
});
|
||||
|
||||
return () => view.destroy();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (view && view.state.doc.toString() !== value) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (view) {
|
||||
view.dispatch({ effects: setErrorLine.of(errorLine ?? null) });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={container}></div>
|
||||
24
frontend/src/lib/components/Loading.svelte
Normal file
24
frontend/src/lib/components/Loading.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
let { lines = 3 } = $props<{ lines?: number }>();
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each Array(lines) as _, i}
|
||||
<div class="loading-bar" style="animation-delay: {i * 100}ms;"></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loading-bar {
|
||||
height: 4rem;
|
||||
border-radius: 0.75rem;
|
||||
background: linear-gradient(90deg, var(--color-muted) 25%, var(--color-border) 50%, var(--color-muted) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
</style>
|
||||
13
frontend/src/lib/components/MdiIcon.svelte
Normal file
13
frontend/src/lib/components/MdiIcon.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import * as mdi from '@mdi/js';
|
||||
|
||||
let { name = '', size = 18 } = $props<{ name: string; size?: number }>();
|
||||
|
||||
function getPath(iconName: string): string {
|
||||
return (mdi as any)[iconName] || '';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if name && getPath(name)}
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor"><path d={getPath(name)} /></svg>
|
||||
{/if}
|
||||
109
frontend/src/lib/components/Modal.svelte
Normal file
109
frontend/src/lib/components/Modal.svelte
Normal file
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import MdiIcon from './MdiIcon.svelte';
|
||||
|
||||
let { open = false, title = '', onclose, children } = $props<{
|
||||
open: boolean;
|
||||
title?: string;
|
||||
onclose: () => void;
|
||||
children: import('svelte').Snippet;
|
||||
}>();
|
||||
|
||||
let visible = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
// Small delay for enter animation
|
||||
requestAnimationFrame(() => { visible = true; });
|
||||
} else {
|
||||
visible = false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={open ? handleKeydown : undefined} />
|
||||
|
||||
{#if open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
class:visible
|
||||
style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; display: flex; align-items: center; justify-content: center;"
|
||||
onclick={onclose}
|
||||
>
|
||||
<div
|
||||
class="modal-panel"
|
||||
class:visible
|
||||
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; width: 100%; max-width: 32rem; max-height: 80vh; margin: 1rem; display: flex; flex-direction: column;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; padding: 1.5rem 1.5rem 1rem;">
|
||||
<h3 style="font-size: 1.125rem; font-weight: 600;">{title}</h3>
|
||||
<button class="modal-close" onclick={onclose}>
|
||||
<MdiIcon name="mdiClose" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div style="padding: 0 1.5rem 1.5rem; overflow-y: auto;">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
background: rgba(0, 0, 0, 0);
|
||||
backdrop-filter: blur(0px);
|
||||
transition: background 0.25s ease, backdrop-filter 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-backdrop.visible {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.97);
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.12),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .modal-panel {
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
0 0 48px var(--color-glow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
|
||||
.modal-panel.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: var(--color-muted);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
</style>
|
||||
21
frontend/src/lib/components/PageHeader.svelte
Normal file
21
frontend/src/lib/components/PageHeader.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
let { title, description = '', children } = $props<{
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="animate-fade-slide-in">
|
||||
<h2 class="text-2xl font-semibold tracking-tight">{title}</h2>
|
||||
{#if description}
|
||||
<p class="text-sm mt-1.5" style="color: var(--color-muted-foreground);">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if children}
|
||||
<div class="animate-fade-slide-in" style="animation-delay: 60ms;">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
149
frontend/src/lib/components/Snackbar.svelte
Normal file
149
frontend/src/lib/components/Snackbar.svelte
Normal file
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import { getSnacks, removeSnack, type Snack } from '$lib/stores/snackbar.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
const snacks = $derived(getSnacks());
|
||||
|
||||
let expandedIds = $state<Set<number>>(new Set());
|
||||
|
||||
function toggleDetail(id: number) {
|
||||
const next = new Set(expandedIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
expandedIds = next;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, string> = {
|
||||
success: 'mdiCheckCircle',
|
||||
error: 'mdiAlertCircle',
|
||||
info: 'mdiInformation',
|
||||
warning: 'mdiAlert',
|
||||
};
|
||||
|
||||
const accentMap: Record<string, string> = {
|
||||
success: '#059669',
|
||||
error: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
warning: '#f59e0b',
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if snacks.length > 0}
|
||||
<div
|
||||
style="position: fixed; left: 50%; transform: translateX(-50%); z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem; width: 90%; max-width: 26rem; pointer-events: none;"
|
||||
class="snackbar-container"
|
||||
>
|
||||
{#each snacks as snack (snack.id)}
|
||||
<div
|
||||
in:fly={{ y: 40, duration: 300 }}
|
||||
out:fade={{ duration: 200 }}
|
||||
class="snack-item"
|
||||
style="--snack-accent: {accentMap[snack.type]};"
|
||||
>
|
||||
<span class="snack-icon" style="color: {accentMap[snack.type]};">
|
||||
<MdiIcon name={iconMap[snack.type]} size={18} />
|
||||
</span>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p class="snack-message">{snack.message}</p>
|
||||
{#if snack.detail}
|
||||
<button class="snack-detail-toggle" onclick={() => toggleDetail(snack.id)}>
|
||||
{expandedIds.has(snack.id) ? 'Hide details' : 'Show details'}
|
||||
</button>
|
||||
{#if expandedIds.has(snack.id)}
|
||||
<pre class="snack-detail">{snack.detail}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<button class="snack-close" onclick={() => removeSnack(snack.id)} aria-label="Dismiss">
|
||||
<MdiIcon name="mdiClose" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.snackbar-container {
|
||||
bottom: 5rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.snackbar-container {
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.snack-item {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border-left: 3px solid var(--snack-accent);
|
||||
background: var(--color-card);
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .snack-item {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 16px color-mix(in srgb, var(--snack-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.snack-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.snack-message {
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.snack-detail-toggle {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-top: 0.25rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.snack-detail-toggle:hover {
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.snack-detail {
|
||||
font-size: 0.7rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-muted-foreground);
|
||||
margin: 0.25rem 0 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.snack-close {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
padding: 0.125rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.15s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.snack-close:hover {
|
||||
color: var(--color-foreground);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
</style>
|
||||
426
frontend/src/lib/i18n/en.json
Normal file
426
frontend/src/lib/i18n/en.json
Normal file
@@ -0,0 +1,426 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Immich Watcher",
|
||||
"tagline": "Album notifications"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"servers": "Servers",
|
||||
"trackers": "Trackers",
|
||||
"trackingConfigs": "Tracking",
|
||||
"templateConfigs": "Templates",
|
||||
"telegramBots": "Bots",
|
||||
"targets": "Targets",
|
||||
"users": "Users",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Sign in",
|
||||
"signInTitle": "Sign in to your account",
|
||||
"signingIn": "Signing in...",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"setupTitle": "Welcome",
|
||||
"setupDescription": "Create your admin account to get started",
|
||||
"createAccount": "Create account",
|
||||
"creatingAccount": "Creating account...",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 6 characters",
|
||||
"loginWithImmich": "Login with Immich",
|
||||
"or": "or"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"description": "Overview of your Immich Watcher setup",
|
||||
"servers": "Servers",
|
||||
"activeTrackers": "Active Trackers",
|
||||
"targets": "Targets",
|
||||
"recentEvents": "Recent Events",
|
||||
"noEvents": "No events yet. Create a tracker to start monitoring albums.",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"servers": {
|
||||
"title": "Servers",
|
||||
"description": "Manage Immich server connections",
|
||||
"addServer": "Add Server",
|
||||
"cancel": "Cancel",
|
||||
"name": "Name",
|
||||
"url": "Immich URL",
|
||||
"urlPlaceholder": "http://immich:2283",
|
||||
"apiKey": "API Key",
|
||||
"apiKeyKeep": "API Key (leave empty to keep current)",
|
||||
"connecting": "Connecting...",
|
||||
"noServers": "No servers configured yet.",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this server?",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"checking": "Checking...",
|
||||
"loadError": "Failed to load servers."
|
||||
},
|
||||
"trackers": {
|
||||
"title": "Trackers",
|
||||
"description": "Monitor albums for changes",
|
||||
"newTracker": "New Tracker",
|
||||
"cancel": "Cancel",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Family photos tracker",
|
||||
"server": "Server",
|
||||
"selectServer": "Select server...",
|
||||
"albums": "Albums",
|
||||
"eventTypes": "Event Types",
|
||||
"notificationTargets": "Notification Targets",
|
||||
"scanInterval": "Scan Interval (seconds)",
|
||||
"createTracker": "Create Tracker",
|
||||
"noTrackers": "No trackers yet. Add a server first, then create a tracker.",
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this tracker?",
|
||||
"albums_count": "album(s)",
|
||||
"every": "every",
|
||||
"trackImages": "Track images",
|
||||
"trackVideos": "Track videos",
|
||||
"favoritesOnly": "Favorites only",
|
||||
"includePeople": "Include people in notifications",
|
||||
"includeAssetDetails": "Include asset details",
|
||||
"maxAssetsToShow": "Max assets to show",
|
||||
"sortBy": "Sort by",
|
||||
"sortOrder": "Sort order",
|
||||
"sortNone": "Original order",
|
||||
"sortDate": "Date",
|
||||
"sortRating": "Rating",
|
||||
"sortName": "Name",
|
||||
"sortRandom": "Random",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending",
|
||||
"quietHoursStart": "Quiet hours start",
|
||||
"quietHoursEnd": "Quiet hours end"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Templates",
|
||||
"description": "Jinja2 message templates for notifications",
|
||||
"newTemplate": "New Template",
|
||||
"cancel": "Cancel",
|
||||
"name": "Name",
|
||||
"body": "Template Body (Jinja2)",
|
||||
"variables": "Variables",
|
||||
"preview": "Preview",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this template?",
|
||||
"create": "Create Template",
|
||||
"update": "Update Template",
|
||||
"noTemplates": "No templates yet. A default template will be used if none is configured.",
|
||||
"eventType": "Event type",
|
||||
"allEvents": "All events",
|
||||
"assetsAdded": "Assets added",
|
||||
"assetsRemoved": "Assets removed",
|
||||
"albumRenamed": "Album renamed",
|
||||
"albumDeleted": "Album deleted"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Targets",
|
||||
"description": "Notification destinations (Telegram, webhooks)",
|
||||
"addTarget": "Add Target",
|
||||
"cancel": "Cancel",
|
||||
"type": "Type",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "My notifications",
|
||||
"botToken": "Bot Token",
|
||||
"chatId": "Chat ID",
|
||||
"webhookUrl": "Webhook URL",
|
||||
"create": "Add Target",
|
||||
"test": "Test",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this target?",
|
||||
"noTargets": "No notification targets configured yet.",
|
||||
"testSent": "Test sent successfully!",
|
||||
"aiCaptions": "Enable AI captions",
|
||||
"telegramSettings": "Telegram Settings",
|
||||
"maxMedia": "Max media to send",
|
||||
"maxGroupSize": "Max group size",
|
||||
"chunkDelay": "Delay between groups (ms)",
|
||||
"maxAssetSize": "Max asset size (MB)",
|
||||
"videoWarning": "Video size warning",
|
||||
"disableUrlPreview": "Disable link previews",
|
||||
"sendLargeAsDocuments": "Send large photos as documents"
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"description": "Manage user accounts (admin only)",
|
||||
"addUser": "Add User",
|
||||
"cancel": "Cancel",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"role": "Role",
|
||||
"roleUser": "User",
|
||||
"roleAdmin": "Admin",
|
||||
"create": "Create User",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this user?",
|
||||
"joined": "joined"
|
||||
},
|
||||
"telegramBot": {
|
||||
"title": "Telegram Bots",
|
||||
"description": "Register and manage Telegram bots",
|
||||
"addBot": "Add Bot",
|
||||
"name": "Display name",
|
||||
"namePlaceholder": "Family notifications bot",
|
||||
"token": "Bot Token",
|
||||
"tokenPlaceholder": "123456:ABC-DEF...",
|
||||
"noBots": "No bots registered yet.",
|
||||
"chats": "Chats",
|
||||
"noChats": "No chats found. Send a message to the bot first.",
|
||||
"refreshChats": "Refresh",
|
||||
"selectBot": "Select bot",
|
||||
"selectChat": "Select chat",
|
||||
"private": "Private",
|
||||
"group": "Group",
|
||||
"supergroup": "Supergroup",
|
||||
"channel": "Channel",
|
||||
"confirmDelete": "Delete this bot?",
|
||||
"commands": "Commands",
|
||||
"enabledCommands": "Enabled Commands",
|
||||
"defaultCount": "Default result count",
|
||||
"responseMode": "Response mode",
|
||||
"modeMedia": "Media (send photos)",
|
||||
"modeText": "Text (send links)",
|
||||
"botLocale": "Bot language",
|
||||
"rateLimits": "Rate Limits",
|
||||
"rateSearch": "Search cooldown",
|
||||
"rateFind": "Find cooldown",
|
||||
"rateDefault": "Default cooldown",
|
||||
"syncCommands": "Sync to Telegram",
|
||||
"discoverChats": "Discover chats from Telegram",
|
||||
"clickToCopy": "Click to copy chat ID",
|
||||
"chatsDiscovered": "Chats discovered",
|
||||
"chatDeleted": "Chat removed"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"title": "Tracking Configs",
|
||||
"description": "Define what events and assets to react to",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Default tracking",
|
||||
"noConfigs": "No tracking configs yet.",
|
||||
"eventTracking": "Event Tracking",
|
||||
"assetsAdded": "Assets added",
|
||||
"assetsRemoved": "Assets removed",
|
||||
"albumRenamed": "Album renamed",
|
||||
"albumDeleted": "Album deleted",
|
||||
"trackImages": "Track images",
|
||||
"trackVideos": "Track videos",
|
||||
"favoritesOnly": "Favorites only",
|
||||
"assetDisplay": "Asset Display",
|
||||
"includePeople": "Include people",
|
||||
"includeDetails": "Include asset details",
|
||||
"maxAssets": "Max assets to show",
|
||||
"sortBy": "Sort by",
|
||||
"sortOrder": "Sort order",
|
||||
"periodicSummary": "Periodic Summary",
|
||||
"enabled": "Enabled",
|
||||
"intervalDays": "Interval (days)",
|
||||
"startDate": "Start date",
|
||||
"times": "Times (HH:MM)",
|
||||
"scheduledAssets": "Scheduled Assets",
|
||||
"albumMode": "Album mode",
|
||||
"limit": "Limit",
|
||||
"assetType": "Asset type",
|
||||
"minRating": "Min rating",
|
||||
"memoryMode": "Memory Mode (On This Day)",
|
||||
"test": "Test",
|
||||
"confirmDelete": "Delete this tracking config?",
|
||||
"sortNone": "None",
|
||||
"sortDate": "Date",
|
||||
"sortRating": "Rating",
|
||||
"sortName": "Name",
|
||||
"orderDesc": "Descending",
|
||||
"orderAsc": "Ascending",
|
||||
"albumModePerAlbum": "Per album",
|
||||
"albumModeCombined": "Combined",
|
||||
"albumModeRandom": "Random",
|
||||
"assetTypeAll": "All",
|
||||
"assetTypePhoto": "Photo",
|
||||
"assetTypeVideo": "Video"
|
||||
},
|
||||
"templateConfig": {
|
||||
"title": "Template Configs",
|
||||
"description": "Define how notification messages are formatted",
|
||||
"newConfig": "New Config",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Default EN",
|
||||
"descriptionPlaceholder": "e.g. English templates for family notifications",
|
||||
"noConfigs": "No template configs yet.",
|
||||
"eventMessages": "Event Messages",
|
||||
"assetsAdded": "Assets added",
|
||||
"assetsRemoved": "Assets removed",
|
||||
"albumRenamed": "Album renamed",
|
||||
"albumDeleted": "Album deleted",
|
||||
"assetFormatting": "Asset Formatting",
|
||||
"imageTemplate": "Image item",
|
||||
"videoTemplate": "Video item",
|
||||
"assetsWrapper": "Assets wrapper",
|
||||
"moreMessage": "More message",
|
||||
"peopleFormat": "People format",
|
||||
"dateLocation": "Date & Location",
|
||||
"dateFormat": "Date format",
|
||||
"commonDate": "Common date",
|
||||
"uniqueDate": "Per-asset date",
|
||||
"locationFormat": "Location format",
|
||||
"commonLocation": "Common location",
|
||||
"uniqueLocation": "Per-asset location",
|
||||
"favoriteIndicator": "Favorite indicator",
|
||||
"scheduledMessages": "Scheduled Messages",
|
||||
"periodicSummary": "Periodic summary",
|
||||
"periodicAlbum": "Per-album item",
|
||||
"scheduledAssets": "Scheduled assets",
|
||||
"memoryMode": "Memory mode",
|
||||
"settings": "Settings",
|
||||
"previewAs": "Preview as",
|
||||
"preview": "Preview",
|
||||
"variables": "Variables",
|
||||
"assetFields": "Asset fields (in {% for asset in added_assets %})",
|
||||
"albumFields": "Album fields (in {% for album in albums %})",
|
||||
"confirmDelete": "Delete this template config?"
|
||||
},
|
||||
"templateVars": {
|
||||
"message_assets_added": { "description": "Notification when new assets are added to an album" },
|
||||
"message_assets_removed": { "description": "Notification when assets are removed from an album" },
|
||||
"message_album_renamed": { "description": "Notification when an album is renamed" },
|
||||
"message_album_deleted": { "description": "Notification when an album is deleted" },
|
||||
"periodic_summary_message": { "description": "Periodic album summary (scheduler not yet implemented)" },
|
||||
"scheduled_assets_message": { "description": "Scheduled asset delivery (scheduler not yet implemented)" },
|
||||
"memory_mode_message": { "description": "\"On This Day\" memories (scheduler not yet implemented)" },
|
||||
"album_id": "Album ID (UUID)",
|
||||
"album_name": "Album name",
|
||||
"album_url": "Public share URL (empty if not shared)",
|
||||
"added_count": "Number of assets added",
|
||||
"removed_count": "Number of assets removed",
|
||||
"change_type": "Type of change (assets_added, assets_removed, album_renamed, album_deleted)",
|
||||
"people": "Detected people names (list, use {{ people | join(', ') }})",
|
||||
"added_assets": "List of asset dicts (use {% for asset in added_assets %})",
|
||||
"removed_assets": "List of removed asset IDs (strings)",
|
||||
"shared": "Whether album is shared (boolean)",
|
||||
"target_type": "Target type: 'telegram' or 'webhook'",
|
||||
"has_videos": "Whether added assets contain videos (boolean)",
|
||||
"has_photos": "Whether added assets contain photos (boolean)",
|
||||
"old_name": "Previous album name (rename events)",
|
||||
"new_name": "New album name (rename events)",
|
||||
"old_shared": "Was album shared before rename (boolean)",
|
||||
"new_shared": "Is album shared after rename (boolean)",
|
||||
"albums": "List of album dicts (use {% for album in albums %})",
|
||||
"assets": "List of asset dicts (use {% for asset in assets %})",
|
||||
"date": "Current date string",
|
||||
"asset_id": "Asset ID (UUID)",
|
||||
"asset_filename": "Original filename",
|
||||
"asset_type": "IMAGE or VIDEO",
|
||||
"asset_created_at": "Creation date/time (ISO 8601)",
|
||||
"asset_owner": "Owner display name",
|
||||
"asset_owner_id": "Owner user ID",
|
||||
"asset_description": "User or EXIF description",
|
||||
"asset_people": "People detected in this asset (list)",
|
||||
"asset_is_favorite": "Whether asset is favorited (boolean)",
|
||||
"asset_rating": "Star rating (1-5 or null)",
|
||||
"asset_latitude": "GPS latitude (float or null)",
|
||||
"asset_longitude": "GPS longitude (float or null)",
|
||||
"asset_city": "City name",
|
||||
"asset_state": "State/region name",
|
||||
"asset_country": "Country name",
|
||||
"asset_url": "Public viewer URL (if shared)",
|
||||
"asset_download_url": "Direct download URL (if shared)",
|
||||
"asset_photo_url": "Preview image URL (images only, if shared)",
|
||||
"asset_playback_url": "Video playback URL (videos only, if shared)",
|
||||
"album_name_field": "Album name (in album list)",
|
||||
"album_asset_count": "Total assets in album",
|
||||
"album_url_field": "Album share URL",
|
||||
"album_shared": "Whether album is shared"
|
||||
},
|
||||
"hints": {
|
||||
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
|
||||
"scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.",
|
||||
"memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.",
|
||||
"favoritesOnly": "Only include assets marked as favorites in Immich.",
|
||||
"maxAssets": "Maximum number of asset details to include in a single notification message.",
|
||||
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
|
||||
"times": "Time(s) of day to send notifications, in HH:MM format. Use commas for multiple times: 09:00,18:00",
|
||||
"albumMode": "Per album: separate notification per album. Combined: one notification with all albums. Random: pick one album randomly.",
|
||||
"minRating": "Only include assets with at least this star rating (0 = no filter).",
|
||||
"eventMessages": "Templates for real-time event notifications. Use {variables} for dynamic content.",
|
||||
"assetFormatting": "How individual assets are formatted within notification messages.",
|
||||
"dateLocation": "Date and location formatting in notifications. Uses strftime syntax for dates.",
|
||||
"scheduledMessages": "Templates for periodic summaries, scheduled photo picks, and On This Day memories.",
|
||||
"aiCaptions": "Use Claude AI to generate a natural-language caption for notifications instead of the template.",
|
||||
"maxMedia": "Maximum number of photos/videos to attach per notification (0 = text only).",
|
||||
"groupSize": "Telegram media groups can contain 2-10 items. Larger batches are split into chunks.",
|
||||
"chunkDelay": "Delay in milliseconds between sending media chunks. Prevents Telegram rate limiting.",
|
||||
"maxAssetSize": "Skip assets larger than this size in MB. Telegram limits files to 50 MB.",
|
||||
"trackingConfig": "Controls which events trigger notifications and how assets are filtered.",
|
||||
"templateConfig": "Controls the message format. Uses default templates if not set.",
|
||||
"scanInterval": "How often to poll the Immich server for changes, in seconds. Lower = faster detection but more API calls.",
|
||||
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
|
||||
"responseMode": "Media: send actual photos. Text: send filenames/links only. Media mode uses more bandwidth.",
|
||||
"botLocale": "Language for command descriptions in Telegram's menu and bot response messages.",
|
||||
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit."
|
||||
},
|
||||
"snack": {
|
||||
"serverSaved": "Server saved",
|
||||
"serverDeleted": "Server deleted",
|
||||
"trackerCreated": "Tracker created",
|
||||
"trackerUpdated": "Tracker updated",
|
||||
"trackerDeleted": "Tracker deleted",
|
||||
"trackerPaused": "Tracker paused",
|
||||
"trackerResumed": "Tracker resumed",
|
||||
"targetSaved": "Target saved",
|
||||
"targetDeleted": "Target deleted",
|
||||
"targetTestSent": "Test notification sent",
|
||||
"templateSaved": "Template config saved",
|
||||
"templateDeleted": "Template config deleted",
|
||||
"trackingConfigSaved": "Tracking config saved",
|
||||
"trackingConfigDeleted": "Tracking config deleted",
|
||||
"botRegistered": "Bot registered",
|
||||
"botDeleted": "Bot deleted",
|
||||
"userCreated": "User created",
|
||||
"userDeleted": "User deleted",
|
||||
"passwordChanged": "Password changed",
|
||||
"copied": "Copied to clipboard",
|
||||
"genericError": "Something went wrong",
|
||||
"commandsSaved": "Commands config saved",
|
||||
"commandsSynced": "Commands synced to Telegram"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"description": "Description",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"none": "None",
|
||||
"noneDefault": "None (default)",
|
||||
"loadError": "Failed to load data",
|
||||
"headersInvalid": "Invalid JSON",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"test": "Test",
|
||||
"create": "Create",
|
||||
"changePassword": "Change Password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"syntaxError": "Syntax error",
|
||||
"undefinedVar": "Unknown variable",
|
||||
"line": "line"
|
||||
}
|
||||
}
|
||||
63
frontend/src/lib/i18n/index.svelte.ts
Normal file
63
frontend/src/lib/i18n/index.svelte.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Reactive i18n module using Svelte 5 $state rune.
|
||||
* Locale changes automatically propagate to all components using t().
|
||||
*/
|
||||
|
||||
import en from './en.json';
|
||||
import ru from './ru.json';
|
||||
|
||||
export type Locale = 'en' | 'ru';
|
||||
|
||||
const translations: Record<Locale, Record<string, any>> = { en, ru };
|
||||
|
||||
function detectLocale(): Locale {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('locale') as Locale | null;
|
||||
if (saved && saved in translations) return saved;
|
||||
}
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const lang = navigator.language.slice(0, 2);
|
||||
if (lang in translations) return lang as Locale;
|
||||
}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
let currentLocale = $state<Locale>(detectLocale());
|
||||
|
||||
export function getLocale(): Locale {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
export function setLocale(locale: Locale) {
|
||||
currentLocale = locale;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('locale', locale);
|
||||
}
|
||||
}
|
||||
|
||||
export function initLocale() {
|
||||
// No-op: locale is auto-detected at module load via $state.
|
||||
// Kept for backward compatibility with existing onMount calls.
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a translated string by dot-separated key.
|
||||
* Falls back to English if key not found in current locale.
|
||||
* Reactive: re-evaluates when currentLocale changes.
|
||||
*/
|
||||
export function t(key: string, fallback?: string): string {
|
||||
return resolve(translations[currentLocale], key)
|
||||
?? resolve(translations.en, key)
|
||||
?? fallback
|
||||
?? key;
|
||||
}
|
||||
|
||||
function resolve(obj: any, path: string): string | undefined {
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return undefined;
|
||||
current = current[part];
|
||||
}
|
||||
return typeof current === 'string' ? current : undefined;
|
||||
}
|
||||
2
frontend/src/lib/i18n/index.ts
Normal file
2
frontend/src/lib/i18n/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export from the .svelte.ts module which supports $state runes
|
||||
export { t, getLocale, setLocale, initLocale, type Locale } from './index.svelte';
|
||||
426
frontend/src/lib/i18n/ru.json
Normal file
426
frontend/src/lib/i18n/ru.json
Normal file
@@ -0,0 +1,426 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Immich Watcher",
|
||||
"tagline": "Уведомления об альбомах"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Главная",
|
||||
"servers": "Серверы",
|
||||
"trackers": "Трекеры",
|
||||
"trackingConfigs": "Отслеживание",
|
||||
"templateConfigs": "Шаблоны",
|
||||
"telegramBots": "Боты",
|
||||
"targets": "Получатели",
|
||||
"users": "Пользователи",
|
||||
"logout": "Выход"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Войти",
|
||||
"signInTitle": "Вход в аккаунт",
|
||||
"signingIn": "Вход...",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"confirmPassword": "Подтвердите пароль",
|
||||
"setupTitle": "Добро пожаловать",
|
||||
"setupDescription": "Создайте учётную запись администратора",
|
||||
"createAccount": "Создать аккаунт",
|
||||
"creatingAccount": "Создание...",
|
||||
"passwordMismatch": "Пароли не совпадают",
|
||||
"passwordTooShort": "Пароль должен быть не менее 6 символов",
|
||||
"loginWithImmich": "Войти через Immich",
|
||||
"or": "или"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Главная",
|
||||
"description": "Обзор настроек Immich Watcher",
|
||||
"servers": "Серверы",
|
||||
"activeTrackers": "Активные трекеры",
|
||||
"targets": "Получатели",
|
||||
"recentEvents": "Последние события",
|
||||
"noEvents": "Событий пока нет. Создайте трекер для отслеживания альбомов.",
|
||||
"loading": "Загрузка..."
|
||||
},
|
||||
"servers": {
|
||||
"title": "Серверы",
|
||||
"description": "Управление подключениями к Immich",
|
||||
"addServer": "Добавить сервер",
|
||||
"cancel": "Отмена",
|
||||
"name": "Название",
|
||||
"url": "URL Immich",
|
||||
"urlPlaceholder": "http://immich:2283",
|
||||
"apiKey": "API ключ",
|
||||
"apiKeyKeep": "API ключ (оставьте пустым, чтобы сохранить текущий)",
|
||||
"connecting": "Подключение...",
|
||||
"noServers": "Серверы не настроены.",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить этот сервер?",
|
||||
"online": "В сети",
|
||||
"offline": "Не в сети",
|
||||
"checking": "Проверка...",
|
||||
"loadError": "Не удалось загрузить серверы."
|
||||
},
|
||||
"trackers": {
|
||||
"title": "Трекеры",
|
||||
"description": "Отслеживание изменений в альбомах",
|
||||
"newTracker": "Новый трекер",
|
||||
"cancel": "Отмена",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Трекер семейных фото",
|
||||
"server": "Сервер",
|
||||
"selectServer": "Выберите сервер...",
|
||||
"albums": "Альбомы",
|
||||
"eventTypes": "Типы событий",
|
||||
"notificationTargets": "Получатели уведомлений",
|
||||
"scanInterval": "Интервал проверки (секунды)",
|
||||
"createTracker": "Создать трекер",
|
||||
"noTrackers": "Трекеров пока нет. Сначала добавьте сервер, затем создайте трекер.",
|
||||
"active": "Активен",
|
||||
"paused": "Приостановлен",
|
||||
"pause": "Пауза",
|
||||
"resume": "Возобновить",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить этот трекер?",
|
||||
"albums_count": "альбом(ов)",
|
||||
"every": "каждые",
|
||||
"trackImages": "Отслеживать фото",
|
||||
"trackVideos": "Отслеживать видео",
|
||||
"favoritesOnly": "Только избранные",
|
||||
"includePeople": "Включать людей в уведомления",
|
||||
"includeAssetDetails": "Включать детали файлов",
|
||||
"maxAssetsToShow": "Макс. файлов в уведомлении",
|
||||
"sortBy": "Сортировка",
|
||||
"sortOrder": "Порядок",
|
||||
"sortNone": "Исходный порядок",
|
||||
"sortDate": "Дата",
|
||||
"sortRating": "Рейтинг",
|
||||
"sortName": "Имя",
|
||||
"sortRandom": "Случайный",
|
||||
"ascending": "По возрастанию",
|
||||
"descending": "По убыванию",
|
||||
"quietHoursStart": "Тихие часы начало",
|
||||
"quietHoursEnd": "Тихие часы конец"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Шаблоны",
|
||||
"description": "Шаблоны сообщений Jinja2 для уведомлений",
|
||||
"newTemplate": "Новый шаблон",
|
||||
"cancel": "Отмена",
|
||||
"name": "Название",
|
||||
"body": "Текст шаблона (Jinja2)",
|
||||
"variables": "Переменные",
|
||||
"preview": "Предпросмотр",
|
||||
"edit": "Редактировать",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить этот шаблон?",
|
||||
"create": "Создать шаблон",
|
||||
"update": "Обновить шаблон",
|
||||
"noTemplates": "Шаблонов пока нет. Без шаблона будет использован шаблон по умолчанию.",
|
||||
"eventType": "Тип события",
|
||||
"allEvents": "Все события",
|
||||
"assetsAdded": "Добавлены файлы",
|
||||
"assetsRemoved": "Удалены файлы",
|
||||
"albumRenamed": "Альбом переименован",
|
||||
"albumDeleted": "Альбом удалён"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Получатели",
|
||||
"description": "Адреса уведомлений (Telegram, вебхуки)",
|
||||
"addTarget": "Добавить получателя",
|
||||
"cancel": "Отмена",
|
||||
"type": "Тип",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Мои уведомления",
|
||||
"botToken": "Токен бота",
|
||||
"chatId": "ID чата",
|
||||
"webhookUrl": "URL вебхука",
|
||||
"create": "Добавить",
|
||||
"test": "Тест",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить этого получателя?",
|
||||
"noTargets": "Получатели уведомлений не настроены.",
|
||||
"testSent": "Тестовое уведомление отправлено!",
|
||||
"aiCaptions": "Включить AI подписи",
|
||||
"telegramSettings": "Настройки Telegram",
|
||||
"maxMedia": "Макс. медиафайлов",
|
||||
"maxGroupSize": "Макс. размер группы",
|
||||
"chunkDelay": "Задержка между группами (мс)",
|
||||
"maxAssetSize": "Макс. размер файла (МБ)",
|
||||
"videoWarning": "Предупреждение о размере видео",
|
||||
"disableUrlPreview": "Отключить превью ссылок",
|
||||
"sendLargeAsDocuments": "Отправлять большие фото как документы"
|
||||
},
|
||||
"users": {
|
||||
"title": "Пользователи",
|
||||
"description": "Управление аккаунтами (только админ)",
|
||||
"addUser": "Добавить пользователя",
|
||||
"cancel": "Отмена",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"role": "Роль",
|
||||
"roleUser": "Пользователь",
|
||||
"roleAdmin": "Администратор",
|
||||
"create": "Создать",
|
||||
"delete": "Удалить",
|
||||
"confirmDelete": "Удалить этого пользователя?",
|
||||
"joined": "зарегистрирован"
|
||||
},
|
||||
"telegramBot": {
|
||||
"title": "Telegram боты",
|
||||
"description": "Регистрация и управление Telegram ботами",
|
||||
"addBot": "Добавить бота",
|
||||
"name": "Отображаемое имя",
|
||||
"namePlaceholder": "Бот семейных уведомлений",
|
||||
"token": "Токен бота",
|
||||
"tokenPlaceholder": "123456:ABC-DEF...",
|
||||
"noBots": "Ботов пока нет.",
|
||||
"chats": "Чаты",
|
||||
"noChats": "Чатов не найдено. Сначала отправьте сообщение боту.",
|
||||
"refreshChats": "Обновить",
|
||||
"selectBot": "Выберите бота",
|
||||
"selectChat": "Выберите чат",
|
||||
"private": "Личный",
|
||||
"group": "Группа",
|
||||
"supergroup": "Супергруппа",
|
||||
"channel": "Канал",
|
||||
"confirmDelete": "Удалить этого бота?",
|
||||
"commands": "Команды",
|
||||
"enabledCommands": "Включённые команды",
|
||||
"defaultCount": "Кол-во результатов",
|
||||
"responseMode": "Режим ответа",
|
||||
"modeMedia": "Медиа (отправка фото)",
|
||||
"modeText": "Текст (ссылки)",
|
||||
"botLocale": "Язык бота",
|
||||
"rateLimits": "Ограничения частоты",
|
||||
"rateSearch": "Кулдаун поиска",
|
||||
"rateFind": "Кулдаун поиска файлов",
|
||||
"rateDefault": "Кулдаун по умолчанию",
|
||||
"syncCommands": "Синхронизировать с Telegram",
|
||||
"discoverChats": "Обнаружить чаты из Telegram",
|
||||
"clickToCopy": "Нажмите, чтобы скопировать ID чата",
|
||||
"chatsDiscovered": "Чаты обнаружены",
|
||||
"chatDeleted": "Чат удалён"
|
||||
},
|
||||
"trackingConfig": {
|
||||
"title": "Конфигурации отслеживания",
|
||||
"description": "Определите, на какие события и файлы реагировать",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "Основное отслеживание",
|
||||
"noConfigs": "Конфигураций отслеживания пока нет.",
|
||||
"eventTracking": "Отслеживание событий",
|
||||
"assetsAdded": "Добавлены файлы",
|
||||
"assetsRemoved": "Удалены файлы",
|
||||
"albumRenamed": "Альбом переименован",
|
||||
"albumDeleted": "Альбом удалён",
|
||||
"trackImages": "Фото",
|
||||
"trackVideos": "Видео",
|
||||
"favoritesOnly": "Только избранные",
|
||||
"assetDisplay": "Отображение файлов",
|
||||
"includePeople": "Включать людей",
|
||||
"includeDetails": "Включать детали",
|
||||
"maxAssets": "Макс. файлов",
|
||||
"sortBy": "Сортировка",
|
||||
"sortOrder": "Порядок",
|
||||
"periodicSummary": "Периодическая сводка",
|
||||
"enabled": "Включено",
|
||||
"intervalDays": "Интервал (дни)",
|
||||
"startDate": "Дата начала",
|
||||
"times": "Время (ЧЧ:ММ)",
|
||||
"scheduledAssets": "Запланированные фото",
|
||||
"albumMode": "Режим альбомов",
|
||||
"limit": "Лимит",
|
||||
"assetType": "Тип файлов",
|
||||
"minRating": "Мин. рейтинг",
|
||||
"memoryMode": "Воспоминания (В этот день)",
|
||||
"test": "Тест",
|
||||
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
|
||||
"sortNone": "Нет",
|
||||
"sortDate": "Дата",
|
||||
"sortRating": "Рейтинг",
|
||||
"sortName": "Имя",
|
||||
"orderDesc": "По убыванию",
|
||||
"orderAsc": "По возрастанию",
|
||||
"albumModePerAlbum": "По альбомам",
|
||||
"albumModeCombined": "Объединённый",
|
||||
"albumModeRandom": "Случайный",
|
||||
"assetTypeAll": "Все",
|
||||
"assetTypePhoto": "Фото",
|
||||
"assetTypeVideo": "Видео"
|
||||
},
|
||||
"templateConfig": {
|
||||
"title": "Конфигурации шаблонов",
|
||||
"description": "Определите формат уведомлений",
|
||||
"newConfig": "Новая конфигурация",
|
||||
"name": "Название",
|
||||
"namePlaceholder": "По умолчанию RU",
|
||||
"descriptionPlaceholder": "напр. Русские шаблоны для семейных уведомлений",
|
||||
"noConfigs": "Конфигураций шаблонов пока нет.",
|
||||
"eventMessages": "Сообщения о событиях",
|
||||
"assetsAdded": "Добавлены файлы",
|
||||
"assetsRemoved": "Удалены файлы",
|
||||
"albumRenamed": "Альбом переименован",
|
||||
"albumDeleted": "Альбом удалён",
|
||||
"assetFormatting": "Форматирование файлов",
|
||||
"imageTemplate": "Шаблон фото",
|
||||
"videoTemplate": "Шаблон видео",
|
||||
"assetsWrapper": "Обёртка списка",
|
||||
"moreMessage": "Сообщение \"ещё\"",
|
||||
"peopleFormat": "Формат людей",
|
||||
"dateLocation": "Дата и место",
|
||||
"dateFormat": "Формат даты",
|
||||
"commonDate": "Общая дата",
|
||||
"uniqueDate": "Дата файла",
|
||||
"locationFormat": "Формат места",
|
||||
"commonLocation": "Общее место",
|
||||
"uniqueLocation": "Место файла",
|
||||
"favoriteIndicator": "Индикатор избранного",
|
||||
"scheduledMessages": "Запланированные сообщения",
|
||||
"periodicSummary": "Периодическая сводка",
|
||||
"periodicAlbum": "Элемент альбома",
|
||||
"scheduledAssets": "Запланированные фото",
|
||||
"memoryMode": "Воспоминания",
|
||||
"settings": "Настройки",
|
||||
"previewAs": "Предпросмотр как",
|
||||
"preview": "Предпросмотр",
|
||||
"variables": "Переменные",
|
||||
"assetFields": "Поля файла (в {% for asset in added_assets %})",
|
||||
"albumFields": "Поля альбома (в {% for album in albums %})",
|
||||
"confirmDelete": "Удалить эту конфигурацию шаблона?"
|
||||
},
|
||||
"templateVars": {
|
||||
"message_assets_added": { "description": "Уведомление о добавлении файлов в альбом" },
|
||||
"message_assets_removed": { "description": "Уведомление об удалении файлов из альбома" },
|
||||
"message_album_renamed": { "description": "Уведомление о переименовании альбома" },
|
||||
"message_album_deleted": { "description": "Уведомление об удалении альбома" },
|
||||
"periodic_summary_message": { "description": "Периодическая сводка альбомов (планировщик не реализован)" },
|
||||
"scheduled_assets_message": { "description": "Запланированная подборка фото (планировщик не реализован)" },
|
||||
"memory_mode_message": { "description": "«В этот день» — воспоминания (планировщик не реализован)" },
|
||||
"album_id": "ID альбома (UUID)",
|
||||
"album_name": "Название альбома",
|
||||
"album_url": "Публичная ссылка (пусто, если не расшарен)",
|
||||
"added_count": "Количество добавленных файлов",
|
||||
"removed_count": "Количество удалённых файлов",
|
||||
"change_type": "Тип изменения (assets_added, assets_removed, album_renamed, album_deleted)",
|
||||
"people": "Обнаруженные люди (список, {{ people | join(', ') }})",
|
||||
"added_assets": "Список файлов ({% for asset in added_assets %})",
|
||||
"removed_assets": "Список ID удалённых файлов (строки)",
|
||||
"shared": "Общий альбом (boolean)",
|
||||
"target_type": "Тип получателя: 'telegram' или 'webhook'",
|
||||
"has_videos": "Содержат ли добавленные файлы видео (boolean)",
|
||||
"has_photos": "Содержат ли добавленные файлы фото (boolean)",
|
||||
"old_name": "Прежнее название альбома (при переименовании)",
|
||||
"new_name": "Новое название альбома (при переименовании)",
|
||||
"old_shared": "Был ли общим до переименования (boolean)",
|
||||
"new_shared": "Является ли общим после переименования (boolean)",
|
||||
"albums": "Список альбомов ({% for album in albums %})",
|
||||
"assets": "Список файлов ({% for asset in assets %})",
|
||||
"date": "Текущая дата",
|
||||
"asset_id": "ID файла (UUID)",
|
||||
"asset_filename": "Имя файла",
|
||||
"asset_type": "IMAGE или VIDEO",
|
||||
"asset_created_at": "Дата создания (ISO 8601)",
|
||||
"asset_owner": "Имя владельца",
|
||||
"asset_owner_id": "ID владельца",
|
||||
"asset_description": "Описание (EXIF или пользовательское)",
|
||||
"asset_people": "Люди на этом файле (список)",
|
||||
"asset_is_favorite": "В избранном (boolean)",
|
||||
"asset_rating": "Рейтинг (1-5 или null)",
|
||||
"asset_latitude": "GPS широта (float или null)",
|
||||
"asset_longitude": "GPS долгота (float или null)",
|
||||
"asset_city": "Город",
|
||||
"asset_state": "Регион",
|
||||
"asset_country": "Страна",
|
||||
"asset_url": "Ссылка для просмотра (если расшарен)",
|
||||
"asset_download_url": "Ссылка для скачивания (если расшарен)",
|
||||
"asset_photo_url": "URL превью (только фото, если расшарен)",
|
||||
"asset_playback_url": "URL видео (только видео, если расшарен)",
|
||||
"album_name_field": "Название альбома (в списке альбомов)",
|
||||
"album_asset_count": "Всего файлов в альбоме",
|
||||
"album_url_field": "Ссылка на альбом",
|
||||
"album_shared": "Общий альбом"
|
||||
},
|
||||
"hints": {
|
||||
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
|
||||
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
|
||||
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
|
||||
"favoritesOnly": "Включать только ассеты, отмеченные как избранные в Immich.",
|
||||
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
||||
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
|
||||
"times": "Время отправки уведомлений в формате ЧЧ:ММ. Для нескольких значений через запятую: 09:00,18:00",
|
||||
"albumMode": "По альбому: отдельное уведомление для каждого. Объединённый: одно уведомление со всеми. Случайный: выбирается один альбом.",
|
||||
"minRating": "Включать только ассеты с рейтингом не ниже указанного (0 = без фильтра).",
|
||||
"eventMessages": "Шаблоны уведомлений о событиях в реальном времени. Используйте {переменные} для динамического контента.",
|
||||
"assetFormatting": "Форматирование отдельных ассетов в сообщениях уведомлений.",
|
||||
"dateLocation": "Форматирование даты и местоположения. Использует синтаксис strftime для дат.",
|
||||
"scheduledMessages": "Шаблоны для периодических сводок, подборок фото и воспоминаний «В этот день».",
|
||||
"aiCaptions": "Использовать Claude AI для генерации описания уведомления вместо шаблона.",
|
||||
"maxMedia": "Максимальное количество фото/видео в одном уведомлении (0 = только текст).",
|
||||
"groupSize": "Медиагруппы Telegram содержат 2-10 элементов. Большие пакеты разбиваются на части.",
|
||||
"chunkDelay": "Задержка в миллисекундах между отправкой порций медиа. Предотвращает ограничение Telegram.",
|
||||
"maxAssetSize": "Пропускать файлы больше указанного размера в МБ. Лимит Telegram — 50 МБ.",
|
||||
"trackingConfig": "Управляет тем, какие события вызывают уведомления и как фильтруются ассеты.",
|
||||
"templateConfig": "Управляет форматом сообщений. Используются шаблоны по умолчанию, если не задано.",
|
||||
"scanInterval": "Как часто опрашивать сервер Immich на предмет изменений (в секундах). Меньше = быстрее обнаружение, но больше запросов к API.",
|
||||
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
|
||||
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
|
||||
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
|
||||
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений."
|
||||
},
|
||||
"snack": {
|
||||
"serverSaved": "Сервер сохранён",
|
||||
"serverDeleted": "Сервер удалён",
|
||||
"trackerCreated": "Трекер создан",
|
||||
"trackerUpdated": "Трекер обновлён",
|
||||
"trackerDeleted": "Трекер удалён",
|
||||
"trackerPaused": "Трекер приостановлен",
|
||||
"trackerResumed": "Трекер возобновлён",
|
||||
"targetSaved": "Цель сохранена",
|
||||
"targetDeleted": "Цель удалена",
|
||||
"targetTestSent": "Тестовое уведомление отправлено",
|
||||
"templateSaved": "Шаблон сохранён",
|
||||
"templateDeleted": "Шаблон удалён",
|
||||
"trackingConfigSaved": "Конфигурация сохранена",
|
||||
"trackingConfigDeleted": "Конфигурация удалена",
|
||||
"botRegistered": "Бот зарегистрирован",
|
||||
"botDeleted": "Бот удалён",
|
||||
"userCreated": "Пользователь создан",
|
||||
"userDeleted": "Пользователь удалён",
|
||||
"passwordChanged": "Пароль изменён",
|
||||
"copied": "Скопировано",
|
||||
"genericError": "Что-то пошло не так",
|
||||
"commandsSaved": "Конфигурация команд сохранена",
|
||||
"commandsSynced": "Команды синхронизированы с Telegram"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"delete": "Удалить",
|
||||
"edit": "Редактировать",
|
||||
"description": "Описание",
|
||||
"close": "Закрыть",
|
||||
"confirm": "Подтвердить",
|
||||
"error": "Ошибка",
|
||||
"success": "Успешно",
|
||||
"none": "Нет",
|
||||
"noneDefault": "Нет (по умолчанию)",
|
||||
"loadError": "Не удалось загрузить данные",
|
||||
"headersInvalid": "Невалидный JSON",
|
||||
"language": "Язык",
|
||||
"theme": "Тема",
|
||||
"light": "Светлая",
|
||||
"dark": "Тёмная",
|
||||
"system": "Системная",
|
||||
"test": "Тест",
|
||||
"create": "Создать",
|
||||
"changePassword": "Сменить пароль",
|
||||
"currentPassword": "Текущий пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
"passwordChanged": "Пароль успешно изменён",
|
||||
"expand": "Развернуть",
|
||||
"collapse": "Свернуть",
|
||||
"syntaxError": "Ошибка синтаксиса",
|
||||
"undefinedVar": "Неизвестная переменная",
|
||||
"line": "строка"
|
||||
}
|
||||
}
|
||||
1
frontend/src/lib/index.ts
Normal file
1
frontend/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
78
frontend/src/lib/stores/snackbar.svelte.ts
Normal file
78
frontend/src/lib/stores/snackbar.svelte.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export type SnackType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
export interface Snack {
|
||||
id: number;
|
||||
type: SnackType;
|
||||
message: string;
|
||||
detail?: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUTS: Record<SnackType, number> = {
|
||||
success: 3000,
|
||||
info: 3000,
|
||||
warning: 4000,
|
||||
error: 5000,
|
||||
};
|
||||
|
||||
const MAX_VISIBLE = 3;
|
||||
|
||||
let nextId = 1;
|
||||
let snacks = $state<Snack[]>([]);
|
||||
const timers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
|
||||
export function getSnacks(): Snack[] {
|
||||
return snacks;
|
||||
}
|
||||
|
||||
export function addSnack(
|
||||
type: SnackType,
|
||||
message: string,
|
||||
options?: { detail?: string; timeout?: number },
|
||||
): void {
|
||||
const id = nextId++;
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUTS[type];
|
||||
const snack: Snack = { id, type, message, detail: options?.detail, timeout };
|
||||
|
||||
snacks = [snack, ...snacks];
|
||||
|
||||
// Enforce max visible
|
||||
while (snacks.length > MAX_VISIBLE) {
|
||||
const oldest = snacks[snacks.length - 1];
|
||||
removeSnack(oldest.id);
|
||||
}
|
||||
|
||||
// Auto-dismiss
|
||||
if (timeout > 0) {
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => removeSnack(id), timeout),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSnack(id: number): void {
|
||||
const timer = timers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
}
|
||||
snacks = snacks.filter((s) => s.id !== id);
|
||||
}
|
||||
|
||||
// Convenience functions
|
||||
export function snackSuccess(message: string): void {
|
||||
addSnack('success', message);
|
||||
}
|
||||
|
||||
export function snackError(message: string, detail?: string): void {
|
||||
addSnack('error', message, { detail });
|
||||
}
|
||||
|
||||
export function snackInfo(message: string): void {
|
||||
addSnack('info', message);
|
||||
}
|
||||
|
||||
export function snackWarning(message: string): void {
|
||||
addSnack('warning', message);
|
||||
}
|
||||
54
frontend/src/lib/theme.svelte.ts
Normal file
54
frontend/src/lib/theme.svelte.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Theme management with Svelte 5 runes.
|
||||
* Supports light, dark, and system preference.
|
||||
*/
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
let theme = $state<Theme>('system');
|
||||
let resolved = $state<'light' | 'dark'>('light');
|
||||
|
||||
export function getTheme() {
|
||||
return {
|
||||
get current() { return theme; },
|
||||
get resolved() { return resolved; },
|
||||
get isDark() { return resolved === 'dark'; },
|
||||
};
|
||||
}
|
||||
|
||||
export function setTheme(newTheme: Theme) {
|
||||
theme = newTheme;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
applyTheme();
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const saved = localStorage.getItem('theme') as Theme | null;
|
||||
if (saved && ['light', 'dark', 'system'].includes(saved)) {
|
||||
theme = saved;
|
||||
}
|
||||
}
|
||||
applyTheme();
|
||||
|
||||
// Listen for system preference changes
|
||||
if (typeof window !== 'undefined') {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (theme === 'system') applyTheme();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
if (theme === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
} else {
|
||||
resolved = theme;
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
}
|
||||
294
frontend/src/routes/+layout.svelte
Normal file
294
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,294 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
|
||||
import { t, initLocale, getLocale, setLocale, type Locale } from '$lib/i18n';
|
||||
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Snackbar from '$lib/components/Snackbar.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
const auth = getAuth();
|
||||
const theme = getTheme();
|
||||
|
||||
let showPasswordForm = $state(false);
|
||||
let pwdCurrent = $state('');
|
||||
let pwdNew = $state('');
|
||||
let pwdMsg = $state('');
|
||||
let pwdSuccess = $state(false);
|
||||
|
||||
async function changePassword(e: SubmitEvent) {
|
||||
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
|
||||
try {
|
||||
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
|
||||
pwdMsg = t('common.passwordChanged');
|
||||
pwdSuccess = true;
|
||||
pwdCurrent = ''; pwdNew = '';
|
||||
snackSuccess(t('snack.passwordChanged'));
|
||||
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }, 2000);
|
||||
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
||||
}
|
||||
|
||||
let collapsed = $state(false);
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
|
||||
{ href: '/servers', key: 'nav.servers', icon: 'mdiServer' },
|
||||
{ href: '/trackers', key: 'nav.trackers', icon: 'mdiRadar' },
|
||||
{ href: '/tracking-configs', key: 'nav.trackingConfigs', icon: 'mdiCog' },
|
||||
{ href: '/template-configs', key: 'nav.templateConfigs', icon: 'mdiFileDocumentEdit' },
|
||||
{ href: '/telegram-bots', key: 'nav.telegramBots', icon: 'mdiRobot' },
|
||||
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
|
||||
];
|
||||
|
||||
const isAuthPage = $derived(
|
||||
page.url.pathname === '/login' || page.url.pathname === '/setup'
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
initLocale();
|
||||
initTheme();
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||
}
|
||||
await loadUser();
|
||||
if (!auth.user && !isAuthPage) {
|
||||
goto('/login');
|
||||
}
|
||||
});
|
||||
|
||||
function cycleTheme() {
|
||||
const order: Theme[] = ['light', 'dark', 'system'];
|
||||
const idx = order.indexOf(theme.current);
|
||||
setTheme(order[(idx + 1) % order.length]);
|
||||
}
|
||||
|
||||
function toggleLocale() {
|
||||
setLocale(getLocale() === 'en' ? 'ru' : 'en');
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
collapsed = !collapsed;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('sidebar_collapsed', String(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
return page.url.pathname === href;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isAuthPage}
|
||||
{@render children()}
|
||||
{:else if auth.loading}
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-5 h-5 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if auth.user}
|
||||
<div class="flex h-screen">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="sidebar {collapsed ? 'w-[3.75rem]' : 'w-[15rem]'} flex flex-col max-md:hidden"
|
||||
style="background: var(--color-sidebar); border-right: 1px solid var(--color-border); transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center {collapsed ? 'justify-center p-2.5' : 'justify-between px-5 py-4'}" style="border-bottom: 1px solid var(--color-border);">
|
||||
{#if !collapsed}
|
||||
<div class="animate-fade-slide-in">
|
||||
<h1 class="text-base font-semibold tracking-tight" style="color: var(--color-foreground);">
|
||||
<span style="color: var(--color-primary);">Immich</span> Watcher
|
||||
</h1>
|
||||
<p class="text-[0.7rem] text-[var(--color-muted-foreground)] mt-0.5 tracking-wide uppercase">{t('app.tagline')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={toggleSidebar}
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground); background: transparent;"
|
||||
onmouseenter={(e) => { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; }}
|
||||
title={collapsed ? t('common.expand') : t('common.collapse')}>
|
||||
<MdiIcon name={collapsed ? 'mdiChevronRight' : 'mdiChevronLeft'} size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="flex-1 p-2 space-y-0.5 overflow-y-auto">
|
||||
{#each navItems as item}
|
||||
<a
|
||||
href={item.href}
|
||||
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative"
|
||||
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive(item.href) ? '500' : '400'};"
|
||||
onmouseenter={(e) => { if (!isActive(item.href)) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||
onmouseleave={(e) => { if (!isActive(item.href)) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
||||
title={collapsed ? t(item.key) : ''}
|
||||
>
|
||||
{#if isActive(item.href)}
|
||||
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||
{/if}
|
||||
<MdiIcon name={item.icon} size={18} />
|
||||
{#if !collapsed}<span class="truncate">{t(item.key)}</span>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{#if auth.isAdmin}
|
||||
<a
|
||||
href="/users"
|
||||
class="nav-item group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative"
|
||||
style="color: {isActive('/users') ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive('/users') ? 'var(--color-sidebar-active)' : 'transparent'}; font-weight: {isActive('/users') ? '500' : '400'};"
|
||||
onmouseenter={(e) => { if (!isActive('/users')) { e.currentTarget.style.background = 'var(--color-muted)'; e.currentTarget.style.color = 'var(--color-foreground)'; } }}
|
||||
onmouseleave={(e) => { if (!isActive('/users')) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--color-muted-foreground)'; } }}
|
||||
title={collapsed ? t('nav.users') : ''}
|
||||
>
|
||||
{#if isActive('/users')}
|
||||
<div style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
|
||||
{/if}
|
||||
<MdiIcon name="mdiAccountGroup" size={18} />
|
||||
{#if !collapsed}<span class="truncate">{t('nav.users')}</span>{/if}
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="border-top: 1px solid var(--color-border);">
|
||||
<!-- Theme & Language -->
|
||||
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-2' : 'gap-1.5 px-4 py-2.5'}">
|
||||
<button onclick={toggleLocale}
|
||||
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs font-medium transition-all duration-200"
|
||||
style="background: var(--color-muted); color: var(--color-muted-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.boxShadow = '0 0 8px var(--color-glow)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||
title={t('common.language')}>
|
||||
{getLocale().toUpperCase()}
|
||||
</button>
|
||||
<button onclick={cycleTheme}
|
||||
class="flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs transition-all duration-200"
|
||||
style="background: var(--color-muted); color: var(--color-muted-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.boxShadow = '0 0 8px var(--color-glow)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||
title={t('common.theme')}>
|
||||
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : theme.current === 'system' ? 'mdiDesktopTowerMonitor' : 'mdiWeatherSunny'} size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- User info -->
|
||||
<div class="p-2.5" style="border-top: 1px solid var(--color-border);">
|
||||
{#if collapsed}
|
||||
<button onclick={logout}
|
||||
class="w-full flex justify-center py-2 rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.background = 'var(--color-muted)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.background = 'transparent'; }}
|
||||
title={t('nav.logout')}>
|
||||
<MdiIcon name="mdiLogout" size={16} />
|
||||
</button>
|
||||
{:else}
|
||||
<div class="px-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-7 h-7 rounded-full flex items-center justify-center text-[0.7rem] font-semibold"
|
||||
style="background: var(--color-primary); color: var(--color-primary-foreground);">
|
||||
{auth.user.username[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{auth.user.username}</p>
|
||||
<p class="text-[0.65rem] tracking-wide uppercase" style="color: var(--color-muted-foreground);">{auth.user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={logout}
|
||||
class="p-1.5 rounded-lg transition-all duration-200"
|
||||
style="color: var(--color-muted-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-foreground)'; e.currentTarget.style.background = 'var(--color-muted)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; e.currentTarget.style.background = 'transparent'; }}
|
||||
title={t('nav.logout')}>
|
||||
<MdiIcon name="mdiLogout" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<button onclick={() => showPasswordForm = true}
|
||||
class="text-[0.7rem] mt-1.5 transition-colors duration-200 flex items-center gap-1"
|
||||
style="color: var(--color-muted-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.color = 'var(--color-primary)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.color = 'var(--color-muted-foreground)'; }}>
|
||||
<MdiIcon name="mdiKeyVariant" size={12} />
|
||||
{t('common.changePassword')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile bottom nav -->
|
||||
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
|
||||
{#each navItems.slice(0, 5) as item}
|
||||
<a href={item.href}
|
||||
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
|
||||
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
|
||||
<MdiIcon name={item.icon} size={20} />
|
||||
</a>
|
||||
{/each}
|
||||
<button onclick={logout}
|
||||
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs" style="color: var(--color-muted-foreground);">
|
||||
<MdiIcon name="mdiLogout" size={20} />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 overflow-auto pb-16 md:pb-0">
|
||||
{#key page.url.pathname}
|
||||
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/key}
|
||||
</main>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-5 h-5 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Password change modal -->
|
||||
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }}>
|
||||
<form onsubmit={changePassword} class="space-y-3">
|
||||
<div>
|
||||
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
|
||||
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||
<input id="pwd-new" type="password" bind:value={pwdNew} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if pwdMsg}
|
||||
<p class="text-sm" style="color: var({pwdSuccess ? '--color-success-fg' : '--color-error-fg'});">{pwdMsg}</p>
|
||||
{/if}
|
||||
<button type="submit"
|
||||
class="w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200"
|
||||
style="background: var(--color-primary); color: var(--color-primary-foreground);"
|
||||
onmouseenter={(e) => { e.currentTarget.style.boxShadow = '0 0 16px var(--color-glow-strong)'; }}
|
||||
onmouseleave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Snackbar />
|
||||
|
||||
<style>
|
||||
@media (max-width: 767px) {
|
||||
.mobile-nav { display: flex !important; }
|
||||
}
|
||||
</style>
|
||||
260
frontend/src/routes/+page.svelte
Normal file
260
frontend/src/routes/+page.svelte
Normal file
@@ -0,0 +1,260 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
let status = $state<any>(null);
|
||||
let loaded = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Animated counters
|
||||
let displayServers = $state(0);
|
||||
let displayActive = $state(0);
|
||||
let displayTotal = $state(0);
|
||||
let displayTargets = $state(0);
|
||||
|
||||
function animateCount(from: number, to: number, setter: (v: number) => void, duration = 600) {
|
||||
if (to === 0) { setter(0); return; }
|
||||
const start = performance.now();
|
||||
function frame(now: number) {
|
||||
const elapsed = now - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3); // ease-out cubic
|
||||
setter(Math.round(from + (to - from) * eased));
|
||||
if (progress < 1) requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
status = await api('/status');
|
||||
// Animate counts
|
||||
setTimeout(() => {
|
||||
animateCount(0, status.servers, (v) => displayServers = v);
|
||||
animateCount(0, status.trackers.active, (v) => displayActive = v);
|
||||
animateCount(0, status.trackers.total, (v) => displayTotal = v);
|
||||
animateCount(0, status.targets, (v) => displayTargets = v);
|
||||
}, 200);
|
||||
} catch (err: any) {
|
||||
error = err.message || t('common.error');
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
});
|
||||
|
||||
const statCards = $derived(status ? [
|
||||
{ icon: 'mdiServer', label: 'dashboard.servers', value: displayServers, color: '#0d9488' },
|
||||
{ icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' },
|
||||
{ icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' },
|
||||
] : []);
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
const eventIcons: Record<string, string> = {
|
||||
assets_added: 'mdiImagePlus',
|
||||
assets_removed: 'mdiImageMinus',
|
||||
album_renamed: 'mdiRename',
|
||||
album_deleted: 'mdiDeleteAlert',
|
||||
};
|
||||
|
||||
const eventColors: Record<string, string> = {
|
||||
assets_added: '#059669',
|
||||
assets_removed: '#ef4444',
|
||||
album_renamed: '#6366f1',
|
||||
album_deleted: '#dc2626',
|
||||
};
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('dashboard.title')} description={t('dashboard.description')} />
|
||||
|
||||
{#if !loaded}
|
||||
<Loading lines={4} />
|
||||
{:else if error}
|
||||
<Card>
|
||||
<div class="flex items-center gap-2" style="color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlertCircle" size={20} />
|
||||
<p class="text-sm">{error}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else if status}
|
||||
<!-- Stat cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8 stagger-children">
|
||||
{#each statCards as card, i}
|
||||
<div class="stat-card" style="--accent: {card.color};">
|
||||
<div class="stat-card-inner">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="stat-icon" style="background: {card.color}15; color: {card.color};">
|
||||
<MdiIcon name={card.icon} size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm" style="color: var(--color-muted-foreground);">{t(card.label)}</p>
|
||||
<p class="stat-value font-mono" style="animation-delay: {i * 80 + 200}ms;">
|
||||
{card.value}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Recent events -->
|
||||
<h3 class="text-base font-semibold mb-3 flex items-center gap-2">
|
||||
<MdiIcon name="mdiPulse" size={18} />
|
||||
{t('dashboard.recentEvents')}
|
||||
</h3>
|
||||
{#if status.recent_events.length === 0}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;">
|
||||
<MdiIcon name="mdiCalendarBlank" size={40} />
|
||||
</div>
|
||||
<p class="text-sm">{t('dashboard.noEvents')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="event-timeline stagger-children">
|
||||
{#each status.recent_events as event, i}
|
||||
<div class="event-item" style="animation-delay: {i * 60}ms;">
|
||||
<!-- Timeline dot -->
|
||||
<div class="event-dot" style="background: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; box-shadow: 0 0 8px {eventColors[event.event_type] || 'var(--color-muted-foreground)'}40;"></div>
|
||||
{#if i < status.recent_events.length - 1}
|
||||
<div class="event-line"></div>
|
||||
{/if}
|
||||
<!-- Content -->
|
||||
<div class="event-content">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: {eventColors[event.event_type] || 'var(--color-muted-foreground)'}; flex-shrink: 0;">
|
||||
<MdiIcon name={eventIcons[event.event_type] || 'mdiBell'} size={16} />
|
||||
</span>
|
||||
<span class="text-sm font-medium truncate">{event.album_name}</span>
|
||||
<span class="event-badge">{event.event_type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
<span class="text-xs whitespace-nowrap font-mono" style="color: var(--color-muted-foreground);">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.stat-card {
|
||||
position: relative;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, var(--accent), transparent 60%, var(--color-border));
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-card-inner {
|
||||
background: var(--color-card);
|
||||
border-radius: calc(0.75rem - 1px);
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
animation: countUp 0.5s ease-out both;
|
||||
}
|
||||
|
||||
.stat-suffix {
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* Timeline */
|
||||
.event-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
position: relative;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.event-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 6px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.event-line {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 18px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.event-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 0.625rem;
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.event-content:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 12px var(--color-glow);
|
||||
}
|
||||
|
||||
.event-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
272
frontend/src/routes/login/+page.svelte
Normal file
272
frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { login } from '$lib/auth.svelte';
|
||||
import { t, initLocale, getLocale, setLocale } from '$lib/i18n';
|
||||
import { initTheme, getTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
const theme = getTheme();
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
let mounted = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
initLocale();
|
||||
initTheme();
|
||||
mounted = true;
|
||||
try {
|
||||
const res = await api<{ needs_setup: boolean }>('/auth/needs-setup');
|
||||
if (res.needs_setup) goto('/setup');
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
submitting = true;
|
||||
try {
|
||||
await login(username, password);
|
||||
window.location.href = '/';
|
||||
} catch (err: any) {
|
||||
error = err.message || 'Login failed';
|
||||
}
|
||||
submitting = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-page">
|
||||
<!-- Animated gradient mesh background -->
|
||||
<div class="auth-bg"></div>
|
||||
<div class="auth-grid"></div>
|
||||
|
||||
<!-- Login card -->
|
||||
<div class="auth-card-wrapper" class:visible={mounted}>
|
||||
<div class="auth-card">
|
||||
<!-- Controls -->
|
||||
<div class="flex justify-end gap-1.5 mb-6">
|
||||
<button onclick={() => { setLocale(getLocale() === 'en' ? 'ru' : 'en'); }}
|
||||
class="auth-control-btn">
|
||||
{getLocale().toUpperCase()}
|
||||
</button>
|
||||
<button onclick={() => { const o: Theme[] = ['light','dark','system']; setTheme(o[(o.indexOf(theme.current)+1)%3]); }}
|
||||
class="auth-control-btn">
|
||||
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : 'mdiWeatherSunny'} size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Logo / title -->
|
||||
<div class="text-center mb-8">
|
||||
<div class="auth-logo-icon">
|
||||
<MdiIcon name="mdiEye" size={28} />
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold mt-4 tracking-tight">
|
||||
<span style="color: var(--color-primary);">Immich</span> Watcher
|
||||
</h1>
|
||||
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.signInTitle')}</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="auth-error animate-fade-slide-in">
|
||||
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="auth-label">{t('auth.username')}</label>
|
||||
<input id="username" type="text" bind:value={username} required
|
||||
class="auth-input" placeholder="admin" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="auth-label">{t('auth.password')}</label>
|
||||
<input id="password" type="password" bind:value={password} required
|
||||
class="auth-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={submitting} class="auth-submit">
|
||||
{#if submitting}
|
||||
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
|
||||
{/if}
|
||||
{submitting ? t('auth.signingIn') : t('auth.signIn')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.auth-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
|
||||
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
|
||||
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
|
||||
animation: gradientShift 12s ease-in-out infinite;
|
||||
background-size: 200% 200%;
|
||||
}
|
||||
|
||||
.auth-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
opacity: 0.3;
|
||||
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
.auth-card-wrapper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 1rem;
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.98);
|
||||
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
|
||||
}
|
||||
|
||||
.auth-card-wrapper.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .auth-card {
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.3),
|
||||
0 0 48px var(--color-glow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
|
||||
.auth-logo-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||
}
|
||||
|
||||
.auth-control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
background: var(--color-muted);
|
||||
color: var(--color-muted-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-control-btn:hover {
|
||||
color: var(--color-foreground);
|
||||
box-shadow: 0 0 8px var(--color-glow);
|
||||
}
|
||||
|
||||
.auth-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-fg);
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-submit:hover:not(:disabled) {
|
||||
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.auth-submit:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.auth-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
</style>
|
||||
255
frontend/src/routes/servers/+page.svelte
Normal file
255
frontend/src/routes/servers/+page.svelte
Normal file
@@ -0,0 +1,255 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let servers = $state<any[]>([]);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let form = $state({ name: 'Immich', url: '', api_key: '', icon: '' });
|
||||
let error = $state('');
|
||||
let loadError = $state('');
|
||||
let submitting = $state(false);
|
||||
let loaded = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
let health = $state<Record<number, boolean | null>>({});
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
servers = await api('/servers');
|
||||
loadError = '';
|
||||
} catch (err: any) {
|
||||
loadError = err.message || t('servers.loadError');
|
||||
snackError(loadError);
|
||||
} finally { loaded = true; }
|
||||
// Ping all servers in background
|
||||
for (const s of servers) {
|
||||
health[s.id] = null; // loading
|
||||
api(`/servers/${s.id}/ping`).then(r => health[s.id] = r.online).catch(() => health[s.id] = false);
|
||||
}
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
form = { name: 'Immich', url: '', api_key: '', icon: '' };
|
||||
editing = null; showForm = true;
|
||||
}
|
||||
function edit(s: any) {
|
||||
form = { name: s.name, url: s.url, api_key: '', icon: s.icon || '' };
|
||||
editing = s.id; showForm = true;
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
if (editing) {
|
||||
const body: any = { name: form.name, url: form.url };
|
||||
if (form.api_key) body.api_key = form.api_key;
|
||||
await api(`/servers/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
} else {
|
||||
await api('/servers', { method: 'POST', body: JSON.stringify(form) });
|
||||
}
|
||||
showForm = false; editing = null; await load();
|
||||
snackSuccess(t('snack.serverSaved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
function startDelete(server: any) {
|
||||
confirmDelete = server;
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
const id = confirmDelete.id;
|
||||
confirmDelete = null;
|
||||
try { await api(`/servers/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.serverDeleted')); } catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('servers.title')} description={t('servers.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="header-action-btn"
|
||||
style="background: {showForm ? 'var(--color-muted)' : 'var(--color-primary)'}; color: {showForm ? 'var(--color-foreground)' : 'var(--color-primary-foreground)'};">
|
||||
{#if showForm}
|
||||
<MdiIcon name="mdiClose" size={14} />
|
||||
{t('servers.cancel')}
|
||||
{:else}
|
||||
<MdiIcon name="mdiPlus" size={14} />
|
||||
{t('servers.addServer')}
|
||||
{/if}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else}
|
||||
|
||||
{#if loadError}
|
||||
<Card class="mb-6">
|
||||
<div class="flex items-center gap-2 text-sm" style="color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlertCircle" size={18} />
|
||||
{loadError}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}
|
||||
<div class="flex items-center gap-2 text-sm rounded-lg p-3 mb-4" style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
<form onsubmit={save} class="space-y-3">
|
||||
<div>
|
||||
<div class="flex items-end gap-2">
|
||||
<label for="srv-name" class="block text-sm font-medium mb-1">{t('servers.name')}</label>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
|
||||
<input id="srv-name" bind:value={form.name} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="srv-url" class="block text-sm font-medium mb-1">{t('servers.url')}</label>
|
||||
<input id="srv-url" bind:value={form.url} required placeholder={t('servers.urlPlaceholder')} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="srv-key" class="block text-sm font-medium mb-1">{editing ? t('servers.apiKeyKeep') : t('servers.apiKey')}</label>
|
||||
<input id="srv-key" bind:value={form.api_key} type="password" required={!editing} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<button type="submit" disabled={submitting}
|
||||
class="form-submit-btn">
|
||||
{#if submitting}
|
||||
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
|
||||
{/if}
|
||||
{submitting ? t('servers.connecting') : (editing ? t('common.save') : t('servers.addServer'))}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if servers.length === 0 && !showForm}
|
||||
<Card>
|
||||
<div class="flex flex-col items-center py-8 gap-3" style="color: var(--color-muted-foreground);">
|
||||
<div style="opacity: 0.4;"><MdiIcon name="mdiServerOff" size={40} /></div>
|
||||
<p class="text-sm">{t('servers.noServers')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each servers as server}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="health-dot {health[server.id] === true ? 'online' : health[server.id] === false ? 'offline' : 'checking'}"></div>
|
||||
{#if server.icon}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={server.icon} size={20} /></span>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="font-medium">{server.name}</p>
|
||||
<p class="text-sm font-mono" style="color: var(--color-muted-foreground); font-size: 0.75rem;">{server.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(server)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => startDelete(server)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={!!confirmDelete} title={t('common.delete')} message={t('servers.confirmDelete')}
|
||||
onconfirm={doDelete} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<style>
|
||||
.header-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.header-action-btn:hover {
|
||||
box-shadow: 0 0 16px var(--color-glow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.form-submit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-submit-btn:hover:not(:disabled) {
|
||||
box-shadow: 0 0 16px var(--color-glow-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.form-submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.health-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.health-dot.online {
|
||||
background: #059669;
|
||||
box-shadow: 0 0 8px rgba(5, 150, 105, 0.4);
|
||||
}
|
||||
|
||||
.health-dot.offline {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 8px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.health-dot.checking {
|
||||
background: #f59e0b;
|
||||
animation: pulseCheck 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulseCheck {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(245, 158, 11, 0.3); }
|
||||
50% { box-shadow: 0 0 12px rgba(245, 158, 11, 0.6); }
|
||||
}
|
||||
</style>
|
||||
229
frontend/src/routes/setup/+page.svelte
Normal file
229
frontend/src/routes/setup/+page.svelte
Normal file
@@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { setup } from '$lib/auth.svelte';
|
||||
import { t, initLocale } from '$lib/i18n';
|
||||
import { initTheme } from '$lib/theme.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
|
||||
let username = $state('admin');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
let mounted = $state(false);
|
||||
|
||||
onMount(() => { initLocale(); initTheme(); mounted = true; });
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
if (password !== confirmPassword) { error = t('auth.passwordMismatch'); return; }
|
||||
if (password.length < 6) { error = t('auth.passwordTooShort'); return; }
|
||||
submitting = true;
|
||||
try {
|
||||
await setup(username, password);
|
||||
window.location.href = '/';
|
||||
} catch (err: any) { error = err.message || 'Setup failed'; }
|
||||
submitting = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-page">
|
||||
<div class="auth-bg"></div>
|
||||
<div class="auth-grid"></div>
|
||||
|
||||
<div class="auth-card-wrapper" class:visible={mounted}>
|
||||
<div class="auth-card">
|
||||
<div class="text-center mb-8">
|
||||
<div class="auth-logo-icon">
|
||||
<MdiIcon name="mdiShieldAccount" size={28} />
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold mt-4 tracking-tight">
|
||||
<span style="color: var(--color-primary);">Immich</span> Watcher
|
||||
</h1>
|
||||
<p class="text-sm mt-1" style="color: var(--color-muted-foreground);">{t('auth.setupDescription')}</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="auth-error animate-fade-slide-in">
|
||||
<MdiIcon name="mdiAlertCircle" size={16} />
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="auth-label">{t('auth.username')}</label>
|
||||
<input id="username" type="text" bind:value={username} required class="auth-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="auth-label">{t('auth.password')}</label>
|
||||
<input id="password" type="password" bind:value={password} required class="auth-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirm" class="auth-label">{t('auth.confirmPassword')}</label>
|
||||
<input id="confirm" type="password" bind:value={confirmPassword} required class="auth-input" />
|
||||
</div>
|
||||
<button type="submit" disabled={submitting} class="auth-submit">
|
||||
{#if submitting}
|
||||
<div class="w-4 h-4 rounded-full border-2 border-current border-t-transparent animate-spin"></div>
|
||||
{/if}
|
||||
{submitting ? t('auth.creatingAccount') : t('auth.createAccount')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.auth-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 20% 30%, var(--color-glow-strong), transparent 60%),
|
||||
radial-gradient(ellipse 60% 80% at 80% 70%, rgba(99, 102, 241, 0.08), transparent 60%),
|
||||
radial-gradient(ellipse 50% 50% at 50% 50%, var(--color-glow), transparent 70%);
|
||||
animation: gradientShift 12s ease-in-out infinite;
|
||||
background-size: 200% 200%;
|
||||
}
|
||||
|
||||
.auth-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
opacity: 0.3;
|
||||
background-image: radial-gradient(circle at 1px 1px, var(--color-border) 0.5px, transparent 0);
|
||||
background-size: 32px 32px;
|
||||
}
|
||||
|
||||
.auth-card-wrapper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 1rem;
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.98);
|
||||
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
|
||||
}
|
||||
|
||||
.auth-card-wrapper.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .auth-card {
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.3),
|
||||
0 0 48px var(--color-glow),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
|
||||
.auth-logo-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||
}
|
||||
|
||||
.auth-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.375rem;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.auth-input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-glow), 0 0 16px var(--color-glow);
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-fg);
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-submit:hover:not(:disabled) {
|
||||
box-shadow: 0 0 24px var(--color-glow-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.auth-submit:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.auth-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
</style>
|
||||
298
frontend/src/routes/targets/+page.svelte
Normal file
298
frontend/src/routes/targets/+page.svelte
Normal file
@@ -0,0 +1,298 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let targets = $state<any[]>([]);
|
||||
let trackingConfigs = $state<any[]>([]);
|
||||
let templateConfigs = $state<any[]>([]);
|
||||
let bots = $state<any[]>([]);
|
||||
let botChats = $state<Record<number, any[]>>({});
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let formType = $state<'telegram' | 'webhook'>('telegram');
|
||||
const defaultForm = () => ({ name: '', icon: '', bot_id: 0, chat_id: '', bot_token: '', url: '', headers: '',
|
||||
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
|
||||
disable_url_preview: false, send_large_photos_as_documents: false, ai_captions: false,
|
||||
tracking_config_id: 0, template_config_id: 0 });
|
||||
let form = $state(defaultForm());
|
||||
let error = $state('');
|
||||
let headersError = $state('');
|
||||
let testResult = $state('');
|
||||
let loaded = $state(false);
|
||||
let loadError = $state('');
|
||||
let showTelegramSettings = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
[targets, trackingConfigs, templateConfigs, bots] = await Promise.all([
|
||||
api('/targets'), api('/tracking-configs'), api('/template-configs'), api('/telegram-bots')
|
||||
]);
|
||||
loadError = '';
|
||||
} catch (err: any) { loadError = err.message || t('common.loadError'); snackError(loadError); } finally { loaded = true; }
|
||||
}
|
||||
|
||||
async function loadBotChats() {
|
||||
if (!form.bot_id) return;
|
||||
try { botChats[form.bot_id] = await api(`/telegram-bots/${form.bot_id}/chats`); } catch {}
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); formType = 'telegram'; editing = null; showTelegramSettings = false; showForm = true; }
|
||||
async function edit(tgt: any) {
|
||||
formType = tgt.type;
|
||||
const c = tgt.config || {};
|
||||
form = {
|
||||
name: tgt.name, icon: tgt.icon || '', bot_id: c.bot_id || 0, bot_token: '', chat_id: c.chat_id || '', url: c.url || '', headers: '',
|
||||
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
|
||||
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
|
||||
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
|
||||
ai_captions: c.ai_captions ?? false,
|
||||
tracking_config_id: tgt.tracking_config_id ?? 0,
|
||||
template_config_id: tgt.template_config_id ?? 0,
|
||||
};
|
||||
editing = tgt.id; showTelegramSettings = false; showForm = true;
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; headersError = '';
|
||||
try {
|
||||
let botToken = form.bot_token;
|
||||
// Resolve token from registered bot if selected
|
||||
if (formType === 'telegram' && form.bot_id && !botToken) {
|
||||
const tokenRes = await api(`/telegram-bots/${form.bot_id}/token`);
|
||||
botToken = tokenRes.token;
|
||||
}
|
||||
let parsedHeaders = {};
|
||||
if (formType === 'webhook' && form.headers) {
|
||||
try {
|
||||
parsedHeaders = JSON.parse(form.headers);
|
||||
} catch {
|
||||
headersError = t('common.headersInvalid');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const config = formType === 'telegram'
|
||||
? { ...(botToken ? { bot_token: botToken } : {}), chat_id: form.chat_id,
|
||||
bot_id: form.bot_id || undefined,
|
||||
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
|
||||
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
|
||||
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
|
||||
ai_captions: form.ai_captions }
|
||||
: { url: form.url, headers: parsedHeaders, ai_captions: form.ai_captions };
|
||||
const trkId = form.tracking_config_id || null;
|
||||
const tplId = form.template_config_id || null;
|
||||
if (editing) {
|
||||
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, config, tracking_config_id: trkId, template_config_id: tplId }) });
|
||||
} else {
|
||||
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, config, tracking_config_id: trkId, template_config_id: tplId }) });
|
||||
}
|
||||
showForm = false; editing = null; await load();
|
||||
snackSuccess(t('snack.targetSaved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
async function test(id: number) {
|
||||
testResult = '...';
|
||||
try {
|
||||
const res = await api(`/targets/${id}/test`, { method: 'POST' });
|
||||
testResult = res.success ? t('targets.testSent') : `Failed: ${res.error}`;
|
||||
if (res.success) snackSuccess(t('snack.targetTestSent'));
|
||||
else snackError(`Failed: ${res.error}`);
|
||||
}
|
||||
catch (err: any) { testResult = `Error: ${err.message}`; snackError(err.message); }
|
||||
setTimeout(() => testResult = '', 5000);
|
||||
}
|
||||
async function remove(id: number) {
|
||||
try { await api(`/targets/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.targetDeleted')); } catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('targets.title')} description={t('targets.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('targets.cancel') : t('targets.addTarget')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if loadError}
|
||||
<div class="mb-4 p-3 rounded-md text-sm bg-[var(--color-error-bg)] text-[var(--color-error-fg)]">{loadError}</div>
|
||||
{/if}
|
||||
|
||||
{#if testResult}
|
||||
<div class="mb-4 p-3 rounded-md text-sm {testResult.includes(t('targets.testSent')) ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]'}">{testResult}</div>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-4">
|
||||
<div>
|
||||
<span class="block text-sm font-medium mb-1">{t('targets.type')}</span>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-1 text-sm"><input type="radio" bind:group={formType} value="telegram" /> Telegram</label>
|
||||
<label class="flex items-center gap-1 text-sm"><input type="radio" bind:group={formType} value="webhook" /> Webhook</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-name" class="block text-sm font-medium mb-1">{t('targets.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
|
||||
<input id="tgt-name" bind:value={form.name} required placeholder={t('targets.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
{#if formType === 'telegram'}
|
||||
<!-- Bot selector (required) -->
|
||||
<div>
|
||||
<label for="tgt-bot" class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label>
|
||||
<select id="tgt-bot" bind:value={form.bot_id} onchange={loadBotChats} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0} disabled>— {t('telegramBot.selectBot')} —</option>
|
||||
{#each bots as bot}<option value={bot.id}>{bot.name} (@{bot.bot_username})</option>{/each}
|
||||
</select>
|
||||
{#if bots.length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/telegram-bots" class="underline">→</a></p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chat selector (only shown after bot is selected) -->
|
||||
{#if form.bot_id}
|
||||
<div>
|
||||
<label for="tgt-chat" class="block text-sm font-medium mb-1">{t('telegramBot.selectChat')}</label>
|
||||
{#if (botChats[form.bot_id] || []).length > 0}
|
||||
<select id="tgt-chat" bind:value={form.chat_id}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="">— {t('telegramBot.selectChat')} —</option>
|
||||
{#each botChats[form.bot_id] as chat}
|
||||
<option value={String(chat.id)}>{chat.title || chat.username || 'Unknown'} ({chat.type}) [{chat.id}]</option>
|
||||
{/each}
|
||||
</select>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">
|
||||
<button type="button" onclick={loadBotChats} class="hover:underline">{t('telegramBot.refreshChats')}</button>
|
||||
</p>
|
||||
{:else}
|
||||
<input id="tgt-chat" bind:value={form.chat_id} required placeholder="Chat ID"
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noChats')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Telegram media settings -->
|
||||
<div class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<button type="button" onclick={() => showTelegramSettings = !showTelegramSettings}
|
||||
class="text-sm font-medium cursor-pointer w-full text-left flex items-center justify-between">
|
||||
{t('targets.telegramSettings')}
|
||||
<span class="text-xs transition-transform duration-200" class:rotate-180={showTelegramSettings}>▼</span>
|
||||
</button>
|
||||
{#if showTelegramSettings}
|
||||
<div in:slide={{ duration: 150 }} class="grid grid-cols-2 gap-3 mt-3">
|
||||
<div>
|
||||
<label for="tgt-maxmedia" class="block text-xs mb-1">{t('targets.maxMedia')}<Hint text={t('hints.maxMedia')} /></label>
|
||||
<input id="tgt-maxmedia" type="number" bind:value={form.max_media_to_send} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-groupsize" class="block text-xs mb-1">{t('targets.maxGroupSize')}<Hint text={t('hints.groupSize')} /></label>
|
||||
<input id="tgt-groupsize" type="number" bind:value={form.max_media_per_group} min="2" max="10" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-delay" class="block text-xs mb-1">{t('targets.chunkDelay')}<Hint text={t('hints.chunkDelay')} /></label>
|
||||
<input id="tgt-delay" type="number" bind:value={form.media_delay} min="0" max="60000" step="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-maxsize" class="block text-xs mb-1">{t('targets.maxAssetSize')}<Hint text={t('hints.maxAssetSize')} /></label>
|
||||
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
|
||||
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.send_large_photos_as_documents} /> {t('targets.sendLargeAsDocuments')}</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<label for="tgt-url" class="block text-sm font-medium mb-1">{t('targets.webhookUrl')}</label>
|
||||
<input id="tgt-url" bind:value={form.url} required placeholder="https://..." class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-headers" class="block text-sm font-medium mb-1">Headers (JSON)</label>
|
||||
<input id="tgt-headers" bind:value={form.headers} placeholder={'{"Authorization": "Bearer ..."}'} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" style={headersError ? 'border-color: var(--color-error-fg)' : ''} />
|
||||
{#if headersError}<p class="text-xs text-[var(--color-destructive)] mt-1">{headersError}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Config assignments -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="tgt-trk" class="block text-sm font-medium mb-1">{t('trackingConfig.title')}<Hint text={t('hints.trackingConfig')} /></label>
|
||||
<select id="tgt-trk" bind:value={form.tracking_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('common.none')} —</option>
|
||||
{#each trackingConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tgt-tpl" class="block text-sm font-medium mb-1">{t('templateConfig.title')}<Hint text={t('hints.templateConfig')} /></label>
|
||||
<select id="tgt-tpl" bind:value={form.template_config_id} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0}>— {t('common.noneDefault')} —</option>
|
||||
{#each templateConfigs as tc}<option value={tc.id}>{tc.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.ai_captions} /> {t('targets.aiCaptions')}<Hint text={t('hints.aiCaptions')} /></label>
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">{editing ? t('common.save') : t('targets.create')}</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if targets.length === 0 && !showForm}
|
||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('targets.noTargets')}</p></Card>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each targets as target}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if target.icon}<MdiIcon name={target.icon} />{/if}
|
||||
<p class="font-medium">{target.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{target.type === 'telegram' ? `Chat: ${target.config.chat_id || '***'}` : target.config.url || ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
|
||||
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
title={t('targets.confirmDelete')}
|
||||
message={confirmDelete?.name ?? ''}
|
||||
onconfirm={() => { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }}
|
||||
oncancel={() => confirmDelete = null}
|
||||
/>
|
||||
365
frontend/src/routes/telegram-bots/+page.svelte
Normal file
365
frontend/src/routes/telegram-bots/+page.svelte
Normal file
@@ -0,0 +1,365 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
const ALL_COMMANDS = [
|
||||
'status', 'albums', 'events', 'summary', 'latest',
|
||||
'memory', 'random', 'search', 'find', 'person',
|
||||
'place', 'favorites', 'people', 'help',
|
||||
];
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
enabled: [...ALL_COMMANDS],
|
||||
default_count: 5,
|
||||
response_mode: 'media',
|
||||
rate_limits: { search: 30, find: 30, default: 10 },
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
let bots = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let form = $state({ name: '', icon: '', token: '' });
|
||||
let error = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
// Per-bot expandable sections
|
||||
let chats = $state<Record<number, any[]>>({});
|
||||
let chatsLoading = $state<Record<number, boolean>>({});
|
||||
let expandedBot = $state<number | null>(null);
|
||||
let expandedSection = $state<Record<number, string>>({}); // bot_id -> 'chats' | 'commands'
|
||||
|
||||
// Commands config editing
|
||||
let editingConfig = $state<Record<number, any>>({});
|
||||
let savingConfig = $state<Record<number, boolean>>({});
|
||||
let syncingCommands = $state<Record<number, boolean>>({});
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { bots = await api('/telegram-bots'); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
async function create(e: SubmitEvent) {
|
||||
e.preventDefault(); error = ''; submitting = true;
|
||||
try {
|
||||
await api('/telegram-bots', { method: 'POST', body: JSON.stringify(form) });
|
||||
form = { name: '', icon: '', token: '' }; showForm = false; await load();
|
||||
snackSuccess(t('snack.botRegistered'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.botDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toggleSection(botId: number, section: string) {
|
||||
if (expandedSection[botId] === section) {
|
||||
expandedSection[botId] = '';
|
||||
return;
|
||||
}
|
||||
expandedSection[botId] = section;
|
||||
|
||||
if (section === 'chats') {
|
||||
loadChats(botId);
|
||||
}
|
||||
|
||||
if (section === 'commands') {
|
||||
const bot = bots.find((b: any) => b.id === botId);
|
||||
editingConfig[botId] = JSON.parse(JSON.stringify(bot?.commands_config || DEFAULT_CONFIG));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChats(botId: number) {
|
||||
chatsLoading[botId] = true;
|
||||
try { chats[botId] = await api(`/telegram-bots/${botId}/chats`); }
|
||||
catch { chats[botId] = []; }
|
||||
chatsLoading[botId] = false;
|
||||
}
|
||||
|
||||
async function discoverChats(botId: number) {
|
||||
chatsLoading[botId] = true;
|
||||
try {
|
||||
chats[botId] = await api(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' });
|
||||
snackSuccess(t('telegramBot.chatsDiscovered'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
chatsLoading[botId] = false;
|
||||
}
|
||||
|
||||
async function deleteChat(botId: number, chatDbId: number) {
|
||||
try {
|
||||
await api(`/telegram-bots/${botId}/chats/${chatDbId}`, { method: 'DELETE' });
|
||||
chats[botId] = (chats[botId] || []).filter((c: any) => c.id !== chatDbId);
|
||||
snackSuccess(t('telegramBot.chatDeleted'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
function copyChatId(e: Event, chatId: string) {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(chatId);
|
||||
snackInfo(`${t('snack.copied')}: ${chatId}`);
|
||||
}
|
||||
|
||||
function toggleCommand(botId: number, cmd: string) {
|
||||
const cfg = editingConfig[botId];
|
||||
if (!cfg) return;
|
||||
const idx = cfg.enabled.indexOf(cmd);
|
||||
if (idx >= 0) cfg.enabled.splice(idx, 1);
|
||||
else cfg.enabled.push(cmd);
|
||||
}
|
||||
|
||||
async function saveConfig(botId: number) {
|
||||
savingConfig[botId] = true;
|
||||
try {
|
||||
const updated = await api(`/telegram-bots/${botId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ commands_config: editingConfig[botId] }),
|
||||
});
|
||||
const idx = bots.findIndex((b: any) => b.id === botId);
|
||||
if (idx >= 0) bots[idx] = updated;
|
||||
snackSuccess(t('snack.commandsSaved'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
savingConfig[botId] = false;
|
||||
}
|
||||
|
||||
async function syncCommands(botId: number) {
|
||||
syncingCommands[botId] = true;
|
||||
try {
|
||||
await api(`/telegram-bots/${botId}/sync-commands`, { method: 'POST' });
|
||||
snackSuccess(t('snack.commandsSynced'));
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
syncingCommands[botId] = false;
|
||||
}
|
||||
|
||||
function chatTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
private: t('telegramBot.private'),
|
||||
group: t('telegramBot.group'),
|
||||
supergroup: t('telegramBot.supergroup'),
|
||||
channel: t('telegramBot.channel'),
|
||||
};
|
||||
return map[type] || type;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('telegramBot.title')} description={t('telegramBot.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false) : (showForm = true, form = { name: '', icon: '', token: '' }); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('telegramBot.addBot')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={create} class="space-y-3">
|
||||
<div>
|
||||
<label for="bot-name" class="block text-sm font-medium mb-1">{t('telegramBot.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
|
||||
<input id="bot-name" bind:value={form.name} required placeholder={t('telegramBot.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="bot-token" class="block text-sm font-medium mb-1">{t('telegramBot.token')}</label>
|
||||
<input id="bot-token" bind:value={form.token} required placeholder={t('telegramBot.tokenPlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
<button type="submit" disabled={submitting}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{submitting ? t('common.loading') : t('telegramBot.addBot')}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if bots.length === 0 && !showForm}
|
||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('telegramBot.noBots')}</p></Card>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each bots as bot}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if bot.icon}<MdiIcon name={bot.icon} />{/if}
|
||||
<p class="font-medium">{bot.name}</p>
|
||||
{#if bot.bot_username}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button onclick={() => toggleSection(bot.id, 'chats')}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('telegramBot.chats')} {expandedSection[bot.id] === 'chats' ? '▲' : '▼'}
|
||||
</button>
|
||||
<button onclick={() => toggleSection(bot.id, 'commands')}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('telegramBot.commands')} {expandedSection[bot.id] === 'commands' ? '▲' : '▼'}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(bot.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chats section -->
|
||||
{#if expandedSection[bot.id] === 'chats'}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
|
||||
{#if chatsLoading[bot.id]}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
|
||||
{:else if (chats[bot.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
{#each chats[bot.id] as chat}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer"
|
||||
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
|
||||
title={t('telegramBot.clickToCopy')}
|
||||
role="button" tabindex="0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{chat.title || chat.username || 'Unknown'}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||
</div>
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
|
||||
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<button onclick={() => discoverChats(bot.id)}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
|
||||
<MdiIcon name="mdiSync" size={14} />
|
||||
{t('telegramBot.discoverChats')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Commands config section -->
|
||||
{#if expandedSection[bot.id] === 'commands' && editingConfig[bot.id]}
|
||||
{@const cfg = editingConfig[bot.id]}
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-4" in:slide>
|
||||
<!-- Enabled commands -->
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium mb-2">{t('telegramBot.enabledCommands')}</legend>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
|
||||
{#each ALL_COMMANDS as cmd}
|
||||
<label class="flex items-center gap-1.5 text-sm cursor-pointer px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||
<input type="checkbox" checked={cfg.enabled.includes(cmd)}
|
||||
onchange={() => toggleCommand(bot.id, cmd)}
|
||||
class="rounded" />
|
||||
<span class="font-mono text-xs">/{cmd}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Settings row -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">
|
||||
{t('telegramBot.defaultCount')}
|
||||
<Hint text={t('hints.defaultCount')} />
|
||||
</label>
|
||||
<input type="number" min="1" max="20" bind:value={cfg.default_count}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">
|
||||
{t('telegramBot.responseMode')}
|
||||
<Hint text={t('hints.responseMode')} />
|
||||
</label>
|
||||
<select bind:value={cfg.response_mode}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="media">{t('telegramBot.modeMedia')}</option>
|
||||
<option value="text">{t('telegramBot.modeText')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">
|
||||
{t('telegramBot.botLocale')}
|
||||
<Hint text={t('hints.botLocale')} />
|
||||
</label>
|
||||
<select bind:value={cfg.locale}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate limits -->
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium mb-2">
|
||||
{t('telegramBot.rateLimits')}
|
||||
<Hint text={t('hints.rateLimits')} />
|
||||
</legend>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('telegramBot.rateSearch')} (s)</label>
|
||||
<input type="number" min="0" max="300" bind:value={cfg.rate_limits.search}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('telegramBot.rateFind')} (s)</label>
|
||||
<input type="number" min="0" max="300" bind:value={cfg.rate_limits.find}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('telegramBot.rateDefault')} (s)</label>
|
||||
<input type="number" min="0" max="300" bind:value={cfg.rate_limits.default}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button onclick={() => saveConfig(bot.id)} disabled={savingConfig[bot.id]}
|
||||
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{savingConfig[bot.id] ? t('common.loading') : t('common.save')}
|
||||
</button>
|
||||
<button onclick={() => syncCommands(bot.id)} disabled={syncingCommands[bot.id]}
|
||||
class="px-4 py-2 bg-[var(--color-muted)] text-[var(--color-foreground)] rounded-md text-sm font-medium hover:opacity-80 disabled:opacity-50">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<MdiIcon name="mdiSync" size={16} />
|
||||
{syncingCommands[bot.id] ? t('common.loading') : t('telegramBot.syncCommands')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
315
frontend/src/routes/template-configs/+page.svelte
Normal file
315
frontend/src/routes/template-configs/+page.svelte
Normal file
@@ -0,0 +1,315 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let varsRef = $state<Record<string, any>>({});
|
||||
let showVarsFor = $state<string | null>(null);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<any>(null);
|
||||
let slotPreview = $state<Record<string, string>>({});
|
||||
let slotErrors = $state<Record<string, string>>({});
|
||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||
let slotErrorTypes = $state<Record<string, string>>({});
|
||||
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
|
||||
function validateSlot(slotKey: string, template: string, immediate = false) {
|
||||
// Clear previous timer
|
||||
if (validateTimers[slotKey]) clearTimeout(validateTimers[slotKey]);
|
||||
if (!template) {
|
||||
slotErrors = { ...slotErrors, [slotKey]: '' };
|
||||
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
|
||||
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
|
||||
const { [slotKey]: _, ...rest } = slotPreview;
|
||||
slotPreview = rest;
|
||||
return;
|
||||
}
|
||||
|
||||
const doValidate = async () => {
|
||||
try {
|
||||
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType }) });
|
||||
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
|
||||
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
|
||||
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
|
||||
// Live preview: show rendered result when no error
|
||||
if (res.rendered) {
|
||||
slotPreview = { ...slotPreview, [slotKey]: res.rendered };
|
||||
} else {
|
||||
const { [slotKey]: _, ...rest } = slotPreview;
|
||||
slotPreview = rest;
|
||||
}
|
||||
} catch {
|
||||
// Network error, don't show as template error
|
||||
slotErrors = { ...slotErrors, [slotKey]: '' };
|
||||
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
|
||||
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
|
||||
}
|
||||
};
|
||||
if (immediate) { doValidate(); }
|
||||
else { validateTimers[slotKey] = setTimeout(doValidate, 800); }
|
||||
}
|
||||
|
||||
function refreshAllPreviews() {
|
||||
// Re-validate and re-preview all slots that have content (immediate, no debounce)
|
||||
for (const group of templateSlots) {
|
||||
for (const slot of group.slots) {
|
||||
const template = (form as any)[slot.key];
|
||||
if (template && slot.key !== 'date_format') {
|
||||
validateSlot(slot.key, template, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '', description: '', icon: '',
|
||||
message_assets_added: '',
|
||||
message_assets_removed: '',
|
||||
message_album_renamed: '',
|
||||
message_album_deleted: '',
|
||||
periodic_summary_message: '',
|
||||
scheduled_assets_message: '',
|
||||
memory_mode_message: '',
|
||||
date_format: '%d.%m.%Y, %H:%M UTC',
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let previewTargetType = $state('telegram');
|
||||
|
||||
const templateSlots = [
|
||||
{ group: 'eventMessages', slots: [
|
||||
{ key: 'message_assets_added', label: 'assetsAdded', rows: 10 },
|
||||
{ key: 'message_assets_removed', label: 'assetsRemoved', rows: 3 },
|
||||
{ key: 'message_album_renamed', label: 'albumRenamed', rows: 2 },
|
||||
{ key: 'message_album_deleted', label: 'albumDeleted', rows: 2 },
|
||||
]},
|
||||
{ group: 'scheduledMessages', slots: [
|
||||
{ key: 'periodic_summary_message', label: 'periodicSummary', rows: 6 },
|
||||
{ key: 'scheduled_assets_message', label: 'scheduledAssets', rows: 6 },
|
||||
{ key: 'memory_mode_message', label: 'memoryMode', rows: 6 },
|
||||
]},
|
||||
{ group: 'settings', slots: [
|
||||
{ key: 'date_format', label: 'dateFormat', rows: 1 },
|
||||
]},
|
||||
];
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try {
|
||||
[configs, varsRef] = await Promise.all([
|
||||
api('/template-configs'),
|
||||
api('/template-configs/variables'),
|
||||
]);
|
||||
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; }
|
||||
function edit(c: any) {
|
||||
form = { ...defaultForm(), ...c }; editing = c.id; showForm = true;
|
||||
slotPreview = {}; slotErrors = {};
|
||||
// Trigger initial preview for all populated slots
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try {
|
||||
if (editing) await api(`/template-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||
showForm = false; editing = null; await load();
|
||||
snackSuccess(t('snack.templateSaved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
|
||||
async function preview(configId: number, slotKey: string) {
|
||||
const config = configs.find(c => c.id === configId);
|
||||
if (!config) return;
|
||||
const template = config[slotKey] || '';
|
||||
if (!template) return;
|
||||
try {
|
||||
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType }) });
|
||||
slotPreview[slotKey + '_' + configId] = res.error ? `Error: ${res.error}` : res.rendered;
|
||||
} catch (err: any) { slotPreview[slotKey + '_' + configId] = `Error: ${err.message}`; }
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('templateConfig.title')} description={t('templateConfig.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('templateConfig.newConfig')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<div in:slide>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-5">
|
||||
<div>
|
||||
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
|
||||
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tpc-desc" class="block text-sm font-medium mb-1">{t('common.description')}</label>
|
||||
<input id="tpc-desc" bind:value={form.description} placeholder={t('templateConfig.descriptionPlaceholder')}
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
<!-- Target type selector for preview -->
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="preview-target" class="text-sm font-medium">{t('templateConfig.previewAs')}:</label>
|
||||
<select id="preview-target" bind:value={previewTargetType} onchange={refreshAllPreviews}
|
||||
class="px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#each templateSlots as group}
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t(`templateConfig.${group.group}`)}{#if group.group === 'eventMessages'}<Hint text={t('hints.eventMessages')} />{:else if group.group === 'assetFormatting'}<Hint text={t('hints.assetFormatting')} />{:else if group.group === 'dateLocation'}<Hint text={t('hints.dateLocation')} />{:else if group.group === 'scheduledMessages'}<Hint text={t('hints.scheduledMessages')} />{/if}</legend>
|
||||
<div class="space-y-3 mt-2">
|
||||
{#each group.slots as slot}
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-xs text-[var(--color-muted-foreground)]">{t(`templateConfig.${slot.label}`)}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if varsRef[slot.key]}
|
||||
<button type="button" onclick={() => showVarsFor = slot.key}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if slot.key === 'date_format'}
|
||||
<input bind:value={(form as any)[slot.key]}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono" />
|
||||
{:else}
|
||||
<JinjaEditor value={(form as any)[slot.key] || ''} onchange={(v) => { (form as any)[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} />
|
||||
{#if slotErrors[slot.key]}
|
||||
{#if slotErrorTypes[slot.key] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if slotPreview[slot.key] && !slotErrors[slot.key]}
|
||||
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
|
||||
<pre class="whitespace-pre-wrap text-xs">{slotPreview[slot.key]}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
{/each}
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('templateConfig.noConfigs')}</p></Card>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each configs as config}
|
||||
<Card hover>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if config.icon}<MdiIcon name={config.icon} />{/if}
|
||||
<p class="font-medium">{config.name}</p>
|
||||
</div>
|
||||
{#if config.description}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ml-4">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
|
||||
<!-- Variables reference modal -->
|
||||
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
|
||||
{#if showVarsFor && varsRef[showVarsFor]}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{t(`templateVars.${showVarsFor}.description`, varsRef[showVarsFor].description)}</p>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
|
||||
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.${name}`, desc as string)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if varsRef[showVarsFor].asset_fields && typeof varsRef[showVarsFor].asset_fields === 'object'}
|
||||
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
||||
<p class="text-xs font-medium mb-1">{t('templateConfig.assetFields')}:</p>
|
||||
{#each Object.entries(varsRef[showVarsFor].asset_fields) as [name, desc]}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ asset.' + name + ' }}'}</code>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.asset_${name}`, desc as string)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if varsRef[showVarsFor].album_fields && typeof varsRef[showVarsFor].album_fields === 'object'}
|
||||
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
|
||||
<p class="text-xs font-medium mb-1">{t('templateConfig.albumFields')}:</p>
|
||||
{#each Object.entries(varsRef[showVarsFor].album_fields) as [name, desc]}
|
||||
<div class="flex items-start gap-2 text-sm">
|
||||
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ album.' + name + ' }}'}</code>
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.album_${name}`, desc as string)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Modal>
|
||||
254
frontend/src/routes/trackers/+page.svelte
Normal file
254
frontend/src/routes/trackers/+page.svelte
Normal file
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let loaded = $state(false);
|
||||
let loadError = $state('');
|
||||
let trackers = $state<any[]>([]);
|
||||
let servers = $state<any[]>([]);
|
||||
let targets = $state<any[]>([]);
|
||||
let albums = $state<any[]>([]);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let albumFilter = $state('');
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
let toggling = $state<Record<number, boolean>>({});
|
||||
let testingPeriodic = $state<Record<number, boolean>>({});
|
||||
let testingMemory = $state<Record<number, boolean>>({});
|
||||
let testFeedback = $state<Record<number, string>>({});
|
||||
const defaultForm = () => ({
|
||||
name: '', icon: '', server_id: 0, album_ids: [] as string[],
|
||||
target_ids: [] as number[], scan_interval: 60,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
let error = $state('');
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
loadError = '';
|
||||
try {
|
||||
[trackers, servers, targets] = await Promise.all([api('/trackers'), api('/servers'), api('/targets')]);
|
||||
} catch (err: any) {
|
||||
loadError = err.message || 'Failed to load data';
|
||||
snackError(loadError);
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
}
|
||||
async function loadAlbums() { if (!form.server_id) return; albums = await api(`/servers/${form.server_id}/albums`); }
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; albums = []; }
|
||||
async function edit(trk: any) {
|
||||
form = {
|
||||
name: trk.name, icon: trk.icon || '', server_id: trk.server_id, album_ids: [...trk.album_ids],
|
||||
target_ids: [...trk.target_ids], scan_interval: trk.scan_interval,
|
||||
};
|
||||
editing = trk.id; showForm = true;
|
||||
if (form.server_id) await loadAlbums();
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
try {
|
||||
if (editing) {
|
||||
await api(`/trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.trackerUpdated'));
|
||||
} else {
|
||||
await api('/trackers', { method: 'POST', body: JSON.stringify(form) });
|
||||
snackSuccess(t('snack.trackerCreated'));
|
||||
}
|
||||
showForm = false; editing = null; await load();
|
||||
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
|
||||
}
|
||||
async function toggle(tracker: any) {
|
||||
if (toggling[tracker.id]) return;
|
||||
toggling[tracker.id] = true;
|
||||
try {
|
||||
await api(`/trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
||||
await load();
|
||||
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
|
||||
} catch (err: any) { snackError(err.message); } finally { toggling[tracker.id] = false; }
|
||||
}
|
||||
function startDelete(tracker: any) { confirmDelete = tracker; }
|
||||
async function doDelete() {
|
||||
if (!confirmDelete) return;
|
||||
try {
|
||||
await api(`/trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
snackSuccess(t('snack.trackerDeleted'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
confirmDelete = null;
|
||||
}
|
||||
async function testPeriodic(tracker: any) {
|
||||
if (testingPeriodic[tracker.id]) return;
|
||||
testingPeriodic[tracker.id] = true;
|
||||
testFeedback[tracker.id] = '';
|
||||
try {
|
||||
await api(`/trackers/${tracker.id}/test-periodic`, { method: 'POST' });
|
||||
testFeedback[tracker.id] = 'ok';
|
||||
} catch {
|
||||
testFeedback[tracker.id] = 'error';
|
||||
} finally {
|
||||
testingPeriodic[tracker.id] = false;
|
||||
setTimeout(() => { testFeedback[tracker.id] = ''; }, 3000);
|
||||
}
|
||||
}
|
||||
async function testMemory(tracker: any) {
|
||||
if (testingMemory[tracker.id]) return;
|
||||
testingMemory[tracker.id] = true;
|
||||
testFeedback[tracker.id] = '';
|
||||
try {
|
||||
await api(`/trackers/${tracker.id}/test-memory`, { method: 'POST' });
|
||||
testFeedback[tracker.id] = 'ok';
|
||||
} catch {
|
||||
testFeedback[tracker.id] = 'error';
|
||||
} finally {
|
||||
testingMemory[tracker.id] = false;
|
||||
setTimeout(() => { testFeedback[tracker.id] = ''; }, 3000);
|
||||
}
|
||||
}
|
||||
function toggleAlbum(albumId: string) { form.album_ids = form.album_ids.includes(albumId) ? form.album_ids.filter(id => id !== albumId) : [...form.album_ids, albumId]; }
|
||||
function toggleTarget(targetId: number) { form.target_ids = form.target_ids.includes(targetId) ? form.target_ids.filter(id => id !== targetId) : [...form.target_ids, targetId]; }
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('trackers.title')} description={t('trackers.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('trackers.cancel') : t('trackers.newTracker')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else if loadError}
|
||||
<Card>
|
||||
<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3">
|
||||
{loadError}
|
||||
</div>
|
||||
<button onclick={load} class="mt-3 px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md hover:bg-[var(--color-muted)]">
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</Card>
|
||||
{:else if showForm}
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-4">
|
||||
<div>
|
||||
<label for="trk-name" class="block text-sm font-medium mb-1">{t('trackers.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
|
||||
<input id="trk-name" bind:value={form.name} required placeholder={t('trackers.namePlaceholder')} class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="trk-server" class="block text-sm font-medium mb-1">{t('trackers.server')}</label>
|
||||
<select id="trk-server" bind:value={form.server_id} onchange={loadAlbums} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value={0} disabled>{t('trackers.selectServer')}</option>
|
||||
{#each servers as s}<option value={s.id}>{s.name}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#if albums.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('trackers.albums')} ({albums.length})</label>
|
||||
<input type="text" bind:value={albumFilter} placeholder="Filter albums..."
|
||||
class="w-full px-3 py-1.5 mb-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<div class="max-h-56 overflow-y-auto border border-[var(--color-border)] rounded-md p-2 space-y-1">
|
||||
{#each albums.filter(a => !albumFilter || a.albumName.toLowerCase().includes(albumFilter.toLowerCase())) as album}
|
||||
<label class="flex items-center justify-between text-sm cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
|
||||
<span class="flex items-center gap-2">
|
||||
<input type="checkbox" checked={form.album_ids.includes(album.id)} onchange={() => toggleAlbum(album.id)} />
|
||||
{album.albumName} <span class="text-[var(--color-muted-foreground)]">({album.assetCount})</span>
|
||||
</span>
|
||||
{#if album.updatedAt}
|
||||
<span class="text-xs text-[var(--color-muted-foreground)]">{new Date(album.updatedAt).toLocaleDateString()}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('trackers.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="10" max="3600" class="w-32 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
|
||||
{#if targets.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('trackers.notificationTargets')}</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each targets as tgt}
|
||||
<label class="flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" checked={form.target_ids.includes(tgt.id)} onchange={() => toggleTarget(tgt.id)} />
|
||||
{tgt.name} ({tgt.type})
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={submitting} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">{editing ? t('common.save') : t('trackers.createTracker')}</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !loaded}
|
||||
<!-- skeleton shown above -->
|
||||
{:else if trackers.length === 0 && !showForm}
|
||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('trackers.noTrackers')}</p></Card>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each trackers as tracker}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if tracker.icon}<MdiIcon name={tracker.icon} />{/if}
|
||||
<p class="font-medium">{tracker.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
||||
{tracker.enabled ? t('trackers.active') : t('trackers.paused')}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{tracker.album_ids.length} {t('trackers.albums_count')} · {t('trackers.every')} {tracker.scan_interval}s · {tracker.target_ids.length} {t('trackers.targets')}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
|
||||
<IconButton icon="mdiPlay" title={t('common.test')} onclick={async () => { await api(`/trackers/${tracker.id}/trigger`, { method: 'POST' }); }} />
|
||||
<IconButton icon="mdiCalendarClock" title={t('trackers.testPeriodic')} onclick={() => testPeriodic(tracker)} disabled={testingPeriodic[tracker.id]} />
|
||||
<IconButton icon="mdiHistory" title={t('trackers.testMemory')} onclick={() => testMemory(tracker)} disabled={testingMemory[tracker.id]} />
|
||||
{#if testFeedback[tracker.id]}
|
||||
<span class="text-xs {testFeedback[tracker.id] === 'ok' ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-destructive)]'}">
|
||||
{testFeedback[tracker.id] === 'ok' ? '\u2713' : '\u2717'}
|
||||
</span>
|
||||
{/if}
|
||||
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('trackers.pause') : t('trackers.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
|
||||
<IconButton icon="mdiDelete" title={t('trackers.delete')} onclick={() => startDelete(tracker)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmDelete}
|
||||
title={t('trackers.delete')}
|
||||
message={t('trackers.deleteConfirm')}
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => confirmDelete = null}
|
||||
/>
|
||||
227
frontend/src/routes/tracking-configs/+page.svelte
Normal file
227
frontend/src/routes/tracking-configs/+page.svelte
Normal file
@@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import IconPicker from '$lib/components/IconPicker.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import Hint from '$lib/components/Hint.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
let configs = $state<any[]>([]);
|
||||
let loaded = $state(false);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '', icon: '', track_assets_added: true, track_assets_removed: false,
|
||||
track_album_renamed: true, track_album_deleted: true,
|
||||
track_images: true, track_videos: true, notify_favorites_only: false,
|
||||
include_people: true, include_asset_details: false,
|
||||
max_assets_to_show: 5, assets_order_by: 'none', assets_order: 'descending',
|
||||
periodic_enabled: false, periodic_interval_days: 1, periodic_start_date: '2025-01-01', periodic_times: '12:00',
|
||||
scheduled_enabled: false, scheduled_times: '09:00', scheduled_album_mode: 'per_album',
|
||||
scheduled_limit: 10, scheduled_favorite_only: false, scheduled_asset_type: 'all',
|
||||
scheduled_min_rating: 0, scheduled_order_by: 'random', scheduled_order: 'descending',
|
||||
memory_enabled: false, memory_times: '09:00', memory_album_mode: 'combined',
|
||||
memory_limit: 10, memory_favorite_only: false, memory_asset_type: 'all', memory_min_rating: 0,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { configs = await api('/tracking-configs'); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
function openNew() { form = defaultForm(); editing = null; showForm = true; }
|
||||
function edit(c: any) {
|
||||
form = { ...defaultForm(), ...c };
|
||||
editing = c.id; showForm = true;
|
||||
}
|
||||
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try {
|
||||
if (editing) await api(`/tracking-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
else await api('/tracking-configs', { method: 'POST', body: JSON.stringify(form) });
|
||||
showForm = false; editing = null; await load();
|
||||
snackSuccess(t('snack.trackingConfigSaved'));
|
||||
} catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/tracking-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.trackingConfigDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('trackingConfig.title')} description={t('trackingConfig.description')}>
|
||||
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('common.cancel') : t('trackingConfig.newConfig')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<div in:slide>
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={save} class="space-y-5">
|
||||
<div>
|
||||
<label for="tc-name" class="block text-sm font-medium mb-1">{t('trackingConfig.name')}</label>
|
||||
<div class="flex gap-2">
|
||||
<IconPicker value={form.icon} onselect={(v) => form.icon = v} />
|
||||
<input id="tc-name" bind:value={form.name} required placeholder={t('trackingConfig.namePlaceholder')}
|
||||
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event tracking -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.eventTracking')}</legend>
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_added} /> {t('trackingConfig.assetsAdded')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_assets_removed} /> {t('trackingConfig.assetsRemoved')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_album_renamed} /> {t('trackingConfig.albumRenamed')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_album_deleted} /> {t('trackingConfig.albumDeleted')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_images} /> {t('trackingConfig.trackImages')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.track_videos} /> {t('trackingConfig.trackVideos')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.notify_favorites_only} /> {t('trackingConfig.favoritesOnly')}<Hint text={t('hints.favoritesOnly')} /></label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_people} /> {t('trackingConfig.includePeople')}</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.include_asset_details} /> {t('trackingConfig.includeDetails')}</label>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div>
|
||||
<label for="tc-max" class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label>
|
||||
<input id="tc-max" type="number" bind:value={form.max_assets_to_show} min="0" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="tc-sort" class="block text-xs mb-1">{t('trackingConfig.sortBy')}</label>
|
||||
<select id="tc-sort" bind:value={form.assets_order_by} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="none">{t('trackingConfig.sortNone')}</option><option value="date">{t('trackingConfig.sortDate')}</option><option value="rating">{t('trackingConfig.sortRating')}</option><option value="name">{t('trackingConfig.sortName')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="tc-order" class="block text-xs mb-1">{t('trackingConfig.sortOrder')}</label>
|
||||
<select id="tc-order" bind:value={form.assets_order} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="descending">{t('trackingConfig.orderDesc')}</option><option value="ascending">{t('trackingConfig.orderAsc')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Periodic summary -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.periodicSummary')}<Hint text={t('hints.periodicSummary')} /></legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.periodic_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.periodic_enabled}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.intervalDays')}</label><input type="number" bind:value={form.periodic_interval_days} min="1" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.startDate')}<Hint text={t('hints.periodicStartDate')} /></label><input type="date" bind:value={form.periodic_start_date} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.periodic_times} placeholder="12:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<!-- Scheduled assets -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.scheduledAssets')}<Hint text={t('hints.scheduledAssets')} /></legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.scheduled_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.scheduled_enabled}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.scheduled_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}<Hint text={t('hints.albumMode')} /></label>
|
||||
<select bind:value={form.scheduled_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="per_album">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label><input type="number" bind:value={form.scheduled_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
||||
<select bind:value={form.scheduled_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}<Hint text={t('hints.minRating')} /></label><input type="number" bind:value={form.scheduled_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.scheduled_favorite_only} /> {t('trackingConfig.favoritesOnly')}<Hint text={t('hints.favoritesOnly')} /></label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<!-- Memory mode -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.memoryMode')}<Hint text={t('hints.memoryMode')} /></legend>
|
||||
<label class="flex items-center gap-2 text-sm mt-1"><input type="checkbox" bind:checked={form.memory_enabled} /> {t('trackingConfig.enabled')}</label>
|
||||
{#if form.memory_enabled}
|
||||
<div class="grid grid-cols-3 gap-3 mt-3">
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.times')}<Hint text={t('hints.times')} /></label><input bind:value={form.memory_times} placeholder="09:00" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.albumMode')}<Hint text={t('hints.albumMode')} /></label>
|
||||
<select bind:value={form.memory_album_mode} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="per_album">{t('trackingConfig.albumModePerAlbum')}</option><option value="combined">{t('trackingConfig.albumModeCombined')}</option><option value="random">{t('trackingConfig.albumModeRandom')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.maxAssets')}<Hint text={t('hints.maxAssets')} /></label><input type="number" bind:value={form.memory_limit} min="1" max="100" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.assetType')}</label>
|
||||
<select bind:value={form.memory_asset_type} class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]">
|
||||
<option value="all">{t('trackingConfig.assetTypeAll')}</option><option value="photo">{t('trackingConfig.assetTypePhoto')}</option><option value="video">{t('trackingConfig.assetTypeVideo')}</option>
|
||||
</select></div>
|
||||
<div><label class="block text-xs mb-1">{t('trackingConfig.minRating')}<Hint text={t('hints.minRating')} /></label><input type="number" bind:value={form.memory_min_rating} min="0" max="5" class="w-full px-2 py-1 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" /></div>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" bind:checked={form.memory_favorite_only} /> {t('trackingConfig.favoritesOnly')}<Hint text={t('hints.favoritesOnly')} /></label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{editing ? t('common.save') : t('common.create')}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if configs.length === 0 && !showForm}
|
||||
<Card><p class="text-sm text-[var(--color-muted-foreground)]">{t('trackingConfig.noConfigs')}</p></Card>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each configs as config}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if config.icon}<MdiIcon name={config.icon} />{/if}
|
||||
<p class="font-medium">{config.name}</p>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{[config.track_assets_added && 'added', config.track_assets_removed && 'removed', config.track_album_renamed && 'renamed', config.track_album_deleted && 'deleted'].filter(Boolean).join(', ')}
|
||||
{config.periodic_enabled ? ' · periodic' : ''}
|
||||
{config.scheduled_enabled ? ' · scheduled' : ''}
|
||||
{config.memory_enabled ? ' · memory' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('trackingConfig.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
138
frontend/src/routes/users/+page.svelte
Normal file
138
frontend/src/routes/users/+page.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { getAuth } from '$lib/auth.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
const auth = getAuth();
|
||||
let users = $state<any[]>([]);
|
||||
let showForm = $state(false);
|
||||
let form = $state({ username: '', password: '', role: 'user' });
|
||||
let error = $state('');
|
||||
let loaded = $state(false);
|
||||
let confirmDelete = $state<any>(null);
|
||||
|
||||
// Admin reset password
|
||||
let resetUserId = $state<number | null>(null);
|
||||
let resetUsername = $state('');
|
||||
let resetPassword = $state('');
|
||||
let resetMsg = $state('');
|
||||
let resetSuccess = $state(false);
|
||||
|
||||
onMount(load);
|
||||
async function load() {
|
||||
try { users = await api('/users'); }
|
||||
catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
|
||||
finally { loaded = true; }
|
||||
}
|
||||
|
||||
async function create(e: SubmitEvent) {
|
||||
e.preventDefault(); error = '';
|
||||
try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); snackSuccess(t('snack.userCreated')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
}
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
onconfirm: async () => {
|
||||
try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.userDeleted')); }
|
||||
catch (err: any) { error = err.message; snackError(err.message); }
|
||||
finally { confirmDelete = null; }
|
||||
}
|
||||
};
|
||||
}
|
||||
function openResetPassword(user: any) {
|
||||
resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false;
|
||||
}
|
||||
async function resetUserPassword(e: SubmitEvent) {
|
||||
e.preventDefault(); resetMsg = ''; resetSuccess = false;
|
||||
try {
|
||||
await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) });
|
||||
resetMsg = t('common.passwordChanged');
|
||||
resetSuccess = true;
|
||||
snackSuccess(t('snack.passwordChanged'));
|
||||
setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000);
|
||||
} catch (err: any) { resetMsg = err.message; resetSuccess = false; snackError(err.message); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader title={t('users.title')} description={t('users.description')}>
|
||||
<button onclick={() => showForm = !showForm}
|
||||
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{showForm ? t('users.cancel') : t('users.addUser')}
|
||||
</button>
|
||||
</PageHeader>
|
||||
|
||||
{#if !loaded}<Loading />{:else}
|
||||
|
||||
{#if showForm}
|
||||
<Card class="mb-6">
|
||||
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
|
||||
<form onsubmit={create} class="space-y-3">
|
||||
<div>
|
||||
<label for="usr-name" class="block text-sm font-medium mb-1">{t('users.username')}</label>
|
||||
<input id="usr-name" bind:value={form.username} required class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="usr-pass" class="block text-sm font-medium mb-1">{t('users.password')}</label>
|
||||
<input id="usr-pass" bind:value={form.password} required type="password" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="usr-role" class="block text-sm font-medium mb-1">{t('users.role')}</label>
|
||||
<select id="usr-role" bind:value={form.role} class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]">
|
||||
<option value="user">{t('users.roleUser')}</option>
|
||||
<option value="admin">{t('users.roleAdmin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">{t('users.create')}</button>
|
||||
</form>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each users as user}
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium">{user.username}</p>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">{user.role} · {t('users.joined')} {new Date(user.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if user.id !== auth.user?.id}
|
||||
<IconButton icon="mdiKeyVariant" title={t('common.changePassword')} onclick={() => openResetPassword(user)} />
|
||||
<IconButton icon="mdiDelete" title={t('users.delete')} onclick={() => remove(user.id)} variant="danger" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
|
||||
<!-- Admin reset password modal -->
|
||||
<Modal open={resetUserId !== null} title="{t('common.changePassword')}: {resetUsername}" onclose={() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
|
||||
<form onsubmit={resetUserPassword} class="space-y-3">
|
||||
<div>
|
||||
<label for="reset-pwd" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
|
||||
<input id="reset-pwd" type="password" bind:value={resetPassword} required
|
||||
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{#if resetMsg}
|
||||
<p class="text-sm {resetSuccess ? 'text-[var(--color-success-fg)]' : 'text-[var(--color-error-fg)]'}">{resetMsg}</p>
|
||||
{/if}
|
||||
<button type="submit" class="w-full py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal open={confirmDelete !== null} message={t('users.confirmDelete')}
|
||||
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
|
||||
8
frontend/static/favicon.svg
Normal file
8
frontend/static/favicon.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#4f46e5"/>
|
||||
<circle cx="16" cy="15" r="7" fill="none" stroke="white" stroke-width="2"/>
|
||||
<circle cx="16" cy="15" r="3" fill="white"/>
|
||||
<rect x="11" y="6" width="10" height="3" rx="1" fill="white" opacity="0.7"/>
|
||||
<circle cx="25" cy="8" r="5" fill="#ef4444"/>
|
||||
<circle cx="25" cy="8" r="3" fill="#ef4444" stroke="white" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 457 B |
3
frontend/static/robots.txt
Normal file
3
frontend/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
18
frontend/svelte.config.js
Normal file
18
frontend/svelte.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html'
|
||||
})
|
||||
},
|
||||
vitePlugin: {
|
||||
dynamicCompileOptions: ({ filename }) =>
|
||||
filename.includes('node_modules') ? undefined : { runes: true }
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
12
frontend/vite.config.ts
Normal file
12
frontend/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8420'
|
||||
}
|
||||
}
|
||||
});
|
||||
6
hacs.json
Normal file
6
hacs.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Immich Album Watcher",
|
||||
"homeassistant": "2024.1.0",
|
||||
"render_readme": true,
|
||||
"content_in_root": false
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,232 +0,0 @@
|
||||
"""Config flow for Immich Album Watcher integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_ALBUMS,
|
||||
CONF_API_KEY,
|
||||
CONF_IMMICH_URL,
|
||||
CONF_SCAN_INTERVAL,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def validate_connection(
|
||||
session: aiohttp.ClientSession, url: str, api_key: str
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the Immich connection and return server info."""
|
||||
headers = {"x-api-key": api_key}
|
||||
async with session.get(
|
||||
f"{url.rstrip('/')}/api/server/ping", headers=headers
|
||||
) as response:
|
||||
if response.status == 401:
|
||||
raise InvalidAuth
|
||||
if response.status != 200:
|
||||
raise CannotConnect
|
||||
return await response.json()
|
||||
|
||||
|
||||
async def fetch_albums(
|
||||
session: aiohttp.ClientSession, url: str, api_key: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch all albums from Immich."""
|
||||
headers = {"x-api-key": api_key}
|
||||
async with session.get(
|
||||
f"{url.rstrip('/')}/api/albums", headers=headers
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise CannotConnect
|
||||
return await response.json()
|
||||
|
||||
|
||||
class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Immich Album Watcher."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._url: str | None = None
|
||||
self._api_key: str | None = None
|
||||
self._albums: list[dict[str, Any]] = []
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
return ImmichAlbumWatcherOptionsFlow(config_entry)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step - connection details."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._url = user_input[CONF_IMMICH_URL].rstrip("/")
|
||||
self._api_key = user_input[CONF_API_KEY]
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
|
||||
try:
|
||||
await validate_connection(session, self._url, self._api_key)
|
||||
self._albums = await fetch_albums(session, self._url, self._api_key)
|
||||
|
||||
if not self._albums:
|
||||
errors["base"] = "no_albums"
|
||||
else:
|
||||
return await self.async_step_albums()
|
||||
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except aiohttp.ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_IMMICH_URL): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"docs_url": "https://immich.app/docs/features/command-line-interface#obtain-the-api-key"
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_albums(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle album selection step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
selected_albums = user_input.get(CONF_ALBUMS, [])
|
||||
|
||||
if not selected_albums:
|
||||
errors["base"] = "no_albums_selected"
|
||||
else:
|
||||
# Create unique ID based on URL
|
||||
await self.async_set_unique_id(self._url)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title="Immich Album Watcher",
|
||||
data={
|
||||
CONF_IMMICH_URL: self._url,
|
||||
CONF_API_KEY: self._api_key,
|
||||
},
|
||||
options={
|
||||
CONF_ALBUMS: selected_albums,
|
||||
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
|
||||
},
|
||||
)
|
||||
|
||||
# Build album selection list
|
||||
album_options = {
|
||||
album["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)"
|
||||
for album in self._albums
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="albums",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUMS): cv.multi_select(album_options),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class ImmichAlbumWatcherOptionsFlow(OptionsFlow):
|
||||
"""Handle options flow for Immich Album Watcher."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self._config_entry = config_entry
|
||||
self._albums: list[dict[str, Any]] = []
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the options."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
# 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(
|
||||
title="",
|
||||
data={
|
||||
CONF_ALBUMS: user_input.get(CONF_ALBUMS, []),
|
||||
CONF_SCAN_INTERVAL: user_input.get(
|
||||
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# 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(
|
||||
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ALBUMS, default=current_albums): cv.multi_select(
|
||||
album_options
|
||||
),
|
||||
vol.Required(
|
||||
CONF_SCAN_INTERVAL, default=current_interval
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(Exception):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
class InvalidAuth(Exception):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
packages/core/pyproject.toml
Normal file
26
packages/core/pyproject.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "immich-watcher-core"
|
||||
version = "0.1.0"
|
||||
description = "Core library for Immich album change detection and notifications"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"aiohttp>=3.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"aioresponses>=0.7",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/immich_watcher_core"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
1
packages/core/src/immich_watcher_core/__init__.py
Normal file
1
packages/core/src/immich_watcher_core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Immich Watcher Core - shared library for Immich album change detection and notifications."""
|
||||
403
packages/core/src/immich_watcher_core/asset_utils.py
Normal file
403
packages/core/src/immich_watcher_core/asset_utils.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""Asset filtering, sorting, and URL utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from .constants import (
|
||||
ASSET_TYPE_IMAGE,
|
||||
ASSET_TYPE_VIDEO,
|
||||
ATTR_ASSET_CITY,
|
||||
ATTR_ASSET_COUNTRY,
|
||||
ATTR_ASSET_CREATED,
|
||||
ATTR_ASSET_DESCRIPTION,
|
||||
ATTR_ASSET_DOWNLOAD_URL,
|
||||
ATTR_ASSET_FILENAME,
|
||||
ATTR_ASSET_IS_FAVORITE,
|
||||
ATTR_ASSET_LATITUDE,
|
||||
ATTR_ASSET_LONGITUDE,
|
||||
ATTR_ASSET_OWNER,
|
||||
ATTR_ASSET_OWNER_ID,
|
||||
ATTR_ASSET_PLAYBACK_URL,
|
||||
ATTR_ASSET_RATING,
|
||||
ATTR_ASSET_STATE,
|
||||
ATTR_ASSET_TYPE,
|
||||
ATTR_ASSET_URL,
|
||||
ATTR_PEOPLE,
|
||||
ATTR_THUMBNAIL_URL,
|
||||
)
|
||||
from .models import AssetInfo, SharedLinkInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def filter_assets(
|
||||
assets: list[AssetInfo],
|
||||
*,
|
||||
favorite_only: bool = False,
|
||||
min_rating: int = 1,
|
||||
asset_type: str = "all",
|
||||
min_date: str | None = None,
|
||||
max_date: str | None = None,
|
||||
memory_date: str | None = None,
|
||||
city: str | None = None,
|
||||
state: str | None = None,
|
||||
country: str | None = None,
|
||||
processed_only: bool = True,
|
||||
) -> list[AssetInfo]:
|
||||
"""Filter assets by various criteria.
|
||||
|
||||
Args:
|
||||
assets: List of assets to filter
|
||||
favorite_only: Only include favorite assets
|
||||
min_rating: Minimum rating (1-5)
|
||||
asset_type: "all", "photo", or "video"
|
||||
min_date: Minimum creation date (ISO 8601)
|
||||
max_date: Maximum creation date (ISO 8601)
|
||||
memory_date: Match month/day excluding same year (ISO 8601)
|
||||
city: City substring filter (case-insensitive)
|
||||
state: State substring filter (case-insensitive)
|
||||
country: Country substring filter (case-insensitive)
|
||||
processed_only: Only include fully processed assets
|
||||
|
||||
Returns:
|
||||
Filtered list of assets
|
||||
"""
|
||||
result = list(assets)
|
||||
|
||||
if processed_only:
|
||||
result = [a for a in result if a.is_processed]
|
||||
|
||||
if favorite_only:
|
||||
result = [a for a in result if a.is_favorite]
|
||||
|
||||
if min_rating > 1:
|
||||
result = [a for a in result if a.rating is not None and a.rating >= min_rating]
|
||||
|
||||
if asset_type == "photo":
|
||||
result = [a for a in result if a.type == ASSET_TYPE_IMAGE]
|
||||
elif asset_type == "video":
|
||||
result = [a for a in result if a.type == ASSET_TYPE_VIDEO]
|
||||
|
||||
if min_date:
|
||||
result = [a for a in result if a.created_at >= min_date]
|
||||
if max_date:
|
||||
result = [a for a in result if a.created_at <= max_date]
|
||||
|
||||
if memory_date:
|
||||
try:
|
||||
ref_date = datetime.fromisoformat(memory_date.replace("Z", "+00:00"))
|
||||
ref_year = ref_date.year
|
||||
ref_month = ref_date.month
|
||||
ref_day = ref_date.day
|
||||
|
||||
def matches_memory(asset: AssetInfo) -> bool:
|
||||
try:
|
||||
asset_date = datetime.fromisoformat(
|
||||
asset.created_at.replace("Z", "+00:00")
|
||||
)
|
||||
return (
|
||||
asset_date.month == ref_month
|
||||
and asset_date.day == ref_day
|
||||
and asset_date.year != ref_year
|
||||
)
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
result = [a for a in result if matches_memory(a)]
|
||||
except ValueError:
|
||||
_LOGGER.warning("Invalid memory_date format: %s", memory_date)
|
||||
|
||||
if city:
|
||||
city_lower = city.lower()
|
||||
result = [a for a in result if a.city and city_lower in a.city.lower()]
|
||||
if state:
|
||||
state_lower = state.lower()
|
||||
result = [a for a in result if a.state and state_lower in a.state.lower()]
|
||||
if country:
|
||||
country_lower = country.lower()
|
||||
result = [a for a in result if a.country and country_lower in a.country.lower()]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def sort_assets(
|
||||
assets: list[AssetInfo],
|
||||
order_by: str = "date",
|
||||
order: str = "descending",
|
||||
) -> list[AssetInfo]:
|
||||
"""Sort assets by the specified field.
|
||||
|
||||
Args:
|
||||
assets: List of assets to sort
|
||||
order_by: "date", "rating", "name", or "random"
|
||||
order: "ascending" or "descending"
|
||||
|
||||
Returns:
|
||||
Sorted list of assets
|
||||
"""
|
||||
result = list(assets)
|
||||
|
||||
if order_by == "random":
|
||||
random.shuffle(result)
|
||||
elif order_by == "rating":
|
||||
result = sorted(
|
||||
result,
|
||||
key=lambda a: (a.rating is None, a.rating if a.rating is not None else 0),
|
||||
reverse=(order == "descending"),
|
||||
)
|
||||
elif order_by == "name":
|
||||
result = sorted(
|
||||
result,
|
||||
key=lambda a: a.filename.lower(),
|
||||
reverse=(order == "descending"),
|
||||
)
|
||||
else: # date (default)
|
||||
result = sorted(
|
||||
result,
|
||||
key=lambda a: a.created_at,
|
||||
reverse=(order == "descending"),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def combine_album_assets(
|
||||
album_assets: dict[str, list[AssetInfo]],
|
||||
total_limit: int,
|
||||
order_by: str = "random",
|
||||
order: str = "descending",
|
||||
) -> list[AssetInfo]:
|
||||
"""Smart combined fetch from multiple albums with quota redistribution.
|
||||
|
||||
Distributes the total_limit across albums, then redistributes unused
|
||||
quota from albums that returned fewer assets than their share.
|
||||
|
||||
Args:
|
||||
album_assets: Dict mapping album_id -> list of filtered assets
|
||||
total_limit: Maximum total assets to return
|
||||
order_by: Sort method for final result
|
||||
order: Sort direction
|
||||
|
||||
Returns:
|
||||
Combined and sorted list of assets, at most total_limit items
|
||||
|
||||
Example:
|
||||
2 albums, limit=10
|
||||
Album A has 1 matching asset, Album B has 20
|
||||
Pass 1: A gets 5 quota -> returns 1, B gets 5 quota -> returns 5 (total: 6)
|
||||
Pass 2: 4 unused from A redistributed to B -> B gets 4 more (total: 10)
|
||||
"""
|
||||
if not album_assets or total_limit <= 0:
|
||||
return []
|
||||
|
||||
num_albums = len(album_assets)
|
||||
per_album = max(1, total_limit // num_albums)
|
||||
|
||||
# Pass 1: initial even distribution
|
||||
collected: dict[str, list[AssetInfo]] = {}
|
||||
remainder = 0
|
||||
|
||||
for album_id, assets in album_assets.items():
|
||||
take = min(per_album, len(assets))
|
||||
collected[album_id] = assets[:take]
|
||||
unused = per_album - take
|
||||
remainder += unused
|
||||
|
||||
# Pass 2: redistribute remainder to albums that have more
|
||||
if remainder > 0:
|
||||
for album_id, assets in album_assets.items():
|
||||
if remainder <= 0:
|
||||
break
|
||||
already_taken = len(collected[album_id])
|
||||
available = len(assets) - already_taken
|
||||
if available > 0:
|
||||
extra = min(remainder, available)
|
||||
collected[album_id].extend(assets[already_taken : already_taken + extra])
|
||||
remainder -= extra
|
||||
|
||||
# Combine all
|
||||
combined = []
|
||||
for assets in collected.values():
|
||||
combined.extend(assets)
|
||||
|
||||
# Trim to exact limit
|
||||
combined = combined[:total_limit]
|
||||
|
||||
# Sort the combined result
|
||||
return sort_assets(combined, order_by=order_by, order=order)
|
||||
|
||||
|
||||
# --- Shared link URL helpers ---
|
||||
|
||||
|
||||
def get_accessible_links(links: list[SharedLinkInfo]) -> list[SharedLinkInfo]:
|
||||
"""Get all accessible (no password, not expired) shared links."""
|
||||
return [link for link in links if link.is_accessible]
|
||||
|
||||
|
||||
def get_protected_links(links: list[SharedLinkInfo]) -> list[SharedLinkInfo]:
|
||||
"""Get password-protected but not expired shared links."""
|
||||
return [link for link in links if link.has_password and not link.is_expired]
|
||||
|
||||
|
||||
def get_public_url(external_url: str, links: list[SharedLinkInfo]) -> str | None:
|
||||
"""Get the public URL if album has an accessible shared link."""
|
||||
accessible = get_accessible_links(links)
|
||||
if accessible:
|
||||
return f"{external_url}/share/{accessible[0].key}"
|
||||
return None
|
||||
|
||||
|
||||
def get_any_url(external_url: str, links: list[SharedLinkInfo]) -> str | None:
|
||||
"""Get any non-expired URL (prefers accessible, falls back to protected)."""
|
||||
accessible = get_accessible_links(links)
|
||||
if accessible:
|
||||
return f"{external_url}/share/{accessible[0].key}"
|
||||
non_expired = [link for link in links if not link.is_expired]
|
||||
if non_expired:
|
||||
return f"{external_url}/share/{non_expired[0].key}"
|
||||
return None
|
||||
|
||||
|
||||
def get_protected_url(external_url: str, links: list[SharedLinkInfo]) -> str | None:
|
||||
"""Get a protected URL if any password-protected link exists."""
|
||||
protected = get_protected_links(links)
|
||||
if protected:
|
||||
return f"{external_url}/share/{protected[0].key}"
|
||||
return None
|
||||
|
||||
|
||||
def get_protected_password(links: list[SharedLinkInfo]) -> str | None:
|
||||
"""Get the password for the first protected link."""
|
||||
protected = get_protected_links(links)
|
||||
if protected and protected[0].password:
|
||||
return protected[0].password
|
||||
return None
|
||||
|
||||
|
||||
def get_public_urls(external_url: str, links: list[SharedLinkInfo]) -> list[str]:
|
||||
"""Get all accessible public URLs."""
|
||||
return [f"{external_url}/share/{link.key}" for link in get_accessible_links(links)]
|
||||
|
||||
|
||||
def get_protected_urls(external_url: str, links: list[SharedLinkInfo]) -> list[str]:
|
||||
"""Get all password-protected URLs."""
|
||||
return [f"{external_url}/share/{link.key}" for link in get_protected_links(links)]
|
||||
|
||||
|
||||
# --- Asset URL builders ---
|
||||
|
||||
|
||||
def _get_best_link_key(links: list[SharedLinkInfo]) -> str | None:
|
||||
"""Get the best available link key (prefers accessible, falls back to non-expired)."""
|
||||
accessible = get_accessible_links(links)
|
||||
if accessible:
|
||||
return accessible[0].key
|
||||
non_expired = [link for link in links if not link.is_expired]
|
||||
if non_expired:
|
||||
return non_expired[0].key
|
||||
return None
|
||||
|
||||
|
||||
def get_asset_public_url(
|
||||
external_url: str, links: list[SharedLinkInfo], asset_id: str
|
||||
) -> str | None:
|
||||
"""Get the public viewer URL for an asset (web page)."""
|
||||
key = _get_best_link_key(links)
|
||||
if key:
|
||||
return f"{external_url}/share/{key}/photos/{asset_id}"
|
||||
return None
|
||||
|
||||
|
||||
def get_asset_download_url(
|
||||
external_url: str, links: list[SharedLinkInfo], asset_id: str
|
||||
) -> str | None:
|
||||
"""Get the direct download URL for an asset (media file)."""
|
||||
key = _get_best_link_key(links)
|
||||
if key:
|
||||
return f"{external_url}/api/assets/{asset_id}/original?key={key}"
|
||||
return None
|
||||
|
||||
|
||||
def get_asset_video_url(
|
||||
external_url: str, links: list[SharedLinkInfo], asset_id: str
|
||||
) -> str | None:
|
||||
"""Get the transcoded video playback URL for a video asset."""
|
||||
key = _get_best_link_key(links)
|
||||
if key:
|
||||
return f"{external_url}/api/assets/{asset_id}/video/playback?key={key}"
|
||||
return None
|
||||
|
||||
|
||||
def get_asset_photo_url(
|
||||
external_url: str, links: list[SharedLinkInfo], asset_id: str
|
||||
) -> str | None:
|
||||
"""Get the preview-sized thumbnail URL for a photo asset."""
|
||||
key = _get_best_link_key(links)
|
||||
if key:
|
||||
return f"{external_url}/api/assets/{asset_id}/thumbnail?size=preview&key={key}"
|
||||
return None
|
||||
|
||||
|
||||
def build_asset_detail(
|
||||
asset: AssetInfo,
|
||||
external_url: str,
|
||||
shared_links: list[SharedLinkInfo],
|
||||
include_thumbnail: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Build asset detail dictionary with all available data.
|
||||
|
||||
Args:
|
||||
asset: AssetInfo object
|
||||
external_url: Base URL for constructing links
|
||||
shared_links: Available shared links for URL building
|
||||
include_thumbnail: If True, include thumbnail_url
|
||||
|
||||
Returns:
|
||||
Dictionary with asset details using ATTR_* constants
|
||||
"""
|
||||
asset_detail: dict[str, Any] = {
|
||||
"id": asset.id,
|
||||
ATTR_ASSET_TYPE: asset.type,
|
||||
ATTR_ASSET_FILENAME: asset.filename,
|
||||
ATTR_ASSET_CREATED: asset.created_at,
|
||||
ATTR_ASSET_OWNER: asset.owner_name,
|
||||
ATTR_ASSET_OWNER_ID: asset.owner_id,
|
||||
ATTR_ASSET_DESCRIPTION: asset.description,
|
||||
ATTR_PEOPLE: asset.people,
|
||||
ATTR_ASSET_IS_FAVORITE: asset.is_favorite,
|
||||
ATTR_ASSET_RATING: asset.rating,
|
||||
ATTR_ASSET_LATITUDE: asset.latitude,
|
||||
ATTR_ASSET_LONGITUDE: asset.longitude,
|
||||
ATTR_ASSET_CITY: asset.city,
|
||||
ATTR_ASSET_STATE: asset.state,
|
||||
ATTR_ASSET_COUNTRY: asset.country,
|
||||
}
|
||||
|
||||
if include_thumbnail:
|
||||
asset_detail[ATTR_THUMBNAIL_URL] = (
|
||||
f"{external_url}/api/assets/{asset.id}/thumbnail"
|
||||
)
|
||||
|
||||
asset_url = get_asset_public_url(external_url, shared_links, asset.id)
|
||||
if asset_url:
|
||||
asset_detail[ATTR_ASSET_URL] = asset_url
|
||||
|
||||
download_url = get_asset_download_url(external_url, shared_links, asset.id)
|
||||
if download_url:
|
||||
asset_detail[ATTR_ASSET_DOWNLOAD_URL] = download_url
|
||||
|
||||
if asset.type == ASSET_TYPE_VIDEO:
|
||||
video_url = get_asset_video_url(external_url, shared_links, asset.id)
|
||||
if video_url:
|
||||
asset_detail[ATTR_ASSET_PLAYBACK_URL] = video_url
|
||||
elif asset.type == ASSET_TYPE_IMAGE:
|
||||
photo_url = get_asset_photo_url(external_url, shared_links, asset.id)
|
||||
if photo_url:
|
||||
asset_detail["photo_url"] = photo_url
|
||||
|
||||
return asset_detail
|
||||
115
packages/core/src/immich_watcher_core/change_detector.py
Normal file
115
packages/core/src/immich_watcher_core/change_detector.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Album change detection logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from .models import AlbumChange, AlbumData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def detect_album_changes(
|
||||
old_state: AlbumData,
|
||||
new_state: AlbumData,
|
||||
pending_asset_ids: set[str],
|
||||
) -> tuple[AlbumChange | None, set[str]]:
|
||||
"""Detect changes between two album states.
|
||||
|
||||
Args:
|
||||
old_state: Previous album data
|
||||
new_state: Current album data
|
||||
pending_asset_ids: Set of asset IDs that were detected but not yet
|
||||
fully processed by Immich (no thumbhash yet)
|
||||
|
||||
Returns:
|
||||
Tuple of (change or None if no changes, updated pending_asset_ids)
|
||||
"""
|
||||
added_ids = new_state.asset_ids - old_state.asset_ids
|
||||
removed_ids = old_state.asset_ids - new_state.asset_ids
|
||||
|
||||
_LOGGER.debug(
|
||||
"Change detection: added_ids=%d, removed_ids=%d, pending=%d",
|
||||
len(added_ids),
|
||||
len(removed_ids),
|
||||
len(pending_asset_ids),
|
||||
)
|
||||
|
||||
# Make a mutable copy of pending set
|
||||
pending = set(pending_asset_ids)
|
||||
|
||||
# Track new unprocessed assets and collect processed ones
|
||||
added_assets = []
|
||||
for aid in added_ids:
|
||||
if aid not in new_state.assets:
|
||||
_LOGGER.debug("Asset %s: not in assets dict", aid)
|
||||
continue
|
||||
asset = new_state.assets[aid]
|
||||
_LOGGER.debug(
|
||||
"New asset %s (%s): is_processed=%s, filename=%s",
|
||||
aid,
|
||||
asset.type,
|
||||
asset.is_processed,
|
||||
asset.filename,
|
||||
)
|
||||
if asset.is_processed:
|
||||
added_assets.append(asset)
|
||||
else:
|
||||
pending.add(aid)
|
||||
_LOGGER.debug("Asset %s added to pending (not yet processed)", aid)
|
||||
|
||||
# Check if any pending assets are now processed
|
||||
newly_processed = []
|
||||
for aid in list(pending):
|
||||
if aid not in new_state.assets:
|
||||
# Asset was removed, no longer pending
|
||||
pending.discard(aid)
|
||||
continue
|
||||
asset = new_state.assets[aid]
|
||||
if asset.is_processed:
|
||||
_LOGGER.debug(
|
||||
"Pending asset %s (%s) is now processed: filename=%s",
|
||||
aid,
|
||||
asset.type,
|
||||
asset.filename,
|
||||
)
|
||||
newly_processed.append(asset)
|
||||
pending.discard(aid)
|
||||
|
||||
# Include newly processed pending assets
|
||||
added_assets.extend(newly_processed)
|
||||
|
||||
# Detect metadata changes
|
||||
name_changed = old_state.name != new_state.name
|
||||
sharing_changed = old_state.shared != new_state.shared
|
||||
|
||||
# Return None only if nothing changed at all
|
||||
if not added_assets and not removed_ids and not name_changed and not sharing_changed:
|
||||
return None, pending
|
||||
|
||||
# Determine primary change type (use added_assets not added_ids)
|
||||
change_type = "changed"
|
||||
if name_changed and not added_assets and not removed_ids and not sharing_changed:
|
||||
change_type = "album_renamed"
|
||||
elif sharing_changed and not added_assets and not removed_ids and not name_changed:
|
||||
change_type = "album_sharing_changed"
|
||||
elif added_assets and not removed_ids and not name_changed and not sharing_changed:
|
||||
change_type = "assets_added"
|
||||
elif removed_ids and not added_assets and not name_changed and not sharing_changed:
|
||||
change_type = "assets_removed"
|
||||
|
||||
change = AlbumChange(
|
||||
album_id=new_state.id,
|
||||
album_name=new_state.name,
|
||||
change_type=change_type,
|
||||
added_count=len(added_assets),
|
||||
removed_count=len(removed_ids),
|
||||
added_assets=added_assets,
|
||||
removed_asset_ids=list(removed_ids),
|
||||
old_name=old_state.name if name_changed else None,
|
||||
new_name=new_state.name if name_changed else None,
|
||||
old_shared=old_state.shared if sharing_changed else None,
|
||||
new_shared=new_state.shared if sharing_changed else None,
|
||||
)
|
||||
|
||||
return change, pending
|
||||
64
packages/core/src/immich_watcher_core/constants.py
Normal file
64
packages/core/src/immich_watcher_core/constants.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Shared constants for Immich Watcher."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
# 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 = "album_changed"
|
||||
EVENT_ASSETS_ADDED: Final = "assets_added"
|
||||
EVENT_ASSETS_REMOVED: Final = "assets_removed"
|
||||
EVENT_ALBUM_RENAMED: Final = "album_renamed"
|
||||
EVENT_ALBUM_DELETED: Final = "album_deleted"
|
||||
EVENT_ALBUM_SHARING_CHANGED: Final = "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"
|
||||
546
packages/core/src/immich_watcher_core/immich_client.py
Normal file
546
packages/core/src/immich_watcher_core/immich_client.py
Normal file
@@ -0,0 +1,546 @@
|
||||
"""Async Immich API client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .models import AlbumData, SharedLinkInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImmichClient:
|
||||
"""Async client for the Immich API.
|
||||
|
||||
Accepts an aiohttp.ClientSession via constructor so that
|
||||
Home Assistant can provide its managed session and the standalone
|
||||
server can create its own.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
url: str,
|
||||
api_key: str,
|
||||
) -> None:
|
||||
"""Initialize the client.
|
||||
|
||||
Args:
|
||||
session: aiohttp client session (caller manages lifecycle)
|
||||
url: Immich server base URL (e.g. http://immich:2283)
|
||||
api_key: Immich API key
|
||||
"""
|
||||
self._session = session
|
||||
self._url = url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._external_domain: str | None = None
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""Return the Immich API URL."""
|
||||
return self._url
|
||||
|
||||
@property
|
||||
def external_url(self) -> str:
|
||||
"""Return the external URL for public links.
|
||||
|
||||
Uses externalDomain from Immich server config if set,
|
||||
otherwise falls back to the connection URL.
|
||||
"""
|
||||
if self._external_domain:
|
||||
return self._external_domain.rstrip("/")
|
||||
return self._url
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
"""Return the API key."""
|
||||
return self._api_key
|
||||
|
||||
def get_internal_download_url(self, url: str) -> str:
|
||||
"""Convert an external URL to internal URL for faster downloads.
|
||||
|
||||
If the URL starts with the external domain, replace it with the
|
||||
internal connection URL to download via local network.
|
||||
"""
|
||||
if self._external_domain:
|
||||
external = self._external_domain.rstrip("/")
|
||||
if url.startswith(external):
|
||||
return url.replace(external, self._url, 1)
|
||||
return url
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict[str, str]:
|
||||
"""Return common API headers."""
|
||||
return {"x-api-key": self._api_key}
|
||||
|
||||
@property
|
||||
def _json_headers(self) -> dict[str, str]:
|
||||
"""Return API headers for JSON requests."""
|
||||
return {
|
||||
"x-api-key": self._api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def ping(self) -> bool:
|
||||
"""Validate connection to Immich server."""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/server/ping",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
return response.status == 200
|
||||
except aiohttp.ClientError:
|
||||
return False
|
||||
|
||||
async def get_server_config(self) -> str | None:
|
||||
"""Fetch server config and return the external domain (if set).
|
||||
|
||||
Also updates the internal external_domain cache.
|
||||
"""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/server/config",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
external_domain = data.get("externalDomain", "") or ""
|
||||
self._external_domain = external_domain
|
||||
if external_domain:
|
||||
_LOGGER.debug(
|
||||
"Using external domain from Immich: %s", external_domain
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"No external domain configured in Immich, using connection URL"
|
||||
)
|
||||
return external_domain or None
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch server config: HTTP %s", response.status
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch server config: %s", err)
|
||||
return None
|
||||
|
||||
async def get_users(self) -> dict[str, str]:
|
||||
"""Fetch all users from Immich.
|
||||
|
||||
Returns:
|
||||
Dict mapping user_id -> display name
|
||||
"""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/users",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return {
|
||||
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 {}
|
||||
|
||||
async def get_people(self) -> dict[str, str]:
|
||||
"""Fetch all people from Immich.
|
||||
|
||||
Returns:
|
||||
Dict mapping person_id -> name
|
||||
"""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/people",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
people_list = data.get("people", data) if isinstance(data, dict) else data
|
||||
return {
|
||||
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 {}
|
||||
|
||||
async def get_shared_links(self, album_id: str) -> list[SharedLinkInfo]:
|
||||
"""Fetch shared links for an album from Immich.
|
||||
|
||||
Args:
|
||||
album_id: The album ID to filter links for
|
||||
|
||||
Returns:
|
||||
List of SharedLinkInfo for the specified album
|
||||
"""
|
||||
links: list[SharedLinkInfo] = []
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/shared-links",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
for link in data:
|
||||
album = link.get("album")
|
||||
key = link.get("key")
|
||||
if album and key and album.get("id") == album_id:
|
||||
link_info = SharedLinkInfo.from_api_response(link)
|
||||
links.append(link_info)
|
||||
_LOGGER.debug(
|
||||
"Found shared link for album: key=%s, has_password=%s",
|
||||
key[:8],
|
||||
link_info.has_password,
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch shared links: %s", err)
|
||||
return links
|
||||
|
||||
async def get_album(
|
||||
self,
|
||||
album_id: str,
|
||||
users_cache: dict[str, str] | None = None,
|
||||
) -> AlbumData | None:
|
||||
"""Fetch album data from Immich.
|
||||
|
||||
Args:
|
||||
album_id: The album ID to fetch
|
||||
users_cache: Optional user_id -> name mapping for owner resolution
|
||||
|
||||
Returns:
|
||||
AlbumData if found, None if album doesn't exist (404)
|
||||
|
||||
Raises:
|
||||
ImmichApiError: On non-200/404 HTTP responses or connection errors
|
||||
"""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/albums/{album_id}",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 404:
|
||||
_LOGGER.warning("Album %s not found", album_id)
|
||||
return None
|
||||
if response.status != 200:
|
||||
raise ImmichApiError(
|
||||
f"Error fetching album {album_id}: HTTP {response.status}"
|
||||
)
|
||||
data = await response.json()
|
||||
return AlbumData.from_api_response(data, users_cache)
|
||||
except aiohttp.ClientError as err:
|
||||
raise ImmichApiError(f"Error communicating with Immich: {err}") from err
|
||||
|
||||
async def get_albums(self) -> list[dict[str, Any]]:
|
||||
"""Fetch all albums from Immich.
|
||||
|
||||
Returns:
|
||||
List of album dicts with id, albumName, assetCount, etc.
|
||||
"""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/albums",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
_LOGGER.warning("Failed to fetch albums: HTTP %s", response.status)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Failed to fetch albums: %s", err)
|
||||
return []
|
||||
|
||||
async def create_shared_link(
|
||||
self, album_id: str, password: str | None = None
|
||||
) -> bool:
|
||||
"""Create a new shared link for an album.
|
||||
|
||||
Args:
|
||||
album_id: The album to share
|
||||
password: Optional password for the link
|
||||
|
||||
Returns:
|
||||
True if created successfully
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"albumId": album_id,
|
||||
"type": "ALBUM",
|
||||
"allowDownload": True,
|
||||
"allowUpload": False,
|
||||
"showMetadata": True,
|
||||
}
|
||||
if password:
|
||||
payload["password"] = password
|
||||
|
||||
try:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/shared-links",
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status == 201:
|
||||
_LOGGER.info(
|
||||
"Successfully created shared link for album %s", album_id
|
||||
)
|
||||
return True
|
||||
error_text = await response.text()
|
||||
_LOGGER.error(
|
||||
"Failed to create shared link: HTTP %s - %s",
|
||||
response.status,
|
||||
error_text,
|
||||
)
|
||||
return False
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Error creating shared link: %s", err)
|
||||
return False
|
||||
|
||||
async def delete_shared_link(self, link_id: str) -> bool:
|
||||
"""Delete a shared link.
|
||||
|
||||
Args:
|
||||
link_id: The shared link ID to delete
|
||||
|
||||
Returns:
|
||||
True if deleted successfully
|
||||
"""
|
||||
try:
|
||||
async with self._session.delete(
|
||||
f"{self._url}/api/shared-links/{link_id}",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
_LOGGER.info("Successfully deleted shared link")
|
||||
return True
|
||||
error_text = await response.text()
|
||||
_LOGGER.error(
|
||||
"Failed to delete shared link: HTTP %s - %s",
|
||||
response.status,
|
||||
error_text,
|
||||
)
|
||||
return False
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Error deleting shared link: %s", err)
|
||||
return False
|
||||
|
||||
async def set_shared_link_password(
|
||||
self, link_id: str, password: str | None
|
||||
) -> bool:
|
||||
"""Update the password for a shared link.
|
||||
|
||||
Args:
|
||||
link_id: The shared link ID
|
||||
password: New password (None to remove)
|
||||
|
||||
Returns:
|
||||
True if updated successfully
|
||||
"""
|
||||
payload = {"password": password if password else None}
|
||||
try:
|
||||
async with self._session.patch(
|
||||
f"{self._url}/api/shared-links/{link_id}",
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
_LOGGER.info("Successfully updated shared link password")
|
||||
return True
|
||||
_LOGGER.error(
|
||||
"Failed to update shared link password: HTTP %s",
|
||||
response.status,
|
||||
)
|
||||
return False
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Error updating shared link password: %s", err)
|
||||
return False
|
||||
|
||||
|
||||
async def search_smart(
|
||||
self,
|
||||
query: str,
|
||||
album_ids: list[str] | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Semantic search via Immich CLIP (smart search).
|
||||
|
||||
Args:
|
||||
query: Natural language search query
|
||||
album_ids: Optional list of album IDs to scope results to
|
||||
limit: Max results to return
|
||||
|
||||
Returns:
|
||||
List of asset dicts from search results
|
||||
"""
|
||||
payload: dict[str, Any] = {"query": query, "page": 1, "size": limit}
|
||||
try:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/smart",
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
items = data.get("assets", {}).get("items", [])
|
||||
if album_ids:
|
||||
# Post-filter: only keep assets from tracked albums
|
||||
tracked = set(album_ids)
|
||||
items = [
|
||||
a for a in items
|
||||
if any(
|
||||
alb.get("id") in tracked
|
||||
for alb in a.get("albums", [])
|
||||
)
|
||||
]
|
||||
return items[:limit]
|
||||
_LOGGER.warning("Smart search failed: HTTP %s", response.status)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Smart search error: %s", err)
|
||||
return []
|
||||
|
||||
async def search_metadata(
|
||||
self,
|
||||
query: str,
|
||||
album_ids: list[str] | None = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search assets by metadata (filename, description).
|
||||
|
||||
Args:
|
||||
query: Text to search for
|
||||
album_ids: Optional list of album IDs to scope results to
|
||||
limit: Max results to return
|
||||
|
||||
Returns:
|
||||
List of asset dicts from search results
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"originalFileName": query,
|
||||
"page": 1,
|
||||
"size": limit,
|
||||
}
|
||||
try:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/metadata",
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
items = data.get("assets", {}).get("items", [])
|
||||
if album_ids:
|
||||
tracked = set(album_ids)
|
||||
items = [
|
||||
a for a in items
|
||||
if any(
|
||||
alb.get("id") in tracked
|
||||
for alb in a.get("albums", [])
|
||||
)
|
||||
]
|
||||
return items[:limit]
|
||||
_LOGGER.warning("Metadata search failed: HTTP %s", response.status)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Metadata search error: %s", err)
|
||||
return []
|
||||
|
||||
async def search_by_person(
|
||||
self,
|
||||
person_id: str,
|
||||
limit: int = 10,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Find assets containing a specific person.
|
||||
|
||||
Args:
|
||||
person_id: Immich person ID
|
||||
limit: Max results to return
|
||||
|
||||
Returns:
|
||||
List of asset dicts
|
||||
"""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/people/{person_id}/assets",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
return data[:limit]
|
||||
_LOGGER.warning("Person assets failed: HTTP %s", response.status)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Person assets error: %s", err)
|
||||
return []
|
||||
|
||||
async def get_random_assets(
|
||||
self,
|
||||
count: int = 5,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get random assets from Immich.
|
||||
|
||||
Args:
|
||||
count: Number of random assets to return
|
||||
|
||||
Returns:
|
||||
List of asset dicts
|
||||
"""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/assets/random",
|
||||
headers=self._headers,
|
||||
params={"count": count},
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
_LOGGER.warning("Random assets failed: HTTP %s", response.status)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Random assets error: %s", err)
|
||||
return []
|
||||
|
||||
async def download_asset(self, asset_id: str) -> bytes | None:
|
||||
"""Download an asset's original file.
|
||||
|
||||
Args:
|
||||
asset_id: The asset ID to download
|
||||
|
||||
Returns:
|
||||
Raw bytes of the asset, or None on failure
|
||||
"""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/assets/{asset_id}/original",
|
||||
headers=self._headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.read()
|
||||
_LOGGER.warning("Asset download failed: HTTP %s", response.status)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Asset download error: %s", err)
|
||||
return None
|
||||
|
||||
async def get_asset_thumbnail(self, asset_id: str, size: str = "preview") -> bytes | None:
|
||||
"""Download an asset's thumbnail/preview.
|
||||
|
||||
Args:
|
||||
asset_id: The asset ID
|
||||
size: "thumbnail" (small) or "preview" (larger)
|
||||
|
||||
Returns:
|
||||
Raw bytes of the thumbnail, or None on failure
|
||||
"""
|
||||
try:
|
||||
async with self._session.get(
|
||||
f"{self._url}/api/assets/{asset_id}/thumbnail",
|
||||
headers=self._headers,
|
||||
params={"size": size},
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.read()
|
||||
_LOGGER.warning("Thumbnail download failed: HTTP %s", response.status)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Thumbnail download error: %s", err)
|
||||
return None
|
||||
|
||||
|
||||
class ImmichApiError(Exception):
|
||||
"""Raised when an Immich API call fails."""
|
||||
266
packages/core/src/immich_watcher_core/models.py
Normal file
266
packages/core/src/immich_watcher_core/models.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Data models for Immich Watcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from .constants import ASSET_TYPE_IMAGE, ASSET_TYPE_VIDEO
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SharedLinkInfo:
|
||||
"""Data class for shared link information."""
|
||||
|
||||
id: str
|
||||
key: str
|
||||
has_password: bool = False
|
||||
password: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
allow_download: bool = True
|
||||
show_metadata: bool = True
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the link has expired."""
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return datetime.now(self.expires_at.tzinfo) > self.expires_at
|
||||
|
||||
@property
|
||||
def is_accessible(self) -> bool:
|
||||
"""Check if the link is accessible without password and not expired."""
|
||||
return not self.has_password and not self.is_expired
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict[str, Any]) -> SharedLinkInfo:
|
||||
"""Create SharedLinkInfo from API response."""
|
||||
expires_at = None
|
||||
if data.get("expiresAt"):
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(
|
||||
data["expiresAt"].replace("Z", "+00:00")
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
password = data.get("password")
|
||||
return cls(
|
||||
id=data["id"],
|
||||
key=data["key"],
|
||||
has_password=bool(password),
|
||||
password=password if password else None,
|
||||
expires_at=expires_at,
|
||||
allow_download=data.get("allowDownload", True),
|
||||
show_metadata=data.get("showMetadata", True),
|
||||
)
|
||||
|
||||
|
||||
@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 = ""
|
||||
description: str = ""
|
||||
people: list[str] = field(default_factory=list)
|
||||
is_favorite: bool = False
|
||||
rating: int | None = None
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
city: str | None = None
|
||||
state: str | None = None
|
||||
country: str | None = None
|
||||
is_processed: bool = True # Whether asset is fully processed by Immich
|
||||
thumbhash: str | None = None # Perceptual hash for cache validation
|
||||
|
||||
@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, "")
|
||||
|
||||
# Get description - prioritize user-added description over EXIF description
|
||||
description = data.get("description", "") or ""
|
||||
exif_info = data.get("exifInfo")
|
||||
if not description and exif_info:
|
||||
description = exif_info.get("description", "") or ""
|
||||
|
||||
# Get favorites and rating
|
||||
is_favorite = data.get("isFavorite", False)
|
||||
rating = exif_info.get("rating") if exif_info else None
|
||||
|
||||
# Get geolocation
|
||||
latitude = exif_info.get("latitude") if exif_info else None
|
||||
longitude = exif_info.get("longitude") if exif_info else None
|
||||
|
||||
# Get reverse geocoded location
|
||||
city = exif_info.get("city") if exif_info else None
|
||||
state = exif_info.get("state") if exif_info else None
|
||||
country = exif_info.get("country") if exif_info else None
|
||||
|
||||
# Check if asset is fully processed by Immich
|
||||
asset_type = data.get("type", ASSET_TYPE_IMAGE)
|
||||
is_processed = cls._check_processing_status(data, asset_type)
|
||||
thumbhash = data.get("thumbhash")
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
type=asset_type,
|
||||
filename=data.get("originalFileName", ""),
|
||||
created_at=data.get("fileCreatedAt", ""),
|
||||
owner_id=owner_id,
|
||||
owner_name=owner_name,
|
||||
description=description,
|
||||
people=people,
|
||||
is_favorite=is_favorite,
|
||||
rating=rating,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
city=city,
|
||||
state=state,
|
||||
country=country,
|
||||
is_processed=is_processed,
|
||||
thumbhash=thumbhash,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _check_processing_status(data: dict[str, Any], _asset_type: str) -> bool:
|
||||
"""Check if asset has been fully processed by Immich.
|
||||
|
||||
For all assets: Check if thumbnails have been generated (thumbhash exists).
|
||||
Immich generates thumbnails for both photos and videos regardless of
|
||||
whether video transcoding is needed.
|
||||
|
||||
Args:
|
||||
data: Asset data from API response
|
||||
_asset_type: Asset type (IMAGE or VIDEO) - unused but kept for API stability
|
||||
|
||||
Returns:
|
||||
True if asset is fully processed and not trashed/offline/archived, False otherwise
|
||||
"""
|
||||
asset_id = data.get("id", "unknown")
|
||||
asset_type = data.get("type", "unknown")
|
||||
is_offline = data.get("isOffline", False)
|
||||
is_trashed = data.get("isTrashed", False)
|
||||
is_archived = data.get("isArchived", False)
|
||||
thumbhash = data.get("thumbhash")
|
||||
|
||||
_LOGGER.debug(
|
||||
"Asset %s (%s): isOffline=%s, isTrashed=%s, isArchived=%s, thumbhash=%s",
|
||||
asset_id,
|
||||
asset_type,
|
||||
is_offline,
|
||||
is_trashed,
|
||||
is_archived,
|
||||
bool(thumbhash),
|
||||
)
|
||||
|
||||
if is_offline:
|
||||
_LOGGER.debug("Asset %s excluded: offline", asset_id)
|
||||
return False
|
||||
|
||||
if is_trashed:
|
||||
_LOGGER.debug("Asset %s excluded: trashed", asset_id)
|
||||
return False
|
||||
|
||||
if is_archived:
|
||||
_LOGGER.debug("Asset %s excluded: archived", asset_id)
|
||||
return False
|
||||
|
||||
is_processed = bool(thumbhash)
|
||||
if not is_processed:
|
||||
_LOGGER.debug("Asset %s excluded: no thumbhash", asset_id)
|
||||
return is_processed
|
||||
|
||||
|
||||
@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)
|
||||
old_name: str | None = None
|
||||
new_name: str | None = None
|
||||
old_shared: bool | None = None
|
||||
new_shared: bool | None = None
|
||||
@@ -0,0 +1 @@
|
||||
"""Notification providers."""
|
||||
81
packages/core/src/immich_watcher_core/notifications/queue.py
Normal file
81
packages/core/src/immich_watcher_core/notifications/queue.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Persistent notification queue for deferred notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..storage import StorageBackend
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationQueue:
|
||||
"""Persistent queue for notifications deferred during quiet hours.
|
||||
|
||||
Stores full service call parameters so notifications can be replayed
|
||||
exactly as they were originally called.
|
||||
"""
|
||||
|
||||
def __init__(self, backend: StorageBackend) -> None:
|
||||
"""Initialize the notification queue.
|
||||
|
||||
Args:
|
||||
backend: Storage backend for persistence
|
||||
"""
|
||||
self._backend = backend
|
||||
self._data: dict[str, Any] | None = None
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load queue data from storage."""
|
||||
self._data = await self._backend.load() or {"queue": []}
|
||||
_LOGGER.debug(
|
||||
"Loaded notification queue with %d items",
|
||||
len(self._data.get("queue", [])),
|
||||
)
|
||||
|
||||
async def async_enqueue(self, notification_params: dict[str, Any]) -> None:
|
||||
"""Add a notification to the queue."""
|
||||
if self._data is None:
|
||||
self._data = {"queue": []}
|
||||
|
||||
self._data["queue"].append({
|
||||
"params": notification_params,
|
||||
"queued_at": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
await self._backend.save(self._data)
|
||||
_LOGGER.debug(
|
||||
"Queued notification during quiet hours (total: %d)",
|
||||
len(self._data["queue"]),
|
||||
)
|
||||
|
||||
def get_all(self) -> list[dict[str, Any]]:
|
||||
"""Get all queued notifications."""
|
||||
if not self._data:
|
||||
return []
|
||||
return list(self._data.get("queue", []))
|
||||
|
||||
def has_pending(self) -> bool:
|
||||
"""Check if there are pending notifications."""
|
||||
return bool(self._data and self._data.get("queue"))
|
||||
|
||||
async def async_remove_indices(self, indices: list[int]) -> None:
|
||||
"""Remove specific items by index (indices must be in descending order)."""
|
||||
if not self._data or not indices:
|
||||
return
|
||||
for idx in indices:
|
||||
if 0 <= idx < len(self._data["queue"]):
|
||||
del self._data["queue"][idx]
|
||||
await self._backend.save(self._data)
|
||||
|
||||
async def async_clear(self) -> None:
|
||||
"""Clear all queued notifications."""
|
||||
if self._data:
|
||||
self._data["queue"] = []
|
||||
await self._backend.save(self._data)
|
||||
|
||||
async def async_remove(self) -> None:
|
||||
"""Remove all queue data."""
|
||||
await self._backend.remove()
|
||||
self._data = None
|
||||
72
packages/core/src/immich_watcher_core/storage.py
Normal file
72
packages/core/src/immich_watcher_core/storage.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Abstract storage backends and JSON file implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class StorageBackend(Protocol):
|
||||
"""Abstract storage backend for persisting JSON-serializable data."""
|
||||
|
||||
async def load(self) -> dict[str, Any] | None:
|
||||
"""Load data from storage. Returns None if no data exists."""
|
||||
...
|
||||
|
||||
async def save(self, data: dict[str, Any]) -> None:
|
||||
"""Save data to storage."""
|
||||
...
|
||||
|
||||
async def remove(self) -> None:
|
||||
"""Remove all stored data."""
|
||||
...
|
||||
|
||||
|
||||
class JsonFileBackend:
|
||||
"""Simple JSON file storage backend.
|
||||
|
||||
Suitable for standalone server use. For Home Assistant,
|
||||
use an adapter wrapping homeassistant.helpers.storage.Store.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
"""Initialize with a file path.
|
||||
|
||||
Args:
|
||||
path: Path to the JSON file (will be created if it doesn't exist)
|
||||
"""
|
||||
self._path = path
|
||||
|
||||
async def load(self) -> dict[str, Any] | None:
|
||||
"""Load data from the JSON file."""
|
||||
if not self._path.exists():
|
||||
return None
|
||||
try:
|
||||
text = self._path.read_text(encoding="utf-8")
|
||||
return json.loads(text)
|
||||
except (json.JSONDecodeError, OSError) as err:
|
||||
_LOGGER.warning("Failed to load %s: %s", self._path, err)
|
||||
return None
|
||||
|
||||
async def save(self, data: dict[str, Any]) -> None:
|
||||
"""Save data to the JSON file."""
|
||||
try:
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._path.write_text(
|
||||
json.dumps(data, default=str), encoding="utf-8"
|
||||
)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Failed to save %s: %s", self._path, err)
|
||||
|
||||
async def remove(self) -> None:
|
||||
"""Remove the JSON file."""
|
||||
try:
|
||||
if self._path.exists():
|
||||
self._path.unlink()
|
||||
except OSError as err:
|
||||
_LOGGER.error("Failed to remove %s: %s", self._path, err)
|
||||
@@ -0,0 +1 @@
|
||||
"""Telegram notification support."""
|
||||
199
packages/core/src/immich_watcher_core/telegram/cache.py
Normal file
199
packages/core/src/immich_watcher_core/telegram/cache.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Telegram file_id cache with pluggable storage backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from ..storage import StorageBackend
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Default TTL for Telegram file_id cache (48 hours in seconds)
|
||||
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
|
||||
|
||||
|
||||
class TelegramFileCache:
|
||||
"""Cache for Telegram file_ids to avoid re-uploading media.
|
||||
|
||||
When a file is uploaded to Telegram, it returns a file_id that can be reused
|
||||
to send the same file without re-uploading. This cache stores these file_ids
|
||||
keyed by the source URL or asset ID.
|
||||
|
||||
Supports two validation modes:
|
||||
- TTL mode (default): entries expire after a configured time-to-live
|
||||
- Thumbhash mode: entries are validated by comparing stored thumbhash with
|
||||
the current asset thumbhash from Immich
|
||||
"""
|
||||
|
||||
# Maximum number of entries to keep in thumbhash mode to prevent unbounded growth
|
||||
THUMBHASH_MAX_ENTRIES = 2000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backend: StorageBackend,
|
||||
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
|
||||
use_thumbhash: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the Telegram file cache.
|
||||
|
||||
Args:
|
||||
backend: Storage backend for persistence
|
||||
ttl_seconds: Time-to-live for cache entries in seconds (TTL mode only)
|
||||
use_thumbhash: Use thumbhash-based validation instead of TTL
|
||||
"""
|
||||
self._backend = backend
|
||||
self._data: dict[str, Any] | None = None
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._use_thumbhash = use_thumbhash
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load cache data from storage."""
|
||||
self._data = await self._backend.load() or {"files": {}}
|
||||
await self._cleanup_expired()
|
||||
mode = "thumbhash" if self._use_thumbhash else "TTL"
|
||||
_LOGGER.debug(
|
||||
"Loaded Telegram file cache with %d entries (mode: %s)",
|
||||
len(self._data.get("files", {})),
|
||||
mode,
|
||||
)
|
||||
|
||||
async def _cleanup_expired(self) -> None:
|
||||
"""Remove expired cache entries (TTL mode) or trim old entries (thumbhash mode)."""
|
||||
if self._use_thumbhash:
|
||||
files = self._data.get("files", {}) if self._data else {}
|
||||
if len(files) > self.THUMBHASH_MAX_ENTRIES:
|
||||
sorted_keys = sorted(
|
||||
files, key=lambda k: files[k].get("cached_at", "")
|
||||
)
|
||||
keys_to_remove = sorted_keys[: len(files) - self.THUMBHASH_MAX_ENTRIES]
|
||||
for key in keys_to_remove:
|
||||
del files[key]
|
||||
await self._backend.save(self._data)
|
||||
_LOGGER.debug(
|
||||
"Trimmed thumbhash cache from %d to %d entries",
|
||||
len(keys_to_remove) + self.THUMBHASH_MAX_ENTRIES,
|
||||
self.THUMBHASH_MAX_ENTRIES,
|
||||
)
|
||||
return
|
||||
|
||||
if not self._data or "files" not in self._data:
|
||||
return
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expired_keys = []
|
||||
|
||||
for url, entry in self._data["files"].items():
|
||||
cached_at_str = entry.get("cached_at")
|
||||
if cached_at_str:
|
||||
cached_at = datetime.fromisoformat(cached_at_str)
|
||||
age_seconds = (now - cached_at).total_seconds()
|
||||
if age_seconds > self._ttl_seconds:
|
||||
expired_keys.append(url)
|
||||
|
||||
if expired_keys:
|
||||
for key in expired_keys:
|
||||
del self._data["files"][key]
|
||||
await self._backend.save(self._data)
|
||||
_LOGGER.debug("Cleaned up %d expired Telegram cache entries", len(expired_keys))
|
||||
|
||||
def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
|
||||
"""Get cached file_id for a key.
|
||||
|
||||
Args:
|
||||
key: The cache key (URL or asset ID)
|
||||
thumbhash: Current thumbhash for validation (thumbhash mode only).
|
||||
If provided, compares with stored thumbhash. Mismatch = cache miss.
|
||||
|
||||
Returns:
|
||||
Dict with 'file_id' and 'type' if cached and valid, None otherwise
|
||||
"""
|
||||
if not self._data or "files" not in self._data:
|
||||
return None
|
||||
|
||||
entry = self._data["files"].get(key)
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
if self._use_thumbhash:
|
||||
if thumbhash is not None:
|
||||
stored_thumbhash = entry.get("thumbhash")
|
||||
if stored_thumbhash and stored_thumbhash != thumbhash:
|
||||
_LOGGER.debug(
|
||||
"Cache miss for %s: thumbhash changed, removing stale entry",
|
||||
key[:36],
|
||||
)
|
||||
del self._data["files"][key]
|
||||
return None
|
||||
else:
|
||||
cached_at_str = entry.get("cached_at")
|
||||
if cached_at_str:
|
||||
cached_at = datetime.fromisoformat(cached_at_str)
|
||||
age_seconds = (datetime.now(timezone.utc) - cached_at).total_seconds()
|
||||
if age_seconds > self._ttl_seconds:
|
||||
return None
|
||||
|
||||
return {
|
||||
"file_id": entry.get("file_id"),
|
||||
"type": entry.get("type"),
|
||||
}
|
||||
|
||||
async def async_set(
|
||||
self, key: str, file_id: str, media_type: str, thumbhash: str | None = None
|
||||
) -> None:
|
||||
"""Store a file_id for a key.
|
||||
|
||||
Args:
|
||||
key: The cache key (URL or asset ID)
|
||||
file_id: The Telegram file_id
|
||||
media_type: The type of media ('photo', 'video', 'document')
|
||||
thumbhash: Current thumbhash to store alongside file_id (thumbhash mode only)
|
||||
"""
|
||||
if self._data is None:
|
||||
self._data = {"files": {}}
|
||||
|
||||
entry_data: dict[str, Any] = {
|
||||
"file_id": file_id,
|
||||
"type": media_type,
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if thumbhash is not None:
|
||||
entry_data["thumbhash"] = thumbhash
|
||||
|
||||
self._data["files"][key] = entry_data
|
||||
await self._backend.save(self._data)
|
||||
_LOGGER.debug("Cached Telegram file_id for key (type: %s)", media_type)
|
||||
|
||||
async def async_set_many(
|
||||
self, entries: list[tuple[str, str, str, str | None]]
|
||||
) -> None:
|
||||
"""Store multiple file_ids in a single disk write.
|
||||
|
||||
Args:
|
||||
entries: List of (key, file_id, media_type, thumbhash) tuples
|
||||
"""
|
||||
if not entries:
|
||||
return
|
||||
|
||||
if self._data is None:
|
||||
self._data = {"files": {}}
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
for key, file_id, media_type, thumbhash in entries:
|
||||
entry_data: dict[str, Any] = {
|
||||
"file_id": file_id,
|
||||
"type": media_type,
|
||||
"cached_at": now_iso,
|
||||
}
|
||||
if thumbhash is not None:
|
||||
entry_data["thumbhash"] = thumbhash
|
||||
self._data["files"][key] = entry_data
|
||||
|
||||
await self._backend.save(self._data)
|
||||
_LOGGER.debug("Batch cached %d Telegram file_ids", len(entries))
|
||||
|
||||
async def async_remove(self) -> None:
|
||||
"""Remove all cache data."""
|
||||
await self._backend.remove()
|
||||
self._data = None
|
||||
931
packages/core/src/immich_watcher_core/telegram/client.py
Normal file
931
packages/core/src/immich_watcher_core/telegram/client.py
Normal file
@@ -0,0 +1,931 @@
|
||||
"""Telegram Bot API client for sending notifications with media."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
from typing import Any, Callable
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import FormData
|
||||
|
||||
from .cache import TelegramFileCache
|
||||
from .media import (
|
||||
TELEGRAM_API_BASE_URL,
|
||||
TELEGRAM_MAX_PHOTO_SIZE,
|
||||
TELEGRAM_MAX_VIDEO_SIZE,
|
||||
check_photo_limits,
|
||||
extract_asset_id_from_url,
|
||||
is_asset_id,
|
||||
split_media_by_upload_size,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Type alias for notification results
|
||||
NotificationResult = dict[str, Any]
|
||||
|
||||
|
||||
class TelegramClient:
|
||||
"""Async Telegram Bot API client for sending notifications with media.
|
||||
|
||||
Decoupled from Home Assistant - accepts session, caches, and resolver
|
||||
callbacks via constructor.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
bot_token: str,
|
||||
*,
|
||||
url_cache: TelegramFileCache | None = None,
|
||||
asset_cache: TelegramFileCache | None = None,
|
||||
url_resolver: Callable[[str], str] | None = None,
|
||||
thumbhash_resolver: Callable[[str], str | None] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the Telegram client.
|
||||
|
||||
Args:
|
||||
session: aiohttp client session (caller manages lifecycle)
|
||||
bot_token: Telegram Bot API token
|
||||
url_cache: Cache for URL-keyed file_ids (TTL mode)
|
||||
asset_cache: Cache for asset ID-keyed file_ids (thumbhash mode)
|
||||
url_resolver: Optional callback to convert external URLs to internal
|
||||
URLs for faster local downloads
|
||||
thumbhash_resolver: Optional callback to get current thumbhash for
|
||||
an asset ID (for cache validation)
|
||||
"""
|
||||
self._session = session
|
||||
self._token = bot_token
|
||||
self._url_cache = url_cache
|
||||
self._asset_cache = asset_cache
|
||||
self._url_resolver = url_resolver
|
||||
self._thumbhash_resolver = thumbhash_resolver
|
||||
|
||||
def _resolve_url(self, url: str) -> str:
|
||||
"""Convert external URL to internal URL if resolver is available."""
|
||||
if self._url_resolver:
|
||||
return self._url_resolver(url)
|
||||
return url
|
||||
|
||||
def _get_cache_and_key(
|
||||
self,
|
||||
url: str | None,
|
||||
cache_key: str | None = None,
|
||||
) -> tuple[TelegramFileCache | None, str | None, str | None]:
|
||||
"""Determine which cache, key, and thumbhash to use.
|
||||
|
||||
Priority: custom cache_key -> direct asset ID -> extracted asset ID -> URL
|
||||
"""
|
||||
if cache_key:
|
||||
return self._url_cache, cache_key, None
|
||||
|
||||
if url:
|
||||
if is_asset_id(url):
|
||||
thumbhash = self._thumbhash_resolver(url) if self._thumbhash_resolver else None
|
||||
return self._asset_cache, url, thumbhash
|
||||
asset_id = extract_asset_id_from_url(url)
|
||||
if asset_id:
|
||||
thumbhash = self._thumbhash_resolver(asset_id) if self._thumbhash_resolver else None
|
||||
return self._asset_cache, asset_id, thumbhash
|
||||
return self._url_cache, url, None
|
||||
|
||||
return None, None, None
|
||||
|
||||
def _get_cache_for_key(self, key: str, is_asset: bool | None = None) -> TelegramFileCache | None:
|
||||
"""Return asset cache if key is a UUID, otherwise URL cache."""
|
||||
if is_asset is None:
|
||||
is_asset = is_asset_id(key)
|
||||
return self._asset_cache if is_asset else self._url_cache
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
chat_id: str,
|
||||
assets: list[dict[str, str]] | None = None,
|
||||
caption: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
disable_web_page_preview: bool | None = None,
|
||||
parse_mode: str = "HTML",
|
||||
max_group_size: int = 10,
|
||||
chunk_delay: int = 0,
|
||||
max_asset_data_size: int | None = None,
|
||||
send_large_photos_as_documents: bool = False,
|
||||
chat_action: str | None = "typing",
|
||||
) -> NotificationResult:
|
||||
"""Send a Telegram notification (text and/or media).
|
||||
|
||||
This is the main entry point. Dispatches to appropriate method
|
||||
based on assets list.
|
||||
"""
|
||||
if not assets:
|
||||
return await self.send_message(
|
||||
chat_id, caption or "", reply_to_message_id,
|
||||
disable_web_page_preview, parse_mode,
|
||||
)
|
||||
|
||||
typing_task = None
|
||||
if chat_action:
|
||||
typing_task = self._start_typing_indicator(chat_id, chat_action)
|
||||
|
||||
try:
|
||||
if len(assets) == 1 and assets[0].get("type") == "photo":
|
||||
return await self._send_photo(
|
||||
chat_id, assets[0].get("url"), caption, reply_to_message_id,
|
||||
parse_mode, max_asset_data_size, send_large_photos_as_documents,
|
||||
assets[0].get("content_type"), assets[0].get("cache_key"),
|
||||
)
|
||||
|
||||
if len(assets) == 1 and assets[0].get("type") == "video":
|
||||
return await self._send_video(
|
||||
chat_id, assets[0].get("url"), caption, reply_to_message_id,
|
||||
parse_mode, max_asset_data_size,
|
||||
assets[0].get("content_type"), assets[0].get("cache_key"),
|
||||
)
|
||||
|
||||
if len(assets) == 1 and assets[0].get("type", "document") == "document":
|
||||
url = assets[0].get("url")
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' for document"}
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
async with self._session.get(download_url) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}"}
|
||||
data = await resp.read()
|
||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||
return {"success": False, "error": f"Media size ({len(data)} bytes) exceeds max_asset_data_size limit ({max_asset_data_size} bytes)"}
|
||||
filename = url.split("/")[-1].split("?")[0] or "file"
|
||||
return await self._send_document(
|
||||
chat_id, data, filename, caption, reply_to_message_id,
|
||||
parse_mode, url, assets[0].get("content_type"),
|
||||
assets[0].get("cache_key"),
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": f"Failed to download media: {err}"}
|
||||
|
||||
return await self._send_media_group(
|
||||
chat_id, assets, caption, reply_to_message_id, max_group_size,
|
||||
chunk_delay, parse_mode, max_asset_data_size,
|
||||
send_large_photos_as_documents,
|
||||
)
|
||||
finally:
|
||||
if typing_task:
|
||||
typing_task.cancel()
|
||||
try:
|
||||
await typing_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
reply_to_message_id: int | None = None,
|
||||
disable_web_page_preview: bool | None = None,
|
||||
parse_mode: str = "HTML",
|
||||
) -> NotificationResult:
|
||||
"""Send a simple text message."""
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendMessage"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"text": text or "Notification",
|
||||
"parse_mode": parse_mode,
|
||||
}
|
||||
if reply_to_message_id:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
if disable_web_page_preview is not None:
|
||||
payload["disable_web_page_preview"] = disable_web_page_preview
|
||||
|
||||
try:
|
||||
_LOGGER.debug("Sending text message to Telegram")
|
||||
async with self._session.post(telegram_url, json=payload) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
return {
|
||||
"success": True,
|
||||
"message_id": result.get("result", {}).get("message_id"),
|
||||
}
|
||||
_LOGGER.error("Telegram API error: %s", result)
|
||||
return {
|
||||
"success": False,
|
||||
"error": result.get("description", "Unknown Telegram error"),
|
||||
"error_code": result.get("error_code"),
|
||||
}
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Telegram message send failed: %s", err)
|
||||
return {"success": False, "error": str(err)}
|
||||
|
||||
async def send_chat_action(
|
||||
self, chat_id: str, action: str = "typing"
|
||||
) -> bool:
|
||||
"""Send a chat action indicator (typing, upload_photo, etc.)."""
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendChatAction"
|
||||
payload = {"chat_id": chat_id, "action": action}
|
||||
|
||||
try:
|
||||
async with self._session.post(telegram_url, json=payload) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
return True
|
||||
_LOGGER.debug("Failed to send chat action: %s", result.get("description"))
|
||||
return False
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.debug("Chat action request failed: %s", err)
|
||||
return False
|
||||
|
||||
def _start_typing_indicator(
|
||||
self, chat_id: str, action: str = "typing"
|
||||
) -> asyncio.Task:
|
||||
"""Start a background task that sends chat action every 4 seconds."""
|
||||
|
||||
async def action_loop() -> None:
|
||||
try:
|
||||
while True:
|
||||
await self.send_chat_action(chat_id, action)
|
||||
await asyncio.sleep(4)
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.debug("Chat action indicator stopped for action '%s'", action)
|
||||
|
||||
return asyncio.create_task(action_loop())
|
||||
|
||||
def _log_error(
|
||||
self,
|
||||
error_code: int | None,
|
||||
description: str,
|
||||
data: bytes | None = None,
|
||||
media_type: str = "photo",
|
||||
) -> None:
|
||||
"""Log detailed Telegram API error with diagnostics."""
|
||||
error_msg = f"Telegram API error ({error_code}): {description}"
|
||||
|
||||
if data:
|
||||
error_msg += f" | Media size: {len(data)} bytes ({len(data) / (1024 * 1024):.2f} MB)"
|
||||
|
||||
if media_type == "photo":
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
img = Image.open(io.BytesIO(data))
|
||||
width, height = img.size
|
||||
dimension_sum = width + height
|
||||
error_msg += f" | Dimensions: {width}x{height} (sum={dimension_sum})"
|
||||
|
||||
if len(data) > TELEGRAM_MAX_PHOTO_SIZE:
|
||||
error_msg += f" | EXCEEDS size limit ({TELEGRAM_MAX_PHOTO_SIZE / (1024 * 1024):.0f} MB)"
|
||||
if dimension_sum > 10000:
|
||||
error_msg += f" | EXCEEDS dimension limit (10000)"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE:
|
||||
error_msg += f" | EXCEEDS upload limit ({TELEGRAM_MAX_VIDEO_SIZE / (1024 * 1024):.0f} MB)"
|
||||
|
||||
suggestions = []
|
||||
if "dimension" in description.lower() or "PHOTO_INVALID_DIMENSIONS" in description:
|
||||
suggestions.append("Photo dimensions too large - consider send_large_photos_as_documents=true")
|
||||
elif "too large" in description.lower() or error_code == 413:
|
||||
suggestions.append("File too large - consider send_large_photos_as_documents=true or max_asset_data_size")
|
||||
elif "entity too large" in description.lower():
|
||||
suggestions.append("Request entity too large - reduce max_group_size or set max_asset_data_size")
|
||||
|
||||
if suggestions:
|
||||
error_msg += f" | Suggestions: {'; '.join(suggestions)}"
|
||||
|
||||
_LOGGER.error(error_msg)
|
||||
|
||||
async def _send_photo(
|
||||
self,
|
||||
chat_id: str,
|
||||
url: str | None,
|
||||
caption: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
parse_mode: str = "HTML",
|
||||
max_asset_data_size: int | None = None,
|
||||
send_large_photos_as_documents: bool = False,
|
||||
content_type: str | None = None,
|
||||
cache_key: str | None = None,
|
||||
) -> NotificationResult:
|
||||
"""Send a single photo to Telegram."""
|
||||
if not content_type:
|
||||
content_type = "image/jpeg"
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' for photo"}
|
||||
|
||||
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key)
|
||||
|
||||
# Check cache
|
||||
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
|
||||
if cached and cached.get("file_id") and effective_cache_key:
|
||||
file_id = cached["file_id"]
|
||||
_LOGGER.debug("Using cached Telegram file_id for photo")
|
||||
payload = {"chat_id": chat_id, "photo": file_id, "parse_mode": parse_mode}
|
||||
if caption:
|
||||
payload["caption"] = caption
|
||||
if reply_to_message_id:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
|
||||
try:
|
||||
async with self._session.post(telegram_url, json=payload) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
|
||||
_LOGGER.debug("Cached file_id failed, will re-upload: %s", result.get("description"))
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.debug("Cached file_id request failed: %s", err)
|
||||
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
_LOGGER.debug("Downloading photo from %s", download_url[:80])
|
||||
async with self._session.get(download_url) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download photo: HTTP {resp.status}"}
|
||||
data = await resp.read()
|
||||
_LOGGER.debug("Downloaded photo: %d bytes", len(data))
|
||||
|
||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||
return {"success": False, "error": f"Photo size ({len(data)} bytes) exceeds max_asset_data_size limit ({max_asset_data_size} bytes)", "skipped": True}
|
||||
|
||||
exceeds_limits, reason, width, height = check_photo_limits(data)
|
||||
if exceeds_limits:
|
||||
if send_large_photos_as_documents:
|
||||
_LOGGER.info("Photo %s, sending as document", reason)
|
||||
return await self._send_document(
|
||||
chat_id, data, "photo.jpg", caption, reply_to_message_id,
|
||||
parse_mode, url, None, cache_key,
|
||||
)
|
||||
return {"success": False, "error": f"Photo {reason}", "skipped": True}
|
||||
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
form.add_field("photo", data, filename="photo.jpg", content_type=content_type)
|
||||
form.add_field("parse_mode", parse_mode)
|
||||
if caption:
|
||||
form.add_field("caption", caption)
|
||||
if reply_to_message_id:
|
||||
form.add_field("reply_to_message_id", str(reply_to_message_id))
|
||||
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
|
||||
_LOGGER.debug("Uploading photo to Telegram")
|
||||
async with self._session.post(telegram_url, data=form) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
photos = result.get("result", {}).get("photo", [])
|
||||
if photos and effective_cache and effective_cache_key:
|
||||
file_id = photos[-1].get("file_id")
|
||||
if file_id:
|
||||
await effective_cache.async_set(effective_cache_key, file_id, "photo", thumbhash=effective_thumbhash)
|
||||
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
|
||||
self._log_error(result.get("error_code"), result.get("description", "Unknown Telegram error"), data, "photo")
|
||||
return {"success": False, "error": result.get("description", "Unknown Telegram error"), "error_code": result.get("error_code")}
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Telegram photo upload failed: %s", err)
|
||||
return {"success": False, "error": str(err)}
|
||||
|
||||
async def _send_video(
|
||||
self,
|
||||
chat_id: str,
|
||||
url: str | None,
|
||||
caption: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
parse_mode: str = "HTML",
|
||||
max_asset_data_size: int | None = None,
|
||||
content_type: str | None = None,
|
||||
cache_key: str | None = None,
|
||||
) -> NotificationResult:
|
||||
"""Send a single video to Telegram."""
|
||||
if not content_type:
|
||||
content_type = "video/mp4"
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' for video"}
|
||||
|
||||
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(url, cache_key)
|
||||
|
||||
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash) if effective_cache and effective_cache_key else None
|
||||
if cached and cached.get("file_id") and effective_cache_key:
|
||||
file_id = cached["file_id"]
|
||||
_LOGGER.debug("Using cached Telegram file_id for video")
|
||||
payload = {"chat_id": chat_id, "video": file_id, "parse_mode": parse_mode}
|
||||
if caption:
|
||||
payload["caption"] = caption
|
||||
if reply_to_message_id:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
|
||||
try:
|
||||
async with self._session.post(telegram_url, json=payload) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
|
||||
_LOGGER.debug("Cached file_id failed, will re-upload: %s", result.get("description"))
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.debug("Cached file_id request failed: %s", err)
|
||||
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
_LOGGER.debug("Downloading video from %s", download_url[:80])
|
||||
async with self._session.get(download_url) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download video: HTTP {resp.status}"}
|
||||
data = await resp.read()
|
||||
_LOGGER.debug("Downloaded video: %d bytes", len(data))
|
||||
|
||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||
return {"success": False, "error": f"Video size ({len(data)} bytes) exceeds max_asset_data_size limit ({max_asset_data_size} bytes)", "skipped": True}
|
||||
|
||||
if len(data) > TELEGRAM_MAX_VIDEO_SIZE:
|
||||
return {"success": False, "error": f"Video size ({len(data) / (1024 * 1024):.1f} MB) exceeds Telegram's {TELEGRAM_MAX_VIDEO_SIZE / (1024 * 1024):.0f} MB upload limit", "skipped": True}
|
||||
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
form.add_field("video", data, filename="video.mp4", content_type=content_type)
|
||||
form.add_field("parse_mode", parse_mode)
|
||||
if caption:
|
||||
form.add_field("caption", caption)
|
||||
if reply_to_message_id:
|
||||
form.add_field("reply_to_message_id", str(reply_to_message_id))
|
||||
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
|
||||
_LOGGER.debug("Uploading video to Telegram")
|
||||
async with self._session.post(telegram_url, data=form) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
video = result.get("result", {}).get("video", {})
|
||||
if video and effective_cache and effective_cache_key:
|
||||
file_id = video.get("file_id")
|
||||
if file_id:
|
||||
await effective_cache.async_set(effective_cache_key, file_id, "video", thumbhash=effective_thumbhash)
|
||||
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
|
||||
self._log_error(result.get("error_code"), result.get("description", "Unknown Telegram error"), data, "video")
|
||||
return {"success": False, "error": result.get("description", "Unknown Telegram error"), "error_code": result.get("error_code")}
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Telegram video upload failed: %s", err)
|
||||
return {"success": False, "error": str(err)}
|
||||
|
||||
async def _send_document(
|
||||
self,
|
||||
chat_id: str,
|
||||
data: bytes,
|
||||
filename: str = "file",
|
||||
caption: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
parse_mode: str = "HTML",
|
||||
source_url: str | None = None,
|
||||
content_type: str | None = None,
|
||||
cache_key: str | None = None,
|
||||
) -> NotificationResult:
|
||||
"""Send a file as a document to Telegram."""
|
||||
if not content_type:
|
||||
content_type, _ = mimetypes.guess_type(filename)
|
||||
if not content_type:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
effective_cache, effective_cache_key, effective_thumbhash = self._get_cache_and_key(source_url, cache_key)
|
||||
|
||||
if effective_cache and effective_cache_key:
|
||||
cached = effective_cache.get(effective_cache_key, thumbhash=effective_thumbhash)
|
||||
if cached and cached.get("file_id") and cached.get("type") == "document":
|
||||
file_id = cached["file_id"]
|
||||
_LOGGER.debug("Using cached Telegram file_id for document")
|
||||
payload = {"chat_id": chat_id, "document": file_id, "parse_mode": parse_mode}
|
||||
if caption:
|
||||
payload["caption"] = caption
|
||||
if reply_to_message_id:
|
||||
payload["reply_to_message_id"] = reply_to_message_id
|
||||
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
|
||||
try:
|
||||
async with self._session.post(telegram_url, json=payload) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
return {"success": True, "message_id": result.get("result", {}).get("message_id"), "cached": True}
|
||||
_LOGGER.debug("Cached file_id failed, will re-upload: %s", result.get("description"))
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.debug("Cached file_id request failed: %s", err)
|
||||
|
||||
try:
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
form.add_field("document", data, filename=filename, content_type=content_type)
|
||||
form.add_field("parse_mode", parse_mode)
|
||||
if caption:
|
||||
form.add_field("caption", caption)
|
||||
if reply_to_message_id:
|
||||
form.add_field("reply_to_message_id", str(reply_to_message_id))
|
||||
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
|
||||
_LOGGER.debug("Uploading document to Telegram (%d bytes, %s)", len(data), content_type)
|
||||
async with self._session.post(telegram_url, data=form) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
if effective_cache_key and effective_cache:
|
||||
document = result.get("result", {}).get("document", {})
|
||||
file_id = document.get("file_id")
|
||||
if file_id:
|
||||
await effective_cache.async_set(effective_cache_key, file_id, "document", thumbhash=effective_thumbhash)
|
||||
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
|
||||
self._log_error(result.get("error_code"), result.get("description", "Unknown Telegram error"), data, "document")
|
||||
return {"success": False, "error": result.get("description", "Unknown Telegram error"), "error_code": result.get("error_code")}
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Telegram document upload failed: %s", err)
|
||||
return {"success": False, "error": str(err)}
|
||||
|
||||
async def _send_media_group(
|
||||
self,
|
||||
chat_id: str,
|
||||
assets: list[dict[str, str]],
|
||||
caption: str | None = None,
|
||||
reply_to_message_id: int | None = None,
|
||||
max_group_size: int = 10,
|
||||
chunk_delay: int = 0,
|
||||
parse_mode: str = "HTML",
|
||||
max_asset_data_size: int | None = None,
|
||||
send_large_photos_as_documents: bool = False,
|
||||
) -> NotificationResult:
|
||||
"""Send media assets as media group(s)."""
|
||||
chunks = [assets[i:i + max_group_size] for i in range(0, len(assets), max_group_size)]
|
||||
all_message_ids = []
|
||||
|
||||
_LOGGER.debug(
|
||||
"Sending %d media items in %d chunk(s) of max %d items (delay: %dms)",
|
||||
len(assets), len(chunks), max_group_size, chunk_delay,
|
||||
)
|
||||
|
||||
for chunk_idx, chunk in enumerate(chunks):
|
||||
if chunk_idx > 0 and chunk_delay > 0:
|
||||
await asyncio.sleep(chunk_delay / 1000)
|
||||
|
||||
# Single-item chunks use dedicated APIs
|
||||
if len(chunk) == 1:
|
||||
item = chunk[0]
|
||||
media_type = item.get("type", "document")
|
||||
url = item.get("url")
|
||||
item_content_type = item.get("content_type")
|
||||
item_cache_key = item.get("cache_key")
|
||||
chunk_caption = caption if chunk_idx == 0 else None
|
||||
chunk_reply_to = reply_to_message_id if chunk_idx == 0 else None
|
||||
result = None
|
||||
|
||||
if media_type == "photo":
|
||||
result = await self._send_photo(
|
||||
chat_id, url, chunk_caption, chunk_reply_to, parse_mode,
|
||||
max_asset_data_size, send_large_photos_as_documents,
|
||||
item_content_type, item_cache_key,
|
||||
)
|
||||
elif media_type == "video":
|
||||
result = await self._send_video(
|
||||
chat_id, url, chunk_caption, chunk_reply_to, parse_mode,
|
||||
max_asset_data_size, item_content_type, item_cache_key,
|
||||
)
|
||||
else:
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing 'url' for document", "failed_at_chunk": chunk_idx + 1}
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
async with self._session.get(download_url) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download media: HTTP {resp.status}", "failed_at_chunk": chunk_idx + 1}
|
||||
data = await resp.read()
|
||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||
continue
|
||||
filename = url.split("/")[-1].split("?")[0] or "file"
|
||||
result = await self._send_document(
|
||||
chat_id, data, filename, chunk_caption, chunk_reply_to,
|
||||
parse_mode, url, item_content_type, item_cache_key,
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": f"Failed to download media: {err}", "failed_at_chunk": chunk_idx + 1}
|
||||
|
||||
if result is None:
|
||||
continue
|
||||
if not result.get("success"):
|
||||
result["failed_at_chunk"] = chunk_idx + 1
|
||||
return result
|
||||
all_message_ids.append(result.get("message_id"))
|
||||
continue
|
||||
|
||||
# Multi-item chunk: collect media items
|
||||
result = await self._process_media_group_chunk(
|
||||
chat_id, chunk, chunk_idx, len(chunks), caption,
|
||||
reply_to_message_id, max_group_size, chunk_delay, parse_mode,
|
||||
max_asset_data_size, send_large_photos_as_documents, all_message_ids,
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
return {"success": True, "message_ids": all_message_ids, "chunks_sent": len(chunks)}
|
||||
|
||||
async def _process_media_group_chunk(
|
||||
self,
|
||||
chat_id: str,
|
||||
chunk: list[dict[str, str]],
|
||||
chunk_idx: int,
|
||||
total_chunks: int,
|
||||
caption: str | None,
|
||||
reply_to_message_id: int | None,
|
||||
max_group_size: int,
|
||||
chunk_delay: int,
|
||||
parse_mode: str,
|
||||
max_asset_data_size: int | None,
|
||||
send_large_photos_as_documents: bool,
|
||||
all_message_ids: list,
|
||||
) -> NotificationResult | None:
|
||||
"""Process a multi-item media group chunk. Returns error result or None on success."""
|
||||
# media_items: (type, media_ref, filename, cache_key, is_cached, content_type)
|
||||
media_items: list[tuple[str, str | bytes, str, str, bool, str | None]] = []
|
||||
oversized_photos: list[tuple[bytes, str | None, str, str | None]] = []
|
||||
documents_to_send: list[tuple[bytes, str | None, str, str | None, str, str | None]] = []
|
||||
skipped_count = 0
|
||||
|
||||
for i, item in enumerate(chunk):
|
||||
url = item.get("url")
|
||||
if not url:
|
||||
return {"success": False, "error": f"Missing 'url' in item {chunk_idx * max_group_size + i}"}
|
||||
|
||||
media_type = item.get("type", "document")
|
||||
item_content_type = item.get("content_type")
|
||||
custom_cache_key = item.get("cache_key")
|
||||
extracted_asset_id = extract_asset_id_from_url(url) if not custom_cache_key else None
|
||||
item_cache_key = custom_cache_key or extracted_asset_id or url
|
||||
|
||||
if media_type not in ("photo", "video", "document"):
|
||||
return {"success": False, "error": f"Invalid type '{media_type}' in item {chunk_idx * max_group_size + i}"}
|
||||
|
||||
if media_type == "document":
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
async with self._session.get(download_url) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download media {chunk_idx * max_group_size + i}: HTTP {resp.status}"}
|
||||
data = await resp.read()
|
||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||
skipped_count += 1
|
||||
continue
|
||||
doc_caption = caption if chunk_idx == 0 and i == 0 and not media_items and not documents_to_send else None
|
||||
filename = url.split("/")[-1].split("?")[0] or f"file_{i}"
|
||||
documents_to_send.append((data, doc_caption, url, custom_cache_key, filename, item_content_type))
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": f"Failed to download media {chunk_idx * max_group_size + i}: {err}"}
|
||||
continue
|
||||
|
||||
# Check cache for photos/videos
|
||||
ck_is_asset = is_asset_id(item_cache_key)
|
||||
item_cache = self._get_cache_for_key(item_cache_key, ck_is_asset)
|
||||
item_thumbhash = self._thumbhash_resolver(item_cache_key) if ck_is_asset and self._thumbhash_resolver else None
|
||||
cached = item_cache.get(item_cache_key, thumbhash=item_thumbhash) if item_cache else None
|
||||
if cached and cached.get("file_id"):
|
||||
ext = "jpg" if media_type == "photo" else "mp4"
|
||||
filename = f"media_{chunk_idx * max_group_size + i}.{ext}"
|
||||
media_items.append((media_type, cached["file_id"], filename, item_cache_key, True, item_content_type))
|
||||
continue
|
||||
|
||||
try:
|
||||
download_url = self._resolve_url(url)
|
||||
async with self._session.get(download_url) as resp:
|
||||
if resp.status != 200:
|
||||
return {"success": False, "error": f"Failed to download media {chunk_idx * max_group_size + i}: HTTP {resp.status}"}
|
||||
data = await resp.read()
|
||||
|
||||
if max_asset_data_size is not None and len(data) > max_asset_data_size:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
if media_type == "video" and len(data) > TELEGRAM_MAX_VIDEO_SIZE:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
if media_type == "photo":
|
||||
exceeds_limits, reason, _, _ = check_photo_limits(data)
|
||||
if exceeds_limits:
|
||||
if send_large_photos_as_documents:
|
||||
photo_caption = caption if chunk_idx == 0 and i == 0 and not media_items else None
|
||||
oversized_photos.append((data, photo_caption, url, custom_cache_key))
|
||||
continue
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
ext = "jpg" if media_type == "photo" else "mp4"
|
||||
filename = f"media_{chunk_idx * max_group_size + i}.{ext}"
|
||||
media_items.append((media_type, data, filename, item_cache_key, False, item_content_type))
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": f"Failed to download media {chunk_idx * max_group_size + i}: {err}"}
|
||||
|
||||
if not media_items and not oversized_photos and not documents_to_send:
|
||||
return None
|
||||
|
||||
# Send media groups
|
||||
if media_items:
|
||||
media_sub_groups = split_media_by_upload_size(media_items, TELEGRAM_MAX_VIDEO_SIZE)
|
||||
first_caption_used = False
|
||||
|
||||
for sub_idx, sub_group_items in enumerate(media_sub_groups):
|
||||
is_first = chunk_idx == 0 and sub_idx == 0
|
||||
sub_caption = caption if is_first and not first_caption_used and not oversized_photos else None
|
||||
sub_reply_to = reply_to_message_id if is_first else None
|
||||
|
||||
if sub_idx > 0 and chunk_delay > 0:
|
||||
await asyncio.sleep(chunk_delay / 1000)
|
||||
|
||||
result = await self._send_sub_group(
|
||||
chat_id, sub_group_items, sub_caption, sub_reply_to,
|
||||
parse_mode, chunk_idx, sub_idx, len(media_sub_groups),
|
||||
all_message_ids,
|
||||
)
|
||||
if result is not None:
|
||||
if result.get("caption_used"):
|
||||
first_caption_used = True
|
||||
del result["caption_used"]
|
||||
if not result.get("success", True):
|
||||
return result
|
||||
|
||||
# Send oversized photos as documents
|
||||
for i, (data, photo_caption, photo_url, photo_cache_key) in enumerate(oversized_photos):
|
||||
result = await self._send_document(
|
||||
chat_id, data, f"photo_{i}.jpg", photo_caption, None,
|
||||
parse_mode, photo_url, None, photo_cache_key,
|
||||
)
|
||||
if result.get("success"):
|
||||
all_message_ids.append(result.get("message_id"))
|
||||
|
||||
# Send documents
|
||||
for i, (data, doc_caption, doc_url, doc_cache_key, filename, doc_ct) in enumerate(documents_to_send):
|
||||
result = await self._send_document(
|
||||
chat_id, data, filename, doc_caption, None,
|
||||
parse_mode, doc_url, doc_ct, doc_cache_key,
|
||||
)
|
||||
if result.get("success"):
|
||||
all_message_ids.append(result.get("message_id"))
|
||||
|
||||
return None
|
||||
|
||||
async def _send_sub_group(
|
||||
self,
|
||||
chat_id: str,
|
||||
items: list[tuple],
|
||||
caption: str | None,
|
||||
reply_to: int | None,
|
||||
parse_mode: str,
|
||||
chunk_idx: int,
|
||||
sub_idx: int,
|
||||
total_sub_groups: int,
|
||||
all_message_ids: list,
|
||||
) -> NotificationResult | None:
|
||||
"""Send a sub-group of media items. Returns error result, caption_used marker, or None."""
|
||||
# Single item - use sendPhoto/sendVideo
|
||||
if len(items) == 1:
|
||||
sg_type, sg_ref, sg_fname, sg_ck, sg_cached, sg_ct = items[0]
|
||||
api_method = "sendPhoto" if sg_type == "photo" else "sendVideo"
|
||||
media_field = "photo" if sg_type == "photo" else "video"
|
||||
|
||||
try:
|
||||
if sg_cached:
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, media_field: sg_ref, "parse_mode": parse_mode}
|
||||
if caption:
|
||||
payload["caption"] = caption
|
||||
if reply_to:
|
||||
payload["reply_to_message_id"] = reply_to
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{api_method}"
|
||||
async with self._session.post(telegram_url, json=payload) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
all_message_ids.append(result["result"].get("message_id"))
|
||||
return {"caption_used": True} if caption else None
|
||||
sg_cached = False
|
||||
|
||||
if not sg_cached:
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
sg_content_type = sg_ct or ("image/jpeg" if sg_type == "photo" else "video/mp4")
|
||||
form.add_field(media_field, sg_ref, filename=sg_fname, content_type=sg_content_type)
|
||||
form.add_field("parse_mode", parse_mode)
|
||||
if caption:
|
||||
form.add_field("caption", caption)
|
||||
if reply_to:
|
||||
form.add_field("reply_to_message_id", str(reply_to))
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{api_method}"
|
||||
async with self._session.post(telegram_url, data=form) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
all_message_ids.append(result["result"].get("message_id"))
|
||||
# Cache uploaded file
|
||||
ck_is_asset = is_asset_id(sg_ck)
|
||||
sg_cache = self._get_cache_for_key(sg_ck, ck_is_asset)
|
||||
if sg_cache:
|
||||
sg_thumbhash = self._thumbhash_resolver(sg_ck) if ck_is_asset and self._thumbhash_resolver else None
|
||||
result_data = result.get("result", {})
|
||||
if sg_type == "photo":
|
||||
photos = result_data.get("photo", [])
|
||||
if photos:
|
||||
await sg_cache.async_set(sg_ck, photos[-1].get("file_id"), "photo", thumbhash=sg_thumbhash)
|
||||
elif sg_type == "video":
|
||||
video = result_data.get("video", {})
|
||||
if video.get("file_id"):
|
||||
await sg_cache.async_set(sg_ck, video["file_id"], "video", thumbhash=sg_thumbhash)
|
||||
return {"caption_used": True} if caption else None
|
||||
self._log_error(result.get("error_code"), result.get("description", "Unknown"), sg_ref if isinstance(sg_ref, bytes) else None, sg_type)
|
||||
return {"success": False, "error": result.get("description", "Unknown"), "failed_at_chunk": chunk_idx + 1}
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
|
||||
return None
|
||||
|
||||
# Multiple items - sendMediaGroup
|
||||
all_cached = all(item[4] for item in items)
|
||||
|
||||
if all_cached:
|
||||
media_json = []
|
||||
for i, (media_type, file_id, _, _, _, _) in enumerate(items):
|
||||
mij: dict[str, Any] = {"type": media_type, "media": file_id}
|
||||
if i == 0 and caption:
|
||||
mij["caption"] = caption
|
||||
mij["parse_mode"] = parse_mode
|
||||
media_json.append(mij)
|
||||
|
||||
payload = {"chat_id": chat_id, "media": media_json}
|
||||
if reply_to:
|
||||
payload["reply_to_message_id"] = reply_to
|
||||
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendMediaGroup"
|
||||
try:
|
||||
async with self._session.post(telegram_url, json=payload) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
all_message_ids.extend(msg.get("message_id") for msg in result.get("result", []))
|
||||
return {"caption_used": True} if caption else None
|
||||
all_cached = False
|
||||
except aiohttp.ClientError:
|
||||
all_cached = False
|
||||
|
||||
if not all_cached:
|
||||
form = FormData()
|
||||
form.add_field("chat_id", chat_id)
|
||||
if reply_to:
|
||||
form.add_field("reply_to_message_id", str(reply_to))
|
||||
|
||||
media_json = []
|
||||
upload_idx = 0
|
||||
keys_to_cache: list[tuple[str, int, str, bool, str | None]] = []
|
||||
|
||||
for i, (media_type, media_ref, filename, item_cache_key, is_cached, item_ct) in enumerate(items):
|
||||
if is_cached:
|
||||
mij = {"type": media_type, "media": media_ref}
|
||||
else:
|
||||
attach_name = f"file{upload_idx}"
|
||||
mij = {"type": media_type, "media": f"attach://{attach_name}"}
|
||||
ct = item_ct or ("image/jpeg" if media_type == "photo" else "video/mp4")
|
||||
form.add_field(attach_name, media_ref, filename=filename, content_type=ct)
|
||||
ck_is_asset = is_asset_id(item_cache_key)
|
||||
ck_thumbhash = self._thumbhash_resolver(item_cache_key) if ck_is_asset and self._thumbhash_resolver else None
|
||||
keys_to_cache.append((item_cache_key, i, media_type, ck_is_asset, ck_thumbhash))
|
||||
upload_idx += 1
|
||||
|
||||
if i == 0 and caption:
|
||||
mij["caption"] = caption
|
||||
mij["parse_mode"] = parse_mode
|
||||
media_json.append(mij)
|
||||
|
||||
form.add_field("media", json.dumps(media_json))
|
||||
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendMediaGroup"
|
||||
|
||||
try:
|
||||
async with self._session.post(telegram_url, data=form) as response:
|
||||
result = await response.json()
|
||||
if response.status == 200 and result.get("ok"):
|
||||
all_message_ids.extend(msg.get("message_id") for msg in result.get("result", []))
|
||||
|
||||
# Batch cache new file_ids
|
||||
if keys_to_cache:
|
||||
result_messages = result.get("result", [])
|
||||
cache_batches: dict[int, tuple[TelegramFileCache, list[tuple[str, str, str, str | None]]]] = {}
|
||||
for ck, result_idx, m_type, ck_is_asset, ck_thumbhash in keys_to_cache:
|
||||
ck_cache = self._get_cache_for_key(ck, ck_is_asset)
|
||||
if result_idx >= len(result_messages) or not ck_cache:
|
||||
continue
|
||||
msg = result_messages[result_idx]
|
||||
file_id = None
|
||||
if m_type == "photo":
|
||||
photos = msg.get("photo", [])
|
||||
if photos:
|
||||
file_id = photos[-1].get("file_id")
|
||||
elif m_type == "video":
|
||||
video = msg.get("video", {})
|
||||
file_id = video.get("file_id")
|
||||
if file_id:
|
||||
cache_id = id(ck_cache)
|
||||
if cache_id not in cache_batches:
|
||||
cache_batches[cache_id] = (ck_cache, [])
|
||||
cache_batches[cache_id][1].append((ck, file_id, m_type, ck_thumbhash))
|
||||
for ck_cache, batch_entries in cache_batches.values():
|
||||
await ck_cache.async_set_many(batch_entries)
|
||||
|
||||
return {"caption_used": True} if caption else None
|
||||
|
||||
_LOGGER.error("Telegram API error for media group: %s", result.get("description"))
|
||||
return {"success": False, "error": result.get("description", "Unknown"), "failed_at_chunk": chunk_idx + 1}
|
||||
except aiohttp.ClientError as err:
|
||||
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
|
||||
|
||||
return None
|
||||
133
packages/core/src/immich_watcher_core/telegram/media.py
Normal file
133
packages/core/src/immich_watcher_core/telegram/media.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Telegram media utilities - constants, URL helpers, and size splitting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
# Telegram constants
|
||||
TELEGRAM_API_BASE_URL: Final = "https://api.telegram.org/bot"
|
||||
TELEGRAM_MAX_PHOTO_SIZE: Final = 10 * 1024 * 1024 # 10 MB
|
||||
TELEGRAM_MAX_VIDEO_SIZE: Final = 50 * 1024 * 1024 # 50 MB
|
||||
TELEGRAM_MAX_DIMENSION_SUM: Final = 10000 # Max width + height in pixels
|
||||
|
||||
# Regex pattern for Immich asset ID (UUID format)
|
||||
_ASSET_ID_PATTERN = re.compile(r"^[a-f0-9-]{36}$")
|
||||
|
||||
# Regex patterns to extract asset ID from Immich URLs
|
||||
_IMMICH_ASSET_ID_PATTERNS = [
|
||||
re.compile(r"/api/assets/([a-f0-9-]{36})/(?:original|thumbnail|video)"),
|
||||
re.compile(r"/share/[^/]+/photos/([a-f0-9-]{36})"),
|
||||
]
|
||||
|
||||
|
||||
def is_asset_id(value: str) -> bool:
|
||||
"""Check if a string is a valid Immich asset ID (UUID format)."""
|
||||
return bool(_ASSET_ID_PATTERN.match(value))
|
||||
|
||||
|
||||
def extract_asset_id_from_url(url: str) -> str | None:
|
||||
"""Extract asset ID from Immich URL if possible.
|
||||
|
||||
Supports:
|
||||
- /api/assets/{asset_id}/original?...
|
||||
- /api/assets/{asset_id}/thumbnail?...
|
||||
- /api/assets/{asset_id}/video/playback?...
|
||||
- /share/{key}/photos/{asset_id}
|
||||
"""
|
||||
if not url:
|
||||
return None
|
||||
for pattern in _IMMICH_ASSET_ID_PATTERNS:
|
||||
match = pattern.search(url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def split_media_by_upload_size(
|
||||
media_items: list[tuple], max_upload_size: int
|
||||
) -> list[list[tuple]]:
|
||||
"""Split media items into sub-groups respecting upload size limit.
|
||||
|
||||
Cached items (file_id references) don't count toward upload size since
|
||||
they aren't uploaded. Only items with bytes data count.
|
||||
|
||||
Args:
|
||||
media_items: List of tuples where index [1] is str (file_id) or bytes (data)
|
||||
and index [4] is bool (is_cached)
|
||||
max_upload_size: Maximum total upload size in bytes per group
|
||||
|
||||
Returns:
|
||||
List of sub-groups, each respecting the size limit
|
||||
"""
|
||||
if not media_items:
|
||||
return []
|
||||
|
||||
groups: list[list[tuple]] = []
|
||||
current_group: list[tuple] = []
|
||||
current_size = 0
|
||||
|
||||
for item in media_items:
|
||||
media_ref = item[1]
|
||||
is_cached = item[4]
|
||||
|
||||
# Cached items don't count toward upload size
|
||||
item_size = 0 if is_cached else (len(media_ref) if isinstance(media_ref, bytes) else 0)
|
||||
|
||||
# If adding this item would exceed the limit and we have items already,
|
||||
# start a new group
|
||||
if current_group and current_size + item_size > max_upload_size:
|
||||
groups.append(current_group)
|
||||
current_group = []
|
||||
current_size = 0
|
||||
|
||||
current_group.append(item)
|
||||
current_size += item_size
|
||||
|
||||
if current_group:
|
||||
groups.append(current_group)
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def check_photo_limits(
|
||||
data: bytes,
|
||||
) -> tuple[bool, str | None, int | None, int | None]:
|
||||
"""Check if photo data exceeds Telegram photo limits.
|
||||
|
||||
Telegram limits for photos:
|
||||
- Max file size: 10 MB
|
||||
- Max dimension sum: ~10,000 pixels (width + height)
|
||||
|
||||
Returns:
|
||||
Tuple of (exceeds_limits, reason, width, height)
|
||||
"""
|
||||
if len(data) > TELEGRAM_MAX_PHOTO_SIZE:
|
||||
return (
|
||||
True,
|
||||
f"size {len(data)} bytes exceeds {TELEGRAM_MAX_PHOTO_SIZE} bytes limit",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
img = Image.open(io.BytesIO(data))
|
||||
width, height = img.size
|
||||
dimension_sum = width + height
|
||||
|
||||
if dimension_sum > TELEGRAM_MAX_DIMENSION_SUM:
|
||||
return (
|
||||
True,
|
||||
f"dimensions {width}x{height} (sum={dimension_sum}) exceed {TELEGRAM_MAX_DIMENSION_SUM} limit",
|
||||
width,
|
||||
height,
|
||||
)
|
||||
|
||||
return False, None, width, height
|
||||
except ImportError:
|
||||
return False, None, None, None
|
||||
except Exception:
|
||||
return False, None, None, None
|
||||
0
packages/core/tests/__init__.py
Normal file
0
packages/core/tests/__init__.py
Normal file
237
packages/core/tests/test_asset_utils.py
Normal file
237
packages/core/tests/test_asset_utils.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""Tests for asset filtering, sorting, and URL utilities."""
|
||||
|
||||
from immich_watcher_core.asset_utils import (
|
||||
build_asset_detail,
|
||||
combine_album_assets,
|
||||
filter_assets,
|
||||
get_any_url,
|
||||
get_public_url,
|
||||
get_protected_url,
|
||||
sort_assets,
|
||||
)
|
||||
from immich_watcher_core.models import AssetInfo, SharedLinkInfo
|
||||
|
||||
|
||||
def _make_asset(
|
||||
asset_id: str = "a1",
|
||||
asset_type: str = "IMAGE",
|
||||
filename: str = "photo.jpg",
|
||||
created_at: str = "2024-01-15T10:30:00Z",
|
||||
is_favorite: bool = False,
|
||||
rating: int | None = None,
|
||||
city: str | None = None,
|
||||
country: str | None = None,
|
||||
) -> AssetInfo:
|
||||
return AssetInfo(
|
||||
id=asset_id,
|
||||
type=asset_type,
|
||||
filename=filename,
|
||||
created_at=created_at,
|
||||
is_favorite=is_favorite,
|
||||
rating=rating,
|
||||
city=city,
|
||||
country=country,
|
||||
is_processed=True,
|
||||
)
|
||||
|
||||
|
||||
class TestFilterAssets:
|
||||
def test_favorite_only(self):
|
||||
assets = [_make_asset("a1", is_favorite=True), _make_asset("a2")]
|
||||
result = filter_assets(assets, favorite_only=True)
|
||||
assert len(result) == 1
|
||||
assert result[0].id == "a1"
|
||||
|
||||
def test_min_rating(self):
|
||||
assets = [
|
||||
_make_asset("a1", rating=5),
|
||||
_make_asset("a2", rating=2),
|
||||
_make_asset("a3"), # no rating
|
||||
]
|
||||
result = filter_assets(assets, min_rating=3)
|
||||
assert len(result) == 1
|
||||
assert result[0].id == "a1"
|
||||
|
||||
def test_asset_type_photo(self):
|
||||
assets = [
|
||||
_make_asset("a1", asset_type="IMAGE"),
|
||||
_make_asset("a2", asset_type="VIDEO"),
|
||||
]
|
||||
result = filter_assets(assets, asset_type="photo")
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "IMAGE"
|
||||
|
||||
def test_date_range(self):
|
||||
assets = [
|
||||
_make_asset("a1", created_at="2024-01-10T00:00:00Z"),
|
||||
_make_asset("a2", created_at="2024-01-15T00:00:00Z"),
|
||||
_make_asset("a3", created_at="2024-01-20T00:00:00Z"),
|
||||
]
|
||||
result = filter_assets(
|
||||
assets, min_date="2024-01-12T00:00:00Z", max_date="2024-01-18T00:00:00Z"
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].id == "a2"
|
||||
|
||||
def test_memory_date(self):
|
||||
assets = [
|
||||
_make_asset("a1", created_at="2023-03-19T10:00:00Z"), # same month/day, different year
|
||||
_make_asset("a2", created_at="2024-03-19T10:00:00Z"), # same year as reference
|
||||
_make_asset("a3", created_at="2023-06-15T10:00:00Z"), # different date
|
||||
]
|
||||
result = filter_assets(assets, memory_date="2024-03-19T00:00:00Z")
|
||||
assert len(result) == 1
|
||||
assert result[0].id == "a1"
|
||||
|
||||
def test_city_filter(self):
|
||||
assets = [
|
||||
_make_asset("a1", city="Paris"),
|
||||
_make_asset("a2", city="London"),
|
||||
_make_asset("a3"),
|
||||
]
|
||||
result = filter_assets(assets, city="paris")
|
||||
assert len(result) == 1
|
||||
assert result[0].id == "a1"
|
||||
|
||||
|
||||
class TestSortAssets:
|
||||
def test_sort_by_date_descending(self):
|
||||
assets = [
|
||||
_make_asset("a1", created_at="2024-01-10T00:00:00Z"),
|
||||
_make_asset("a2", created_at="2024-01-20T00:00:00Z"),
|
||||
_make_asset("a3", created_at="2024-01-15T00:00:00Z"),
|
||||
]
|
||||
result = sort_assets(assets, order_by="date", order="descending")
|
||||
assert [a.id for a in result] == ["a2", "a3", "a1"]
|
||||
|
||||
def test_sort_by_name(self):
|
||||
assets = [
|
||||
_make_asset("a1", filename="charlie.jpg"),
|
||||
_make_asset("a2", filename="alice.jpg"),
|
||||
_make_asset("a3", filename="bob.jpg"),
|
||||
]
|
||||
result = sort_assets(assets, order_by="name", order="ascending")
|
||||
assert [a.id for a in result] == ["a2", "a3", "a1"]
|
||||
|
||||
def test_sort_by_rating(self):
|
||||
assets = [
|
||||
_make_asset("a1", rating=3),
|
||||
_make_asset("a2", rating=5),
|
||||
_make_asset("a3"), # None rating
|
||||
]
|
||||
result = sort_assets(assets, order_by="rating", order="descending")
|
||||
# With descending + (is_none, value) key: None goes last when reversed
|
||||
# (True, 0) vs (False, 5) vs (False, 3) - reversed: (True, 0), (False, 5), (False, 3)
|
||||
# Actually: reversed sort puts (True,0) first. Let's just check rated come before unrated
|
||||
rated = [a for a in result if a.rating is not None]
|
||||
assert rated[0].id == "a2"
|
||||
assert rated[1].id == "a1"
|
||||
|
||||
|
||||
class TestUrlHelpers:
|
||||
def _make_links(self):
|
||||
return [
|
||||
SharedLinkInfo(id="l1", key="public-key"),
|
||||
SharedLinkInfo(id="l2", key="protected-key", has_password=True, password="pass123"),
|
||||
]
|
||||
|
||||
def test_get_public_url(self):
|
||||
links = self._make_links()
|
||||
url = get_public_url("https://immich.example.com", links)
|
||||
assert url == "https://immich.example.com/share/public-key"
|
||||
|
||||
def test_get_protected_url(self):
|
||||
links = self._make_links()
|
||||
url = get_protected_url("https://immich.example.com", links)
|
||||
assert url == "https://immich.example.com/share/protected-key"
|
||||
|
||||
def test_get_any_url_prefers_public(self):
|
||||
links = self._make_links()
|
||||
url = get_any_url("https://immich.example.com", links)
|
||||
assert url == "https://immich.example.com/share/public-key"
|
||||
|
||||
def test_get_any_url_falls_back_to_protected(self):
|
||||
links = [SharedLinkInfo(id="l1", key="prot-key", has_password=True, password="x")]
|
||||
url = get_any_url("https://immich.example.com", links)
|
||||
assert url == "https://immich.example.com/share/prot-key"
|
||||
|
||||
def test_no_links(self):
|
||||
assert get_public_url("https://example.com", []) is None
|
||||
assert get_any_url("https://example.com", []) is None
|
||||
|
||||
|
||||
class TestBuildAssetDetail:
|
||||
def test_build_image_detail(self):
|
||||
asset = _make_asset("a1", asset_type="IMAGE")
|
||||
links = [SharedLinkInfo(id="l1", key="key1")]
|
||||
detail = build_asset_detail(asset, "https://immich.example.com", links)
|
||||
assert detail["id"] == "a1"
|
||||
assert "url" in detail
|
||||
assert "download_url" in detail
|
||||
assert "photo_url" in detail
|
||||
assert "thumbnail_url" in detail
|
||||
|
||||
def test_build_video_detail(self):
|
||||
asset = _make_asset("a1", asset_type="VIDEO")
|
||||
links = [SharedLinkInfo(id="l1", key="key1")]
|
||||
detail = build_asset_detail(asset, "https://immich.example.com", links)
|
||||
assert "playback_url" in detail
|
||||
assert "photo_url" not in detail
|
||||
|
||||
def test_no_shared_links(self):
|
||||
asset = _make_asset("a1")
|
||||
detail = build_asset_detail(asset, "https://immich.example.com", [])
|
||||
assert "url" not in detail
|
||||
assert "download_url" not in detail
|
||||
assert "thumbnail_url" in detail # always present
|
||||
|
||||
|
||||
class TestCombineAlbumAssets:
|
||||
def test_even_distribution(self):
|
||||
"""Both albums have plenty, split evenly."""
|
||||
a = [_make_asset(f"a{i}") for i in range(10)]
|
||||
b = [_make_asset(f"b{i}") for i in range(10)]
|
||||
result = combine_album_assets({"A": a, "B": b}, total_limit=6, order_by="name")
|
||||
assert len(result) == 6
|
||||
|
||||
def test_smart_redistribution(self):
|
||||
"""Album A has 1 photo, Album B has 20. Limit=10 should get 10 total."""
|
||||
a = [_make_asset("a1", created_at="2023-03-19T10:00:00Z")]
|
||||
b = [_make_asset(f"b{i}", created_at=f"2023-03-19T{10+i}:00:00Z") for i in range(20)]
|
||||
result = combine_album_assets({"A": a, "B": b}, total_limit=10, order_by="name")
|
||||
assert len(result) == 10
|
||||
# a1 should be in result
|
||||
ids = {r.id for r in result}
|
||||
assert "a1" in ids
|
||||
|
||||
def test_redistribution_with_3_albums(self):
|
||||
"""3 albums: A has 1, B has 2, C has 20. Limit=12."""
|
||||
a = [_make_asset("a1")]
|
||||
b = [_make_asset("b1"), _make_asset("b2")]
|
||||
c = [_make_asset(f"c{i}") for i in range(20)]
|
||||
result = combine_album_assets({"A": a, "B": b, "C": c}, total_limit=12, order_by="name")
|
||||
assert len(result) == 12
|
||||
# All of A and B should be included
|
||||
ids = {r.id for r in result}
|
||||
assert "a1" in ids
|
||||
assert "b1" in ids
|
||||
assert "b2" in ids
|
||||
# C fills the remaining 9
|
||||
c_count = sum(1 for r in result if r.id.startswith("c"))
|
||||
assert c_count == 9
|
||||
|
||||
def test_all_albums_empty(self):
|
||||
result = combine_album_assets({"A": [], "B": []}, total_limit=10)
|
||||
assert result == []
|
||||
|
||||
def test_single_album(self):
|
||||
a = [_make_asset(f"a{i}") for i in range(5)]
|
||||
result = combine_album_assets({"A": a}, total_limit=3, order_by="name")
|
||||
assert len(result) == 3
|
||||
|
||||
def test_total_less_than_limit(self):
|
||||
"""Both albums together have fewer than limit."""
|
||||
a = [_make_asset("a1")]
|
||||
b = [_make_asset("b1"), _make_asset("b2")]
|
||||
result = combine_album_assets({"A": a, "B": b}, total_limit=10, order_by="name")
|
||||
assert len(result) == 3
|
||||
139
packages/core/tests/test_change_detector.py
Normal file
139
packages/core/tests/test_change_detector.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Tests for change detection logic."""
|
||||
|
||||
from immich_watcher_core.change_detector import detect_album_changes
|
||||
from immich_watcher_core.models import AlbumData, AssetInfo
|
||||
|
||||
|
||||
def _make_album(
|
||||
album_id: str = "album-1",
|
||||
name: str = "Test Album",
|
||||
shared: bool = False,
|
||||
assets: dict[str, AssetInfo] | None = None,
|
||||
) -> AlbumData:
|
||||
"""Helper to create AlbumData for testing."""
|
||||
if assets is None:
|
||||
assets = {}
|
||||
return AlbumData(
|
||||
id=album_id,
|
||||
name=name,
|
||||
asset_count=len(assets),
|
||||
photo_count=0,
|
||||
video_count=0,
|
||||
created_at="2024-01-01T00:00:00Z",
|
||||
updated_at="2024-01-15T10:30:00Z",
|
||||
shared=shared,
|
||||
owner="Alice",
|
||||
thumbnail_asset_id=None,
|
||||
asset_ids=set(assets.keys()),
|
||||
assets=assets,
|
||||
)
|
||||
|
||||
|
||||
def _make_asset(asset_id: str, is_processed: bool = True) -> AssetInfo:
|
||||
"""Helper to create AssetInfo for testing."""
|
||||
return AssetInfo(
|
||||
id=asset_id,
|
||||
type="IMAGE",
|
||||
filename=f"{asset_id}.jpg",
|
||||
created_at="2024-01-15T10:30:00Z",
|
||||
is_processed=is_processed,
|
||||
thumbhash="abc" if is_processed else None,
|
||||
)
|
||||
|
||||
|
||||
class TestDetectAlbumChanges:
|
||||
def test_no_changes(self):
|
||||
a1 = _make_asset("a1")
|
||||
old = _make_album(assets={"a1": a1})
|
||||
new = _make_album(assets={"a1": a1})
|
||||
change, pending = detect_album_changes(old, new, set())
|
||||
assert change is None
|
||||
assert pending == set()
|
||||
|
||||
def test_assets_added(self):
|
||||
a1 = _make_asset("a1")
|
||||
a2 = _make_asset("a2")
|
||||
old = _make_album(assets={"a1": a1})
|
||||
new = _make_album(assets={"a1": a1, "a2": a2})
|
||||
change, pending = detect_album_changes(old, new, set())
|
||||
assert change is not None
|
||||
assert change.change_type == "assets_added"
|
||||
assert change.added_count == 1
|
||||
assert change.added_assets[0].id == "a2"
|
||||
|
||||
def test_assets_removed(self):
|
||||
a1 = _make_asset("a1")
|
||||
a2 = _make_asset("a2")
|
||||
old = _make_album(assets={"a1": a1, "a2": a2})
|
||||
new = _make_album(assets={"a1": a1})
|
||||
change, pending = detect_album_changes(old, new, set())
|
||||
assert change is not None
|
||||
assert change.change_type == "assets_removed"
|
||||
assert change.removed_count == 1
|
||||
assert "a2" in change.removed_asset_ids
|
||||
|
||||
def test_mixed_changes(self):
|
||||
a1 = _make_asset("a1")
|
||||
a2 = _make_asset("a2")
|
||||
a3 = _make_asset("a3")
|
||||
old = _make_album(assets={"a1": a1, "a2": a2})
|
||||
new = _make_album(assets={"a1": a1, "a3": a3})
|
||||
change, pending = detect_album_changes(old, new, set())
|
||||
assert change is not None
|
||||
assert change.change_type == "changed"
|
||||
assert change.added_count == 1
|
||||
assert change.removed_count == 1
|
||||
|
||||
def test_album_renamed(self):
|
||||
a1 = _make_asset("a1")
|
||||
old = _make_album(name="Old Name", assets={"a1": a1})
|
||||
new = _make_album(name="New Name", assets={"a1": a1})
|
||||
change, pending = detect_album_changes(old, new, set())
|
||||
assert change is not None
|
||||
assert change.change_type == "album_renamed"
|
||||
assert change.old_name == "Old Name"
|
||||
assert change.new_name == "New Name"
|
||||
|
||||
def test_sharing_changed(self):
|
||||
a1 = _make_asset("a1")
|
||||
old = _make_album(shared=False, assets={"a1": a1})
|
||||
new = _make_album(shared=True, assets={"a1": a1})
|
||||
change, pending = detect_album_changes(old, new, set())
|
||||
assert change is not None
|
||||
assert change.change_type == "album_sharing_changed"
|
||||
assert change.old_shared is False
|
||||
assert change.new_shared is True
|
||||
|
||||
def test_pending_asset_becomes_processed(self):
|
||||
a1 = _make_asset("a1")
|
||||
a2_unprocessed = _make_asset("a2", is_processed=False)
|
||||
a2_processed = _make_asset("a2", is_processed=True)
|
||||
|
||||
old = _make_album(assets={"a1": a1, "a2": a2_unprocessed})
|
||||
new = _make_album(assets={"a1": a1, "a2": a2_processed})
|
||||
|
||||
# a2 is in pending set
|
||||
change, pending = detect_album_changes(old, new, {"a2"})
|
||||
assert change is not None
|
||||
assert change.added_count == 1
|
||||
assert change.added_assets[0].id == "a2"
|
||||
assert "a2" not in pending
|
||||
|
||||
def test_unprocessed_asset_added_to_pending(self):
|
||||
a1 = _make_asset("a1")
|
||||
a2 = _make_asset("a2", is_processed=False)
|
||||
old = _make_album(assets={"a1": a1})
|
||||
new = _make_album(assets={"a1": a1, "a2": a2})
|
||||
change, pending = detect_album_changes(old, new, set())
|
||||
# No change because a2 is unprocessed
|
||||
assert change is None
|
||||
assert "a2" in pending
|
||||
|
||||
def test_pending_asset_removed(self):
|
||||
a1 = _make_asset("a1")
|
||||
old = _make_album(assets={"a1": a1})
|
||||
new = _make_album(assets={"a1": a1})
|
||||
# a2 was pending but now gone from album
|
||||
change, pending = detect_album_changes(old, new, {"a2"})
|
||||
assert change is None
|
||||
assert "a2" not in pending
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user