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
|
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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
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": {
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user