"""Immich Album Watcher integration for Home Assistant.""" from __future__ import annotations import logging from dataclasses import dataclass from datetime import datetime from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_track_time_change from .const import ( CONF_ALBUM_ID, CONF_ALBUM_NAME, CONF_API_KEY, CONF_HUB_NAME, CONF_IMMICH_URL, CONF_QUIET_HOURS_END, CONF_QUIET_HOURS_START, CONF_SCAN_INTERVAL, CONF_TELEGRAM_CACHE_TTL, DEFAULT_SCAN_INTERVAL, DEFAULT_TELEGRAM_CACHE_TTL, DOMAIN, PLATFORMS, ) from .coordinator import ImmichAlbumWatcherCoordinator from .storage import ImmichAlbumStorage, NotificationQueue, TelegramFileCache _LOGGER = logging.getLogger(__name__) @dataclass class ImmichHubData: """Data for the Immich hub.""" name: str url: str api_key: str scan_interval: int telegram_cache_ttl: int quiet_hours_start: str quiet_hours_end: str @dataclass class ImmichAlbumRuntimeData: """Runtime data for an album subentry.""" coordinator: ImmichAlbumWatcherCoordinator album_id: str album_name: str type ImmichConfigEntry = ConfigEntry[ImmichHubData] async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool: """Set up Immich Album Watcher hub from a config entry.""" hass.data.setdefault(DOMAIN, {}) hub_name = entry.data.get(CONF_HUB_NAME, "Immich") url = entry.data[CONF_IMMICH_URL] api_key = entry.data[CONF_API_KEY] scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) telegram_cache_ttl = entry.options.get(CONF_TELEGRAM_CACHE_TTL, DEFAULT_TELEGRAM_CACHE_TTL) quiet_hours_start = entry.options.get(CONF_QUIET_HOURS_START, "") quiet_hours_end = entry.options.get(CONF_QUIET_HOURS_END, "") # Store hub data entry.runtime_data = ImmichHubData( name=hub_name, url=url, api_key=api_key, scan_interval=scan_interval, telegram_cache_ttl=telegram_cache_ttl, quiet_hours_start=quiet_hours_start, quiet_hours_end=quiet_hours_end, ) # Create storage for persisting album state across restarts storage = ImmichAlbumStorage(hass, entry.entry_id) await storage.async_load() # Create and load Telegram file caches once per hub (shared across all albums) # TTL is in hours from config, convert to seconds cache_ttl_seconds = telegram_cache_ttl * 60 * 60 # URL-based cache for non-Immich URLs or URLs without extractable asset IDs telegram_cache = TelegramFileCache(hass, entry.entry_id, ttl_seconds=cache_ttl_seconds) await telegram_cache.async_load() # Asset ID-based cache for Immich URLs — uses thumbhash validation instead of TTL telegram_asset_cache = TelegramFileCache( hass, f"{entry.entry_id}_assets", use_thumbhash=True ) await telegram_asset_cache.async_load() # Create notification queue for quiet hours notification_queue = NotificationQueue(hass, entry.entry_id) await notification_queue.async_load() # Store hub reference hass.data[DOMAIN][entry.entry_id] = { "hub": entry.runtime_data, "subentries": {}, "storage": storage, "telegram_cache": telegram_cache, "telegram_asset_cache": telegram_asset_cache, "notification_queue": notification_queue, } # Track loaded subentries to detect changes hass.data[DOMAIN][entry.entry_id]["loaded_subentries"] = set(entry.subentries.keys()) # Set up coordinators for all subentries (albums) for subentry_id, subentry in entry.subentries.items(): await _async_setup_subentry_coordinator(hass, entry, subentry) # Forward platform setup once - platforms will iterate through subentries await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Register quiet hours end timer _register_quiet_hours_timer(hass, entry) # Check if there are queued notifications from before restart (outside quiet hours) if notification_queue.has_pending() and not _is_quiet_hours(quiet_hours_start, quiet_hours_end): hass.async_create_task(_process_notification_queue(hass, entry)) # Register update listener for options and subentry changes entry.async_on_unload(entry.add_update_listener(_async_update_listener)) _LOGGER.info( "Immich Album Watcher hub set up successfully with %d albums", len(entry.subentries), ) return True async def _async_setup_subentry_coordinator( hass: HomeAssistant, entry: ImmichConfigEntry, subentry: ConfigSubentry ) -> None: """Set up coordinator for an album subentry.""" 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"] telegram_cache: TelegramFileCache = hass.data[DOMAIN][entry.entry_id]["telegram_cache"] telegram_asset_cache: TelegramFileCache = hass.data[DOMAIN][entry.entry_id]["telegram_asset_cache"] _LOGGER.debug("Setting up coordinator for album: %s (%s)", album_name, album_id) # Create coordinator for this album coordinator = ImmichAlbumWatcherCoordinator( hass, url=hub_data.url, api_key=hub_data.api_key, album_id=album_id, album_name=album_name, scan_interval=hub_data.scan_interval, hub_name=hub_data.name, storage=storage, telegram_cache=telegram_cache, telegram_asset_cache=telegram_asset_cache, ) # 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() # Store subentry runtime data subentry_data = ImmichAlbumRuntimeData( coordinator=coordinator, album_id=album_id, album_name=album_name, ) hass.data[DOMAIN][entry.entry_id]["subentries"][subentry.subentry_id] = subentry_data _LOGGER.info("Coordinator for album '%s' set up successfully", album_name) def _is_quiet_hours(start_str: str, end_str: str, hass: HomeAssistant | None = None) -> bool: """Check if current time is within quiet hours.""" from datetime import time as dt_time from homeassistant.util import dt as dt_util if not start_str or not end_str: return False try: now = dt_util.now().time() start_time = dt_time.fromisoformat(start_str) end_time = dt_time.fromisoformat(end_str) except ValueError: return False if start_time <= end_time: return start_time <= now < end_time else: # Crosses midnight (e.g., 22:00 - 08:00) return now >= start_time or now < end_time def _register_quiet_hours_timer(hass: HomeAssistant, entry: ImmichConfigEntry) -> None: """Register a timer to process the notification queue when quiet hours end.""" entry_data = hass.data[DOMAIN][entry.entry_id] # Cancel existing timer if any unsub = entry_data.pop("quiet_hours_unsub", None) if unsub: unsub() end_str = entry.options.get(CONF_QUIET_HOURS_END, "") start_str = entry.options.get(CONF_QUIET_HOURS_START, "") if not end_str or not start_str: return try: from datetime import time as dt_time end_time = dt_time.fromisoformat(end_str) except ValueError: _LOGGER.warning("Invalid quiet hours end time: %s", end_str) return async def _on_quiet_hours_end(_now: datetime) -> None: """Handle quiet hours end — process queued notifications.""" queue: NotificationQueue = entry_data["notification_queue"] if queue.has_pending(): _LOGGER.info("Quiet hours ended, processing queued notifications") await _process_notification_queue(hass, entry) unsub = async_track_time_change( hass, _on_quiet_hours_end, hour=end_time.hour, minute=end_time.minute, second=0 ) entry_data["quiet_hours_unsub"] = unsub entry.async_on_unload(unsub) _LOGGER.debug("Registered quiet hours timer for %s", end_str) async def _process_notification_queue( hass: HomeAssistant, entry: ImmichConfigEntry ) -> None: """Process all queued notifications via the HA service call.""" import asyncio from homeassistant.helpers import entity_registry as er entry_data = hass.data[DOMAIN].get(entry.entry_id) if not entry_data: return queue: NotificationQueue = entry_data["notification_queue"] items = queue.get_all() if not items: return # Find a fallback sensor entity for items that don't have entity_id stored ent_reg = er.async_get(hass) fallback_entity_id = None for ent in er.async_entries_for_config_entry(ent_reg, entry.entry_id): if ent.domain == "sensor": fallback_entity_id = ent.entity_id break if not fallback_entity_id: _LOGGER.warning("No sensor entity found to process notification queue") return _LOGGER.info("Processing %d queued notifications", len(items)) for i, item in enumerate(items): params = item.get("params", {}) try: # Use stored entity_id from the original call, fall back to discovered one target_entity_id = params.pop("entity_id", None) or fallback_entity_id # Call the service with ignore_quiet_hours=True to prevent re-queuing await hass.services.async_call( DOMAIN, "send_telegram_notification", {**params, "ignore_quiet_hours": True}, target={"entity_id": target_entity_id}, blocking=True, ) except Exception: _LOGGER.exception("Failed to send queued notification %d/%d", i + 1, len(items)) # Small delay between notifications to avoid rate limiting if i < len(items) - 1: await asyncio.sleep(1) await queue.async_clear() _LOGGER.info("Processed %d queued notifications", len(items)) async def _async_update_listener( hass: HomeAssistant, entry: ImmichConfigEntry ) -> None: """Handle config entry updates (options or subentry changes).""" entry_data = hass.data[DOMAIN][entry.entry_id] loaded_subentries = entry_data.get("loaded_subentries", set()) current_subentries = set(entry.subentries.keys()) # Check if subentries changed if loaded_subentries != current_subentries: _LOGGER.info( "Subentries changed (loaded: %d, current: %d), reloading entry", len(loaded_subentries), len(current_subentries), ) await hass.config_entries.async_reload(entry.entry_id) return # Handle options-only update new_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) new_quiet_start = entry.options.get(CONF_QUIET_HOURS_START, "") new_quiet_end = entry.options.get(CONF_QUIET_HOURS_END, "") # Update hub data entry.runtime_data.scan_interval = new_interval entry.runtime_data.quiet_hours_start = new_quiet_start entry.runtime_data.quiet_hours_end = new_quiet_end # Update all subentry coordinators subentries_data = entry_data["subentries"] for subentry_data in subentries_data.values(): subentry_data.coordinator.update_scan_interval(new_interval) # Re-register quiet hours timer _register_quiet_hours_timer(hass, entry) _LOGGER.info("Updated hub options (scan_interval=%d, quiet_hours=%s-%s)", new_interval, new_quiet_start or "disabled", new_quiet_end or "disabled") async def async_unload_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool: """Unload a config entry.""" # Unload all platforms unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: # Clean up hub data hass.data[DOMAIN].pop(entry.entry_id, None) _LOGGER.info("Immich Album Watcher hub unloaded") return unload_ok