9 Commits

10 changed files with 240 additions and 12 deletions

16
CLAUDE.md Normal file
View 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
View File

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

View File

@@ -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 For detailed documentation, see the [integration README](custom_components/immich_album_watcher/README.md).
| 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) |
## Installation ## Installation
@@ -15,13 +11,15 @@ A collection of custom integrations for Home Assistant.
1. Open HACS in Home Assistant 1. Open HACS in Home Assistant
2. Click on the three dots in the top right corner 2. Click on the three dots in the top right corner
3. Select **Custom repositories** 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 5. Select **Integration** as the category
6. Click **Add** 6. Click **Add**
7. Search for "Immich Album Watcher" in HACS and install it 7. Search for "Immich Album Watcher" in HACS and install it
8. Restart Home Assistant 8. Restart Home Assistant
9. Add the integration via **Settings****Devices & Services****Add Integration** 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 ### Manual Installation
1. Download or clone this repository 1. Download or clone this repository

View File

@@ -60,6 +60,8 @@ A Home Assistant custom integration that monitors [Immich](https://immich.app/)
5. Enter your Immich server URL and API key 5. Enter your Immich server URL and API key
6. Select the albums you want to monitor 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 ## Configuration
| Option | Description | Default | | Option | Description | Default |

View File

@@ -20,6 +20,7 @@ from .const import (
PLATFORMS, PLATFORMS,
) )
from .coordinator import ImmichAlbumWatcherCoordinator from .coordinator import ImmichAlbumWatcherCoordinator
from .storage import ImmichAlbumStorage
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -63,10 +64,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo
scan_interval=scan_interval, 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 # Store hub reference
hass.data[DOMAIN][entry.entry_id] = { hass.data[DOMAIN][entry.entry_id] = {
"hub": entry.runtime_data, "hub": entry.runtime_data,
"subentries": {}, "subentries": {},
"storage": storage,
} }
# Track loaded subentries to detect changes # Track loaded subentries to detect changes
@@ -97,6 +103,7 @@ async def _async_setup_subentry_coordinator(
hub_data: ImmichHubData = entry.runtime_data hub_data: ImmichHubData = entry.runtime_data
album_id = subentry.data[CONF_ALBUM_ID] album_id = subentry.data[CONF_ALBUM_ID]
album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") 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) _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, album_name=album_name,
scan_interval=hub_data.scan_interval, scan_interval=hub_data.scan_interval,
hub_name=hub_data.name, 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 # Fetch initial data
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()

View File

@@ -5,7 +5,10 @@ from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .storage import ImmichAlbumStorage
import aiohttp import aiohttp
@@ -221,6 +224,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
album_name: str, album_name: str,
scan_interval: int, scan_interval: int,
hub_name: str = "Immich", hub_name: str = "Immich",
storage: ImmichAlbumStorage | None = None,
) -> None: ) -> None:
"""Initialize the coordinator.""" """Initialize the coordinator."""
super().__init__( super().__init__(
@@ -239,6 +243,8 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
self._people_cache: dict[str, str] = {} # person_id -> name self._people_cache: dict[str, str] = {} # person_id -> name
self._users_cache: dict[str, str] = {} # user_id -> name self._users_cache: dict[str, str] = {} # user_id -> name
self._shared_links: list[SharedLinkInfo] = [] self._shared_links: list[SharedLinkInfo] = []
self._storage = storage
self._persisted_asset_ids: set[str] | None = None
@property @property
def immich_url(self) -> str: def immich_url(self) -> str:
@@ -268,6 +274,23 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
"""Force an immediate refresh.""" """Force an immediate refresh."""
await self.async_request_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]]: async def async_get_recent_assets(self, count: int = 10) -> list[dict[str, Any]]:
"""Get recent assets from the album.""" """Get recent assets from the album."""
if self.data is None: if self.data is None:
@@ -503,6 +526,47 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
album.has_new_assets = change.added_count > 0 album.has_new_assets = change.added_count > 0
album.last_change_time = datetime.now() album.last_change_time = datetime.now()
self._fire_events(change, album) 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: else:
album.has_new_assets = False album.has_new_assets = False
@@ -517,6 +581,12 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]):
# Update previous state # Update previous state
self._previous_state = album 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 return album
except aiohttp.ClientError as err: except aiohttp.ClientError as err:

View File

@@ -4,9 +4,9 @@
"codeowners": ["@alexei.dolgolyov"], "codeowners": ["@alexei.dolgolyov"],
"config_flow": true, "config_flow": true,
"dependencies": [], "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", "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": [], "requirements": [],
"version": "1.2.0" "version": "1.3.0"
} }

View File

@@ -63,6 +63,7 @@ async def async_setup_entry(
coordinator = subentry_data.coordinator coordinator = subentry_data.coordinator
entities: list[SensorEntity] = [ entities: list[SensorEntity] = [
ImmichAlbumIdSensor(coordinator, entry, subentry),
ImmichAlbumAssetCountSensor(coordinator, entry, subentry), ImmichAlbumAssetCountSensor(coordinator, entry, subentry),
ImmichAlbumPhotoCountSensor(coordinator, entry, subentry), ImmichAlbumPhotoCountSensor(coordinator, entry, subentry),
ImmichAlbumVideoCountSensor(coordinator, entry, subentry), ImmichAlbumVideoCountSensor(coordinator, entry, subentry),
@@ -160,6 +161,47 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se
return {"assets": assets} 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): class ImmichAlbumAssetCountSensor(ImmichAlbumBaseSensor):
"""Sensor representing an Immich album asset count.""" """Sensor representing an Immich album asset count."""

View 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

View File

@@ -1,6 +1,9 @@
{ {
"entity": { "entity": {
"sensor": { "sensor": {
"album_id": {
"name": "{album_name}: Album ID"
},
"album_asset_count": { "album_asset_count": {
"name": "{album_name}: Asset Count" "name": "{album_name}: Asset Count"
}, },