6ae0ed1787
Release / release (push) Successful in 2s
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.
224 lines
6.8 KiB
Python
224 lines
6.8 KiB
Python
"""Emby Media Player integration for Home Assistant."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import TypeAlias
|
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import Platform
|
|
from homeassistant.core import HomeAssistant
|
|
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, EmbyAuthenticationError, EmbyConnectionError
|
|
from .const import (
|
|
CONF_API_KEY,
|
|
CONF_HOST,
|
|
CONF_PORT,
|
|
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
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
PLATFORMS = [Platform.MEDIA_PLAYER]
|
|
|
|
|
|
@dataclass
|
|
class EmbyRuntimeData:
|
|
"""Runtime data for Emby integration."""
|
|
|
|
coordinator: EmbyCoordinator
|
|
api: EmbyApiClient
|
|
websocket: EmbyWebSocket
|
|
user_id: str
|
|
server_id: str | None = None
|
|
|
|
|
|
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:
|
|
"""Set up Emby Media Player from a config entry."""
|
|
host = entry.data[CONF_HOST]
|
|
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)
|
|
)
|
|
|
|
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)
|
|
|
|
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,
|
|
)
|
|
|
|
try:
|
|
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
|
|
|
|
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,
|
|
)
|
|
|
|
coordinator = EmbyCoordinator(
|
|
hass=hass,
|
|
api=api,
|
|
websocket=websocket,
|
|
scan_interval=scan_interval,
|
|
)
|
|
|
|
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
|
|
|
|
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
|
|
)
|
|
|
|
entry.runtime_data = EmbyRuntimeData(
|
|
coordinator=coordinator,
|
|
api=api,
|
|
websocket=websocket,
|
|
user_id=user_id,
|
|
server_id=server_id,
|
|
)
|
|
|
|
# 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)
|
|
|
|
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
|
|
|
return True
|
|
|
|
|
|
async def _async_update_listener(
|
|
hass: HomeAssistant, entry: EmbyConfigEntry
|
|
) -> None:
|
|
"""Handle options update."""
|
|
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:
|
|
"""Unload a config entry."""
|
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
|
|
if unload_ok:
|
|
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
|