Files
haos-hacs-emby-media-player/custom_components/emby_player/config_flow.py
T
alexei.dolgolyov 6ae0ed1787
Release / release (push) Successful in 2s
chore: release v0.2.0
Production-readiness pass: security hardening, performance improvements,
new services (send_message, set_repeat, refresh_library), diagnostics,
reauth flow, image proxy, per-instance device IDs, exponential WS
reconnect backoff, ID validation, stale device cleanup, and supporting
integration plumbing. Three rounds of independent code review applied.

See RELEASE_NOTES.md for the full changelog.
2026-05-26 13:16:36 +03:00

336 lines
11 KiB
Python

"""Config flow for Emby Media Player integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.core import callback
from homeassistant.helpers import instance_id
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
BooleanSelector,
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .api import EmbyApiClient, EmbyAuthenticationError, EmbyConnectionError
from .const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SCAN_INTERVAL,
CONF_SSL,
CONF_USER_ID,
CONF_VERIFY_SSL,
DEFAULT_PORT,
DEFAULT_SCAN_INTERVAL,
DEFAULT_SSL,
DEFAULT_VERIFY_SSL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
def _make_temp_device_id(prefix: str) -> str:
"""Build a temporary device id used during config flow."""
return f"hass-config-{prefix[:12]}"
class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Emby Media Player."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._host: str | None = None
self._port: int = DEFAULT_PORT
self._api_key: str | None = None
self._ssl: bool = DEFAULT_SSL
self._verify_ssl: bool = DEFAULT_VERIFY_SSL
self._users: list[dict[str, Any]] = []
self._server_info: dict[str, Any] = {}
self._reauth_entry: ConfigEntry | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step - server connection."""
errors: dict[str, str] = {}
if user_input is not None:
self._host = user_input[CONF_HOST].strip()
self._port = int(user_input.get(CONF_PORT, DEFAULT_PORT))
self._api_key = user_input[CONF_API_KEY].strip()
self._ssl = user_input.get(CONF_SSL, DEFAULT_SSL)
self._verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
errors = await self._probe()
if not errors and self._users:
return await self.async_step_user_select()
if not errors:
errors["base"] = "no_users"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_HOST, default=self._host or vol.UNDEFINED
): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)),
vol.Optional(
CONF_PORT, default=self._port
): NumberSelector(
NumberSelectorConfig(
min=1, max=65535, mode=NumberSelectorMode.BOX
)
),
vol.Required(CONF_API_KEY): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
vol.Optional(CONF_SSL, default=self._ssl): BooleanSelector(),
vol.Optional(
CONF_VERIFY_SSL, default=self._verify_ssl
): BooleanSelector(),
}
),
errors=errors,
)
async def _probe(self) -> dict[str, str]:
"""Try to connect & list users with the current self.* settings."""
assert self._host is not None
assert self._api_key is not None
errors: dict[str, str] = {}
session = async_get_clientsession(self.hass)
hass_uuid = await instance_id.async_get(self.hass)
api = EmbyApiClient(
host=self._host,
port=self._port,
api_key=self._api_key,
ssl=self._ssl,
verify_ssl=self._verify_ssl,
session=session,
device_id=_make_temp_device_id(hass_uuid),
)
try:
self._server_info = await api.test_connection()
self._users = await api.get_users()
except EmbyAuthenticationError:
errors["base"] = "invalid_auth"
except EmbyConnectionError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected error during Emby config probe")
errors["base"] = "unknown"
return errors
async def async_step_user_select(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user selection step."""
errors: dict[str, str] = {}
if user_input is not None:
user_id = user_input[CONF_USER_ID]
user_name = next(
(u["Name"] for u in self._users if u["Id"] == user_id),
"Unknown",
)
server_id = self._server_info.get("Id", self._host)
await self.async_set_unique_id(f"{server_id}_{user_id}")
self._abort_if_unique_id_configured()
server_name = self._server_info.get("ServerName", self._host)
return self.async_create_entry(
title=f"{server_name} ({user_name})",
data={
CONF_HOST: self._host,
CONF_PORT: self._port,
CONF_API_KEY: self._api_key,
CONF_SSL: self._ssl,
CONF_VERIFY_SSL: self._verify_ssl,
CONF_USER_ID: user_id,
},
options={
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
},
)
user_options = [
{"value": user["Id"], "label": user.get("Name", "Unknown")}
for user in self._users
]
return self.async_show_form(
step_id="user_select",
data_schema=vol.Schema(
{
vol.Required(CONF_USER_ID): SelectSelector(
SelectSelectorConfig(
options=user_options,
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
errors=errors,
)
# -------------------------------------------------------------------------
# Reauthentication
# -------------------------------------------------------------------------
def _resolve_reauth_entry(self) -> ConfigEntry | None:
"""Resolve the reauth target entry from context.
Uses ``_get_reauth_entry`` when available (HA 2024.11+), falling back
to the documented context dict.
"""
getter = getattr(self, "_get_reauth_entry", None)
if callable(getter):
try:
return getter()
except Exception as err: # noqa: BLE001 - version-specific
_LOGGER.debug(
"_get_reauth_entry helper unavailable, falling back: %s",
err,
)
entry_id = self.context.get("entry_id")
if not entry_id:
return None
return self.hass.config_entries.async_get_entry(entry_id)
async def async_step_reauth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the reauth flow."""
self._reauth_entry = self._resolve_reauth_entry()
if self._reauth_entry is not None:
self._host = self._reauth_entry.data.get(CONF_HOST)
self._port = int(
self._reauth_entry.data.get(CONF_PORT, DEFAULT_PORT)
)
self._ssl = self._reauth_entry.data.get(CONF_SSL, DEFAULT_SSL)
self._verify_ssl = self._reauth_entry.data.get(
CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL
)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication with a new API key."""
errors: dict[str, str] = {}
if user_input is not None and self._reauth_entry is not None:
self._api_key = user_input[CONF_API_KEY].strip()
errors = await self._probe()
if not errors:
new_data = {
**self._reauth_entry.data,
CONF_API_KEY: self._api_key,
}
# Prefer the unified helper when available; fall back manually.
helper = getattr(
self, "async_update_reload_and_abort", None
)
if callable(helper):
return helper(
self._reauth_entry,
data=new_data,
reason="reauth_successful",
)
self.hass.config_entries.async_update_entry(
self._reauth_entry, data=new_data
)
await self.hass.config_entries.async_reload(
self._reauth_entry.entry_id
)
return self.async_abort(reason="reauth_successful")
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
),
errors=errors,
description_placeholders={
"host": self._host or "",
},
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> EmbyOptionsFlow:
"""Get the options flow for this handler."""
return EmbyOptionsFlow(config_entry)
class EmbyOptionsFlow(OptionsFlow):
"""Handle options flow for Emby Media Player."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self._config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
current_interval = self._config_entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_SCAN_INTERVAL, default=current_interval
): NumberSelector(
NumberSelectorConfig(
min=5,
max=60,
step=1,
mode=NumberSelectorMode.SLIDER,
unit_of_measurement="seconds",
)
),
}
),
)