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.
This commit is contained in:
@@ -14,7 +14,10 @@ from homeassistant.config_entries import (
|
||||
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,
|
||||
@@ -34,15 +37,22 @@ from .const import (
|
||||
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."""
|
||||
|
||||
@@ -54,8 +64,10 @@ class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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
|
||||
@@ -68,65 +80,72 @@ class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Testing connection to %s:%s (SSL: %s)",
|
||||
self._host,
|
||||
self._port,
|
||||
self._ssl,
|
||||
)
|
||||
errors = await self._probe()
|
||||
|
||||
# Test connection
|
||||
api = EmbyApiClient(
|
||||
host=self._host,
|
||||
port=self._port,
|
||||
api_key=self._api_key,
|
||||
ssl=self._ssl,
|
||||
)
|
||||
|
||||
try:
|
||||
self._server_info = await api.test_connection()
|
||||
self._users = await api.get_users()
|
||||
await api.close()
|
||||
|
||||
if not self._users:
|
||||
errors["base"] = "no_users"
|
||||
else:
|
||||
return await self.async_step_user_select()
|
||||
|
||||
except EmbyAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except EmbyConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
finally:
|
||||
await api.close()
|
||||
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): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.TEXT)
|
||||
),
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): NumberSelector(
|
||||
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,
|
||||
min=1, max=65535, mode=NumberSelectorMode.BOX
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_API_KEY): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool,
|
||||
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:
|
||||
@@ -135,14 +154,11 @@ class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
user_id = user_input[CONF_USER_ID]
|
||||
|
||||
# Find user name
|
||||
user_name = next(
|
||||
(u["Name"] for u in self._users if u["Id"] == user_id),
|
||||
"Unknown",
|
||||
)
|
||||
|
||||
# Create unique ID based on server ID and user
|
||||
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()
|
||||
@@ -156,6 +172,7 @@ class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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={
|
||||
@@ -163,9 +180,9 @@ class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
# Build user selection options
|
||||
user_options = [
|
||||
{"value": user["Id"], "label": user["Name"]} for user in self._users
|
||||
{"value": user["Id"], "label": user.get("Name", "Unknown")}
|
||||
for user in self._users
|
||||
]
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -183,6 +200,94 @@ class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user