"""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