diff --git a/custom_components/immich_album_watcher/__init__.py b/custom_components/immich_album_watcher/__init__.py index e9c5b3b..1a5b69e 100644 --- a/custom_components/immich_album_watcher/__init__.py +++ b/custom_components/immich_album_watcher/__init__.py @@ -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() diff --git a/custom_components/immich_album_watcher/coordinator.py b/custom_components/immich_album_watcher/coordinator.py index 6f868f3..f31e15f 100644 --- a/custom_components/immich_album_watcher/coordinator.py +++ b/custom_components/immich_album_watcher/coordinator.py @@ -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: diff --git a/custom_components/immich_album_watcher/manifest.json b/custom_components/immich_album_watcher/manifest.json index 259db56..6781010 100644 --- a/custom_components/immich_album_watcher/manifest.json +++ b/custom_components/immich_album_watcher/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues", "requirements": [], - "version": "1.2.0" + "version": "1.3.0" } diff --git a/custom_components/immich_album_watcher/storage.py b/custom_components/immich_album_watcher/storage.py new file mode 100644 index 0000000..1826e80 --- /dev/null +++ b/custom_components/immich_album_watcher/storage.py @@ -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