Repository: home-assistant/core
Pull request #145125: Add Immich integration
Author: login: mib1185
Labels: name: cla-signed
name: new-integration
name: has-tests
name: quality-scale
name: integration: immich
Created: 2025-05-17T22:39:19Z | Merged: 2025-05-18T19:28:15Z

<!--
  You are amazing! Thanks for contributing to our project!
  Please, DO NOT DELETE ANY TEXT from this template! (unless instructed).
-->
## Proposed change
<!--
  Describe the big picture of your changes here to communicate to the
  maintainers why we should accept this pull request. If it fixes a bug
  or resolves a feature request, be sure to link to that issue in the
  additional information section.
-->
This adds a the new [immich](https://immich.app/) integration. It is based on the specially created [`aioimmich`](https://github.com/mib1185/aioimmich) lib. As starting point, it implements the config flow (_incl. re-auth flow_) and some statistics sensors.

Next it will get the media_source platform implemented.


TODO
- [x] docs
- [x] brands -> https://github.com/home-assistant/brands/pull/7082
we've already the [`immich`](https://github.com/home-assistant/brands/tree/master/custom_integrations/immich) domain under the custom components - should we move or copy it under `core_integrations`?


## Type of change
<!--
  What type of change does your PR introduce to Home Assistant?
  NOTE: Please, check only 1! box!
  If your PR requires multiple boxes to be checked, you'll most likely need to
  split it into multiple PRs. This makes things easier and faster to code review.
-->

- [ ] Dependency upgrade
- [ ] Bugfix (non-breaking change which fixes an issue)
- [x] New integration (thank you!)
- [ ] New feature (which adds functionality to an existing integration)
- [ ] Deprecation (breaking change to happen in the future)
- [ ] Breaking change (fix/feature causing existing functionality to break)
- [ ] Code quality improvements to existing code or addition of tests

## Additional information
<!--
  Details are important, and help maintainers processing your PR.
  Please be sure to fill out additional details, if applicable.
-->

- This PR fixes or closes issue: fixes #
- This PR is related to issue: 
- Link to documentation pull request: https://github.com/home-assistant/home-assistant.io/pull/39078
- Link to developer documentation pull request: 
- Link to frontend pull request: 

## Checklist
<!--
  Put an `x` in the boxes that apply. You can also fill these out after
  creating the PR. If you're unsure about any of them, don't hesitate to ask.
  We're here to help! This is simply a reminder of what we are going to look
  for before merging your code.
-->

- [x] The code change is tested and works locally.
- [x] Local tests pass. **Your PR cannot be merged unless tests pass**
- [x] There is no commented out code in this PR.
- [x] I have followed the [development checklist][dev-checklist]
- [x] I have followed the [perfect PR recommendations][perfect-pr]
- [x] The code has been formatted using Ruff (`ruff format homeassistant tests`)
- [x] Tests have been added to verify that the new code works.

If user exposed functionality or configuration variables are added/changed:

- [x] Documentation added/updated for [www.home-assistant.io][docs-repository]

If the code communicates with devices, web services, or third-party tools:

- [x] The [manifest file][manifest-docs] has all fields filled out correctly.  
      Updated and included derived files by running: `python3 -m script.hassfest`.
- [x] New or updated dependencies have been added to `requirements_all.txt`.  
      Updated by running `python3 -m script.gen_requirements_all`.
- [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.

<!--
  This project is very active and we have a high turnover of pull requests.

  Unfortunately, the number of incoming pull requests is higher than what our
  reviewers can review and merge so there is a long backlog of pull requests
  waiting for review. You can help here!
  
  By reviewing another pull request, you will help raise the code quality of
  that pull request and the final review will be faster. This way the general
  pace of pull request reviews will go up and your wait time will go down.
  
  When picking a pull request to review, try to choose one that hasn't yet
  been reviewed.

  Thanks for helping out!
-->

To help with the load of incoming pull requests:

- [x] I have reviewed two other [open pull requests][prs] in this repository.

[prs]: https://github.com/home-assistant/core/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+-label%3Awaiting-for-upstream+sort%3Acreated-desc+review%3Anone+-status%3Afailure

<!--
  Thank you for contributing <3

  Below, some useful links you could explore:
-->
[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/
[manifest-docs]: https://developers.home-assistant.io/docs/creating_integration_manifest/
[quality-scale]: https://developers.home-assistant.io/docs/integration_quality_scale_index/
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr



Diff:
diff --git a/.strict-typing b/.strict-typing
index 5648bbe3dd2ece..1ae56cd74d8700 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -270,6 +270,7 @@ homeassistant.components.image_processing.*
 homeassistant.components.image_upload.*
 homeassistant.components.imap.*
 homeassistant.components.imgw_pib.*
+homeassistant.components.immich.*
 homeassistant.components.incomfort.*
 homeassistant.components.input_button.*
 homeassistant.components.input_select.*
diff --git a/CODEOWNERS b/CODEOWNERS
index b8d7ea952eed5d..bbbfb9394e280e 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -710,6 +710,8 @@ build.json @home-assistant/supervisor
 /tests/components/imeon_inverter/ @Imeon-Energy
 /homeassistant/components/imgw_pib/ @bieniu
 /tests/components/imgw_pib/ @bieniu
+/homeassistant/components/immich/ @mib1185
+/tests/components/immich/ @mib1185
 /homeassistant/components/improv_ble/ @emontnemery
 /tests/components/improv_ble/ @emontnemery
 /homeassistant/components/incomfort/ @jbouwh
diff --git a/homeassistant/components/immich/__init__.py b/homeassistant/components/immich/__init__.py
new file mode 100644
index 00000000000000..18782ec6fd3d54
--- /dev/null
+++ b/homeassistant/components/immich/__init__.py
@@ -0,0 +1,56 @@
+"""The Immich integration."""
+
+from __future__ import annotations
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_VERIFY_SSL,
+    Platform,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .coordinator import ImmichConfigEntry, ImmichDataUpdateCoordinator
+
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool:
+    """Set up Immich from a config entry."""
+
+    session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL])
+    immich = Immich(
+        session,
+        entry.data[CONF_API_KEY],
+        entry.data[CONF_HOST],
+        entry.data[CONF_PORT],
+        entry.data[CONF_SSL],
+    )
+
+    try:
+        user_info = await immich.users.async_get_my_user()
+    except ImmichUnauthorizedError as err:
+        raise ConfigEntryAuthFailed from err
+    except CONNECT_ERRORS as err:
+        raise ConfigEntryNotReady from err
+
+    coordinator = ImmichDataUpdateCoordinator(hass, entry, immich, user_info.is_admin)
+    await coordinator.async_config_entry_first_refresh()
+    entry.runtime_data = coordinator
+
+    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+    return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool:
+    """Unload a config entry."""
+    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/immich/config_flow.py b/homeassistant/components/immich/config_flow.py
new file mode 100644
index 00000000000000..69fae3ff1ebed6
--- /dev/null
+++ b/homeassistant/components/immich/config_flow.py
@@ -0,0 +1,174 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.users.models import ImmichUser
+import voluptuous as vol
+from yarl import URL
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_URL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+    TextSelector,
+    TextSelectorConfig,
+    TextSelectorType,
+)
+
+from .const import DEFAULT_VERIFY_SSL, DOMAIN
+
+
+class InvalidUrl(HomeAssistantError):
+    """Error to indicate invalid URL."""
+
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_URL): TextSelector(
+            config=TextSelectorConfig(type=TextSelectorType.URL)
+        ),
+        vol.Required(CONF_API_KEY): TextSelector(
+            config=TextSelectorConfig(type=TextSelectorType.PASSWORD)
+        ),
+        vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+    }
+)
+
+
+def _parse_url(url: str) -> tuple[str, int, bool]:
+    """Parse the URL and return host, port, and ssl."""
+    parsed_url = URL(url)
+    if (
+        (host := parsed_url.host) is None
+        or (port := parsed_url.port) is None
+        or (scheme := parsed_url.scheme) is None
+    ):
+        raise InvalidUrl
+    return host, port, scheme == "https"
+
+
+async def check_user_info(
+    hass: HomeAssistant, host: str, port: int, ssl: bool, verify_ssl: bool, api_key: str
+) -> ImmichUser:
+    """Test connection and fetch own user info."""
+    session = async_get_clientsession(hass, verify_ssl)
+    immich = Immich(session, api_key, host, port, ssl)
+    return await immich.users.async_get_my_user()
+
+
+class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Immich."""
+
+    VERSION = 1
+
+    _name: str
+    _current_data: Mapping[str, Any]
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, str] = {}
+        if user_input is not None:
+            try:
+                (host, port, ssl) = _parse_url(user_input[CONF_URL])
+            except InvalidUrl:
+                errors[CONF_URL] = "invalid_url"
+            else:
+                try:
+                    my_user_info = await check_user_info(
+                        self.hass,
+                        host,
+                        port,
+                        ssl,
+                        user_input[CONF_VERIFY_SSL],
+                        user_input[CONF_API_KEY],
+                    )
+                except ImmichUnauthorizedError:
+                    errors["base"] = "invalid_auth"
+                except CONNECT_ERRORS:
+                    errors["base"] = "cannot_connect"
+                except Exception:
+                    _LOGGER.exception("Unexpected exception")
+                    errors["base"] = "unknown"
+                else:
+                    await self.async_set_unique_id(my_user_info.user_id)
+                    self._abort_if_unique_id_configured()
+                    return self.async_create_entry(
+                        title=my_user_info.name,
+                        data={
+                            CONF_HOST: host,
+                            CONF_PORT: port,
+                            CONF_SSL: ssl,
+                            CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
+                            CONF_API_KEY: user_input[CONF_API_KEY],
+                        },
+                    )
+
+        return self.async_show_form(
+            step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+        )
+
+    async def async_step_reauth(
+        self, entry_data: Mapping[str, Any]
+    ) -> ConfigFlowResult:
+        """Trigger a reauthentication flow."""
+        self._current_data = entry_data
+        self._name = entry_data[CONF_HOST]
+
+        return await self.async_step_reauth_confirm()
+
+    async def async_step_reauth_confirm(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle reauthorization flow."""
+        errors = {}
+
+        if user_input is not None:
+            try:
+                my_user_info = await check_user_info(
+                    self.hass,
+                    self._current_data[CONF_HOST],
+                    self._current_data[CONF_PORT],
+                    self._current_data[CONF_SSL],
+                    self._current_data[CONF_VERIFY_SSL],
+                    user_input[CONF_API_KEY],
+                )
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                await self.async_set_unique_id(my_user_info.user_id)
+                self._abort_if_unique_id_mismatch()
+                return self.async_update_reload_and_abort(
+                    self._get_reauth_entry(), data_updates=user_input
+                )
+
+        return self.async_show_form(
+            step_id="reauth_confirm",
+            data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
+            description_placeholders={"name": self._name},
+            errors=errors,
+        )
diff --git a/homeassistant/components/immich/const.py b/homeassistant/components/immich/const.py
new file mode 100644
index 00000000000000..47180967a67465
--- /dev/null
+++ b/homeassistant/components/immich/const.py
@@ -0,0 +1,7 @@
+"""Constants for the Immich integration."""
+
+DOMAIN = "immich"
+
+DEFAULT_PORT = 2283
+DEFAULT_USE_SSL = False
+DEFAULT_VERIFY_SSL = False
diff --git a/homeassistant/components/immich/coordinator.py b/homeassistant/components/immich/coordinator.py
new file mode 100644
index 00000000000000..e1904a62e2423c
--- /dev/null
+++ b/homeassistant/components/immich/coordinator.py
@@ -0,0 +1,74 @@
+"""Coordinator for the Immich integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import (
+    ImmichServerAbout,
+    ImmichServerStatistics,
+    ImmichServerStorage,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class ImmichData:
+    """Data class for storing data from the API."""
+
+    server_about: ImmichServerAbout
+    server_storage: ImmichServerStorage
+    server_usage: ImmichServerStatistics | None
+
+
+type ImmichConfigEntry = ConfigEntry[ImmichDataUpdateCoordinator]
+
+
+class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]):
+    """Class to manage fetching IMGW-PIB data API."""
+
+    config_entry: ImmichConfigEntry
+
+    def __init__(
+        self, hass: HomeAssistant, entry: ConfigEntry, api: Immich, is_admin: bool
+    ) -> None:
+        """Initialize the data update coordinator."""
+        self.api = api
+        self.is_admin = is_admin
+        super().__init__(
+            hass,
+            _LOGGER,
+            config_entry=entry,
+            name=DOMAIN,
+            update_interval=timedelta(seconds=60),
+        )
+
+    async def _async_update_data(self) -> ImmichData:
+        """Update data via internal method."""
+        try:
+            server_about = await self.api.server.async_get_about_info()
+            server_storage = await self.api.server.async_get_storage_info()
+            server_usage = (
+                await self.api.server.async_get_server_statistics()
+                if self.is_admin
+                else None
+            )
+        except ImmichUnauthorizedError as err:
+            raise ConfigEntryAuthFailed from err
+        except CONNECT_ERRORS as err:
+            raise UpdateFailed from err
+
+        return ImmichData(server_about, server_storage, server_usage)
diff --git a/homeassistant/components/immich/entity.py b/homeassistant/components/immich/entity.py
new file mode 100644
index 00000000000000..f99f8872ce592c
--- /dev/null
+++ b/homeassistant/components/immich/entity.py
@@ -0,0 +1,27 @@
+"""Base entity for the Immich integration."""
+
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import ImmichDataUpdateCoordinator
+
+
+class ImmichEntity(CoordinatorEntity[ImmichDataUpdateCoordinator]):
+    """Define immich base entity."""
+
+    _attr_has_entity_name = True
+
+    def __init__(
+        self,
+        coordinator: ImmichDataUpdateCoordinator,
+    ) -> None:
+        """Initialize."""
+        super().__init__(coordinator)
+
+        self._attr_device_info = DeviceInfo(
+            identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
+            manufacturer="Immich",
+            sw_version=coordinator.data.server_about.version,
+            entry_type=DeviceEntryType.SERVICE,
+        )
diff --git a/homeassistant/components/immich/icons.json b/homeassistant/components/immich/icons.json
new file mode 100644
index 00000000000000..15bac6370a638a
--- /dev/null
+++ b/homeassistant/components/immich/icons.json
@@ -0,0 +1,15 @@
+{
+  "entity": {
+    "sensor": {
+      "disk_usage": {
+        "default": "mdi:database"
+      },
+      "photos_count": {
+        "default": "mdi:file-image"
+      },
+      "videos_count": {
+        "default": "mdi:file-video"
+      }
+    }
+  }
+}
diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json
new file mode 100644
index 00000000000000..bb8cbe720fdba0
--- /dev/null
+++ b/homeassistant/components/immich/manifest.json
@@ -0,0 +1,11 @@
+{
+  "domain": "immich",
+  "name": "Immich",
+  "codeowners": ["@mib1185"],
+  "config_flow": true,
+  "documentation": "https://www.home-assistant.io/integrations/immich",
+  "iot_class": "local_polling",
+  "loggers": ["aioimmich"],
+  "quality_scale": "silver",
+  "requirements": ["aioimmich==0.5.0"]
+}
diff --git a/homeassistant/components/immich/quality_scale.yaml b/homeassistant/components/immich/quality_scale.yaml
new file mode 100644
index 00000000000000..e89127871e2b07
--- /dev/null
+++ b/homeassistant/components/immich/quality_scale.yaml
@@ -0,0 +1,76 @@
+rules:
+  # Bronze
+  action-setup:
+    status: done
+    comment: No integration specific actions
+  appropriate-polling: done
+  brands: done
+  common-modules: done
+  config-flow-test-coverage: done
+  config-flow: done
+  dependency-transparency: done
+  docs-actions:
+    status: done
+    comment: No integration specific actions
+  docs-high-level-description: done
+  docs-installation-instructions: done
+  docs-removal-instructions: done
+  entity-event-setup: done
+  entity-unique-id: done
+  has-entity-name: done
+  runtime-data: done
+  test-before-configure: done
+  test-before-setup: done
+  unique-config-entry: done
+
+  # Silver
+  action-exceptions:
+    status: done
+    comment: No integration specific actions
+  config-entry-unloading: done
+  docs-configuration-parameters: done
+  docs-installation-parameters: done
+  entity-unavailable: done
+  integration-owner: done
+  log-when-unavailable: done
+  parallel-updates: done
+  reauthentication-flow: done
+  test-coverage: done
+
+  # Gold
+  devices: done
+  diagnostics: todo
+  discovery-update-info:
+    status: exempt
+    comment: Service can't be discovered
+  discovery:
+    status: exempt
+    comment: Service can't be discovered
+  docs-data-update: todo
+  docs-examples: todo
+  docs-known-limitations: todo
+  docs-supported-devices: todo
+  docs-supported-functions: todo
+  docs-troubleshooting: todo
+  docs-use-cases: todo
+  dynamic-devices:
+    status: exempt
+    comment: Only one device entry per config entry
+  entity-category: todo
+  entity-device-class: done
+  entity-disabled-by-default: done
+  entity-translations: done
+  exception-translations: todo
+  icon-translations: done
+  reconfiguration-flow: todo
+  repair-issues:
+    status: exempt
+    comment: No repair issues needed
+  stale-devices:
+    status: exempt
+    comment: Only one device entry per config entry
+
+  # Platinum
+  async-dependency: done
+  inject-websession: done
+  strict-typing: done
diff --git a/homeassistant/components/immich/sensor.py b/homeassistant/components/immich/sensor.py
new file mode 100644
index 00000000000000..f8eeed2935a8b3
--- /dev/null
+++ b/homeassistant/components/immich/sensor.py
@@ -0,0 +1,147 @@
+"""Sensor platform for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from homeassistant.components.sensor import (
+    SensorDeviceClass,
+    SensorEntity,
+    SensorEntityDescription,
+    SensorStateClass,
+)
+from homeassistant.const import PERCENTAGE, UnitOfInformation
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from .coordinator import ImmichConfigEntry, ImmichData, ImmichDataUpdateCoordinator
+from .entity import ImmichEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class ImmichSensorEntityDescription(SensorEntityDescription):
+    """Immich sensor entity description."""
+
+    value: Callable[[ImmichData], StateType]
+    is_suitable: Callable[[ImmichData], bool] = lambda _: True
+
+
+SENSOR_TYPES: tuple[ImmichSensorEntityDescription, ...] = (
+    ImmichSensorEntityDescription(
+        key="disk_size",
+        translation_key="disk_size",
+        native_unit_of_measurement=UnitOfInformation.BYTES,
+        suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+        suggested_display_precision=1,
+        device_class=SensorDeviceClass.DATA_SIZE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_storage.disk_size_raw,
+    ),
+    ImmichSensorEntityDescription(
+        key="disk_available",
+        translation_key="disk_available",
+        native_unit_of_measurement=UnitOfInformation.BYTES,
+        suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+        suggested_display_precision=1,
+        device_class=SensorDeviceClass.DATA_SIZE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_storage.disk_available_raw,
+    ),
+    ImmichSensorEntityDescription(
+        key="disk_use",
+        translation_key="disk_use",
+        native_unit_of_measurement=UnitOfInformation.BYTES,
+        suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+        suggested_display_precision=1,
+        device_class=SensorDeviceClass.DATA_SIZE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_storage.disk_use_raw,
+        entity_registry_enabled_default=False,
+    ),
+    ImmichSensorEntityDescription(
+        key="disk_usage",
+        translation_key="disk_usage",
+        native_unit_of_measurement=PERCENTAGE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_storage.disk_usage_percentage,
+        entity_registry_enabled_default=False,
+    ),
+    ImmichSensorEntityDescription(
+        key="photos_count",
+        translation_key="photos_count",
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_usage.photos if data.server_usage else None,
+        is_suitable=lambda data: data.server_usage is not None,
+    ),
+    ImmichSensorEntityDescription(
+        key="videos_count",
+        translation_key="videos_count",
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_usage.videos if data.server_usage else None,
+        is_suitable=lambda data: data.server_usage is not None,
+    ),
+    ImmichSensorEntityDescription(
+        key="usage_by_photos",
+        translation_key="usage_by_photos",
+        native_unit_of_measurement=UnitOfInformation.BYTES,
+        suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+        suggested_display_precision=1,
+        device_class=SensorDeviceClass.DATA_SIZE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda d: d.server_usage.usage_photos if d.server_usage else None,
+        is_suitable=lambda data: data.server_usage is not None,
+        entity_registry_enabled_default=False,
+    ),
+    ImmichSensorEntityDescription(
+        key="usage_by_videos",
+        translation_key="usage_by_videos",
+        native_unit_of_measurement=UnitOfInformation.BYTES,
+        suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+        suggested_display_precision=1,
+        device_class=SensorDeviceClass.DATA_SIZE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda d: d.server_usage.usage_videos if d.server_usage else None,
+        is_suitable=lambda data: data.server_usage is not None,
+        entity_registry_enabled_default=False,
+    ),
+)
+
+
+async def async_setup_entry(
+    hass: HomeAssistant,
+    entry: ImmichConfigEntry,
+    async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+    """Add immich server state sensors."""
+    coordinator = entry.runtime_data
+    async_add_entities(
+        ImmichSensorEntity(coordinator, description)
+        for description in SENSOR_TYPES
+        if description.is_suitable(coordinator.data)
+    )
+
+
+class ImmichSensorEntity(ImmichEntity, SensorEntity):
+    """Define Immich sensor entity."""
+
+    entity_description: ImmichSensorEntityDescription
+
+    def __init__(
+        self,
+        coordinator: ImmichDataUpdateCoordinator,
+        description: ImmichSensorEntityDescription,
+    ) -> None:
+        """Initialize."""
+        super().__init__(coordinator)
+        self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
+        self.entity_description = description
+
+    @property
+    def native_value(self) -> StateType:
+        """Return the value reported by the sensor."""
+        return self.entity_description.value(self.coordinator.data)
diff --git a/homeassistant/components/immich/strings.json b/homeassistant/components/immich/strings.json
new file mode 100644
index 00000000000000..875eb79f50b0d1
--- /dev/null
+++ b/homeassistant/components/immich/strings.json
@@ -0,0 +1,73 @@
+{
+  "common": {
+    "data_desc_url": "The full URL of your immich instance.",
+    "data_desc_api_key": "API key to connect to your immich instance.",
+    "data_desc_ssl_verify": "Whether to verify the SSL certificate when SSL encryption is used to connect to your immich instance."
+  },
+  "config": {
+    "step": {
+      "user": {
+        "data": {
+          "url": "[%key:common::config_flow::data::url%]",
+          "api_key": "[%key:common::config_flow::data::api_key%]",
+          "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+        },
+        "data_description": {
+          "url": "[%key:component::immich::common::data_desc_url%]",
+          "api_key": "[%key:component::immich::common::data_desc_api_key%]",
+          "verify_ssl": "[%key:component::immich::common::data_desc_ssl_verify%]"
+        }
+      },
+      "reauth_confirm": {
+        "description": "Update the API key for {name}.",
+        "data": {
+          "api_key": "[%key:common::config_flow::data::api_key%]"
+        },
+        "data_description": {
+          "api_key": "[%key:component::immich::common::data_desc_api_key%]"
+        }
+      }
+    },
+    "error": {
+      "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+      "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+      "invalid_url": "The provided URL is invalid.",
+      "unknown": "[%key:common::config_flow::error::unknown%]"
+    },
+    "abort": {
+      "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+      "unique_id_mismatch": "The provided API key does not match the configured user.",
+      "already_configured": "This user is already configured for this immich instance."
+    }
+  },
+  "entity": {
+    "sensor": {
+      "disk_size": {
+        "name": "Disk size"
+      },
+      "disk_available": {
+        "name": "Disk available"
+      },
+      "disk_use": {
+        "name": "Disk used"
+      },
+      "disk_usage": {
+        "name": "Disk usage"
+      },
+      "photos_count": {
+        "name": "Photos count",
+        "unit_of_measurement": "photos"
+      },
+      "videos_count": {
+        "name": "Videos count",
+        "unit_of_measurement": "videos"
+      },
+      "usage_by_photos": {
+        "name": "Disk used by photos"
+      },
+      "usage_by_videos": {
+        "name": "Disk used by videos"
+      }
+    }
+  }
+}
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index e4815c82543278..1b7536ed4b9045 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -288,6 +288,7 @@
         "imap",
         "imeon_inverter",
         "imgw_pib",
+        "immich",
         "improv_ble",
         "incomfort",
         "inkbird",
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index 85f9ae5e8a92e2..ccb670250915be 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -2959,6 +2959,12 @@
       "config_flow": true,
       "iot_class": "cloud_polling"
     },
+    "immich": {
+      "name": "Immich",
+      "integration_type": "hub",
+      "config_flow": true,
+      "iot_class": "local_polling"
+    },
     "improv_ble": {
       "name": "Improv via BLE",
       "integration_type": "device",
diff --git a/mypy.ini b/mypy.ini
index 518d1953fb36fa..cf3314f515ce9e 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -2456,6 +2456,16 @@ disallow_untyped_defs = true
 warn_return_any = true
 warn_unreachable = true
 
+[mypy-homeassistant.components.immich.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
 [mypy-homeassistant.components.incomfort.*]
 check_untyped_defs = true
 disallow_incomplete_defs = true
diff --git a/requirements_all.txt b/requirements_all.txt
index 593ce825b20e31..14b09291873dde 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -276,6 +276,9 @@ aiohue==4.7.4
 # homeassistant.components.imap
 aioimaplib==2.0.1
 
+# homeassistant.components.immich
+aioimmich==0.5.0
+
 # homeassistant.components.apache_kafka
 aiokafka==0.10.0
 
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 3858e46d78e1b4..b23e1b45419057 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -261,6 +261,9 @@ aiohue==4.7.4
 # homeassistant.components.imap
 aioimaplib==2.0.1
 
+# homeassistant.components.immich
+aioimmich==0.5.0
+
 # homeassistant.components.apache_kafka
 aiokafka==0.10.0
 
diff --git a/tests/components/immich/__init__.py b/tests/components/immich/__init__.py
new file mode 100644
index 00000000000000..604ab84d68d707
--- /dev/null
+++ b/tests/components/immich/__init__.py
@@ -0,0 +1,13 @@
+"""Tests for the Immich integration."""
+
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+
+
+async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
+    """Fixture for setting up the component."""
+    config_entry.add_to_hass(hass)
+
+    await hass.config_entries.async_setup(config_entry.entry_id)
+    await hass.async_block_till_done()
diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py
new file mode 100644
index 00000000000000..2c9483c39551c0
--- /dev/null
+++ b/tests/components/immich/conftest.py
@@ -0,0 +1,136 @@
+"""Common fixtures for the Immich tests."""
+
+from collections.abc import AsyncGenerator, Generator
+from datetime import datetime
+from unittest.mock import AsyncMock, patch
+
+from aioimmich import ImmichServer, ImmichUsers
+from aioimmich.server.models import (
+    ImmichServerAbout,
+    ImmichServerStatistics,
+    ImmichServerStorage,
+)
+from aioimmich.users.models import AvatarColor, ImmichUser, UserStatus
+import pytest
+
+from homeassistant.components.immich.const import DOMAIN
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_VERIFY_SSL,
+)
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+def mock_setup_entry() -> Generator[AsyncMock]:
+    """Override async_setup_entry."""
+    with patch(
+        "homeassistant.components.immich.async_setup_entry", return_value=True
+    ) as mock_setup_entry:
+        yield mock_setup_entry
+
+
+@pytest.fixture
+def mock_config_entry() -> MockConfigEntry:
+    """Mock a config entry."""
+    return MockConfigEntry(
+        domain=DOMAIN,
+        data={
+            CONF_HOST: "localhost",
+            CONF_PORT: 80,
+            CONF_SSL: False,
+            CONF_API_KEY: "api_key",
+            CONF_VERIFY_SSL: True,
+        },
+        unique_id="e7ef5713-9dab-4bd4-b899-715b0ca4379e",
+        title="Someone",
+    )
+
+
+@pytest.fixture
+def mock_immich_server() -> AsyncMock:
+    """Mock the Immich server."""
+    mock = AsyncMock(spec=ImmichServer)
+    mock.async_get_about_info.return_value = ImmichServerAbout(
+        "v1.132.3",
+        "some_url",
+        False,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+        None,
+    )
+    mock.async_get_storage_info.return_value = ImmichServerStorage(
+        "294.2 GiB",
+        "142.9 GiB",
+        "136.3 GiB",
+        315926315008,
+        153400434688,
+        146402975744,
+        48.56,
+    )
+    mock.async_get_server_statistics.return_value = ImmichServerStatistics(
+        27038, 1836, 119525451912, 54291170551, 65234281361
+    )
+    return mock
+
+
+@pytest.fixture
+def mock_immich_user() -> AsyncMock:
+    """Mock the Immich server."""
+    mock = AsyncMock(spec=ImmichUsers)
+    mock.async_get_my_user.return_value = ImmichUser(
+        "e7ef5713-9dab-4bd4-b899-715b0ca4379e",
+        "user@immich.local",
+        "user",
+        "",
+        AvatarColor.PRIMARY,
+        datetime.fromisoformat("2025-05-11T10:07:46.866Z"),
+        "user",
+        False,
+        True,
+        datetime.fromisoformat("2025-05-11T10:07:46.866Z"),
+        None,
+        None,
+        "",
+        None,
+        None,
+        UserStatus.ACTIVE,
+    )
+    return mock
+
+
+@pytest.fixture
+async def mock_immich(
+    mock_immich_server: AsyncMock, mock_immich_user: AsyncMock
+) -> AsyncGenerator[AsyncMock]:
+    """Mock the Immich API."""
+    with (
+        patch("homeassistant.components.immich.Immich", autospec=True) as mock_immich,
+        patch("homeassistant.components.immich.config_flow.Immich", new=mock_immich),
+    ):
+        client = mock_immich.return_value
+        client.server = mock_immich_server
+        client.users = mock_immich_user
+        yield client
+
+
+@pytest.fixture
+async def mock_non_admin_immich(mock_immich: AsyncMock) -> AsyncMock:
+    """Mock the Immich API."""
+    mock_immich.users.async_get_my_user.return_value.is_admin = False
+    return mock_immich
diff --git a/tests/components/immich/const.py b/tests/components/immich/const.py
new file mode 100644
index 00000000000000..2779a02be5596c
--- /dev/null
+++ b/tests/components/immich/const.py
@@ -0,0 +1,24 @@
+"""Constants for the Immich integration tests."""
+
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_URL,
+    CONF_VERIFY_SSL,
+)
+
+MOCK_USER_DATA = {
+    CONF_URL: "http://localhost",
+    CONF_API_KEY: "abcdef0123456789",
+    CONF_VERIFY_SSL: False,
+}
+
+MOCK_CONFIG_ENTRY_DATA = {
+    CONF_HOST: "localhost",
+    CONF_API_KEY: "abcdef0123456789",
+    CONF_PORT: 80,
+    CONF_SSL: False,
+    CONF_VERIFY_SSL: False,
+}
diff --git a/tests/components/immich/snapshots/test_sensor.ambr b/tests/components/immich/snapshots/test_sensor.ambr
new file mode 100644
index 00000000000000..7284f98f681e67
--- /dev/null
+++ b/tests/components/immich/snapshots/test_sensor.ambr
@@ -0,0 +1,444 @@
+# serializer version: 1
+# name: test_sensors[sensor.someone_disk_available-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'config_subentry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.someone_disk_available',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+      'sensor': dict({
+        'suggested_display_precision': 1,
+      }),
+      'sensor.private': dict({
+        'suggested_unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+      }),
+    }),
+    'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
+    'original_icon': None,
+    'original_name': 'Disk available',
+    'platform': 'immich',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': 'disk_available',
+    'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_disk_available',
+    'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+  })
+# ---
+# name: test_sensors[sensor.someone_disk_available-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'data_size',
+      'friendly_name': 'Someone Disk available',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.someone_disk_available',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '136.34839630127',
+  })
+# ---
+# name: test_sensors[sensor.someone_disk_size-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'config_subentry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.someone_disk_size',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+      'sensor': dict({
+        'suggested_display_precision': 1,
+      }),
+      'sensor.private': dict({
+        'suggested_unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+      }),
+    }),
+    'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
+    'original_icon': None,
+    'original_name': 'Disk size',
+    'platform': 'immich',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': 'disk_size',
+    'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_disk_size',
+    'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+  })
+# ---
+# name: test_sensors[sensor.someone_disk_size-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'data_size',
+      'friendly_name': 'Someone Disk size',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.someone_disk_size',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '294.229309082031',
+  })
+# ---
+# name: test_sensors[sensor.someone_disk_usage-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'config_subentry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.someone_disk_usage',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': None,
+    'original_icon': None,
+    'original_name': 'Disk usage',
+    'platform': 'immich',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': 'disk_usage',
+    'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_disk_usage',
+    'unit_of_measurement': '%',
+  })
+# ---
+# name: test_sensors[sensor.someone_disk_usage-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'friendly_name': 'Someone Disk usage',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': '%',
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.someone_disk_usage',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '48.56',
+  })
+# ---
+# name: test_sensors[sensor.someone_disk_used-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'config_subentry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.someone_disk_used',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+      'sensor': dict({
+        'suggested_display_precision': 1,
+      }),
+      'sensor.private': dict({
+        'suggested_unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+      }),
+    }),
+    'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
+    'original_icon': None,
+    'original_name': 'Disk used',
+    'platform': 'immich',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': 'disk_use',
+    'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_disk_use',
+    'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+  })
+# ---
+# name: test_sensors[sensor.someone_disk_used-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'data_size',
+      'friendly_name': 'Someone Disk used',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.someone_disk_used',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '142.865287780762',
+  })
+# ---
+# name: test_sensors[sensor.someone_disk_used_by_photos-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'config_subentry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.someone_disk_used_by_photos',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+      'sensor': dict({
+        'suggested_display_precision': 1,
+      }),
+      'sensor.private': dict({
+        'suggested_unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+      }),
+    }),
+    'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
+    'original_icon': None,
+    'original_name': 'Disk used by photos',
+    'platform': 'immich',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': 'usage_by_photos',
+    'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_usage_by_photos',
+    'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+  })
+# ---
+# name: test_sensors[sensor.someone_disk_used_by_photos-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'data_size',
+      'friendly_name': 'Someone Disk used by photos',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.someone_disk_used_by_photos',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '50.5625927364454',
+  })
+# ---
+# name: test_sensors[sensor.someone_disk_used_by_videos-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'config_subentry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.someone_disk_used_by_videos',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+      'sensor': dict({
+        'suggested_display_precision': 1,
+      }),
+      'sensor.private': dict({
+        'suggested_unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+      }),
+    }),
+    'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
+    'original_icon': None,
+    'original_name': 'Disk used by videos',
+    'platform': 'immich',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': 'usage_by_videos',
+    'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_usage_by_videos',
+    'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+  })
+# ---
+# name: test_sensors[sensor.someone_disk_used_by_videos-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'data_size',
+      'friendly_name': 'Someone Disk used by videos',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.someone_disk_used_by_videos',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '60.754158870317',
+  })
+# ---
+# name: test_sensors[sensor.someone_photos_count-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'config_subentry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.someone_photos_count',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': None,
+    'original_icon': None,
+    'original_name': 'Photos count',
+    'platform': 'immich',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': 'photos_count',
+    'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_photos_count',
+    'unit_of_measurement': 'photos',
+  })
+# ---
+# name: test_sensors[sensor.someone_photos_count-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'friendly_name': 'Someone Photos count',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': 'photos',
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.someone_photos_count',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '27038',
+  })
+# ---
+# name: test_sensors[sensor.someone_videos_count-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'config_subentry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.someone_videos_count',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+    }),
+    'original_device_class': None,
+    'original_icon': None,
+    'original_name': 'Videos count',
+    'platform': 'immich',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': 'videos_count',
+    'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_videos_count',
+    'unit_of_measurement': 'videos',
+  })
+# ---
+# name: test_sensors[sensor.someone_videos_count-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'friendly_name': 'Someone Videos count',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': 'videos',
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.someone_videos_count',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '1836',
+  })
+# ---
diff --git a/tests/components/immich/test_config_flow.py b/tests/components/immich/test_config_flow.py
new file mode 100644
index 00000000000000..e26cb4df5a1673
--- /dev/null
+++ b/tests/components/immich/test_config_flow.py
@@ -0,0 +1,244 @@
+"""Test the Immich config flow."""
+
+from unittest.mock import AsyncMock, Mock
+
+from aiohttp import ClientError
+from aioimmich.exceptions import ImmichUnauthorizedError
+import pytest
+
+from homeassistant.components.immich.const import DOMAIN
+from homeassistant.config_entries import SOURCE_USER
+from homeassistant.const import CONF_API_KEY, CONF_URL
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+
+from .const import MOCK_CONFIG_ENTRY_DATA, MOCK_USER_DATA
+
+from tests.common import MockConfigEntry
+
+
+async def test_step_user(
+    hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_immich: Mock
+) -> None:
+    """Test a user initiated config flow."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": SOURCE_USER}
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "user"
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        MOCK_USER_DATA,
+    )
+
+    assert result["type"] is FlowResultType.CREATE_ENTRY
+    assert result["title"] == "user"
+    assert result["data"] == MOCK_CONFIG_ENTRY_DATA
+    assert result["result"].unique_id == "e7ef5713-9dab-4bd4-b899-715b0ca4379e"
+    assert len(mock_setup_entry.mock_calls) == 1
+
+
+@pytest.mark.parametrize(
+    ("exception", "error"),
+    [
+        (
+            ImmichUnauthorizedError(
+                {
+                    "message": "Invalid API key",
+                    "error": "Unauthenticated",
+                    "statusCode": 401,
+                    "correlationId": "abcdefg",
+                }
+            ),
+            "invalid_auth",
+        ),
+        (ClientError, "cannot_connect"),
+        (Exception, "unknown"),
+    ],
+)
+async def test_step_user_error_handling(
+    hass: HomeAssistant,
+    mock_setup_entry: AsyncMock,
+    mock_immich: Mock,
+    exception: Exception,
+    error: str,
+) -> None:
+    """Test a user initiated config flow with errors."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": SOURCE_USER}
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "user"
+
+    mock_immich.users.async_get_my_user.side_effect = exception
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        MOCK_USER_DATA,
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "user"
+    assert result["errors"] == {"base": error}
+
+    mock_immich.users.async_get_my_user.side_effect = None
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        MOCK_USER_DATA,
+    )
+
+    assert result["type"] is FlowResultType.CREATE_ENTRY
+
+
+async def test_step_user_invalid_url(
+    hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_immich: Mock
+) -> None:
+    """Test a user initiated config flow with errors."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": SOURCE_USER}
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "user"
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        {**MOCK_USER_DATA, CONF_URL: "hts://invalid"},
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "user"
+    assert result["errors"] == {CONF_URL: "invalid_url"}
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        MOCK_USER_DATA,
+    )
+
+    assert result["type"] is FlowResultType.CREATE_ENTRY
+
+
+async def test_user_already_configured(
+    hass: HomeAssistant, mock_immich: Mock, mock_config_entry: MockConfigEntry
+) -> None:
+    """Test starting a flow by user when already configured."""
+    mock_config_entry.add_to_hass(hass)
+
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": SOURCE_USER}
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "user"
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        MOCK_USER_DATA,
+    )
+    assert result["type"] is FlowResultType.ABORT
+    assert result["reason"] == "already_configured"
+
+
+async def test_reauth_flow(
+    hass: HomeAssistant,
+    mock_setup_entry: AsyncMock,
+    mock_immich: Mock,
+    mock_config_entry: MockConfigEntry,
+) -> None:
+    """Test reauthentication flow."""
+    mock_config_entry.add_to_hass(hass)
+    result = await mock_config_entry.start_reauth_flow(hass)
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "reauth_confirm"
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        user_input={
+            CONF_API_KEY: "other_fake_api_key",
+        },
+    )
+
+    assert result["type"] is FlowResultType.ABORT
+    assert result["reason"] == "reauth_successful"
+    assert mock_config_entry.data[CONF_API_KEY] == "other_fake_api_key"
+
+
+@pytest.mark.parametrize(
+    ("exception", "error"),
+    [
+        (
+            ImmichUnauthorizedError(
+                {
+                    "message": "Invalid API key",
+                    "error": "Unauthenticated",
+                    "statusCode": 401,
+                    "correlationId": "abcdefg",
+                }
+            ),
+            "invalid_auth",
+        ),
+        (ClientError, "cannot_connect"),
+        (Exception, "unknown"),
+    ],
+)
+async def test_reauth_flow_error_handling(
+    hass: HomeAssistant,
+    mock_setup_entry: AsyncMock,
+    mock_immich: Mock,
+    mock_config_entry: MockConfigEntry,
+    exception: Exception,
+    error: str,
+) -> None:
+    """Test reauthentication flow with errors."""
+    mock_config_entry.add_to_hass(hass)
+    result = await mock_config_entry.start_reauth_flow(hass)
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "reauth_confirm"
+
+    mock_immich.users.async_get_my_user.side_effect = exception
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        user_input={
+            CONF_API_KEY: "other_fake_api_key",
+        },
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "reauth_confirm"
+    assert result["errors"] == {"base": error}
+
+    mock_immich.users.async_get_my_user.side_effect = None
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        user_input={
+            CONF_API_KEY: "other_fake_api_key",
+        },
+    )
+
+    assert result["type"] is FlowResultType.ABORT
+    assert result["reason"] == "reauth_successful"
+    assert mock_config_entry.data[CONF_API_KEY] == "other_fake_api_key"
+    assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_reauth_flow_mismatch(
+    hass: HomeAssistant,
+    mock_setup_entry: AsyncMock,
+    mock_immich: Mock,
+    mock_config_entry: MockConfigEntry,
+) -> None:
+    """Test reauthentication flow with mis-matching unique id."""
+    mock_config_entry.add_to_hass(hass)
+    result = await mock_config_entry.start_reauth_flow(hass)
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "reauth_confirm"
+
+    mock_immich.users.async_get_my_user.return_value.user_id = "other_user_id"
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        user_input={
+            CONF_API_KEY: "other_fake_api_key",
+        },
+    )
+
+    assert result["type"] is FlowResultType.ABORT
+    assert result["reason"] == "unique_id_mismatch"
diff --git a/tests/components/immich/test_sensor.py b/tests/components/immich/test_sensor.py
new file mode 100644
index 00000000000000..ceebba7b8be118
--- /dev/null
+++ b/tests/components/immich/test_sensor.py
@@ -0,0 +1,45 @@
+"""Test the Immich sensor platform."""
+
+from unittest.mock import Mock, patch
+
+import pytest
+from syrupy import SnapshotAssertion
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+
+from . import setup_integration
+
+from tests.common import MockConfigEntry, snapshot_platform
+
+
+@pytest.mark.usefixtures("entity_registry_enabled_by_default")
+async def test_sensors(
+    hass: HomeAssistant,
+    entity_registry: er.EntityRegistry,
+    snapshot: SnapshotAssertion,
+    mock_immich: Mock,
+    mock_config_entry: MockConfigEntry,
+) -> None:
+    """Test the Immich sensor platform."""
+
+    with patch("homeassistant.components.immich.PLATFORMS", [Platform.SENSOR]):
+        await setup_integration(hass, mock_config_entry)
+
+    await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+
+
+async def test_admin_sensors(
+    hass: HomeAssistant,
+    mock_non_admin_immich: Mock,
+    mock_config_entry: MockConfigEntry,
+) -> None:
+    """Test the integration doesn't create admin sensors if not admin."""
+
+    await setup_integration(hass, mock_config_entry)
+
+    assert hass.states.get("sensor.mock_title_photos_count") is None
+    assert hass.states.get("sensor.mock_title_videos_count") is None
+    assert hass.states.get("sensor.mock_title_disk_used_by_photos") is None
+    assert hass.states.get("sensor.mock_title_disk_used_by_videos") is None


Reviews:
user: login: joostlek
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-18T01:06:22Z
user: login: mib1185
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T09:58:56Z
user: login: mib1185
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T11:47:14Z
user: login: joostlek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:06:34Z
user: login: mib1185
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:12:15Z
user: login: joostlek
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-18T12:19:18Z
user: login: zweckj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:19:53Z
user: login: zweckj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:20:54Z
user: login: mib1185
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:22:45Z
user: login: mib1185
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:25:16Z
user: login: mib1185
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:27:19Z
user: login: mib1185
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:30:36Z
user: login: mib1185
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:33:44Z
user: login: mib1185
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:34:29Z
user: login: joostlek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:35:18Z
user: login: joostlek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:46:09Z
user: login: mib1185
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:48:32Z
user: login: joostlek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T12:57:34Z
user: login: mib1185
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T14:24:56Z
user: login: mib1185
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T18:04:08Z
user: login: joostlek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T18:05:06Z
user: login: joostlek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T18:05:32Z
user: login: joostlek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-18T18:06:14Z
user: login: joostlek
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-18T19:09:36Z

Review comments:
diff_hunk: @@ -0,0 +1,121 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DEFAULT_PORT, DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_HOST): str,
+        vol.Required(CONF_API_KEY): str,
+        vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+        vol.Required(CONF_SSL, default=DEFAULT_USE_SSL): bool,
path: homeassistant/components/immich/config_flow.py
user: login: joostlek
body: Instead of asking host, port and SSL, considered just asking the URL and splitting it out using YARL?

I do this in overseerr and it leaves a nice user experience
created_at: 2025-05-18T01:02:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DEFAULT_PORT, DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_HOST): str,
+        vol.Required(CONF_API_KEY): str,
+        vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+        vol.Required(CONF_SSL, default=DEFAULT_USE_SSL): bool,
+        vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+    }
+)
+
+
+async def check_server_info(
+    hass: HomeAssistant, data: dict[str, Any]
+) -> ImmichServerAbout:
+    """Try to read server info."""
+    session = async_get_clientsession(hass, data[CONF_VERIFY_SSL])
+    immich = Immich(
+        session, data[CONF_API_KEY], data[CONF_HOST], data[CONF_PORT], data[CONF_SSL]
+    )
+    return await immich.server.async_get_about_info()
+
+
+class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Immich."""
+
+    VERSION = 1
+
+    _name: str
+    _current_data: Mapping[str, Any]
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, str] = {}
+        if user_input is not None:
+            self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+            try:
+                await check_server_info(self.hass, user_input)
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_create_entry(
+                    title=user_input[CONF_HOST], data=user_input
+                )
+
+        return self.async_show_form(
+            step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+        )
+
+    async def async_step_reauth(
+        self, entry_data: Mapping[str, Any]
+    ) -> ConfigFlowResult:
+        """Trigger a reauthentication flow."""
+        self._current_data = entry_data
+        self._name = entry_data[CONF_HOST]
+
+        return await self.async_step_reauth_confirm()
+
+    async def async_step_reauth_confirm(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle reauthorization flow."""
+        errors = {}
+
+        if user_input is not None:
+            try:
+                await check_server_info(self.hass, {**self._current_data, **user_input})
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_update_reload_and_abort(
+                    self._get_reauth_entry(),
+                    data={**self._current_data, **user_input},
path: homeassistant/components/immich/config_flow.py
user: login: joostlek
body: Can't we use data_updates?
created_at: 2025-05-18T01:03:03Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,68 @@
+"""Coordinator for the Immich integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import (
+    ImmichServerAbout,
+    ImmichServerStatistics,
+    ImmichServerStorage,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class ImmichData:
+    """Data class for storing data from the API."""
+
+    server_about: ImmichServerAbout
+    server_storage: ImmichServerStorage
+    server_usage: ImmichServerStatistics
+
+
+type ImmichConfigEntry = ConfigEntry[ImmichDataUpdateCoordinator]
+
+
+class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]):
+    """Class to manage fetching IMGW-PIB data API."""
+
+    config_entry: ImmichConfigEntry
+
+    def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: Immich) -> None:
+        """Initialize the data update coordinator."""
+        self.api = api
+        self.config_entry = entry
path: homeassistant/components/immich/coordinator.py
user: login: joostlek
body: Is already stored there
created_at: 2025-05-18T01:03:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,68 @@
+"""Coordinator for the Immich integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import (
+    ImmichServerAbout,
+    ImmichServerStatistics,
+    ImmichServerStorage,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class ImmichData:
+    """Data class for storing data from the API."""
+
+    server_about: ImmichServerAbout
+    server_storage: ImmichServerStorage
+    server_usage: ImmichServerStatistics
+
+
+type ImmichConfigEntry = ConfigEntry[ImmichDataUpdateCoordinator]
+
+
+class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]):
+    """Class to manage fetching IMGW-PIB data API."""
+
+    config_entry: ImmichConfigEntry
+
+    def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: Immich) -> None:
+        """Initialize the data update coordinator."""
+        self.api = api
+        self.config_entry = entry
+
+        super().__init__(
+            hass,
+            _LOGGER,
+            name=DOMAIN,
+            update_interval=timedelta(seconds=60),
+        )
path: homeassistant/components/immich/coordinator.py
user: login: joostlek
body: We should pass the entry to the coordinator
created_at: 2025-05-18T01:03:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,28 @@
+"""Base entity for the Immich integration."""
+
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import ImmichDataUpdateCoordinator
+
+
+class ImmichEntity(CoordinatorEntity[ImmichDataUpdateCoordinator]):
+    """Define immich base entity."""
+
+    _attr_has_entity_name = True
+
+    def __init__(
+        self,
+        coordinator: ImmichDataUpdateCoordinator,
+    ) -> None:
+        """Initialize."""
+        super().__init__(coordinator)
+
+        self._attr_device_info = DeviceInfo(
+            identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
+            name=coordinator.config_entry.title,
path: homeassistant/components/immich/entity.py
user: login: joostlek
body: Name can be omitted as this is the default behaviour
created_at: 2025-05-18T01:04:21Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,140 @@
+"""Sensor platform for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from homeassistant.components.sensor import (
+    SensorDeviceClass,
+    SensorEntity,
+    SensorEntityDescription,
+    SensorStateClass,
+)
+from homeassistant.const import PERCENTAGE, UnitOfInformation
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from .coordinator import ImmichConfigEntry, ImmichData, ImmichDataUpdateCoordinator
+from .entity import ImmichEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class ImmichSensorEntityDescription(SensorEntityDescription):
+    """IMGW-PIB sensor entity description."""
path: homeassistant/components/immich/sensor.py
user: login: joostlek
body: Stale docstring
created_at: 2025-05-18T01:04:49Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,140 @@
+"""Sensor platform for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from homeassistant.components.sensor import (
+    SensorDeviceClass,
+    SensorEntity,
+    SensorEntityDescription,
+    SensorStateClass,
+)
+from homeassistant.const import PERCENTAGE, UnitOfInformation
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from .coordinator import ImmichConfigEntry, ImmichData, ImmichDataUpdateCoordinator
+from .entity import ImmichEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class ImmichSensorEntityDescription(SensorEntityDescription):
+    """IMGW-PIB sensor entity description."""
+
+    value: Callable[[ImmichData], StateType]
+
+
+SENSOR_TYPES: tuple[ImmichSensorEntityDescription, ...] = (
+    ImmichSensorEntityDescription(
+        key="disk_size",
+        translation_key="disk_size",
+        native_unit_of_measurement=UnitOfInformation.BYTES,
+        suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+        suggested_display_precision=1,
+        device_class=SensorDeviceClass.DATA_SIZE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_storage.disk_size_raw,
+    ),
+    ImmichSensorEntityDescription(
+        key="disk_available",
+        translation_key="disk_available",
+        native_unit_of_measurement=UnitOfInformation.BYTES,
+        suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+        suggested_display_precision=1,
+        device_class=SensorDeviceClass.DATA_SIZE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_storage.disk_available_raw,
+    ),
+    ImmichSensorEntityDescription(
+        key="disk_use",
+        translation_key="disk_use",
+        native_unit_of_measurement=UnitOfInformation.BYTES,
+        suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+        suggested_display_precision=1,
+        device_class=SensorDeviceClass.DATA_SIZE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_storage.disk_use_raw,
+        entity_registry_enabled_default=False,
+    ),
+    ImmichSensorEntityDescription(
+        key="disk_usage",
+        translation_key="disk_usage",
+        native_unit_of_measurement=PERCENTAGE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_storage.disk_usage_percentage,
+        entity_registry_enabled_default=False,
+    ),
+    ImmichSensorEntityDescription(
+        key="photos_count",
+        translation_key="photos_count",
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_usage.photos,
+    ),
+    ImmichSensorEntityDescription(
+        key="videos_count",
+        translation_key="videos_count",
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_usage.videos,
+    ),
+    ImmichSensorEntityDescription(
+        key="usage_by_photos",
+        translation_key="usage_by_photos",
+        native_unit_of_measurement=UnitOfInformation.BYTES,
+        suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+        suggested_display_precision=1,
+        device_class=SensorDeviceClass.DATA_SIZE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_usage.usage_photos,
+        entity_registry_enabled_default=False,
+    ),
+    ImmichSensorEntityDescription(
+        key="usage_by_videos",
+        translation_key="usage_by_videos",
+        native_unit_of_measurement=UnitOfInformation.BYTES,
+        suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+        suggested_display_precision=1,
+        device_class=SensorDeviceClass.DATA_SIZE,
+        state_class=SensorStateClass.MEASUREMENT,
+        value=lambda data: data.server_usage.usage_videos,
+        entity_registry_enabled_default=False,
+    ),
+)
+
+
+async def async_setup_entry(
+    hass: HomeAssistant,
+    entry: ImmichConfigEntry,
+    async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+    """Add immich server state sensors."""
+    coordinator = entry.runtime_data
+    async_add_entities(
+        ImmichSensorEntity(coordinator, description) for description in SENSOR_TYPES
+    )
+
+
+class ImmichSensorEntity(ImmichEntity, SensorEntity):
+    """Define IMGW-PIB sensor entity."""
path: homeassistant/components/immich/sensor.py
user: login: joostlek
body: Stale docstring
created_at: 2025-05-18T01:05:43Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,75 @@
+{
+  "common": {
+    "data_desc_host": "Hostname or IP address of your immich instance.",
+    "data_desc_api_key": "API key to connect to your immich instance.",
+    "data_desc_port": "Port number of your immich instance (default 2283).",
+    "data_desc_ssl": "Whether to use SSL encryption to connect to your immich instance.",
+    "data_desc_ssl_verify": "Whether to verify the SSL certificate when SSL encryption is used to connect to your immich instance."
+  },
+  "config": {
+    "step": {
+      "user": {
+        "data": {
+          "host": "[%key:common::config_flow::data::host%]",
+          "api_key": "[%key:common::config_flow::data::api_key%]",
+          "port": "[%key:common::config_flow::data::port%]",
+          "ssl": "[%key:common::config_flow::data::ssl%]",
+          "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+        },
+        "data_description": {
+          "host": "[%key:component::immich::common::data_desc_host%]",
+          "api_key": "[%key:component::immich::common::data_desc_api_key%]",
+          "port": "[%key:component::immich::common::data_desc_port%]",
+          "ssl": "[%key:component::immich::common::data_desc_ssl%]",
+          "verify_ssl": "[%key:component::immich::common::data_desc_ssl_verify%]"
+        }
+      },
+      "reauth_confirm": {
+        "description": "Update the API key for {name}.",
+        "data": {
+          "api_key": "[%key:common::config_flow::data::api_key%]"
+        },
+        "data_description": {
+          "api_key": "[%key:component::immich::common::data_desc_api_key%]"
+        }
+      }
+    },
+    "error": {
+      "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+      "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+      "unknown": "[%key:common::config_flow::error::unknown%]"
+    },
+    "abort": {
+      "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+      "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+    }
+  },
+  "entity": {
+    "sensor": {
+      "disk_size": {
+        "name": "Disk size"
+      },
+      "disk_available": {
+        "name": "Disk available"
+      },
+      "disk_use": {
+        "name": "Disk used"
+      },
+      "disk_usage": {
+        "name": "Disk usage"
+      },
+      "photos_count": {
+        "name": "Photos count"
+      },
+      "videos_count": {
+        "name": "Videos count"
+      },
path: homeassistant/components/immich/strings.json
user: login: joostlek
body: Add a translated unit of measurement
created_at: 2025-05-18T01:06:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DEFAULT_PORT, DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_HOST): str,
+        vol.Required(CONF_API_KEY): str,
+        vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+        vol.Required(CONF_SSL, default=DEFAULT_USE_SSL): bool,
path: homeassistant/components/immich/config_flow.py
user: login: mib1185
body: nice ... I like that idea :+1: 
created_at: 2025-05-18T09:58:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DEFAULT_PORT, DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_HOST): str,
+        vol.Required(CONF_API_KEY): str,
+        vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+        vol.Required(CONF_SSL, default=DEFAULT_USE_SSL): bool,
+        vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+    }
+)
+
+
+async def check_server_info(
+    hass: HomeAssistant, data: dict[str, Any]
+) -> ImmichServerAbout:
+    """Try to read server info."""
+    session = async_get_clientsession(hass, data[CONF_VERIFY_SSL])
+    immich = Immich(
+        session, data[CONF_API_KEY], data[CONF_HOST], data[CONF_PORT], data[CONF_SSL]
+    )
+    return await immich.server.async_get_about_info()
+
+
+class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Immich."""
+
+    VERSION = 1
+
+    _name: str
+    _current_data: Mapping[str, Any]
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, str] = {}
+        if user_input is not None:
+            self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+            try:
+                await check_server_info(self.hass, user_input)
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_create_entry(
+                    title=user_input[CONF_HOST], data=user_input
+                )
+
+        return self.async_show_form(
+            step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+        )
+
+    async def async_step_reauth(
+        self, entry_data: Mapping[str, Any]
+    ) -> ConfigFlowResult:
+        """Trigger a reauthentication flow."""
+        self._current_data = entry_data
+        self._name = entry_data[CONF_HOST]
+
+        return await self.async_step_reauth_confirm()
+
+    async def async_step_reauth_confirm(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle reauthorization flow."""
+        errors = {}
+
+        if user_input is not None:
+            try:
+                await check_server_info(self.hass, {**self._current_data, **user_input})
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_update_reload_and_abort(
+                    self._get_reauth_entry(),
+                    data={**self._current_data, **user_input},
path: homeassistant/components/immich/config_flow.py
user: login: mib1185
body: I'm not sure what exactly you mean :see_no_evil: 
created_at: 2025-05-18T11:47:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DEFAULT_PORT, DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_HOST): str,
+        vol.Required(CONF_API_KEY): str,
+        vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+        vol.Required(CONF_SSL, default=DEFAULT_USE_SSL): bool,
+        vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+    }
+)
+
+
+async def check_server_info(
+    hass: HomeAssistant, data: dict[str, Any]
+) -> ImmichServerAbout:
+    """Try to read server info."""
+    session = async_get_clientsession(hass, data[CONF_VERIFY_SSL])
+    immich = Immich(
+        session, data[CONF_API_KEY], data[CONF_HOST], data[CONF_PORT], data[CONF_SSL]
+    )
+    return await immich.server.async_get_about_info()
+
+
+class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Immich."""
+
+    VERSION = 1
+
+    _name: str
+    _current_data: Mapping[str, Any]
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, str] = {}
+        if user_input is not None:
+            self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+            try:
+                await check_server_info(self.hass, user_input)
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_create_entry(
+                    title=user_input[CONF_HOST], data=user_input
+                )
+
+        return self.async_show_form(
+            step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+        )
+
+    async def async_step_reauth(
+        self, entry_data: Mapping[str, Any]
+    ) -> ConfigFlowResult:
+        """Trigger a reauthentication flow."""
+        self._current_data = entry_data
+        self._name = entry_data[CONF_HOST]
+
+        return await self.async_step_reauth_confirm()
+
+    async def async_step_reauth_confirm(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle reauthorization flow."""
+        errors = {}
+
+        if user_input is not None:
+            try:
+                await check_server_info(self.hass, {**self._current_data, **user_input})
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_update_reload_and_abort(
+                    self._get_reauth_entry(),
+                    data={**self._current_data, **user_input},
path: homeassistant/components/immich/config_flow.py
user: login: joostlek
body: Data updates just updates the fields you pass to it
created_at: 2025-05-18T12:06:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,175 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+from yarl import URL
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_URL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+    TextSelector,
+    TextSelectorConfig,
+    TextSelectorType,
+)
+
+from .const import DEFAULT_VERIFY_SSL, DOMAIN
+
+
+class InvalidUrl(HomeAssistantError):
+    """Error to indicate invalid URL."""
+
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_URL): TextSelector(
+            config=TextSelectorConfig(type=TextSelectorType.URL)
+        ),
+        vol.Required(CONF_API_KEY): TextSelector(
+            config=TextSelectorConfig(type=TextSelectorType.PASSWORD)
+        ),
+        vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+    }
+)
+
+
+def _parse_url(url: str) -> tuple[str, int, bool]:
+    """Parse the URL and return host, port, and ssl."""
+    parsed_url = URL(url)
+    if (
+        (host := parsed_url.host) is None
+        or (port := parsed_url.port) is None
+        or (scheme := parsed_url.scheme) is None
+    ):
+        raise InvalidUrl
+    return host, port, scheme == "https"
+
+
+async def check_server_info(
+    hass: HomeAssistant, host: str, port: int, ssl: bool, verify_ssl: bool, api_key: str
+) -> ImmichServerAbout:
+    """Try to read server info."""
+    session = async_get_clientsession(hass, verify_ssl)
+    immich = Immich(session, api_key, host, port, ssl)
+    return await immich.server.async_get_about_info()
+
+
+class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Immich."""
+
+    VERSION = 1
+
+    _name: str
+    _current_data: Mapping[str, Any]
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, str] = {}
+        if user_input is not None:
+            try:
+                (host, port, ssl) = _parse_url(user_input[CONF_URL])
+            except InvalidUrl:
+                errors[CONF_URL] = "invalid_url"
+            else:
+                self._async_abort_entries_match(
+                    {CONF_HOST: host, CONF_PORT: port, CONF_SSL: ssl}
path: homeassistant/components/immich/config_flow.py
user: login: joostlek
body: Hmm, should we just still match on host?
created_at: 2025-05-18T12:06:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,175 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+from yarl import URL
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_URL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+    TextSelector,
+    TextSelectorConfig,
+    TextSelectorType,
+)
+
+from .const import DEFAULT_VERIFY_SSL, DOMAIN
+
+
+class InvalidUrl(HomeAssistantError):
+    """Error to indicate invalid URL."""
+
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_URL): TextSelector(
+            config=TextSelectorConfig(type=TextSelectorType.URL)
+        ),
+        vol.Required(CONF_API_KEY): TextSelector(
+            config=TextSelectorConfig(type=TextSelectorType.PASSWORD)
+        ),
+        vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+    }
+)
+
+
+def _parse_url(url: str) -> tuple[str, int, bool]:
+    """Parse the URL and return host, port, and ssl."""
+    parsed_url = URL(url)
+    if (
+        (host := parsed_url.host) is None
+        or (port := parsed_url.port) is None
+        or (scheme := parsed_url.scheme) is None
+    ):
+        raise InvalidUrl
+    return host, port, scheme == "https"
+
+
+async def check_server_info(
+    hass: HomeAssistant, host: str, port: int, ssl: bool, verify_ssl: bool, api_key: str
+) -> ImmichServerAbout:
+    """Try to read server info."""
+    session = async_get_clientsession(hass, verify_ssl)
+    immich = Immich(session, api_key, host, port, ssl)
+    return await immich.server.async_get_about_info()
+
+
+class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Immich."""
+
+    VERSION = 1
+
+    _name: str
+    _current_data: Mapping[str, Any]
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, str] = {}
+        if user_input is not None:
+            try:
+                (host, port, ssl) = _parse_url(user_input[CONF_URL])
+            except InvalidUrl:
+                errors[CONF_URL] = "invalid_url"
+            else:
+                self._async_abort_entries_match(
+                    {CONF_HOST: host, CONF_PORT: port, CONF_SSL: ssl}
+                )
+                try:
+                    await check_server_info(
+                        self.hass,
+                        host,
+                        port,
+                        ssl,
+                        user_input[CONF_VERIFY_SSL],
+                        user_input[CONF_API_KEY],
+                    )
+                except ImmichUnauthorizedError:
+                    errors["base"] = "invalid_auth"
+                except CONNECT_ERRORS:
+                    errors["base"] = "cannot_connect"
+                except Exception:
+                    _LOGGER.exception("Unexpected exception")
+                    errors["base"] = "unknown"
+                else:
+                    (host, port, ssl) = _parse_url(user_input[CONF_URL])
path: homeassistant/components/immich/config_flow.py
user: login: joostlek
body: Why do we do this twice?
created_at: 2025-05-18T12:07:30Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+rules:
+  # Bronze
+  action-setup:
+    status: done
+    comment: "No integration specific actions"
+  appropriate-polling: done
+  brands: done
+  common-modules: done
+  config-flow-test-coverage: done
+  config-flow: done
+  dependency-transparency: done
+  docs-actions:
+    status: done
+    comment: "No integration specific actions"
path: homeassistant/components/immich/quality_scale.yaml
user: login: joostlek
body: Why in quotes?
created_at: 2025-05-18T12:08:13Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+rules:
+  # Bronze
+  action-setup:
+    status: done
+    comment: "No integration specific actions"
+  appropriate-polling: done
+  brands: done
+  common-modules: done
+  config-flow-test-coverage: done
+  config-flow: done
+  dependency-transparency: done
+  docs-actions:
+    status: done
+    comment: "No integration specific actions"
+  docs-high-level-description: done
+  docs-installation-instructions: done
+  docs-removal-instructions: done
+  entity-event-setup: done
+  entity-unique-id: done
+  has-entity-name: done
+  runtime-data: done
+  test-before-configure: done
+  test-before-setup: done
+  unique-config-entry: done
+
+  # Silver
+  action-exceptions:
+    status: done
+    comment: "No integration specific actions"
+  config-entry-unloading: done
+  docs-configuration-parameters: done
+  docs-installation-parameters: done
+  entity-unavailable: done
+  integration-owner: done
+  log-when-unavailable: done
+  parallel-updates: done
+  reauthentication-flow: done
+  test-coverage: done
+
+  # Gold
+  devices: done
+  diagnostics: todo
+  discovery-update-info:
+    status: exempt
+    comment: "Service can't be discovered"
+  discovery:
+    status: exempt
+    comment: "Service can't be discovered"
path: homeassistant/components/immich/quality_scale.yaml
user: login: joostlek
body: There's a custom addon we can use for discovery
created_at: 2025-05-18T12:08:35Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DEFAULT_PORT, DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_HOST): str,
+        vol.Required(CONF_API_KEY): str,
+        vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+        vol.Required(CONF_SSL, default=DEFAULT_USE_SSL): bool,
+        vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+    }
+)
+
+
+async def check_server_info(
+    hass: HomeAssistant, data: dict[str, Any]
+) -> ImmichServerAbout:
+    """Try to read server info."""
+    session = async_get_clientsession(hass, data[CONF_VERIFY_SSL])
+    immich = Immich(
+        session, data[CONF_API_KEY], data[CONF_HOST], data[CONF_PORT], data[CONF_SSL]
+    )
+    return await immich.server.async_get_about_info()
+
+
+class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Immich."""
+
+    VERSION = 1
+
+    _name: str
+    _current_data: Mapping[str, Any]
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, str] = {}
+        if user_input is not None:
+            self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+            try:
+                await check_server_info(self.hass, user_input)
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_create_entry(
+                    title=user_input[CONF_HOST], data=user_input
+                )
+
+        return self.async_show_form(
+            step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+        )
+
+    async def async_step_reauth(
+        self, entry_data: Mapping[str, Any]
+    ) -> ConfigFlowResult:
+        """Trigger a reauthentication flow."""
+        self._current_data = entry_data
+        self._name = entry_data[CONF_HOST]
+
+        return await self.async_step_reauth_confirm()
+
+    async def async_step_reauth_confirm(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle reauthorization flow."""
+        errors = {}
+
+        if user_input is not None:
+            try:
+                await check_server_info(self.hass, {**self._current_data, **user_input})
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_update_reload_and_abort(
+                    self._get_reauth_entry(),
+                    data={**self._current_data, **user_input},
path: homeassistant/components/immich/config_flow.py
user: login: mib1185
body: I don't know what you mean with "Data updates" - is it a helper function?
created_at: 2025-05-18T12:12:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+{
+  "common": {
+    "data_desc_url": "The URL of your immich instance. (_e.g. `https://immich.example.com`_)",
path: homeassistant/components/immich/strings.json
user: login: joostlek
body: Either remove the example or use placeholders 
created_at: 2025-05-18T12:16:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+"""Common fixtures for the Immich tests."""
+
+from collections.abc import Generator
+from unittest.mock import AsyncMock, Mock, patch
+
+from aioimmich.server.models import (
+    ImmichServerAbout,
+    ImmichServerStatistics,
+    ImmichServerStorage,
+)
+import pytest
+
+
+@pytest.fixture
+def mock_setup_entry() -> Generator[AsyncMock]:
+    """Override async_setup_entry."""
+    with patch(
+        "homeassistant.components.immich.async_setup_entry", return_value=True
+    ) as mock_setup_entry:
+        yield mock_setup_entry
+
+
+@pytest.fixture
+def mock_immich_data():
+    """Mock the immich data."""
+    return {
+        "server_about": ImmichServerAbout(
+            "v1.132.3",
+            "some_url",
+            False,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+        ),
+        "server_storage": ImmichServerStorage(
+            "294.2 GiB",
+            "142.9 GiB",
+            "136.3 GiB",
+            315926315008,
+            153400434688,
+            146402975744,
+            48.56,
+        ),
+        "server_usage": ImmichServerStatistics(
+            27038, 1836, 119525451912, 54291170551, 65234281361
+        ),
+    }
+
+
+@pytest.fixture
+def mock_immich(mock_immich_data) -> Mock:
+    """Mock the Immich API."""
+    return Mock(
+        server=Mock(
+            async_get_about_info=AsyncMock(
+                return_value=mock_immich_data["server_about"]
+            ),
+            async_get_storage_info=AsyncMock(
+                return_value=mock_immich_data["server_storage"]
+            ),
+            async_get_server_statistics=AsyncMock(
+                return_value=mock_immich_data["server_usage"]
+            ),
+        )
+    )
path: tests/components/immich/conftest.py
user: login: joostlek
body: It would be nice if we can directly patch out the Immich class so we don't have to do any inline patches
created_at: 2025-05-18T12:17:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,444 @@
+# serializer version: 1
+# name: test_sensors[sensor.mock_title_disk_available-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'config_subentry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.mock_title_disk_available',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+      'sensor': dict({
+        'suggested_display_precision': 1,
+      }),
+      'sensor.private': dict({
+        'suggested_unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+      }),
+    }),
+    'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
+    'original_icon': None,
+    'original_name': 'Disk available',
+    'platform': 'immich',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': 'disk_available',
+    'unique_id': 'abcdef0123456789_disk_available',
+    'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+  })
+# ---
+# name: test_sensors[sensor.mock_title_disk_available-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'data_size',
+      'friendly_name': 'Mock Title Disk available',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.mock_title_disk_available',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '136.34839630127',
+  })
+# ---
+# name: test_sensors[sensor.mock_title_disk_size-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'config_subentry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.mock_title_disk_size',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+      'sensor': dict({
+        'suggested_display_precision': 1,
+      }),
+      'sensor.private': dict({
+        'suggested_unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+      }),
+    }),
+    'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
+    'original_icon': None,
+    'original_name': 'Disk size',
+    'platform': 'immich',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': 'disk_size',
+    'unique_id': 'abcdef0123456789_disk_size',
+    'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+  })
+# ---
+# name: test_sensors[sensor.mock_title_disk_size-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'data_size',
+      'friendly_name': 'Mock Title Disk size',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.mock_title_disk_size',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '294.229309082031',
path: tests/components/immich/snapshots/test_sensor.ambr
user: login: joostlek
body: Should we add a suggested precision?
created_at: 2025-05-18T12:18:05Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,192 @@
+"""Test the Immich config flow."""
+
+from unittest.mock import AsyncMock, Mock, patch
+
+from aiohttp import ClientError
+from aioimmich.exceptions import ImmichUnauthorizedError
+
+from homeassistant import config_entries
+from homeassistant.components.immich.const import DOMAIN
+from homeassistant.const import CONF_API_KEY, CONF_URL
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+
+from .const import MOCK_CONFIG_ENTRY_DATA, MOCK_USER_DATA
+
+from tests.common import MockConfigEntry
+
+MOCK_CONFIG_FLOW_SIDE_EFFECTS = [
+    ImmichUnauthorizedError(
+        {
+            "message": "Invalid API key",
+            "error": "Unauthenticated",
+            "statusCode": 401,
+            "correlationId": "abcdefg",
+        }
+    ),
+    ClientError,
+    Exception("Unknown error"),
+    None,
+]
+
+
+async def test_step_user(
+    hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_immich: Mock
+) -> None:
+    """Test a user initiated config flow."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": config_entries.SOURCE_USER}
path: tests/components/immich/test_config_flow.py
user: login: joostlek
body: ```suggestion
        DOMAIN, context={"source": SOURCE_USER}
```

created_at: 2025-05-18T12:18:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,192 @@
+"""Test the Immich config flow."""
+
+from unittest.mock import AsyncMock, Mock, patch
+
+from aiohttp import ClientError
+from aioimmich.exceptions import ImmichUnauthorizedError
+
+from homeassistant import config_entries
+from homeassistant.components.immich.const import DOMAIN
+from homeassistant.const import CONF_API_KEY, CONF_URL
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+
+from .const import MOCK_CONFIG_ENTRY_DATA, MOCK_USER_DATA
+
+from tests.common import MockConfigEntry
+
+MOCK_CONFIG_FLOW_SIDE_EFFECTS = [
+    ImmichUnauthorizedError(
+        {
+            "message": "Invalid API key",
+            "error": "Unauthenticated",
+            "statusCode": 401,
+            "correlationId": "abcdefg",
+        }
+    ),
+    ClientError,
+    Exception("Unknown error"),
+    None,
+]
+
+
+async def test_step_user(
+    hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_immich: Mock
+) -> None:
+    """Test a user initiated config flow."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": config_entries.SOURCE_USER}
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "user"
+
+    with (
+        patch(
+            "homeassistant.components.immich.config_flow.Immich",
+            return_value=mock_immich,
+        ),
+    ):
+        mock_immich.server.async_get_about_info.side_effect = (
+            MOCK_CONFIG_FLOW_SIDE_EFFECTS
+        )
+
+        # Test invalid auth
path: tests/components/immich/test_config_flow.py
user: login: joostlek
body: Let's split the sad flows from the happy flow tests
created_at: 2025-05-18T12:18:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,192 @@
+"""Test the Immich config flow."""
+
+from unittest.mock import AsyncMock, Mock, patch
+
+from aiohttp import ClientError
+from aioimmich.exceptions import ImmichUnauthorizedError
+
+from homeassistant import config_entries
+from homeassistant.components.immich.const import DOMAIN
+from homeassistant.const import CONF_API_KEY, CONF_URL
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+
+from .const import MOCK_CONFIG_ENTRY_DATA, MOCK_USER_DATA
+
+from tests.common import MockConfigEntry
+
+MOCK_CONFIG_FLOW_SIDE_EFFECTS = [
+    ImmichUnauthorizedError(
+        {
+            "message": "Invalid API key",
+            "error": "Unauthenticated",
+            "statusCode": 401,
+            "correlationId": "abcdefg",
+        }
+    ),
+    ClientError,
+    Exception("Unknown error"),
+    None,
+]
+
+
+async def test_step_user(
+    hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_immich: Mock
+) -> None:
+    """Test a user initiated config flow."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": config_entries.SOURCE_USER}
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "user"
+
+    with (
+        patch(
+            "homeassistant.components.immich.config_flow.Immich",
+            return_value=mock_immich,
+        ),
+    ):
+        mock_immich.server.async_get_about_info.side_effect = (
+            MOCK_CONFIG_FLOW_SIDE_EFFECTS
+        )
+
+        # Test invalid auth
+        result = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            MOCK_USER_DATA,
+        )
+        await hass.async_block_till_done()
path: tests/components/immich/test_config_flow.py
user: login: joostlek
body: ```suggestion
```

created_at: 2025-05-18T12:18:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DEFAULT_PORT, DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_HOST): str,
+        vol.Required(CONF_API_KEY): str,
+        vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+        vol.Required(CONF_SSL, default=DEFAULT_USE_SSL): bool,
+        vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+    }
+)
+
+
+async def check_server_info(
+    hass: HomeAssistant, data: dict[str, Any]
+) -> ImmichServerAbout:
+    """Try to read server info."""
+    session = async_get_clientsession(hass, data[CONF_VERIFY_SSL])
+    immich = Immich(
+        session, data[CONF_API_KEY], data[CONF_HOST], data[CONF_PORT], data[CONF_SSL]
+    )
+    return await immich.server.async_get_about_info()
+
+
+class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Immich."""
+
+    VERSION = 1
+
+    _name: str
+    _current_data: Mapping[str, Any]
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, str] = {}
+        if user_input is not None:
+            self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+            try:
+                await check_server_info(self.hass, user_input)
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_create_entry(
+                    title=user_input[CONF_HOST], data=user_input
+                )
+
+        return self.async_show_form(
+            step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+        )
+
+    async def async_step_reauth(
+        self, entry_data: Mapping[str, Any]
+    ) -> ConfigFlowResult:
+        """Trigger a reauthentication flow."""
+        self._current_data = entry_data
+        self._name = entry_data[CONF_HOST]
+
+        return await self.async_step_reauth_confirm()
+
+    async def async_step_reauth_confirm(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle reauthorization flow."""
+        errors = {}
+
+        if user_input is not None:
+            try:
+                await check_server_info(self.hass, {**self._current_data, **user_input})
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_update_reload_and_abort(
+                    self._get_reauth_entry(),
+                    data={**self._current_data, **user_input},
path: homeassistant/components/immich/config_flow.py
user: login: zweckj
body: it's a parameter
created_at: 2025-05-18T12:19:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DEFAULT_PORT, DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_HOST): str,
+        vol.Required(CONF_API_KEY): str,
+        vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+        vol.Required(CONF_SSL, default=DEFAULT_USE_SSL): bool,
+        vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+    }
+)
+
+
+async def check_server_info(
+    hass: HomeAssistant, data: dict[str, Any]
+) -> ImmichServerAbout:
+    """Try to read server info."""
+    session = async_get_clientsession(hass, data[CONF_VERIFY_SSL])
+    immich = Immich(
+        session, data[CONF_API_KEY], data[CONF_HOST], data[CONF_PORT], data[CONF_SSL]
+    )
+    return await immich.server.async_get_about_info()
+
+
+class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Immich."""
+
+    VERSION = 1
+
+    _name: str
+    _current_data: Mapping[str, Any]
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, str] = {}
+        if user_input is not None:
+            self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+            try:
+                await check_server_info(self.hass, user_input)
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_create_entry(
+                    title=user_input[CONF_HOST], data=user_input
+                )
+
+        return self.async_show_form(
+            step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+        )
+
+    async def async_step_reauth(
+        self, entry_data: Mapping[str, Any]
+    ) -> ConfigFlowResult:
+        """Trigger a reauthentication flow."""
+        self._current_data = entry_data
+        self._name = entry_data[CONF_HOST]
+
+        return await self.async_step_reauth_confirm()
+
+    async def async_step_reauth_confirm(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle reauthorization flow."""
+        errors = {}
+
+        if user_input is not None:
+            try:
+                await check_server_info(self.hass, {**self._current_data, **user_input})
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_update_reload_and_abort(
+                    self._get_reauth_entry(),
+                    data={**self._current_data, **user_input},
path: homeassistant/components/immich/config_flow.py
user: login: zweckj
body: https://github.com/home-assistant/core/blob/705a987547a51261514fa4929b42287fb99bace3/homeassistant/components/ohme/config_flow.py#L91-L93
created_at: 2025-05-18T12:20:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,121 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DEFAULT_PORT, DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_HOST): str,
+        vol.Required(CONF_API_KEY): str,
+        vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+        vol.Required(CONF_SSL, default=DEFAULT_USE_SSL): bool,
+        vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+    }
+)
+
+
+async def check_server_info(
+    hass: HomeAssistant, data: dict[str, Any]
+) -> ImmichServerAbout:
+    """Try to read server info."""
+    session = async_get_clientsession(hass, data[CONF_VERIFY_SSL])
+    immich = Immich(
+        session, data[CONF_API_KEY], data[CONF_HOST], data[CONF_PORT], data[CONF_SSL]
+    )
+    return await immich.server.async_get_about_info()
+
+
+class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Immich."""
+
+    VERSION = 1
+
+    _name: str
+    _current_data: Mapping[str, Any]
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, str] = {}
+        if user_input is not None:
+            self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
+            try:
+                await check_server_info(self.hass, user_input)
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_create_entry(
+                    title=user_input[CONF_HOST], data=user_input
+                )
+
+        return self.async_show_form(
+            step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+        )
+
+    async def async_step_reauth(
+        self, entry_data: Mapping[str, Any]
+    ) -> ConfigFlowResult:
+        """Trigger a reauthentication flow."""
+        self._current_data = entry_data
+        self._name = entry_data[CONF_HOST]
+
+        return await self.async_step_reauth_confirm()
+
+    async def async_step_reauth_confirm(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle reauthorization flow."""
+        errors = {}
+
+        if user_input is not None:
+            try:
+                await check_server_info(self.hass, {**self._current_data, **user_input})
+            except ImmichUnauthorizedError:
+                errors["base"] = "invalid_auth"
+            except CONNECT_ERRORS:
+                errors["base"] = "cannot_connect"
+            except Exception:
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                return self.async_update_reload_and_abort(
+                    self._get_reauth_entry(),
+                    data={**self._current_data, **user_input},
path: homeassistant/components/immich/config_flow.py
user: login: mib1185
body: ahhh ... thx
created_at: 2025-05-18T12:22:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,175 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+from yarl import URL
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_URL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+    TextSelector,
+    TextSelectorConfig,
+    TextSelectorType,
+)
+
+from .const import DEFAULT_VERIFY_SSL, DOMAIN
+
+
+class InvalidUrl(HomeAssistantError):
+    """Error to indicate invalid URL."""
+
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_URL): TextSelector(
+            config=TextSelectorConfig(type=TextSelectorType.URL)
+        ),
+        vol.Required(CONF_API_KEY): TextSelector(
+            config=TextSelectorConfig(type=TextSelectorType.PASSWORD)
+        ),
+        vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+    }
+)
+
+
+def _parse_url(url: str) -> tuple[str, int, bool]:
+    """Parse the URL and return host, port, and ssl."""
+    parsed_url = URL(url)
+    if (
+        (host := parsed_url.host) is None
+        or (port := parsed_url.port) is None
+        or (scheme := parsed_url.scheme) is None
+    ):
+        raise InvalidUrl
+    return host, port, scheme == "https"
+
+
+async def check_server_info(
+    hass: HomeAssistant, host: str, port: int, ssl: bool, verify_ssl: bool, api_key: str
+) -> ImmichServerAbout:
+    """Try to read server info."""
+    session = async_get_clientsession(hass, verify_ssl)
+    immich = Immich(session, api_key, host, port, ssl)
+    return await immich.server.async_get_about_info()
+
+
+class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Immich."""
+
+    VERSION = 1
+
+    _name: str
+    _current_data: Mapping[str, Any]
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, str] = {}
+        if user_input is not None:
+            try:
+                (host, port, ssl) = _parse_url(user_input[CONF_URL])
+            except InvalidUrl:
+                errors[CONF_URL] = "invalid_url"
+            else:
+                self._async_abort_entries_match(
+                    {CONF_HOST: host, CONF_PORT: port, CONF_SSL: ssl}
path: homeassistant/components/immich/config_flow.py
user: login: mib1185
body: yeah ... I think you're right :+1: having multiple instance on the same host is quite uncommon
created_at: 2025-05-18T12:25:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,175 @@
+"""Config flow for the Immich integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from aioimmich import Immich
+from aioimmich.const import CONNECT_ERRORS
+from aioimmich.exceptions import ImmichUnauthorizedError
+from aioimmich.server.models import ImmichServerAbout
+import voluptuous as vol
+from yarl import URL
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import (
+    CONF_API_KEY,
+    CONF_HOST,
+    CONF_PORT,
+    CONF_SSL,
+    CONF_URL,
+    CONF_VERIFY_SSL,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+    TextSelector,
+    TextSelectorConfig,
+    TextSelectorType,
+)
+
+from .const import DEFAULT_VERIFY_SSL, DOMAIN
+
+
+class InvalidUrl(HomeAssistantError):
+    """Error to indicate invalid URL."""
+
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(CONF_URL): TextSelector(
+            config=TextSelectorConfig(type=TextSelectorType.URL)
+        ),
+        vol.Required(CONF_API_KEY): TextSelector(
+            config=TextSelectorConfig(type=TextSelectorType.PASSWORD)
+        ),
+        vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+    }
+)
+
+
+def _parse_url(url: str) -> tuple[str, int, bool]:
+    """Parse the URL and return host, port, and ssl."""
+    parsed_url = URL(url)
+    if (
+        (host := parsed_url.host) is None
+        or (port := parsed_url.port) is None
+        or (scheme := parsed_url.scheme) is None
+    ):
+        raise InvalidUrl
+    return host, port, scheme == "https"
+
+
+async def check_server_info(
+    hass: HomeAssistant, host: str, port: int, ssl: bool, verify_ssl: bool, api_key: str
+) -> ImmichServerAbout:
+    """Try to read server info."""
+    session = async_get_clientsession(hass, verify_ssl)
+    immich = Immich(session, api_key, host, port, ssl)
+    return await immich.server.async_get_about_info()
+
+
+class ImmichConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Immich."""
+
+    VERSION = 1
+
+    _name: str
+    _current_data: Mapping[str, Any]
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, str] = {}
+        if user_input is not None:
+            try:
+                (host, port, ssl) = _parse_url(user_input[CONF_URL])
+            except InvalidUrl:
+                errors[CONF_URL] = "invalid_url"
+            else:
+                self._async_abort_entries_match(
+                    {CONF_HOST: host, CONF_PORT: port, CONF_SSL: ssl}
+                )
+                try:
+                    await check_server_info(
+                        self.hass,
+                        host,
+                        port,
+                        ssl,
+                        user_input[CONF_VERIFY_SSL],
+                        user_input[CONF_API_KEY],
+                    )
+                except ImmichUnauthorizedError:
+                    errors["base"] = "invalid_auth"
+                except CONNECT_ERRORS:
+                    errors["base"] = "cannot_connect"
+                except Exception:
+                    _LOGGER.exception("Unexpected exception")
+                    errors["base"] = "unknown"
+                else:
+                    (host, port, ssl) = _parse_url(user_input[CONF_URL])
path: homeassistant/components/immich/config_flow.py
user: login: mib1185
body: ups ... this is a left over 
created_at: 2025-05-18T12:27:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,444 @@
+# serializer version: 1
+# name: test_sensors[sensor.mock_title_disk_available-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'config_subentry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.mock_title_disk_available',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+      'sensor': dict({
+        'suggested_display_precision': 1,
+      }),
+      'sensor.private': dict({
+        'suggested_unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+      }),
+    }),
+    'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
+    'original_icon': None,
+    'original_name': 'Disk available',
+    'platform': 'immich',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': 'disk_available',
+    'unique_id': 'abcdef0123456789_disk_available',
+    'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+  })
+# ---
+# name: test_sensors[sensor.mock_title_disk_available-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'data_size',
+      'friendly_name': 'Mock Title Disk available',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.mock_title_disk_available',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '136.34839630127',
+  })
+# ---
+# name: test_sensors[sensor.mock_title_disk_size-entry]
+  EntityRegistryEntrySnapshot({
+    'aliases': set({
+    }),
+    'area_id': None,
+    'capabilities': dict({
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+    }),
+    'config_entry_id': <ANY>,
+    'config_subentry_id': <ANY>,
+    'device_class': None,
+    'device_id': <ANY>,
+    'disabled_by': None,
+    'domain': 'sensor',
+    'entity_category': None,
+    'entity_id': 'sensor.mock_title_disk_size',
+    'has_entity_name': True,
+    'hidden_by': None,
+    'icon': None,
+    'id': <ANY>,
+    'labels': set({
+    }),
+    'name': None,
+    'options': dict({
+      'sensor': dict({
+        'suggested_display_precision': 1,
+      }),
+      'sensor.private': dict({
+        'suggested_unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+      }),
+    }),
+    'original_device_class': <SensorDeviceClass.DATA_SIZE: 'data_size'>,
+    'original_icon': None,
+    'original_name': 'Disk size',
+    'platform': 'immich',
+    'previous_unique_id': None,
+    'supported_features': 0,
+    'translation_key': 'disk_size',
+    'unique_id': 'abcdef0123456789_disk_size',
+    'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+  })
+# ---
+# name: test_sensors[sensor.mock_title_disk_size-state]
+  StateSnapshot({
+    'attributes': ReadOnlyDict({
+      'device_class': 'data_size',
+      'friendly_name': 'Mock Title Disk size',
+      'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
+      'unit_of_measurement': <UnitOfInformation.GIBIBYTES: 'GiB'>,
+    }),
+    'context': <ANY>,
+    'entity_id': 'sensor.mock_title_disk_size',
+    'last_changed': <ANY>,
+    'last_reported': <ANY>,
+    'last_updated': <ANY>,
+    'state': '294.229309082031',
path: tests/components/immich/snapshots/test_sensor.ambr
user: login: mib1185
body: they all have a `suggested_display_precision=1` set, but I think this is only considered in the frontend, not the state machine :thinking: 
created_at: 2025-05-18T12:30:36Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+rules:
+  # Bronze
+  action-setup:
+    status: done
+    comment: "No integration specific actions"
+  appropriate-polling: done
+  brands: done
+  common-modules: done
+  config-flow-test-coverage: done
+  config-flow: done
+  dependency-transparency: done
+  docs-actions:
+    status: done
+    comment: "No integration specific actions"
+  docs-high-level-description: done
+  docs-installation-instructions: done
+  docs-removal-instructions: done
+  entity-event-setup: done
+  entity-unique-id: done
+  has-entity-name: done
+  runtime-data: done
+  test-before-configure: done
+  test-before-setup: done
+  unique-config-entry: done
+
+  # Silver
+  action-exceptions:
+    status: done
+    comment: "No integration specific actions"
+  config-entry-unloading: done
+  docs-configuration-parameters: done
+  docs-installation-parameters: done
+  entity-unavailable: done
+  integration-owner: done
+  log-when-unavailable: done
+  parallel-updates: done
+  reauthentication-flow: done
+  test-coverage: done
+
+  # Gold
+  devices: done
+  diagnostics: todo
+  discovery-update-info:
+    status: exempt
+    comment: "Service can't be discovered"
+  discovery:
+    status: exempt
+    comment: "Service can't be discovered"
path: homeassistant/components/immich/quality_scale.yaml
user: login: mib1185
body: is a custom addon really something we need to consider here? Or is it more about the possibility that there could be a proper addon?
created_at: 2025-05-18T12:33:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+{
+  "common": {
+    "data_desc_url": "The URL of your immich instance. (_e.g. `https://immich.example.com`_)",
path: homeassistant/components/immich/strings.json
user: login: mib1185
body: you mean to avoid accidental translations of it?
created_at: 2025-05-18T12:34:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+rules:
+  # Bronze
+  action-setup:
+    status: done
+    comment: "No integration specific actions"
+  appropriate-polling: done
+  brands: done
+  common-modules: done
+  config-flow-test-coverage: done
+  config-flow: done
+  dependency-transparency: done
+  docs-actions:
+    status: done
+    comment: "No integration specific actions"
+  docs-high-level-description: done
+  docs-installation-instructions: done
+  docs-removal-instructions: done
+  entity-event-setup: done
+  entity-unique-id: done
+  has-entity-name: done
+  runtime-data: done
+  test-before-configure: done
+  test-before-setup: done
+  unique-config-entry: done
+
+  # Silver
+  action-exceptions:
+    status: done
+    comment: "No integration specific actions"
+  config-entry-unloading: done
+  docs-configuration-parameters: done
+  docs-installation-parameters: done
+  entity-unavailable: done
+  integration-owner: done
+  log-when-unavailable: done
+  parallel-updates: done
+  reauthentication-flow: done
+  test-coverage: done
+
+  # Gold
+  devices: done
+  diagnostics: todo
+  discovery-update-info:
+    status: exempt
+    comment: "Service can't be discovered"
+  discovery:
+    status: exempt
+    comment: "Service can't be discovered"
path: homeassistant/components/immich/quality_scale.yaml
user: login: joostlek
body: We don't really specify in the rule what add-ons we consider, but _in theory_ we could auto discover the addon
created_at: 2025-05-18T12:35:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,72 @@
+{
+  "common": {
+    "data_desc_url": "The URL of your immich instance. (_e.g. `https://immich.example.com`_)",
path: homeassistant/components/immich/strings.json
user: login: joostlek
body: Yes
created_at: 2025-05-18T12:46:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+rules:
+  # Bronze
+  action-setup:
+    status: done
+    comment: "No integration specific actions"
+  appropriate-polling: done
+  brands: done
+  common-modules: done
+  config-flow-test-coverage: done
+  config-flow: done
+  dependency-transparency: done
+  docs-actions:
+    status: done
+    comment: "No integration specific actions"
+  docs-high-level-description: done
+  docs-installation-instructions: done
+  docs-removal-instructions: done
+  entity-event-setup: done
+  entity-unique-id: done
+  has-entity-name: done
+  runtime-data: done
+  test-before-configure: done
+  test-before-setup: done
+  unique-config-entry: done
+
+  # Silver
+  action-exceptions:
+    status: done
+    comment: "No integration specific actions"
+  config-entry-unloading: done
+  docs-configuration-parameters: done
+  docs-installation-parameters: done
+  entity-unavailable: done
+  integration-owner: done
+  log-when-unavailable: done
+  parallel-updates: done
+  reauthentication-flow: done
+  test-coverage: done
+
+  # Gold
+  devices: done
+  diagnostics: todo
+  discovery-update-info:
+    status: exempt
+    comment: "Service can't be discovered"
+  discovery:
+    status: exempt
+    comment: "Service can't be discovered"
path: homeassistant/components/immich/quality_scale.yaml
user: login: mib1185
body: so this would mean to implement a `hassio` step in the config flow, so that addons could inform the integration to be discovered (_like `vlc_telnet` does_)? How should the schema for the `discovery_info` be documented, so that custom addon developers knows that?
created_at: 2025-05-18T12:48:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+rules:
+  # Bronze
+  action-setup:
+    status: done
+    comment: "No integration specific actions"
+  appropriate-polling: done
+  brands: done
+  common-modules: done
+  config-flow-test-coverage: done
+  config-flow: done
+  dependency-transparency: done
+  docs-actions:
+    status: done
+    comment: "No integration specific actions"
+  docs-high-level-description: done
+  docs-installation-instructions: done
+  docs-removal-instructions: done
+  entity-event-setup: done
+  entity-unique-id: done
+  has-entity-name: done
+  runtime-data: done
+  test-before-configure: done
+  test-before-setup: done
+  unique-config-entry: done
+
+  # Silver
+  action-exceptions:
+    status: done
+    comment: "No integration specific actions"
+  config-entry-unloading: done
+  docs-configuration-parameters: done
+  docs-installation-parameters: done
+  entity-unavailable: done
+  integration-owner: done
+  log-when-unavailable: done
+  parallel-updates: done
+  reauthentication-flow: done
+  test-coverage: done
+
+  # Gold
+  devices: done
+  diagnostics: todo
+  discovery-update-info:
+    status: exempt
+    comment: "Service can't be discovered"
+  discovery:
+    status: exempt
+    comment: "Service can't be discovered"
path: homeassistant/components/immich/quality_scale.yaml
user: login: joostlek
body: This would also mean that you add the discovery part to the addon, Alex Belgium generally is open to adding those
created_at: 2025-05-18T12:57:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+rules:
+  # Bronze
+  action-setup:
+    status: done
+    comment: "No integration specific actions"
+  appropriate-polling: done
+  brands: done
+  common-modules: done
+  config-flow-test-coverage: done
+  config-flow: done
+  dependency-transparency: done
+  docs-actions:
+    status: done
+    comment: "No integration specific actions"
+  docs-high-level-description: done
+  docs-installation-instructions: done
+  docs-removal-instructions: done
+  entity-event-setup: done
+  entity-unique-id: done
+  has-entity-name: done
+  runtime-data: done
+  test-before-configure: done
+  test-before-setup: done
+  unique-config-entry: done
+
+  # Silver
+  action-exceptions:
+    status: done
+    comment: "No integration specific actions"
+  config-entry-unloading: done
+  docs-configuration-parameters: done
+  docs-installation-parameters: done
+  entity-unavailable: done
+  integration-owner: done
+  log-when-unavailable: done
+  parallel-updates: done
+  reauthentication-flow: done
+  test-coverage: done
+
+  # Gold
+  devices: done
+  diagnostics: todo
+  discovery-update-info:
+    status: exempt
+    comment: "Service can't be discovered"
+  discovery:
+    status: exempt
+    comment: "Service can't be discovered"
path: homeassistant/components/immich/quality_scale.yaml
user: login: mib1185
body: ok ... tbh I would like to let this for a later improvement and also keep it in state "exempt" for now, as this addon is no "core" addon and those the integration is not discover-able from a core point of view. Yes, it could be discover-able for some "foreign" addon, but then we've to consider this for every core integration, which integrates a service for which some custom addon could be out there
created_at: 2025-05-18T14:24:56Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,76 @@
+"""Common fixtures for the Immich tests."""
+
+from collections.abc import Generator
+from unittest.mock import AsyncMock, Mock, patch
+
+from aioimmich.server.models import (
+    ImmichServerAbout,
+    ImmichServerStatistics,
+    ImmichServerStorage,
+)
+import pytest
+
+
+@pytest.fixture
+def mock_setup_entry() -> Generator[AsyncMock]:
+    """Override async_setup_entry."""
+    with patch(
+        "homeassistant.components.immich.async_setup_entry", return_value=True
+    ) as mock_setup_entry:
+        yield mock_setup_entry
+
+
+@pytest.fixture
+def mock_immich_data():
+    """Mock the immich data."""
+    return {
+        "server_about": ImmichServerAbout(
+            "v1.132.3",
+            "some_url",
+            False,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+        ),
+        "server_storage": ImmichServerStorage(
+            "294.2 GiB",
+            "142.9 GiB",
+            "136.3 GiB",
+            315926315008,
+            153400434688,
+            146402975744,
+            48.56,
+        ),
+        "server_usage": ImmichServerStatistics(
+            27038, 1836, 119525451912, 54291170551, 65234281361
+        ),
+    }
+
+
+@pytest.fixture
+def mock_immich(mock_immich_data) -> Mock:
+    """Mock the Immich API."""
+    return Mock(
+        server=Mock(
+            async_get_about_info=AsyncMock(
+                return_value=mock_immich_data["server_about"]
+            ),
+            async_get_storage_info=AsyncMock(
+                return_value=mock_immich_data["server_storage"]
+            ),
+            async_get_server_statistics=AsyncMock(
+                return_value=mock_immich_data["server_usage"]
+            ),
+        )
+    )
path: tests/components/immich/conftest.py
user: login: mib1185
body: this was the first an easiest possibility I could imagine (_or better said I copied_ :see_no_evil: )
created_at: 2025-05-18T18:04:08Z
author_association: MEMBER
diff_hunk: @@ -111,8 +110,10 @@ async def async_step_user(
                     _LOGGER.exception("Unexpected exception")
                     errors["base"] = "unknown"
                 else:
+                    await self.async_set_unique_id(my_user_info.user_id)
+                    self._abort_if_unique_id_configured()
                     return self.async_create_entry(
-                        title=user_input[CONF_URL],
+                        title=f"{my_user_info.name} @ {host}",
path: homeassistant/components/immich/config_flow.py
user: login: joostlek
body: I would say remove the host
created_at: 2025-05-18T18:05:06Z
author_association: MEMBER
diff_hunk: @@ -53,7 +54,7 @@ async def test_step_user(
         await hass.async_block_till_done()
 
     assert result["type"] is FlowResultType.CREATE_ENTRY
-    assert result["title"] == "http://localhost"
+    assert result["title"] == "user @ localhost"
     assert result["data"] == MOCK_CONFIG_ENTRY_DATA
path: tests/components/immich/test_config_flow.py
user: login: joostlek
body: Let's also assert the unique id
created_at: 2025-05-18T18:05:32Z
author_association: MEMBER
diff_hunk: @@ -241,3 +250,33 @@ async def test_reauth_flow_error_handling(
     assert result["reason"] == "reauth_successful"
     assert mock_config.data[CONF_API_KEY] == "other_fake_api_key"
     assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_reauth_flow_mismatch(
+    hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_immich: Mock
+) -> None:
+    """Test reauthentication flow with mis-matching unique id."""
+    mock_config = MockConfigEntry(
+        domain=DOMAIN, data=MOCK_CONFIG_ENTRY_DATA, unique_id="some_other_users_id"
+    )
+    mock_config.add_to_hass(hass)
path: tests/components/immich/test_config_flow.py
user: login: joostlek
body: You will reuse the mock config entry, might as well create a fixture for it
created_at: 2025-05-18T18:06:14Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-05-17T23:44:10Z
body: ### :warning: Parser warning

> The parser emitted a warning. Please review your JUnit XML file:
> <pre><code class="language-python">Warning while parsing testcase attributes: Limit of string is 1000 chars, for name, we got 1418 at 1:407685 in /home/runner/work/core/core/test-results-full-3.13-7/junit.xml</code></pre>

author_association: NONE
user: login: home-assistant[bot]
created_at: 2025-05-18T01:06:24Z
body: <!-- ReviewDrafterComment -->
Please take a look at the requested changes, and use the **Ready for review** button when you are done, thanks :+1:

[_Learn more about our pull request process._](https://developers.home-assistant.io/docs/review-process#prs-are-being-drafted-when-changes-are-needed)

author_association: CONTRIBUTOR
user: login: joostlek
created_at: 2025-05-18T01:06:46Z
body: Move brand to core integrations
author_association: MEMBER
user: login: thecode
created_at: 2025-05-18T07:46:32Z
body: > we've already the [immich](https://github.com/home-assistant/brands/tree/master/custom_integrations/immich) domain under the custom components - should we move or copy it under core_integrations?

From previous similar cases, make a PR to move them to core integrations brands
author_association: MEMBER
user: login: joostlek
created_at: 2025-05-18T15:05:38Z
body: Should we take into account that api keys do not have admin rights?
author_association: MEMBER
user: login: mib1185
created_at: 2025-05-18T16:45:26Z
body: > Should we take into account that api keys do not have admin rights?

yepp ... i'm already on to test this :+1: 
author_association: MEMBER