Compare commits
9 Commits
436139ede9
...
v1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e6619cb1c5 | |||
| eedc7792c8 | |||
| 3a0573e432 | |||
| 7c53110c07 | |||
| 03430df5fb | |||
| 2ca26e178a | |||
| 847c39eaa8 | |||
| 9013c5e0c3 | |||
| 557ec91f05 |
16
CLAUDE.md
Normal file
16
CLAUDE.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Project Guidelines
|
||||
|
||||
## Version Management
|
||||
|
||||
Update the integration version in `custom_components/immich_album_watcher/manifest.json` only when changes are made to the **integration content** (files inside `custom_components/immich_album_watcher/`).
|
||||
|
||||
Do NOT bump version for:
|
||||
|
||||
- Repository setup (hacs.json, root README.md, LICENSE, CLAUDE.md)
|
||||
- CI/CD configuration
|
||||
- Other repository-level changes
|
||||
|
||||
Use semantic versioning:
|
||||
- **MAJOR** (x.0.0): Breaking changes
|
||||
- **MINOR** (0.x.0): New features, backward compatible
|
||||
- **PATCH** (0.0.x): Bug fixes, integration documentation updates
|
||||
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.
|
||||
14
README.md
14
README.md
@@ -1,12 +1,8 @@
|
||||
# HAOS Integrations
|
||||
# Immich Album Watcher
|
||||
|
||||
A collection of custom integrations for Home Assistant.
|
||||
A custom Home Assistant integration to monitor Immich albums for changes with sensors, events, and face recognition.
|
||||
|
||||
## Available Integrations
|
||||
|
||||
| Integration | Description | Documentation |
|
||||
|-------------|-------------|---------------|
|
||||
| [Immich Album Watcher](custom_components/immich_album_watcher/) | Monitor Immich albums for changes with sensors, events, and face recognition | [README](custom_components/immich_album_watcher/README.md) |
|
||||
For detailed documentation, see the [integration README](custom_components/immich_album_watcher/README.md).
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -15,13 +11,15 @@ A collection of custom integrations for Home Assistant.
|
||||
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://git.dolgolyov-family.by/alexei.dolgolyov/haos-integrations`
|
||||
4. Add this repository URL: `https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher`
|
||||
5. Select **Integration** as the category
|
||||
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**
|
||||
|
||||
> **Tip:** For the best experience, use this integration with the [Immich Album Watcher Blueprint](https://github.com/DolgolyovAlexei/haos-blueprints/blob/main/Common/Immich%20Album%20Watcher.yaml) to easily create automations for album change notifications.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. Download or clone this repository
|
||||
|
||||
@@ -60,6 +60,8 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
|
||||
5. Enter your Immich server URL and API key
|
||||
6. Select the albums you want to monitor
|
||||
|
||||
> **Tip:** For the best experience, use this integration with the [Immich Album Watcher Blueprint](https://github.com/DolgolyovAlexei/haos-blueprints/blob/main/Common/Immich%20Album%20Watcher.yaml) to easily create automations for album change notifications.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Option | Description | Default |
|
||||
|
||||
@@ -20,6 +20,7 @@ from .const import (
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import ImmichAlbumWatcherCoordinator
|
||||
from .storage import ImmichAlbumStorage
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,10 +64,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo
|
||||
scan_interval=scan_interval,
|
||||
)
|
||||
|
||||
# Create storage for persisting album state across restarts
|
||||
storage = ImmichAlbumStorage(hass, entry.entry_id)
|
||||
await storage.async_load()
|
||||
|
||||
# Store hub reference
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
"hub": entry.runtime_data,
|
||||
"subentries": {},
|
||||
"storage": storage,
|
||||
}
|
||||
|
||||
# Track loaded subentries to detect changes
|
||||
@@ -97,6 +103,7 @@ async def _async_setup_subentry_coordinator(
|
||||
hub_data: ImmichHubData = entry.runtime_data
|
||||
album_id = subentry.data[CONF_ALBUM_ID]
|
||||
album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album")
|
||||
storage: ImmichAlbumStorage = hass.data[DOMAIN][entry.entry_id]["storage"]
|
||||
|
||||
_LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id)
|
||||
|
||||
@@ -109,8 +116,12 @@ async def _async_setup_subentry_coordinator(
|
||||
album_name=album_name,
|
||||
scan_interval=hub_data.scan_interval,
|
||||
hub_name=hub_data.name,
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
# Load persisted state before first refresh to detect changes during downtime
|
||||
await coordinator.async_load_persisted_state()
|
||||
|
||||
# Fetch initial data
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .storage import ImmichAlbumStorage
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -221,6 +224,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
album_name: str,
|
||||
scan_interval: int,
|
||||
hub_name: str = "Immich",
|
||||
storage: ImmichAlbumStorage | None = None,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
@@ -239,6 +243,8 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
self._people_cache: dict[str, str] = {} # person_id -> name
|
||||
self._users_cache: dict[str, str] = {} # user_id -> name
|
||||
self._shared_links: list[SharedLinkInfo] = []
|
||||
self._storage = storage
|
||||
self._persisted_asset_ids: set[str] | None = None
|
||||
|
||||
@property
|
||||
def immich_url(self) -> str:
|
||||
@@ -268,6 +274,23 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
"""Force an immediate refresh."""
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def async_load_persisted_state(self) -> None:
|
||||
"""Load persisted asset IDs from storage.
|
||||
|
||||
This should be called before the first refresh to enable
|
||||
detection of changes that occurred during downtime.
|
||||
"""
|
||||
if self._storage:
|
||||
self._persisted_asset_ids = self._storage.get_album_asset_ids(
|
||||
self._album_id
|
||||
)
|
||||
if self._persisted_asset_ids is not None:
|
||||
_LOGGER.debug(
|
||||
"Loaded %d persisted asset IDs for album '%s'",
|
||||
len(self._persisted_asset_ids),
|
||||
self._album_name,
|
||||
)
|
||||
|
||||
async def async_get_recent_assets(self, count: int = 10) -> list[dict[str, Any]]:
|
||||
"""Get recent assets from the album."""
|
||||
if self.data is None:
|
||||
@@ -503,6 +526,47 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
album.has_new_assets = change.added_count > 0
|
||||
album.last_change_time = datetime.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 = [
|
||||
album.assets[aid]
|
||||
for aid in added_ids
|
||||
if aid in album.assets
|
||||
]
|
||||
|
||||
change = AlbumChange(
|
||||
album_id=album.id,
|
||||
album_name=album.name,
|
||||
change_type=change_type,
|
||||
added_count=len(added_ids),
|
||||
removed_count=len(removed_ids),
|
||||
added_assets=added_assets,
|
||||
removed_asset_ids=list(removed_ids),
|
||||
)
|
||||
album.has_new_assets = change.added_count > 0
|
||||
album.last_change_time = datetime.now()
|
||||
self._fire_events(change, album)
|
||||
_LOGGER.info(
|
||||
"Detected changes during downtime for album '%s': +%d -%d",
|
||||
album.name,
|
||||
len(added_ids),
|
||||
len(removed_ids),
|
||||
)
|
||||
else:
|
||||
album.has_new_assets = False
|
||||
|
||||
# Clear persisted state after first comparison
|
||||
self._persisted_asset_ids = None
|
||||
else:
|
||||
album.has_new_assets = False
|
||||
|
||||
@@ -517,6 +581,12 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
|
||||
# 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
|
||||
|
||||
except aiohttp.ClientError as err:
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
"codeowners": ["@alexei.dolgolyov"],
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/haos-integrations",
|
||||
"documentation": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher",
|
||||
"iot_class": "cloud_polling",
|
||||
"issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/haos-integrations/issues",
|
||||
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues",
|
||||
"requirements": [],
|
||||
"version": "1.2.0"
|
||||
"version": "1.3.0"
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ async def async_setup_entry(
|
||||
coordinator = subentry_data.coordinator
|
||||
|
||||
entities: list[SensorEntity] = [
|
||||
ImmichAlbumIdSensor(coordinator, entry, subentry),
|
||||
ImmichAlbumAssetCountSensor(coordinator, entry, subentry),
|
||||
ImmichAlbumPhotoCountSensor(coordinator, entry, subentry),
|
||||
ImmichAlbumVideoCountSensor(coordinator, entry, subentry),
|
||||
@@ -160,6 +161,47 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
|
||||
return {"assets": assets}
|
||||
|
||||
|
||||
class ImmichAlbumIdSensor(ImmichAlbumBaseSensor):
|
||||
"""Sensor exposing the Immich album ID."""
|
||||
|
||||
_attr_icon = "mdi:identifier"
|
||||
_attr_translation_key = "album_id"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ImmichAlbumWatcherCoordinator,
|
||||
entry: ConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator, entry, subentry)
|
||||
self._attr_unique_id = f"{self._unique_id_prefix}_album_id"
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the album ID."""
|
||||
if self._album_data:
|
||||
return self._album_data.id
|
||||
return None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return extra state attributes."""
|
||||
if not self._album_data:
|
||||
return {}
|
||||
|
||||
attrs: dict[str, Any] = {
|
||||
"album_name": self._album_data.name,
|
||||
}
|
||||
|
||||
# Primary share URL (prefers public, falls back to protected)
|
||||
share_url = self.coordinator.get_any_url()
|
||||
if share_url:
|
||||
attrs["share_url"] = share_url
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor):
|
||||
"""Sensor representing an Immich album asset count."""
|
||||
|
||||
|
||||
65
custom_components/immich_album_watcher/storage.py
Normal file
65
custom_components/immich_album_watcher/storage.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Storage helpers for Immich Album Watcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_KEY_PREFIX = "immich_album_watcher"
|
||||
|
||||
|
||||
class ImmichAlbumStorage:
|
||||
"""Handles persistence of album state across restarts."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
|
||||
"""Initialize the storage."""
|
||||
self._store: Store[dict[str, Any]] = Store(
|
||||
hass, STORAGE_VERSION, f"{STORAGE_KEY_PREFIX}.{entry_id}"
|
||||
)
|
||||
self._data: dict[str, Any] | None = None
|
||||
|
||||
async def async_load(self) -> dict[str, Any]:
|
||||
"""Load data from storage."""
|
||||
self._data = await self._store.async_load() or {"albums": {}}
|
||||
_LOGGER.debug("Loaded storage data with %d albums", len(self._data.get("albums", {})))
|
||||
return self._data
|
||||
|
||||
async def async_save_album_state(self, album_id: str, asset_ids: set[str]) -> None:
|
||||
"""Save album asset IDs to storage."""
|
||||
if self._data is None:
|
||||
self._data = {"albums": {}}
|
||||
|
||||
self._data["albums"][album_id] = {
|
||||
"asset_ids": list(asset_ids),
|
||||
"last_updated": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await self._store.async_save(self._data)
|
||||
|
||||
def get_album_asset_ids(self, album_id: str) -> set[str] | None:
|
||||
"""Get persisted asset IDs for an album.
|
||||
|
||||
Returns None if no persisted state exists for the album.
|
||||
"""
|
||||
if self._data and "albums" in self._data:
|
||||
album_data = self._data["albums"].get(album_id)
|
||||
if album_data:
|
||||
return set(album_data.get("asset_ids", []))
|
||||
return None
|
||||
|
||||
async def async_remove_album(self, album_id: str) -> None:
|
||||
"""Remove an album from storage."""
|
||||
if self._data and "albums" in self._data:
|
||||
self._data["albums"].pop(album_id, None)
|
||||
await self._store.async_save(self._data)
|
||||
|
||||
async def async_remove(self) -> None:
|
||||
"""Remove all storage data."""
|
||||
await self._store.async_remove()
|
||||
self._data = None
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"album_id": {
|
||||
"name": "{album_name}: Album ID"
|
||||
},
|
||||
"album_asset_count": {
|
||||
"name": "{album_name}: Asset Count"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user