commit 4466b9e1029700938d9195948b9086f0ac1258f7 Author: alexei.dolgolyov Date: Thu Jan 29 20:06:55 2026 +0300 Add `Immich Album Watcher` integration diff --git a/immich_album_watcher/__init__.py b/immich_album_watcher/__init__.py new file mode 100644 index 0000000..e96ae60 --- /dev/null +++ b/immich_album_watcher/__init__.py @@ -0,0 +1,80 @@ +"""Immich Album Watcher integration for Home Assistant.""" + +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import ( + CONF_ALBUMS, + CONF_API_KEY, + CONF_IMMICH_URL, + CONF_SCAN_INTERVAL, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + PLATFORMS, +) +from .coordinator import ImmichAlbumWatcherCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Immich Album Watcher from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + url = entry.data[CONF_IMMICH_URL] + api_key = entry.data[CONF_API_KEY] + album_ids = entry.options.get(CONF_ALBUMS, []) + scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + coordinator = ImmichAlbumWatcherCoordinator( + hass, + url=url, + api_key=api_key, + album_ids=album_ids, + scan_interval=scan_interval, + ) + + # Fetch initial data + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + # Set up platforms + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Register update listener for options changes + entry.async_on_unload(entry.add_update_listener(async_update_options)) + + _LOGGER.info( + "Immich Album Watcher set up successfully, watching %d albums", + len(album_ids), + ) + + return True + + +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] + + album_ids = entry.options.get(CONF_ALBUMS, []) + scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + coordinator.update_config(album_ids, scan_interval) + + # Reload the entry to update sensors + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/immich_album_watcher/config_flow.py b/immich_album_watcher/config_flow.py new file mode 100644 index 0000000..70c588b --- /dev/null +++ b/immich_album_watcher/config_flow.py @@ -0,0 +1,232 @@ +"""Config flow for Immich Album Watcher integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_ALBUMS, + CONF_API_KEY, + CONF_IMMICH_URL, + CONF_SCAN_INTERVAL, + DEFAULT_SCAN_INTERVAL, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def validate_connection( + session: aiohttp.ClientSession, url: str, api_key: str +) -> dict[str, Any]: + """Validate the Immich connection and return server info.""" + headers = {"x-api-key": api_key} + async with session.get( + f"{url.rstrip('/')}/api/server/ping", headers=headers + ) as response: + if response.status == 401: + raise InvalidAuth + if response.status != 200: + raise CannotConnect + return await response.json() + + +async def fetch_albums( + session: aiohttp.ClientSession, url: str, api_key: str +) -> list[dict[str, Any]]: + """Fetch all albums from Immich.""" + headers = {"x-api-key": api_key} + async with session.get( + f"{url.rstrip('/')}/api/albums", headers=headers + ) as response: + if response.status != 200: + raise CannotConnect + return await response.json() + + +class ImmichAlbumWatcherConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Immich Album Watcher.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._url: str | None = None + self._api_key: str | None = None + self._albums: list[dict[str, Any]] = [] + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Get the options flow for this handler.""" + return ImmichAlbumWatcherOptionsFlow(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step - connection details.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._url = user_input[CONF_IMMICH_URL].rstrip("/") + self._api_key = user_input[CONF_API_KEY] + + session = async_get_clientsession(self.hass) + + try: + await validate_connection(session, self._url, self._api_key) + self._albums = await fetch_albums(session, self._url, self._api_key) + + if not self._albums: + errors["base"] = "no_albums" + else: + return await self.async_step_albums() + + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + except aiohttp.ClientError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_IMMICH_URL): str, + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + description_placeholders={ + "docs_url": "https://immich.app/docs/features/command-line-interface#obtain-the-api-key" + }, + ) + + async def async_step_albums( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle album selection step.""" + errors: dict[str, str] = {} + + if user_input is not None: + selected_albums = user_input.get(CONF_ALBUMS, []) + + if not selected_albums: + errors["base"] = "no_albums_selected" + else: + # Create unique ID based on URL + await self.async_set_unique_id(self._url) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="Immich Album Watcher", + data={ + CONF_IMMICH_URL: self._url, + CONF_API_KEY: self._api_key, + }, + options={ + CONF_ALBUMS: selected_albums, + CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL, + }, + ) + + # Build album selection list + album_options = { + album["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)" + for album in self._albums + } + + return self.async_show_form( + step_id="albums", + data_schema=vol.Schema( + { + vol.Required(CONF_ALBUMS): cv.multi_select(album_options), + } + ), + errors=errors, + ) + + +class ImmichAlbumWatcherOptionsFlow(OptionsFlow): + """Handle options flow for Immich Album Watcher.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self._config_entry = config_entry + self._albums: list[dict[str, Any]] = [] + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + errors: dict[str, str] = {} + + # Fetch current albums from Immich + session = async_get_clientsession(self.hass) + url = self._config_entry.data[CONF_IMMICH_URL] + api_key = self._config_entry.data[CONF_API_KEY] + + try: + self._albums = await fetch_albums(session, url, api_key) + except Exception: + _LOGGER.exception("Failed to fetch albums") + errors["base"] = "cannot_connect" + + if user_input is not None and not errors: + return self.async_create_entry( + title="", + data={ + CONF_ALBUMS: user_input.get(CONF_ALBUMS, []), + CONF_SCAN_INTERVAL: user_input.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ), + }, + ) + + # Build album selection list + album_options = { + album["id"]: f"{album.get('albumName', 'Unnamed')} ({album.get('assetCount', 0)} assets)" + for album in self._albums + } + + current_albums = self._config_entry.options.get(CONF_ALBUMS, []) + current_interval = self._config_entry.options.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL + ) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_ALBUMS, default=current_albums): cv.multi_select( + album_options + ), + vol.Required( + CONF_SCAN_INTERVAL, default=current_interval + ): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), + } + ), + errors=errors, + ) + + +class CannotConnect(Exception): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(Exception): + """Error to indicate there is invalid auth.""" diff --git a/immich_album_watcher/const.py b/immich_album_watcher/const.py new file mode 100644 index 0000000..a407bff --- /dev/null +++ b/immich_album_watcher/const.py @@ -0,0 +1,37 @@ +"""Constants for the Immich Album Watcher integration.""" + +from datetime import timedelta +from typing import Final + +DOMAIN: Final = "immich_album_watcher" + +# Configuration keys +CONF_IMMICH_URL: Final = "immich_url" +CONF_API_KEY: Final = "api_key" +CONF_ALBUMS: Final = "albums" +CONF_SCAN_INTERVAL: Final = "scan_interval" + +# Defaults +DEFAULT_SCAN_INTERVAL: Final = 60 # seconds + +# Events +EVENT_ALBUM_CHANGED: Final = f"{DOMAIN}_album_changed" +EVENT_ASSETS_ADDED: Final = f"{DOMAIN}_assets_added" +EVENT_ASSETS_REMOVED: Final = f"{DOMAIN}_assets_removed" + +# Attributes +ATTR_ALBUM_ID: Final = "album_id" +ATTR_ALBUM_NAME: Final = "album_name" +ATTR_ASSET_COUNT: Final = "asset_count" +ATTR_ADDED_COUNT: Final = "added_count" +ATTR_REMOVED_COUNT: Final = "removed_count" +ATTR_ADDED_ASSETS: Final = "added_assets" +ATTR_REMOVED_ASSETS: Final = "removed_assets" +ATTR_CHANGE_TYPE: Final = "change_type" +ATTR_LAST_UPDATED: Final = "last_updated" +ATTR_THUMBNAIL_URL: Final = "thumbnail_url" +ATTR_SHARED: Final = "shared" +ATTR_OWNER: Final = "owner" + +# Platforms +PLATFORMS: Final = ["sensor"] diff --git a/immich_album_watcher/coordinator.py b/immich_album_watcher/coordinator.py new file mode 100644 index 0000000..bd9f105 --- /dev/null +++ b/immich_album_watcher/coordinator.py @@ -0,0 +1,204 @@ +"""Data coordinator for Immich Album Watcher.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any + +import aiohttp + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + ATTR_ADDED_ASSETS, + ATTR_ADDED_COUNT, + ATTR_ALBUM_ID, + ATTR_ALBUM_NAME, + ATTR_CHANGE_TYPE, + ATTR_REMOVED_ASSETS, + ATTR_REMOVED_COUNT, + DOMAIN, + EVENT_ALBUM_CHANGED, + EVENT_ASSETS_ADDED, + EVENT_ASSETS_REMOVED, +) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class AlbumData: + """Data class for album information.""" + + id: str + name: str + asset_count: int + updated_at: str + shared: bool + owner: str + thumbnail_asset_id: str | None + asset_ids: set[str] = field(default_factory=set) + + @classmethod + def from_api_response(cls, data: dict[str, Any]) -> AlbumData: + """Create AlbumData from API response.""" + asset_ids = {asset["id"] for asset in data.get("assets", [])} + return cls( + id=data["id"], + name=data.get("albumName", "Unnamed"), + asset_count=data.get("assetCount", len(asset_ids)), + updated_at=data.get("updatedAt", ""), + shared=data.get("shared", False), + owner=data.get("owner", {}).get("name", "Unknown"), + thumbnail_asset_id=data.get("albumThumbnailAssetId"), + asset_ids=asset_ids, + ) + + +@dataclass +class AlbumChange: + """Data class for album changes.""" + + album_id: str + album_name: str + change_type: str + added_count: int = 0 + removed_count: int = 0 + added_asset_ids: list[str] = field(default_factory=list) + removed_asset_ids: list[str] = field(default_factory=list) + + +class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[dict[str, AlbumData]]): + """Coordinator for fetching Immich album data.""" + + def __init__( + self, + hass: HomeAssistant, + url: str, + api_key: str, + album_ids: list[str], + scan_interval: int, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=scan_interval), + ) + self._url = url.rstrip("/") + self._api_key = api_key + self._album_ids = album_ids + self._previous_states: dict[str, AlbumData] = {} + self._session: aiohttp.ClientSession | None = None + + @property + def immich_url(self) -> str: + """Return the Immich URL.""" + return self._url + + def update_config(self, album_ids: list[str], scan_interval: int) -> None: + """Update configuration.""" + self._album_ids = album_ids + self.update_interval = timedelta(seconds=scan_interval) + + async def _async_update_data(self) -> dict[str, AlbumData]: + """Fetch data from Immich API.""" + if self._session is None: + self._session = async_get_clientsession(self.hass) + + headers = {"x-api-key": self._api_key} + albums_data: dict[str, AlbumData] = {} + + for album_id in self._album_ids: + try: + async with self._session.get( + f"{self._url}/api/albums/{album_id}", + headers=headers, + ) as response: + if response.status == 404: + _LOGGER.warning("Album %s not found, skipping", album_id) + continue + if response.status != 200: + raise UpdateFailed( + f"Error fetching album {album_id}: HTTP {response.status}" + ) + + data = await response.json() + album = AlbumData.from_api_response(data) + albums_data[album_id] = album + + # Detect changes + if album_id in self._previous_states: + change = self._detect_change( + self._previous_states[album_id], album + ) + if change: + self._fire_events(change) + + except aiohttp.ClientError as err: + raise UpdateFailed(f"Error communicating with Immich: {err}") from err + + # Update previous states + self._previous_states = albums_data.copy() + + return albums_data + + def _detect_change( + self, old_state: AlbumData, new_state: AlbumData + ) -> AlbumChange | None: + """Detect changes between two album states.""" + added = new_state.asset_ids - old_state.asset_ids + removed = old_state.asset_ids - new_state.asset_ids + + if not added and not removed: + return None + + change_type = "changed" + if added and not removed: + change_type = "assets_added" + elif removed and not added: + change_type = "assets_removed" + + return AlbumChange( + album_id=new_state.id, + album_name=new_state.name, + change_type=change_type, + added_count=len(added), + removed_count=len(removed), + added_asset_ids=list(added), + removed_asset_ids=list(removed), + ) + + def _fire_events(self, change: AlbumChange) -> None: + """Fire Home Assistant events for album changes.""" + event_data = { + ATTR_ALBUM_ID: change.album_id, + ATTR_ALBUM_NAME: change.album_name, + ATTR_CHANGE_TYPE: change.change_type, + ATTR_ADDED_COUNT: change.added_count, + ATTR_REMOVED_COUNT: change.removed_count, + ATTR_ADDED_ASSETS: change.added_asset_ids, + ATTR_REMOVED_ASSETS: change.removed_asset_ids, + } + + # Fire general change event + self.hass.bus.async_fire(EVENT_ALBUM_CHANGED, event_data) + + _LOGGER.info( + "Album '%s' changed: +%d -%d assets", + change.album_name, + change.added_count, + change.removed_count, + ) + + # Fire specific events + if change.added_count > 0: + self.hass.bus.async_fire(EVENT_ASSETS_ADDED, event_data) + + if change.removed_count > 0: + self.hass.bus.async_fire(EVENT_ASSETS_REMOVED, event_data) diff --git a/immich_album_watcher/manifest.json b/immich_album_watcher/manifest.json new file mode 100644 index 0000000..3c9b1da --- /dev/null +++ b/immich_album_watcher/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "immich_album_watcher", + "name": "Immich Album Watcher", + "codeowners": [], + "config_flow": true, + "dependencies": [], + "documentation": "https://github.com/your-repo/immich-album-watcher", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/your-repo/immich-album-watcher/issues", + "requirements": [], + "version": "1.0.0" +} diff --git a/immich_album_watcher/sensor.py b/immich_album_watcher/sensor.py new file mode 100644 index 0000000..162760e --- /dev/null +++ b/immich_album_watcher/sensor.py @@ -0,0 +1,134 @@ +"""Sensor platform for Immich Album Watcher.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.sensor import ( + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + ATTR_ALBUM_ID, + ATTR_ASSET_COUNT, + ATTR_LAST_UPDATED, + ATTR_OWNER, + ATTR_SHARED, + ATTR_THUMBNAIL_URL, + CONF_ALBUMS, + DOMAIN, +) +from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Immich Album Watcher sensors from a config entry.""" + coordinator: ImmichAlbumWatcherCoordinator = hass.data[DOMAIN][entry.entry_id] + album_ids = entry.options.get(CONF_ALBUMS, []) + + entities = [ + ImmichAlbumSensor(coordinator, entry, album_id) + for album_id in album_ids + ] + + async_add_entities(entities) + + +class ImmichAlbumSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], SensorEntity): + """Sensor representing an Immich album.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = "assets" + _attr_icon = "mdi:image-album" + + def __init__( + self, + coordinator: ImmichAlbumWatcherCoordinator, + entry: ConfigEntry, + album_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._album_id = album_id + self._entry = entry + + # Entity IDs and names will be set when data is available + self._attr_unique_id = f"{entry.entry_id}_{album_id}" + self._attr_has_entity_name = True + + @property + def _album_data(self) -> AlbumData | None: + """Get the album data from coordinator.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get(self._album_id) + + @property + def name(self) -> str: + """Return the name of the sensor.""" + if self._album_data: + return self._album_data.name + return f"Album {self._album_id[:8]}" + + @property + def native_value(self) -> int | None: + """Return the state of the sensor (asset count).""" + if self._album_data: + return self._album_data.asset_count + return None + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success and self._album_data is not None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return extra state attributes.""" + if not self._album_data: + return {} + + attrs = { + ATTR_ALBUM_ID: self._album_data.id, + ATTR_ASSET_COUNT: self._album_data.asset_count, + ATTR_LAST_UPDATED: self._album_data.updated_at, + ATTR_SHARED: self._album_data.shared, + ATTR_OWNER: self._album_data.owner, + } + + # Add thumbnail URL if available + if self._album_data.thumbnail_asset_id: + attrs[ATTR_THUMBNAIL_URL] = ( + f"{self.coordinator.immich_url}/api/assets/" + f"{self._album_data.thumbnail_asset_id}/thumbnail" + ) + + return attrs + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return DeviceInfo( + identifiers={(DOMAIN, self._entry.entry_id)}, + name="Immich Album Watcher", + manufacturer="Immich", + entry_type="service", + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() diff --git a/immich_album_watcher/strings.json b/immich_album_watcher/strings.json new file mode 100644 index 0000000..ea69e75 --- /dev/null +++ b/immich_album_watcher/strings.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to Immich", + "description": "Enter your Immich server details. You can get an API key from Immich → User Settings → API Keys.", + "data": { + "immich_url": "Immich URL", + "api_key": "API Key" + }, + "data_description": { + "immich_url": "The URL of your Immich server (e.g., http://192.168.1.100:2283)", + "api_key": "Your Immich API key" + } + }, + "albums": { + "title": "Select Albums", + "description": "Choose which albums to monitor for changes.", + "data": { + "albums": "Albums to watch" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to Immich server", + "invalid_auth": "Invalid API key", + "no_albums": "No albums found on the server", + "no_albums_selected": "Please select at least one album", + "unknown": "Unexpected error occurred" + }, + "abort": { + "already_configured": "This Immich server is already configured" + } + }, + "options": { + "step": { + "init": { + "title": "Immich Album Watcher Options", + "description": "Configure which albums to monitor and how often to check for changes.", + "data": { + "albums": "Albums to watch", + "scan_interval": "Scan interval (seconds)" + }, + "data_description": { + "scan_interval": "How often to check for album changes (10-3600 seconds)" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to Immich server" + } + } +} diff --git a/immich_album_watcher/translations/en.json b/immich_album_watcher/translations/en.json new file mode 100644 index 0000000..ea69e75 --- /dev/null +++ b/immich_album_watcher/translations/en.json @@ -0,0 +1,53 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to Immich", + "description": "Enter your Immich server details. You can get an API key from Immich → User Settings → API Keys.", + "data": { + "immich_url": "Immich URL", + "api_key": "API Key" + }, + "data_description": { + "immich_url": "The URL of your Immich server (e.g., http://192.168.1.100:2283)", + "api_key": "Your Immich API key" + } + }, + "albums": { + "title": "Select Albums", + "description": "Choose which albums to monitor for changes.", + "data": { + "albums": "Albums to watch" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to Immich server", + "invalid_auth": "Invalid API key", + "no_albums": "No albums found on the server", + "no_albums_selected": "Please select at least one album", + "unknown": "Unexpected error occurred" + }, + "abort": { + "already_configured": "This Immich server is already configured" + } + }, + "options": { + "step": { + "init": { + "title": "Immich Album Watcher Options", + "description": "Configure which albums to monitor and how often to check for changes.", + "data": { + "albums": "Albums to watch", + "scan_interval": "Scan interval (seconds)" + }, + "data_description": { + "scan_interval": "How often to check for album changes (10-3600 seconds)" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to Immich server" + } + } +}