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:
@@ -4,14 +4,18 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TypeAlias
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import instance_id
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
from .api import EmbyApiClient, EmbyConnectionError
|
||||
from .api import EmbyApiClient, EmbyAuthenticationError, EmbyConnectionError
|
||||
from .const import (
|
||||
CONF_API_KEY,
|
||||
CONF_HOST,
|
||||
@@ -19,16 +23,17 @@ from .const import (
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_SSL,
|
||||
CONF_USER_ID,
|
||||
CONF_VERIFY_SSL,
|
||||
DEFAULT_DEVICE_VERSION,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import EmbyCoordinator
|
||||
from .services import async_setup_services, async_unload_services
|
||||
from .websocket import EmbyWebSocket
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
@@ -42,9 +47,25 @@ class EmbyRuntimeData:
|
||||
api: EmbyApiClient
|
||||
websocket: EmbyWebSocket
|
||||
user_id: str
|
||||
server_id: str | None = None
|
||||
|
||||
|
||||
type EmbyConfigEntry = ConfigEntry[EmbyRuntimeData]
|
||||
EmbyConfigEntry: TypeAlias = ConfigEntry[EmbyRuntimeData]
|
||||
|
||||
|
||||
def _build_device_id(hass_uuid: str, entry_id: str) -> str:
|
||||
"""Build a stable per-config-entry device id for Emby."""
|
||||
return f"hass-{hass_uuid[:8]}-{entry_id[:8]}"
|
||||
|
||||
|
||||
async def _get_manifest_version(hass: HomeAssistant) -> str:
|
||||
"""Read the integration version from its manifest, with a safe fallback."""
|
||||
try:
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
except Exception as err: # noqa: BLE001 - manifest loading must not block setup
|
||||
_LOGGER.debug("Falling back to default device version: %s", err)
|
||||
return DEFAULT_DEVICE_VERSION
|
||||
return str(integration.version or DEFAULT_DEVICE_VERSION)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EmbyConfigEntry) -> bool:
|
||||
@@ -53,39 +74,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmbyConfigEntry) -> bool
|
||||
port = int(entry.data[CONF_PORT])
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
ssl = entry.data.get(CONF_SSL, DEFAULT_SSL)
|
||||
verify_ssl = entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
|
||||
user_id = entry.data[CONF_USER_ID]
|
||||
scan_interval = int(entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL))
|
||||
|
||||
# Create shared aiohttp session
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
scan_interval = int(
|
||||
entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
)
|
||||
|
||||
hass_uuid = await instance_id.async_get(hass)
|
||||
device_id = _build_device_id(hass_uuid, entry.entry_id)
|
||||
session = async_get_clientsession(hass)
|
||||
client_version = await _get_manifest_version(hass)
|
||||
|
||||
# Create API client
|
||||
api = EmbyApiClient(
|
||||
host=host,
|
||||
port=port,
|
||||
api_key=api_key,
|
||||
ssl=ssl,
|
||||
verify_ssl=verify_ssl,
|
||||
session=session,
|
||||
device_id=device_id,
|
||||
client_version=client_version,
|
||||
)
|
||||
|
||||
# Test connection
|
||||
try:
|
||||
await api.test_connection()
|
||||
server_info = await api.test_connection()
|
||||
except EmbyAuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed for Emby server: {err}"
|
||||
) from err
|
||||
except EmbyConnectionError as err:
|
||||
raise ConfigEntryNotReady(f"Cannot connect to Emby server: {err}") from err
|
||||
raise ConfigEntryNotReady(
|
||||
f"Cannot connect to Emby server: {err}"
|
||||
) from err
|
||||
|
||||
# Create WebSocket client
|
||||
websocket = EmbyWebSocket(
|
||||
host=host,
|
||||
port=port,
|
||||
api_key=api_key,
|
||||
ssl=ssl,
|
||||
verify_ssl=verify_ssl,
|
||||
session=session,
|
||||
device_id=device_id,
|
||||
client_version=client_version,
|
||||
)
|
||||
|
||||
# Create coordinator
|
||||
coordinator = EmbyCoordinator(
|
||||
hass=hass,
|
||||
api=api,
|
||||
@@ -93,43 +125,99 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmbyConfigEntry) -> bool
|
||||
scan_interval=scan_interval,
|
||||
)
|
||||
|
||||
# Set up WebSocket connection
|
||||
await coordinator.async_setup()
|
||||
try:
|
||||
await coordinator.async_setup()
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except Exception:
|
||||
# If first refresh fails, make sure the WebSocket task is cleaned up.
|
||||
await websocket.close()
|
||||
raise
|
||||
|
||||
# Fetch initial data
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
server_id = (
|
||||
server_info.get("Id") if isinstance(server_info, dict) else None
|
||||
)
|
||||
server_name = (
|
||||
server_info.get("ServerName") if isinstance(server_info, dict) else None
|
||||
)
|
||||
|
||||
# Store runtime data
|
||||
entry.runtime_data = EmbyRuntimeData(
|
||||
coordinator=coordinator,
|
||||
api=api,
|
||||
websocket=websocket,
|
||||
user_id=user_id,
|
||||
server_id=server_id,
|
||||
)
|
||||
|
||||
# Set up platforms
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
# Register a hub device for via_device linking.
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer="Emby",
|
||||
name=server_name or entry.title,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
model="Emby Server",
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await async_setup_services(hass)
|
||||
|
||||
# Register update listener for options
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: EmbyConfigEntry) -> None:
|
||||
async def _async_update_listener(
|
||||
hass: HomeAssistant, entry: EmbyConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
scan_interval = int(
|
||||
entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
||||
)
|
||||
entry.runtime_data.coordinator.update_scan_interval(scan_interval)
|
||||
_LOGGER.debug("Updated Emby scan interval to %d seconds", scan_interval)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: EmbyConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: EmbyConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
# Unload platforms
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
# Shut down coordinator (closes WebSocket)
|
||||
await entry.runtime_data.coordinator.async_shutdown()
|
||||
|
||||
# Tear down services when the last entry is unloaded.
|
||||
others_loaded = any(
|
||||
e.entry_id != entry.entry_id
|
||||
for e in hass.config_entries.async_entries(DOMAIN)
|
||||
)
|
||||
if not others_loaded:
|
||||
async_unload_services(hass)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant,
|
||||
entry: EmbyConfigEntry,
|
||||
device_entry: dr.DeviceEntry,
|
||||
) -> bool:
|
||||
"""Allow the user to remove device entries that are no longer present.
|
||||
|
||||
Refuse to remove:
|
||||
- The implicit "hub" device (identifier == entry.entry_id), since
|
||||
removing it would orphan all session entities.
|
||||
- Any device whose session is still present in the coordinator.
|
||||
"""
|
||||
# ``entry.runtime_data`` may be unset if the entry is mid-(re)load.
|
||||
runtime_data = getattr(entry, "runtime_data", None)
|
||||
for domain, identifier in device_entry.identifiers:
|
||||
if domain != DOMAIN:
|
||||
continue
|
||||
if identifier == entry.entry_id:
|
||||
return False # hub device
|
||||
if runtime_data is not None and runtime_data.coordinator.data:
|
||||
if identifier in runtime_data.coordinator.data:
|
||||
return False # session still present
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user