Add persistent storage
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
Reference in New Issue
Block a user