Files
haos-hacs-immich-album-watcher/plans/phase-1-core-library.md
alexei.dolgolyov d0783d0b6a
Some checks failed
Validate / Hassfest (push) Has been cancelled
Add shared core library and architecture plans (Phase 1)
Extract HA-independent logic from the integration into packages/core/
as a standalone Python library (immich-watcher-core). This is the first
phase of restructuring the project to support a standalone web app
alongside the existing HAOS integration.

Core library modules:
- models: SharedLinkInfo, AssetInfo, AlbumData, AlbumChange dataclasses
- immich_client: Async Immich API client (aiohttp, session-injected)
- change_detector: Pure function for album change detection
- asset_utils: Filtering, sorting, URL building utilities
- telegram/client: Full Telegram Bot API (text, photo, video, media groups)
- telegram/cache: File ID cache with pluggable storage backend
- telegram/media: Media size checks, URL extraction, group splitting
- notifications/queue: Persistent notification queue
- storage: StorageBackend protocol + JSON file implementation

All modules have zero Home Assistant imports. 50 unit tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 12:40:08 +03:00

212 lines
8.8 KiB
Markdown

# Phase 1: Extract Core Library
**Status**: Not started
**Parent**: [primary-plan.md](primary-plan.md)
---
## Goal
Extract all HA-independent logic from the integration into `packages/core/` as a standalone Python library (`immich-watcher-core`). This library will be consumed by both the HAOS integration and the standalone server.
---
## Directory Structure
```
packages/core/
pyproject.toml
src/immich_watcher_core/
__init__.py
constants.py # Event types, attribute names, asset types, defaults
models.py # SharedLinkInfo, AssetInfo, AlbumData, AlbumChange
immich_client.py # Async Immich API client (aiohttp)
change_detector.py # detect_album_changes() pure function
asset_utils.py # Asset filtering, sorting, URL building
telegram/
__init__.py
client.py # TelegramClient - full Bot API operations
cache.py # TelegramFileCache with CacheBackend protocol
media.py # _split_media_by_upload_size, photo limit checks, URL helpers
notifications/
__init__.py
queue.py # NotificationQueue with QueueBackend protocol
storage.py # CacheBackend/QueueBackend protocols + JSON file implementations
tests/
__init__.py
test_models.py
test_immich_client.py
test_change_detector.py
test_asset_utils.py
test_telegram_client.py
test_telegram_cache.py
test_notification_queue.py
```
---
## Tasks
### 1. Package setup `[ ]`
- [x] Create `packages/core/pyproject.toml` with:
- Name: `immich-watcher-core`
- Dependencies: `aiohttp`, `jinja2`
- Optional dev deps: `pytest`, `pytest-asyncio`, `aioresponses`
- [x] Create `packages/core/src/immich_watcher_core/__init__.py`
### 2. Extract constants `[ ]`
**Source**: `custom_components/immich_album_watcher/const.py`
Extract to `constants.py`:
- Event names: `EVENT_ALBUM_CHANGED`, `EVENT_ASSETS_ADDED`, etc. (L29-34)
- Attribute names: all `ATTR_*` constants (L37-77)
- Asset types: `ASSET_TYPE_IMAGE`, `ASSET_TYPE_VIDEO` (L80-81)
- Defaults: `DEFAULT_SCAN_INTERVAL`, `DEFAULT_TELEGRAM_CACHE_TTL`, `NEW_ASSETS_RESET_DELAY`, `DEFAULT_SHARE_PASSWORD` (L23-27)
**Keep in HA const.py**: `DOMAIN`, `CONF_*`, `SUBENTRY_TYPE_ALBUM`, `PLATFORMS`, `SERVICE_*` (HA-specific)
### 3. Extract data models `[ ]`
**Source**: `custom_components/immich_album_watcher/coordinator.py` L66-326
Extract to `models.py`:
- `SharedLinkInfo` (L67-111) -- dataclass, zero HA deps
- `AssetInfo` (L114-249) -- dataclass, uses only `ASSET_TYPE_IMAGE` from constants
- `AlbumData` (L252-308) -- dataclass, uses `AssetInfo` + `ASSET_TYPE_*`
- `AlbumChange` (L311-325) -- dataclass, pure data
All use only stdlib + our constants. No changes needed except import paths.
### 4. Extract Immich API client `[ ]`
**Source**: `custom_components/immich_album_watcher/coordinator.py`
Extract to `immich_client.py` as `ImmichClient` class:
- Constructor takes: `session: aiohttp.ClientSession`, `url: str`, `api_key: str`
- `async get_server_config() -> str | None` (L640-668) -- returns external_domain
- `async get_users() -> dict[str, str]` (L617-638) -- user_id -> name
- `async get_people() -> dict[str, str]` (L593-615) -- person_id -> name
- `async get_shared_links(album_id: str) -> list[SharedLinkInfo]` (L670-699)
- `async get_album(album_id: str) -> AlbumData | None` (L876-898 fetch part)
- `async create_shared_link(album_id, password?) -> bool` (L1199-1243)
- `async delete_shared_link(link_id) -> bool` (L1245-1271)
- `async set_shared_link_password(link_id, password?) -> bool` (L1144-1176)
- `async ping() -> bool` -- validate connection (used by config_flow)
- Properties: `url`, `external_url`, `api_key`
- Helper: `get_internal_download_url(url)` (L384-400)
**Key design**: Accept `aiohttp.ClientSession` via constructor. HA provides `async_get_clientsession(hass)`, standalone creates its own.
### 5. Extract asset utilities `[ ]`
**Source**: `custom_components/immich_album_watcher/coordinator.py` L458-591, L761-856
Extract to `asset_utils.py`:
- `filter_assets(assets, ...)` -- favorite_only, min_rating, asset_type, date range, memory_date, geolocation (L498-557)
- `sort_assets(assets, order_by, order)` (L559-581)
- `build_asset_detail(asset, external_url, shared_links, include_thumbnail)` (L801-856)
- URL builders: `get_asset_public_url`, `get_asset_download_url`, `get_asset_video_url`, `get_asset_photo_url` (L761-799)
- Album URL helpers: `get_public_url`, `get_any_url`, `get_protected_url`, etc. (L709-759)
### 6. Extract change detection `[ ]`
**Source**: `custom_components/immich_album_watcher/coordinator.py` L979-1066
Extract to `change_detector.py`:
- `detect_album_changes(old_state, new_state, pending_asset_ids) -> tuple[AlbumChange | None, set[str]]`
- Pure function: takes two `AlbumData` + pending set, returns change + updated pending set
- No HA dependencies
### 7. Extract storage protocols `[ ]`
**Source**: `custom_components/immich_album_watcher/storage.py`
Extract to `storage.py`:
```python
class CacheBackend(Protocol):
"""Abstract storage backend for caches."""
async def load(self) -> dict[str, Any]: ...
async def save(self, data: dict[str, Any]) -> None: ...
async def remove(self) -> None: ...
class QueueBackend(Protocol):
"""Abstract storage backend for queues."""
async def load(self) -> dict[str, Any]: ...
async def save(self, data: dict[str, Any]) -> None: ...
async def remove(self) -> None: ...
class JsonFileBackend:
"""Simple JSON file storage backend (for standalone server)."""
def __init__(self, path: Path): ...
```
### 8. Extract TelegramFileCache `[ ]`
**Source**: `custom_components/immich_album_watcher/storage.py` L71-262
Extract to `telegram/cache.py`:
- `TelegramFileCache` -- takes `CacheBackend` instead of `hass + Store`
- All logic unchanged: TTL mode, thumbhash mode, cleanup, get/set/set_many
- Remove `hass`/`Store` imports
### 9. Extract NotificationQueue `[ ]`
**Source**: `custom_components/immich_album_watcher/storage.py` L265-328
Extract to `notifications/queue.py`:
- `NotificationQueue` -- takes `QueueBackend` instead of `hass + Store`
- All logic unchanged: enqueue, get_all, has_pending, remove_indices, clear
### 10. Extract Telegram client `[ ]`
**Source**: `custom_components/immich_album_watcher/sensor.py` L55-60, L61-111, L114-170, L455-550, L551-1700+
Extract to `telegram/client.py` as `TelegramClient`:
- Constructor: `session: aiohttp.ClientSession`, `bot_token: str`, `cache: TelegramFileCache | None`, `asset_cache: TelegramFileCache | None`, `url_resolver: Callable[[str], str] | None` (for internal URL conversion)
- `async send_notification(chat_id, assets?, caption?, ...)` -- main entry (L455-549)
- `async send_message(...)` (L551-597)
- `async send_chat_action(...)` (L599-665)
- `_log_error(...)` (L667-803)
- `async send_photo(...)` (L805-958)
- `async send_video(...)` (L960-1105)
- `async send_document(...)` (L1107-1215)
- `async send_media_group(...)` (L1217-end)
Extract to `telegram/media.py`:
- Constants: `TELEGRAM_API_BASE_URL`, `TELEGRAM_MAX_PHOTO_SIZE`, `TELEGRAM_MAX_VIDEO_SIZE`, `TELEGRAM_MAX_DIMENSION_SUM` (L56-59)
- `_is_asset_id(value)` (L76-85)
- `_extract_asset_id_from_url(url)` (L88-111)
- `_split_media_by_upload_size(media_items, max_upload_size)` (L114-170)
### 11. Write tests `[ ]`
- `test_models.py` -- `SharedLinkInfo.from_api_response`, `AssetInfo.from_api_response`, `AlbumData.from_api_response`, processing status checks
- `test_immich_client.py` -- Mock aiohttp responses for each API call (use `aioresponses`)
- `test_change_detector.py` -- Various change scenarios: add only, remove only, rename, sharing changed, pending assets becoming processed, no change
- `test_asset_utils.py` -- Filter/sort combinations, URL building
- `test_telegram_cache.py` -- TTL expiry, thumbhash validation, batch set, cleanup
- `test_notification_queue.py` -- Enqueue, get_all, remove_indices, clear
---
## Acceptance Criteria
- [ ] All extracted modules have zero Home Assistant imports
- [ ] `pyproject.toml` is valid and installable (`pip install -e packages/core`)
- [ ] All tests pass
- [ ] The HAOS integration is NOT modified yet (that's Phase 2)
- [ ] No functionality is lost in extraction -- behavior matches original exactly
---
## Key Design Decisions
1. **Session injection**: `ImmichClient` and `TelegramClient` accept `aiohttp.ClientSession` -- no global session creation
2. **Storage protocols**: `CacheBackend`/`QueueBackend` protocols allow HA's `Store` and standalone's SQLite/JSON to satisfy the same interface
3. **URL resolver callback**: Telegram client accepts optional `url_resolver: Callable[[str], str]` for converting external URLs to internal ones (coordinator owns this mapping)
4. **Logging**: Use stdlib `logging` throughout. Consumers configure their own handlers.
5. **No async_get_clientsession**: All HA-specific session management stays in the integration