Files
haos-hacs-emby-media-player/custom_components/emby_player/__init__.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

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