Add persistent storage

This commit is contained in:
2026-01-30 15:30:05 +03:00
parent 7c53110c07
commit 3a0573e432
4 changed files with 148 additions and 2 deletions

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

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/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

@@ -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