chore: release v0.2.0
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.
This commit is contained in:
2026-05-26 13:16:36 +03:00
parent 56c1125ef2
commit 6ae0ed1787
18 changed files with 2170 additions and 645 deletions
+118 -30
View File
@@ -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
+238 -83
View File
@@ -3,22 +3,32 @@
from __future__ import annotations
import logging
import re
from typing import Any
import aiohttp
from .const import (
ALLOWED_IMAGE_TYPES,
COMMAND_DISPLAY_MESSAGE,
COMMAND_MUTE,
COMMAND_SET_REPEAT_MODE,
COMMAND_SET_VOLUME,
COMMAND_UNMUTE,
DEFAULT_DEVICE_VERSION,
DEFAULT_PORT,
DEVICE_ID,
DEVICE_NAME,
DEVICE_VERSION,
EMBY_ID_PATTERN,
ENDPOINT_ARTISTS,
ENDPOINT_ITEMS,
ENDPOINT_LIBRARY_REFRESH,
ENDPOINT_PREFIX_EMBY,
ENDPOINT_PREFIX_NONE,
ENDPOINT_SESSIONS,
ENDPOINT_SYSTEM_INFO,
ENDPOINT_USERS,
ENDPOINT_USERS_PUBLIC,
IMAGE_FETCH_TIMEOUT_SECONDS,
PLAY_COMMAND_PLAY_NOW,
PLAYBACK_COMMAND_NEXT_TRACK,
PLAYBACK_COMMAND_PAUSE,
@@ -30,6 +40,10 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
_REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=15)
_IMAGE_TIMEOUT = aiohttp.ClientTimeout(total=IMAGE_FETCH_TIMEOUT_SECONDS)
_EMBY_ID_RE = re.compile(EMBY_ID_PATTERN)
class EmbyApiError(Exception):
"""Base exception for Emby API errors."""
@@ -43,56 +57,88 @@ class EmbyAuthenticationError(EmbyApiError):
"""Exception for authentication errors."""
def _validate_emby_id(value: str, field_name: str = "id") -> str:
"""Reject IDs that don't look like Emby identifiers."""
if not isinstance(value, str) or not _EMBY_ID_RE.fullmatch(value):
raise EmbyApiError(f"Invalid Emby {field_name}: {value!r}")
return value
class EmbyApiClient:
"""Emby REST API client."""
"""Emby REST API client.
The aiohttp session is owned by the caller (Home Assistant); this class
never closes it.
"""
def __init__(
self,
host: str,
api_key: str,
session: aiohttp.ClientSession,
device_id: str,
port: int = DEFAULT_PORT,
ssl: bool = False,
session: aiohttp.ClientSession | None = None,
verify_ssl: bool = True,
client_version: str = DEFAULT_DEVICE_VERSION,
) -> None:
"""Initialize the Emby API client."""
self._host = host
if not host or not host.strip():
raise ValueError("host must not be empty")
if not api_key:
raise ValueError("api_key must not be empty")
if not device_id:
raise ValueError("device_id must not be empty")
self._host = host.strip().rstrip("/")
self._port = port
self._api_key = api_key
self._ssl = ssl
self._verify_ssl = verify_ssl
self._session = session
self._owns_session = session is None
self._device_id = device_id
self._client_version = client_version
protocol = "https" if ssl else "http"
self._base_url = f"{protocol}://{host}:{port}"
self._base_url = f"{protocol}://{self._host}:{port}"
# Discovered at test_connection(); set to "" if server is configured
# without the /emby prefix.
self._prefix: str = ENDPOINT_PREFIX_EMBY
@property
def base_url(self) -> str:
"""Return the base URL."""
"""Return the base URL (no API prefix)."""
return self._base_url
async def _ensure_session(self) -> aiohttp.ClientSession:
"""Ensure an aiohttp session exists."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
self._owns_session = True
return self._session
@property
def prefix(self) -> str:
"""Return the working API path prefix (e.g. "/emby" or "")."""
return self._prefix
async def close(self) -> None:
"""Close the aiohttp session if we own it."""
if self._owns_session and self._session and not self._session.closed:
await self._session.close()
@property
def device_id(self) -> str:
"""Return the device id used to identify this client to Emby."""
return self._device_id
def _get_headers(self) -> dict[str, str]:
def _get_headers(self, *, content_json: bool = True) -> dict[str, str]:
"""Get headers for API requests."""
return {
headers = {
"X-Emby-Token": self._api_key,
"X-Emby-Client": DEVICE_NAME,
"X-Emby-Device-Name": DEVICE_NAME,
"X-Emby-Device-Id": DEVICE_ID,
"X-Emby-Client-Version": DEVICE_VERSION,
"Content-Type": "application/json",
"X-Emby-Device-Id": self._device_id,
"X-Emby-Client-Version": self._client_version,
"Accept": "application/json",
}
if content_json:
headers["Content-Type"] = "application/json"
return headers
def _ssl_kwarg(self) -> dict[str, Any]:
"""Return the ssl kwarg for aiohttp depending on config."""
if not self._ssl:
return {}
return {"ssl": self._verify_ssl}
async def _request(
self,
@@ -100,22 +146,29 @@ class EmbyApiClient:
endpoint: str,
params: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
*,
absolute: bool = False,
) -> Any:
"""Make an API request."""
session = await self._ensure_session()
url = f"{self._base_url}{endpoint}"
"""Make an API request.
``endpoint`` is expected to begin with "/" (e.g. "/System/Info").
When ``absolute`` is False the discovered prefix ("/emby" by default)
is prepended.
"""
path = endpoint if absolute else f"{self._prefix}{endpoint}"
url = f"{self._base_url}{path}"
_LOGGER.debug("Making %s request to %s", method, url)
try:
async with session.request(
async with self._session.request(
method,
url,
headers=self._get_headers(),
params=params,
json=data,
timeout=aiohttp.ClientTimeout(total=15),
ssl=False if not self._ssl else None, # Disable SSL verification if not using SSL
timeout=_REQUEST_TIMEOUT,
**self._ssl_kwarg(),
) as response:
_LOGGER.debug("Response status: %s", response.status)
@@ -125,19 +178,22 @@ class EmbyApiClient:
raise EmbyAuthenticationError("Access forbidden")
if response.status >= 400:
text = await response.text()
_LOGGER.error("API error %s: %s", response.status, text)
_LOGGER.debug("API error %s: %s", response.status, text)
raise EmbyApiError(f"API error {response.status}: {text}")
if response.status == 204 or response.content_length == 0:
return None
content_type = response.headers.get("Content-Type", "")
if "application/json" in content_type:
return await response.json()
return await response.text()
except aiohttp.ClientError as err:
_LOGGER.error("Connection error to %s: %s", url, err)
_LOGGER.debug("Connection error to %s: %s", url, err)
raise EmbyConnectionError(f"Connection error: {err}") from err
except TimeoutError as err:
_LOGGER.error("Timeout connecting to %s", url)
_LOGGER.debug("Timeout connecting to %s", url)
raise EmbyConnectionError(f"Connection timeout: {err}") from err
async def _get(
@@ -162,34 +218,46 @@ class EmbyApiClient:
async def test_connection(self) -> dict[str, Any]:
"""Test the connection to the Emby server.
Tries both /emby/System/Info and /System/Info endpoints.
Returns server info if successful.
Tries both /emby/System/Info and /System/Info and pins the working
prefix for subsequent calls.
"""
# Try with /emby prefix first (standard Emby)
try:
_LOGGER.debug("Trying connection with /emby prefix")
return await self._get(ENDPOINT_SYSTEM_INFO)
except (EmbyConnectionError, EmbyApiError) as err:
_LOGGER.debug("Connection with /emby prefix failed: %s", err)
last_error: Exception | None = None
for prefix in (ENDPOINT_PREFIX_EMBY, ENDPOINT_PREFIX_NONE):
url_path = f"{prefix}{ENDPOINT_SYSTEM_INFO}"
try:
_LOGGER.debug("Probing %s for connectivity", url_path)
result = await self._request("GET", url_path, absolute=True)
self._prefix = prefix
_LOGGER.debug("Using API prefix %r", prefix or "<none>")
return result
except EmbyAuthenticationError:
raise
except (EmbyConnectionError, EmbyApiError) as err:
last_error = err
continue
# Try without /emby prefix (some Emby configurations)
try:
_LOGGER.debug("Trying connection without /emby prefix")
return await self._get("/System/Info")
except (EmbyConnectionError, EmbyApiError) as err:
_LOGGER.debug("Connection without /emby prefix failed: %s", err)
raise EmbyConnectionError(
f"Cannot connect to Emby server at {self._base_url}. "
"Please verify the host, port, and that the server is running."
) from err
raise EmbyConnectionError(
f"Cannot connect to Emby server at {self._base_url}. "
f"Last error: {last_error}"
)
async def get_server_info(self) -> dict[str, Any]:
"""Get server information."""
return await self._get(ENDPOINT_SYSTEM_INFO)
async def get_users(self) -> list[dict[str, Any]]:
"""Get list of users."""
return await self._get(ENDPOINT_USERS)
"""Get list of users.
Falls back to the public users endpoint if the API key is not an admin
token (HTTP 401/403 on the authenticated endpoint).
"""
try:
return await self._get(ENDPOINT_USERS)
except EmbyAuthenticationError:
_LOGGER.debug(
"API key is not admin, falling back to /Users/Public"
)
return await self._get(ENDPOINT_USERS_PUBLIC)
# -------------------------------------------------------------------------
# Sessions
@@ -203,7 +271,7 @@ class EmbyApiClient:
self, user_id: str | None = None
) -> list[dict[str, Any]]:
"""Get sessions that can be remotely controlled."""
params = {}
params: dict[str, Any] = {}
if user_id:
params["ControllableByUserId"] = user_id
@@ -222,25 +290,25 @@ class EmbyApiClient:
start_position_ticks: int = 0,
) -> None:
"""Send play command to a session."""
if not item_ids:
raise EmbyApiError("item_ids are required")
_validate_emby_id(session_id, "session_id")
for item_id in item_ids:
_validate_emby_id(item_id, "item_id")
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing"
params = {
params: dict[str, Any] = {
"ItemIds": ",".join(item_ids),
"PlayCommand": play_command,
}
if start_position_ticks > 0:
params["StartPositionTicks"] = start_position_ticks
_LOGGER.debug(
"Sending play_media: endpoint=%s, session_id=%s, item_ids=%s, command=%s",
endpoint,
session_id,
item_ids,
play_command,
)
await self._post(endpoint, params=params)
async def _playback_command(self, session_id: str, command: str) -> None:
"""Send a playback command to a session."""
_validate_emby_id(session_id, "session_id")
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing/{command}"
await self._post(endpoint)
@@ -266,28 +334,38 @@ class EmbyApiClient:
async def seek(self, session_id: str, position_ticks: int) -> None:
"""Seek to a position."""
_validate_emby_id(session_id, "session_id")
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing/{PLAYBACK_COMMAND_SEEK}"
await self._post(endpoint, params={"SeekPositionTicks": position_ticks})
# -------------------------------------------------------------------------
# Volume Control
# General Commands
# -------------------------------------------------------------------------
async def _send_command(
self, session_id: str, command: str, arguments: dict[str, Any] | None = None
self,
session_id: str,
command: str,
arguments: dict[str, Any] | None = None,
) -> None:
"""Send a general command to a session."""
"""Send a general command to a session.
Emby's /Command endpoint accepts arguments as strings; numeric values
are stringified here.
"""
_validate_emby_id(session_id, "session_id")
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Command"
data: dict[str, Any] = {"Name": command}
if arguments:
# Emby expects arguments as strings
data["Arguments"] = {k: str(v) for k, v in arguments.items()}
await self._post(endpoint, data=data)
async def set_volume(self, session_id: str, volume: int) -> None:
"""Set volume level (0-100)."""
volume = max(0, min(100, volume))
await self._send_command(session_id, COMMAND_SET_VOLUME, {"Volume": volume})
volume = max(0, min(100, int(volume)))
await self._send_command(
session_id, COMMAND_SET_VOLUME, {"Volume": volume}
)
async def mute(self, session_id: str) -> None:
"""Mute the session."""
@@ -297,15 +375,40 @@ class EmbyApiClient:
"""Unmute the session."""
await self._send_command(session_id, COMMAND_UNMUTE)
async def set_repeat_mode(self, session_id: str, mode: str) -> None:
"""Set the session's repeat mode (RepeatNone/RepeatOne/RepeatAll)."""
await self._send_command(
session_id, COMMAND_SET_REPEAT_MODE, {"RepeatMode": mode}
)
async def display_message(
self,
session_id: str,
text: str,
header: str | None = None,
timeout_ms: int | None = None,
) -> None:
"""Display a message on the client device."""
args: dict[str, Any] = {"Text": text}
if header is not None:
args["Header"] = header
if timeout_ms is not None:
args["TimeoutMs"] = int(timeout_ms)
await self._send_command(session_id, COMMAND_DISPLAY_MESSAGE, args)
# -------------------------------------------------------------------------
# Library Browsing
# -------------------------------------------------------------------------
async def get_views(self, user_id: str) -> list[dict[str, Any]]:
"""Get user's library views (top-level folders)."""
_validate_emby_id(user_id, "user_id")
endpoint = f"{ENDPOINT_USERS}/{user_id}/Views"
result = await self._get(endpoint)
return result.get("Items", [])
if not isinstance(result, dict):
return []
items = result.get("Items", [])
return items if isinstance(items, list) else []
async def get_items(
self,
@@ -321,6 +424,9 @@ class EmbyApiClient:
fields: list[str] | None = None,
) -> dict[str, Any]:
"""Get items from the library."""
_validate_emby_id(user_id, "user_id")
if parent_id is not None:
_validate_emby_id(parent_id, "parent_id")
endpoint = f"{ENDPOINT_USERS}/{user_id}/Items"
params: dict[str, Any] = {
@@ -339,13 +445,13 @@ class EmbyApiClient:
params["SearchTerm"] = search_term
if fields:
params["Fields"] = ",".join(fields)
else:
params["Fields"] = "PrimaryImageAspectRatio,BasicSyncInfo"
return await self._get(endpoint, params=params)
async def get_item(self, user_id: str, item_id: str) -> dict[str, Any]:
"""Get a single item by ID."""
_validate_emby_id(user_id, "user_id")
_validate_emby_id(item_id, "item_id")
endpoint = f"{ENDPOINT_USERS}/{user_id}/Items/{item_id}"
return await self._get(endpoint)
@@ -357,7 +463,9 @@ class EmbyApiClient:
limit: int = 100,
) -> dict[str, Any]:
"""Get artists."""
endpoint = "/emby/Artists"
_validate_emby_id(user_id, "user_id")
if parent_id is not None:
_validate_emby_id(parent_id, "parent_id")
params: dict[str, Any] = {
"UserId": user_id,
"StartIndex": start_index,
@@ -368,25 +476,72 @@ class EmbyApiClient:
if parent_id:
params["ParentId"] = parent_id
return await self._get(endpoint, params=params)
return await self._get(ENDPOINT_ARTISTS, params=params)
def get_image_url(
async def refresh_library(self) -> None:
"""Trigger a server-side library scan."""
await self._post(ENDPOINT_LIBRARY_REFRESH)
# -------------------------------------------------------------------------
# Image fetching
# -------------------------------------------------------------------------
def get_image_path(
self,
item_id: str,
image_type: str = "Primary",
max_width: int | None = None,
max_height: int | None = None,
) -> str:
"""Get the URL for an item's image."""
url = f"{self._base_url}{ENDPOINT_ITEMS}/{item_id}/Images/{image_type}"
params = []
) -> tuple[str, dict[str, str]]:
"""Build the URL and query params for an item image.
Returns ``(url, params)``. The API key is intentionally NOT included;
the caller is responsible for sending the X-Emby-Token header. The
item_id is validated to prevent path traversal.
"""
_validate_emby_id(item_id, "item_id")
if image_type not in ALLOWED_IMAGE_TYPES:
raise EmbyApiError(f"Invalid image_type: {image_type!r}")
url = (
f"{self._base_url}{self._prefix}{ENDPOINT_ITEMS}"
f"/{item_id}/Images/{image_type}"
)
params: dict[str, str] = {}
if max_width:
params.append(f"maxWidth={max_width}")
params["maxWidth"] = str(int(max_width))
if max_height:
params.append(f"maxHeight={max_height}")
params.append(f"api_key={self._api_key}")
params["maxHeight"] = str(int(max_height))
return url, params
if params:
url += "?" + "&".join(params)
async def fetch_image(
self,
item_id: str,
image_type: str = "Primary",
max_width: int | None = None,
max_height: int | None = None,
) -> tuple[bytes, str | None]:
"""Fetch an image. Returns ``(content, content_type)``.
return url
Uses the X-Emby-Token header so the API key is not exposed in URLs.
"""
url, params = self.get_image_path(
item_id, image_type, max_width, max_height
)
try:
async with self._session.get(
url,
params=params,
headers=self._get_headers(content_json=False),
timeout=_IMAGE_TIMEOUT,
**self._ssl_kwarg(),
) as response:
if response.status >= 400:
raise EmbyApiError(
f"Image fetch failed with status {response.status}"
)
content = await response.read()
return content, response.headers.get("Content-Type")
except aiohttp.ClientError as err:
raise EmbyConnectionError(f"Image fetch error: {err}") from err
except TimeoutError as err:
raise EmbyConnectionError(f"Image fetch timeout: {err}") from err
+62 -66
View File
@@ -5,10 +5,15 @@ from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType
from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
MediaClass,
MediaType,
)
from homeassistant.core import HomeAssistant
from .api import EmbyApiClient
from .api import EmbyApiClient, EmbyApiError
from .const import (
ITEM_TYPE_AUDIO,
ITEM_TYPE_COLLECTION_FOLDER,
@@ -25,7 +30,11 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
# Map Emby item types to Home Assistant media classes
# Default page size when no pagination params provided
DEFAULT_PAGE_SIZE = 100
# Maximum items per browse call (HA UI also has limits)
MAX_PAGE_SIZE = 200
ITEM_TYPE_TO_MEDIA_CLASS: dict[str, MediaClass] = {
ITEM_TYPE_MOVIE: MediaClass.MOVIE,
ITEM_TYPE_SERIES: MediaClass.TV_SHOW,
@@ -40,7 +49,6 @@ ITEM_TYPE_TO_MEDIA_CLASS: dict[str, MediaClass] = {
ITEM_TYPE_USER_VIEW: MediaClass.DIRECTORY,
}
# Map Emby item types to Home Assistant media types
ITEM_TYPE_TO_MEDIA_TYPE: dict[str, MediaType | str] = {
ITEM_TYPE_MOVIE: MediaType.MOVIE,
ITEM_TYPE_SERIES: MediaType.TVSHOW,
@@ -52,24 +60,22 @@ ITEM_TYPE_TO_MEDIA_TYPE: dict[str, MediaType | str] = {
ITEM_TYPE_PLAYLIST: MediaType.PLAYLIST,
}
# Item types that can be played directly
PLAYABLE_ITEM_TYPES = {
ITEM_TYPE_MOVIE,
ITEM_TYPE_EPISODE,
ITEM_TYPE_AUDIO,
}
PLAYABLE_ITEM_TYPES = frozenset(
{ITEM_TYPE_MOVIE, ITEM_TYPE_EPISODE, ITEM_TYPE_AUDIO}
)
# Item types that can be expanded (have children)
EXPANDABLE_ITEM_TYPES = {
ITEM_TYPE_SERIES,
ITEM_TYPE_SEASON,
ITEM_TYPE_MUSIC_ALBUM,
ITEM_TYPE_MUSIC_ARTIST,
ITEM_TYPE_PLAYLIST,
ITEM_TYPE_FOLDER,
ITEM_TYPE_COLLECTION_FOLDER,
ITEM_TYPE_USER_VIEW,
}
EXPANDABLE_ITEM_TYPES = frozenset(
{
ITEM_TYPE_SERIES,
ITEM_TYPE_SEASON,
ITEM_TYPE_MUSIC_ALBUM,
ITEM_TYPE_MUSIC_ARTIST,
ITEM_TYPE_PLAYLIST,
ITEM_TYPE_FOLDER,
ITEM_TYPE_COLLECTION_FOLDER,
ITEM_TYPE_USER_VIEW,
}
)
async def async_browse_media(
@@ -80,26 +86,36 @@ async def async_browse_media(
media_content_id: str | None,
) -> BrowseMedia:
"""Browse Emby media library."""
if media_content_id is None or media_content_id == "":
# Return root - library views
return await _build_root_browse(api, user_id)
# Browse specific item/folder
return await _build_item_browse(api, user_id, media_content_id)
try:
if not media_content_id:
return await _build_root_browse(api, user_id)
return await _build_item_browse(api, user_id, media_content_id)
except BrowseError:
raise
except EmbyApiError as err:
_LOGGER.warning("Failed to browse Emby library: %s", err)
raise BrowseError(f"Failed to browse Emby library: {err}") from err
except Exception as err: # noqa: BLE001 - convert any leak into BrowseError
_LOGGER.exception("Unexpected error while browsing Emby library")
raise BrowseError(f"Unexpected error: {err}") from err
async def _build_root_browse(api: EmbyApiClient, user_id: str) -> BrowseMedia:
"""Build root browse media structure (library views)."""
views = await api.get_views(user_id)
if not isinstance(views, list):
views = []
children = []
children: list[BrowseMedia] = []
for view in views:
if not isinstance(view, dict):
continue
item_id = view.get("Id")
if not item_id:
continue
name = view.get("Name", "Unknown")
item_type = view.get("Type", ITEM_TYPE_USER_VIEW)
collection_type = view.get("CollectionType", "")
# Determine media class based on collection type
if collection_type == "movies":
media_class = MediaClass.MOVIE
elif collection_type == "tvshows":
@@ -109,17 +125,14 @@ async def _build_root_browse(api: EmbyApiClient, user_id: str) -> BrowseMedia:
else:
media_class = MediaClass.DIRECTORY
thumbnail = api.get_image_url(item_id, max_width=300) if item_id else None
children.append(
BrowseMedia(
media_class=media_class,
media_content_id=item_id,
media_content_type=MediaType.CHANNELS, # Library view
media_content_type=MediaType.CHANNELS,
title=name,
can_play=False,
can_expand=True,
thumbnail=thumbnail,
)
)
@@ -138,31 +151,33 @@ async def _build_item_browse(
api: EmbyApiClient, user_id: str, item_id: str
) -> BrowseMedia:
"""Build browse media structure for a specific item."""
# Get the item details
item = await api.get_item(user_id, item_id)
item_type = item.get("Type", "")
item_name = item.get("Name", "Unknown")
# Get children items
children_data = await api.get_items(
user_id=user_id,
parent_id=item_id,
limit=200,
fields=["PrimaryImageAspectRatio", "BasicSyncInfo", "Overview"],
limit=MAX_PAGE_SIZE,
fields=["PrimaryImageAspectRatio"],
)
children = []
for child in children_data.get("Items", []):
child_media = _build_browse_media_item(api, child)
raw_children = (
children_data.get("Items", [])
if isinstance(children_data, dict)
else []
)
children: list[BrowseMedia] = []
for child in raw_children:
if not isinstance(child, dict):
continue
child_media = _build_browse_media_item(child)
if child_media:
children.append(child_media)
# Determine media class and type for parent
media_class = ITEM_TYPE_TO_MEDIA_CLASS.get(item_type, MediaClass.DIRECTORY)
media_type = ITEM_TYPE_TO_MEDIA_TYPE.get(item_type, MediaType.CHANNELS)
thumbnail = api.get_image_url(item_id, max_width=300)
return BrowseMedia(
media_class=media_class,
media_content_id=item_id,
@@ -171,11 +186,10 @@ async def _build_item_browse(
can_play=item_type in PLAYABLE_ITEM_TYPES,
can_expand=True,
children=children,
thumbnail=thumbnail,
)
def _build_browse_media_item(api: EmbyApiClient, item: dict[str, Any]) -> BrowseMedia | None:
def _build_browse_media_item(item: dict[str, Any]) -> BrowseMedia | None:
"""Build a BrowseMedia item from Emby item data."""
item_id = item.get("Id")
if not item_id:
@@ -184,7 +198,6 @@ def _build_browse_media_item(api: EmbyApiClient, item: dict[str, Any]) -> Browse
item_type = item.get("Type", "")
name = item.get("Name", "Unknown")
# Build title for episodes with season/episode numbers
if item_type == ITEM_TYPE_EPISODE:
season_num = item.get("ParentIndexNumber")
episode_num = item.get("IndexNumber")
@@ -193,7 +206,6 @@ def _build_browse_media_item(api: EmbyApiClient, item: dict[str, Any]) -> Browse
elif episode_num is not None:
name = f"E{episode_num:02d} - {name}"
# Build title for tracks with track number
if item_type == ITEM_TYPE_AUDIO:
track_num = item.get("IndexNumber")
artists = item.get("Artists", [])
@@ -202,30 +214,14 @@ def _build_browse_media_item(api: EmbyApiClient, item: dict[str, Any]) -> Browse
if artists:
name = f"{name} - {', '.join(artists)}"
# Get media class and type
media_class = ITEM_TYPE_TO_MEDIA_CLASS.get(item_type, MediaClass.VIDEO)
media_type = ITEM_TYPE_TO_MEDIA_TYPE.get(item_type, MediaType.VIDEO)
# Determine if playable/expandable
can_play = item_type in PLAYABLE_ITEM_TYPES
can_expand = item_type in EXPANDABLE_ITEM_TYPES
# Get thumbnail URL
# For episodes, prefer series or season image
image_item_id = item_id
if item_type == ITEM_TYPE_EPISODE:
image_item_id = item.get("SeriesId") or item.get("SeasonId") or item_id
elif item_type == ITEM_TYPE_AUDIO:
image_item_id = item.get("AlbumId") or item_id
thumbnail = api.get_image_url(image_item_id, max_width=300)
return BrowseMedia(
media_class=media_class,
media_content_id=item_id,
media_content_type=media_type,
title=name,
can_play=can_play,
can_expand=can_expand,
thumbnail=thumbnail,
can_play=item_type in PLAYABLE_ITEM_TYPES,
can_expand=item_type in EXPANDABLE_ITEM_TYPES,
)
+151 -46
View File
@@ -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(
+59 -8
View File
@@ -9,31 +9,55 @@ CONF_HOST: Final = "host"
CONF_PORT: Final = "port"
CONF_API_KEY: Final = "api_key"
CONF_SSL: Final = "ssl"
CONF_VERIFY_SSL: Final = "verify_ssl"
CONF_USER_ID: Final = "user_id"
CONF_SCAN_INTERVAL: Final = "scan_interval"
# Defaults
DEFAULT_PORT: Final = 8096
DEFAULT_SSL: Final = False
DEFAULT_SCAN_INTERVAL: Final = 10 # seconds
DEFAULT_VERIFY_SSL: Final = True
DEFAULT_SCAN_INTERVAL: Final = 10 # seconds (polling fallback)
DEFAULT_SCAN_INTERVAL_WS: Final = 300 # seconds, when WebSocket is connected
# Emby ticks conversion (1 tick = 100 nanoseconds = 0.0000001 seconds)
TICKS_PER_SECOND: Final = 10_000_000
# API endpoints (with /emby prefix for Emby Server)
ENDPOINT_SYSTEM_INFO: Final = "/emby/System/Info"
ENDPOINT_SYSTEM_PING: Final = "/emby/System/Ping"
ENDPOINT_USERS: Final = "/emby/Users"
ENDPOINT_SESSIONS: Final = "/emby/Sessions"
ENDPOINT_ITEMS: Final = "/emby/Items"
ENDPOINT_PREFIX_EMBY: Final = "/emby"
ENDPOINT_PREFIX_NONE: Final = ""
ENDPOINT_SYSTEM_INFO: Final = "/System/Info"
ENDPOINT_SYSTEM_PING: Final = "/System/Ping"
ENDPOINT_USERS: Final = "/Users"
ENDPOINT_USERS_PUBLIC: Final = "/Users/Public"
ENDPOINT_SESSIONS: Final = "/Sessions"
ENDPOINT_ITEMS: Final = "/Items"
ENDPOINT_ARTISTS: Final = "/Artists"
ENDPOINT_LIBRARY_REFRESH: Final = "/Library/Refresh"
# WebSocket
WEBSOCKET_PATH: Final = "/embywebsocket"
WS_RECONNECT_MIN_DELAY: Final = 5 # seconds
WS_RECONNECT_MAX_DELAY: Final = 300 # seconds (5 min cap)
WS_HEARTBEAT: Final = 30 # seconds
# Device identification for Home Assistant
DEVICE_ID: Final = "homeassistant_emby_player"
DEVICE_NAME: Final = "Home Assistant"
DEVICE_VERSION: Final = "1.0.0"
# Fallback version string when the manifest version can't be read.
DEFAULT_DEVICE_VERSION: Final = "0.0.0"
# Emby IDs are typically 32-char hex (with optional dashes / underscores);
# bound length to reject pathological inputs while still allowing the slight
# variations seen across Emby Server versions.
EMBY_ID_PATTERN: Final = r"^[A-Za-z0-9_-]{1,128}$"
# Whitelist of Emby image types we may request.
ALLOWED_IMAGE_TYPES: Final = frozenset(
{"Primary", "Backdrop", "Thumb", "Logo", "Banner", "Art", "Disc", "Box"}
)
# Image fetches can be larger than regular API calls.
IMAGE_FETCH_TIMEOUT_SECONDS: Final = 30
# Media types
MEDIA_TYPE_VIDEO: Final = "Video"
@@ -70,6 +94,18 @@ COMMAND_SET_VOLUME: Final = "SetVolume"
COMMAND_MUTE: Final = "Mute"
COMMAND_UNMUTE: Final = "Unmute"
COMMAND_TOGGLE_MUTE: Final = "ToggleMute"
COMMAND_SET_REPEAT_MODE: Final = "SetRepeatMode"
COMMAND_DISPLAY_MESSAGE: Final = "DisplayMessage"
COMMAND_SEND_STRING: Final = "SendString"
# Repeat modes (Emby)
REPEAT_MODE_NONE: Final = "RepeatNone"
REPEAT_MODE_ONE: Final = "RepeatOne"
REPEAT_MODE_ALL: Final = "RepeatAll"
# Shuffle modes (Emby)
SHUFFLE_MODE_SORTED: Final = "Sorted"
SHUFFLE_MODE_SHUFFLE: Final = "Shuffle"
# WebSocket message types
WS_MESSAGE_SESSIONS_START: Final = "SessionsStart"
@@ -78,6 +114,8 @@ WS_MESSAGE_SESSIONS: Final = "Sessions"
WS_MESSAGE_PLAYBACK_START: Final = "PlaybackStart"
WS_MESSAGE_PLAYBACK_STOP: Final = "PlaybackStopped"
WS_MESSAGE_PLAYBACK_PROGRESS: Final = "PlaybackProgress"
WS_MESSAGE_KEEP_ALIVE: Final = "KeepAlive"
WS_MESSAGE_FORCE_KEEP_ALIVE: Final = "ForceKeepAlive"
# Attributes for extra state
ATTR_ITEM_ID: Final = "item_id"
@@ -87,3 +125,16 @@ ATTR_DEVICE_ID: Final = "device_id"
ATTR_DEVICE_NAME: Final = "device_name"
ATTR_CLIENT_NAME: Final = "client_name"
ATTR_USER_NAME: Final = "user_name"
ATTR_PLAY_METHOD: Final = "play_method"
# Service attributes
ATTR_MESSAGE: Final = "message"
ATTR_HEADER: Final = "header"
ATTR_TIMEOUT_MS: Final = "timeout_ms"
ATTR_REPEAT_MODE: Final = "repeat_mode"
# Stale session cleanup
STALE_SESSION_TIMEOUT: Final = 1800 # 30 minutes
# Don't prune devices until the integration has been running this long, to
# avoid wiping freshly restarted entities before they reappear.
STALE_PRUNE_GRACE_SECONDS: Final = 600 # 10 minutes
+195 -62
View File
@@ -3,15 +3,22 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import timedelta
from collections.abc import Callable
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from homeassistant.util.dt import utcnow
from .api import EmbyApiClient, EmbyApiError
from .api import EmbyApiClient, EmbyApiError, EmbyAuthenticationError
from .const import (
DEFAULT_SCAN_INTERVAL_WS,
DOMAIN,
TICKS_PER_SECOND,
WS_MESSAGE_PLAYBACK_PROGRESS,
@@ -24,7 +31,17 @@ from .websocket import EmbyWebSocket
_LOGGER = logging.getLogger(__name__)
@dataclass
def _safe_int(value: Any, default: int | None = None) -> int | None:
"""Best-effort int coercion that tolerates strings and bad data."""
if value is None:
return default
try:
return int(value)
except (TypeError, ValueError):
return default
@dataclass(frozen=True)
class EmbyNowPlaying:
"""Currently playing media information."""
@@ -42,8 +59,8 @@ class EmbyNowPlaying:
duration_ticks: int = 0
primary_image_tag: str | None = None
primary_image_item_id: str | None = None
backdrop_image_tags: list[str] = field(default_factory=list)
genres: list[str] = field(default_factory=list)
backdrop_image_tags: tuple[str, ...] = ()
genres: tuple[str, ...] = ()
production_year: int | None = None
overview: str | None = None
@@ -53,10 +70,11 @@ class EmbyNowPlaying:
return self.duration_ticks / TICKS_PER_SECOND if self.duration_ticks else 0
@dataclass
@dataclass(frozen=True)
class EmbyPlayState:
"""Playback state information."""
updated_at: datetime
is_paused: bool = False
is_muted: bool = False
volume_level: int = 100 # 0-100
@@ -72,7 +90,7 @@ class EmbyPlayState:
return self.position_ticks / TICKS_PER_SECOND if self.position_ticks else 0
@dataclass
@dataclass(frozen=True)
class EmbySession:
"""Represents an Emby client session."""
@@ -86,8 +104,9 @@ class EmbySession:
supports_remote_control: bool = True
now_playing: EmbyNowPlaying | None = None
play_state: EmbyPlayState | None = None
playable_media_types: list[str] = field(default_factory=list)
supported_commands: list[str] = field(default_factory=list)
playable_media_types: tuple[str, ...] = ()
supported_commands: tuple[str, ...] = ()
last_seen: datetime = field(default_factory=utcnow)
@property
def is_playing(self) -> bool:
@@ -114,7 +133,14 @@ class EmbySession:
class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
"""Coordinator for Emby data with WebSocket + polling fallback."""
"""Coordinator for Emby data with WebSocket + polling fallback.
When the WebSocket is connected we trust it as the source of truth for
each session. A slow REST poll still runs as a safety net and merges
its result with the WS-derived state: any session whose ``last_seen``
is newer than the moment we started the REST request keeps the WS
version intact.
"""
def __init__(
self,
@@ -132,18 +158,34 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
)
self.api = api
self._websocket = websocket
self._ws_connected = False
self._remove_ws_callback: callable | None = None
self._poll_interval = scan_interval
self._remove_ws_callback: Callable[[], None] | None = None
# Per-session last-seen timestamps (kept after a session leaves
# ``data`` so we can age it out cleanly).
self._session_last_seen: dict[str, datetime] = {}
@property
def websocket_connected(self) -> bool:
"""Return True if WebSocket is currently connected."""
return self._websocket.connected
def get_session_last_seen(self, session_id: str) -> datetime | None:
"""Return the last time we saw this session, or None if never."""
return self._session_last_seen.get(session_id)
def forget_session(self, session_id: str) -> None:
"""Drop a session's last-seen entry (called when its device is pruned)."""
self._session_last_seen.pop(session_id, None)
async def async_setup(self) -> None:
"""Set up the coordinator with WebSocket connection."""
# Try to establish WebSocket connection
if await self._websocket.connect():
await self._websocket.subscribe_to_sessions()
self._remove_ws_callback = self._websocket.add_callback(
self._handle_ws_message
)
self._ws_connected = True
# When WS is connected, slow REST polling to a safety-net cadence.
self.update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL_WS)
_LOGGER.info("Emby WebSocket connected, using real-time updates")
else:
_LOGGER.warning(
@@ -156,35 +198,114 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
_LOGGER.debug("Handling WebSocket message: %s", message_type)
if message_type == WS_MESSAGE_SESSIONS:
# Full session list received
if isinstance(data, list):
sessions = self._parse_sessions(data)
self._record_last_seen(sessions)
self.async_set_updated_data(sessions)
return
elif message_type in (
if message_type in (
WS_MESSAGE_PLAYBACK_START,
WS_MESSAGE_PLAYBACK_STOP,
WS_MESSAGE_PLAYBACK_PROGRESS,
):
# Individual session update - trigger a refresh to get full state
# We could optimize this by updating only the affected session,
# but a full refresh ensures consistency
self.hass.async_create_task(self.async_request_refresh())
self._apply_playback_event(data, stopped=False)
return
if message_type == WS_MESSAGE_PLAYBACK_STOP:
self._apply_playback_event(data, stopped=True)
@callback
def _apply_playback_event(self, data: Any, *, stopped: bool) -> None:
"""Update a single session in place from a PlaybackProgress/Start/Stop event."""
if not isinstance(data, dict):
return
# SessionId is the per-client session; PlaySessionId is per-stream
# and can collide across devices, so we don't fall back to it.
session_id = data.get("SessionId")
if not session_id or not self.data:
return
session = self.data.get(session_id)
if session is None:
# We don't yet know this session; the next Sessions push will
# introduce it.
return
now = utcnow()
if stopped:
updated = replace(
session,
now_playing=None,
play_state=None,
last_seen=now,
)
else:
now_playing_data = data.get("NowPlayingItem") or data.get("Item")
now_playing = (
self._parse_now_playing(now_playing_data)
if now_playing_data
else session.now_playing
)
play_state_data = data.get("PlayState") or data
play_state = self._parse_play_state(play_state_data)
updated = replace(
session,
now_playing=now_playing,
play_state=play_state,
last_seen=now,
)
new_data = dict(self.data)
new_data[session_id] = updated
self._session_last_seen[session_id] = now
self.async_set_updated_data(new_data)
async def _async_update_data(self) -> dict[str, EmbySession]:
"""Fetch sessions from Emby API (polling fallback)."""
"""Fetch sessions from Emby API (polling fallback / periodic refresh)."""
request_started = utcnow()
try:
sessions_data = await self.api.get_sessions()
return self._parse_sessions(sessions_data)
except EmbyAuthenticationError as err:
# Surfacing ConfigEntryAuthFailed triggers the HA reauth flow.
raise ConfigEntryAuthFailed(
f"Authentication failed: {err}"
) from err
except EmbyApiError as err:
raise UpdateFailed(f"Error fetching Emby sessions: {err}") from err
def _parse_sessions(self, sessions_data: list[dict[str, Any]]) -> dict[str, EmbySession]:
rest_sessions = self._parse_sessions(sessions_data)
# If WebSocket is connected, prefer any session our WS callback has
# touched since we kicked off this REST request — its state is more
# current than what the REST snapshot just returned.
if self._websocket.connected and self.data:
for sid, ws_session in self.data.items():
if (
sid in rest_sessions
and ws_session.last_seen > request_started
):
rest_sessions[sid] = ws_session
self._record_last_seen(rest_sessions)
return rest_sessions
def _record_last_seen(self, sessions: dict[str, EmbySession]) -> None:
"""Update the last-seen map from a freshly parsed sessions dict."""
now = utcnow()
for sid in sessions:
self._session_last_seen[sid] = now
def _parse_sessions(
self, sessions_data: list[dict[str, Any]]
) -> dict[str, EmbySession]:
"""Parse session data into EmbySession objects."""
sessions: dict[str, EmbySession] = {}
now = utcnow()
for session_data in sessions_data:
# Only include sessions that support remote control
if not session_data.get("SupportsRemoteControl", False):
continue
@@ -192,19 +313,21 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
if not session_id:
continue
# Parse now playing item
now_playing = None
now_playing_data = session_data.get("NowPlayingItem")
if now_playing_data:
now_playing = self._parse_now_playing(now_playing_data)
now_playing = (
self._parse_now_playing(now_playing_data)
if now_playing_data
else None
)
# Parse play state
play_state = None
play_state_data = session_data.get("PlayState")
if play_state_data:
play_state = self._parse_play_state(play_state_data)
play_state = (
self._parse_play_state(play_state_data)
if play_state_data
else None
)
session = EmbySession(
sessions[session_id] = EmbySession(
session_id=session_id,
device_id=session_data.get("DeviceId", ""),
device_name=session_data.get("DeviceName", "Unknown Device"),
@@ -212,29 +335,33 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
app_version=session_data.get("ApplicationVersion"),
user_id=session_data.get("UserId"),
user_name=session_data.get("UserName"),
supports_remote_control=session_data.get("SupportsRemoteControl", True),
supports_remote_control=session_data.get(
"SupportsRemoteControl", True
),
now_playing=now_playing,
play_state=play_state,
playable_media_types=session_data.get("PlayableMediaTypes", []),
supported_commands=session_data.get("SupportedCommands", []),
playable_media_types=tuple(
session_data.get("PlayableMediaTypes", []) or []
),
supported_commands=tuple(
session_data.get("SupportedCommands", []) or []
),
last_seen=now,
)
sessions[session_id] = session
return sessions
def _parse_now_playing(self, data: dict[str, Any]) -> EmbyNowPlaying:
"""Parse now playing item data."""
# Get artists as string
artists = data.get("Artists", [])
artists = data.get("Artists", []) or []
artist = ", ".join(artists) if artists else data.get("AlbumArtist")
# Get the image item ID (for series/seasons, might be different from item ID)
# Pick the most relevant image source.
image_item_id = data.get("Id")
if data.get("SeriesId"):
image_item_id = data.get("SeriesId")
elif data.get("ParentId") and data.get("Type") == "Audio":
image_item_id = data.get("ParentId") # Use album ID for music
image_item_id = data.get("ParentId")
return EmbyNowPlaying(
item_id=data.get("Id", ""),
@@ -246,38 +373,44 @@ class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
album_artist=data.get("AlbumArtist"),
series_name=data.get("SeriesName"),
season_name=data.get("SeasonName"),
index_number=data.get("IndexNumber"),
parent_index_number=data.get("ParentIndexNumber"),
duration_ticks=data.get("RunTimeTicks", 0),
index_number=_safe_int(data.get("IndexNumber")),
parent_index_number=_safe_int(data.get("ParentIndexNumber")),
duration_ticks=_safe_int(data.get("RunTimeTicks"), default=0) or 0,
primary_image_tag=data.get("PrimaryImageTag"),
primary_image_item_id=image_item_id,
backdrop_image_tags=data.get("BackdropImageTags", []),
genres=data.get("Genres", []),
production_year=data.get("ProductionYear"),
backdrop_image_tags=tuple(data.get("BackdropImageTags", []) or []),
genres=tuple(data.get("Genres", []) or []),
production_year=_safe_int(data.get("ProductionYear")),
overview=data.get("Overview"),
)
def _parse_play_state(self, data: dict[str, Any]) -> EmbyPlayState:
"""Parse play state data."""
return EmbyPlayState(
is_paused=data.get("IsPaused", False),
is_muted=data.get("IsMuted", False),
volume_level=data.get("VolumeLevel", 100),
position_ticks=data.get("PositionTicks", 0),
can_seek=data.get("CanSeek", True),
repeat_mode=data.get("RepeatMode", "RepeatNone"),
shuffle_mode=data.get("ShuffleMode", "Sorted"),
updated_at=utcnow(),
is_paused=bool(data.get("IsPaused", False)),
is_muted=bool(data.get("IsMuted", False)),
volume_level=_safe_int(data.get("VolumeLevel"), default=100) or 0,
position_ticks=_safe_int(data.get("PositionTicks"), default=0) or 0,
can_seek=bool(data.get("CanSeek", True)),
repeat_mode=str(data.get("RepeatMode") or "RepeatNone"),
shuffle_mode=str(data.get("ShuffleMode") or "Sorted"),
play_method=data.get("PlayMethod"),
)
def update_scan_interval(self, interval: int) -> None:
"""Update the polling scan interval."""
self.update_interval = timedelta(seconds=interval)
_LOGGER.debug("Updated scan interval to %d seconds", interval)
"""Update the polling scan interval.
If WebSocket is connected, this only takes effect after disconnect.
"""
self._poll_interval = interval
if not self._websocket.connected:
self.update_interval = timedelta(seconds=interval)
_LOGGER.debug("Updated polling interval to %d seconds", interval)
async def async_shutdown(self) -> None:
"""Shut down the coordinator."""
if self._remove_ws_callback:
self._remove_ws_callback()
self._remove_ws_callback = None
await self._websocket.close()
@@ -0,0 +1,65 @@
"""Diagnostics support for Emby Media Player."""
from __future__ import annotations
import hashlib
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from . import EmbyConfigEntry
from .const import CONF_API_KEY
TO_REDACT_ENTRY = {CONF_API_KEY}
# Session-level fields that could allow Emby command injection if leaked
# alongside the API key.
TO_REDACT_SESSION = {"session_id", "device_id", "user_id"}
def _stable_token(value: str) -> str:
"""Stable, irreversible token for a session identifier.
The first 10 chars of an MD5 are enough to correlate entries in a single
diagnostics dump without exposing the real Emby ID.
"""
return "sid-" + hashlib.md5(value.encode("utf-8")).hexdigest()[:10] # noqa: S324
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: EmbyConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
runtime = entry.runtime_data
coordinator = runtime.coordinator
sessions_dump: dict[str, Any] = {}
if coordinator.data:
for sid, session in coordinator.data.items():
session_dict = asdict(session)
# Convert datetimes to isoformat for JSON friendliness.
for key, value in list(session_dict.items()):
if hasattr(value, "isoformat"):
session_dict[key] = value.isoformat()
# Nested play_state.updated_at may also be a datetime.
if isinstance(value, dict):
for sub_k, sub_v in list(value.items()):
if hasattr(sub_v, "isoformat"):
value[sub_k] = sub_v.isoformat()
sessions_dump[_stable_token(sid)] = async_redact_data(
session_dict, TO_REDACT_SESSION
)
return {
"entry": {
"title": entry.title,
"data": async_redact_data(dict(entry.data), TO_REDACT_ENTRY),
"options": dict(entry.options),
"unique_id": entry.unique_id,
},
"server_id": runtime.server_id,
"websocket_connected": coordinator.websocket_connected,
"last_update_success": coordinator.last_update_success,
"sessions": sessions_dump,
}
+20 -3
View File
@@ -4,9 +4,26 @@
"codeowners": [],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/your-repo/haos-integration-emby",
"documentation": "https://git.dolgolyov-family.by/alexei.dolgolyov/haos-integration-emby",
"integration_type": "hub",
"iot_class": "local_push",
"issue_tracker": "https://github.com/your-repo/haos-integration-emby/issues",
"issue_tracker": "https://git.dolgolyov-family.by/alexei.dolgolyov/haos-integration-emby/issues",
"loggers": ["custom_components.emby_player"],
"quality_scale": "silver",
"requirements": [],
"version": "1.0.0"
"ssdp": [
{
"manufacturer": "Emby"
},
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
"manufacturer": "Emby"
}
],
"version": "0.2.0",
"zeroconf": [
{
"type": "_emby._tcp.local."
}
]
}
+241 -83
View File
@@ -3,26 +3,30 @@
from __future__ import annotations
import logging
import re
from datetime import datetime
from typing import Any
from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerDeviceClass,
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
RepeatMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from homeassistant.util.dt import utcnow
from . import EmbyConfigEntry, EmbyRuntimeData
from .api import EmbyApiError
from .browse_media import async_browse_media
from .const import (
ATTR_CLIENT_NAME,
@@ -30,21 +34,32 @@ from .const import (
ATTR_DEVICE_NAME,
ATTR_ITEM_ID,
ATTR_ITEM_TYPE,
ATTR_PLAY_METHOD,
ATTR_SESSION_ID,
ATTR_USER_NAME,
DOMAIN,
EMBY_ID_PATTERN,
ITEM_TYPE_AUDIO,
ITEM_TYPE_EPISODE,
ITEM_TYPE_MOVIE,
MEDIA_TYPE_AUDIO,
MEDIA_TYPE_VIDEO,
PLAY_COMMAND_PLAY_LAST,
PLAY_COMMAND_PLAY_NEXT,
PLAY_COMMAND_PLAY_NOW,
REPEAT_MODE_ALL,
REPEAT_MODE_NONE,
REPEAT_MODE_ONE,
STALE_PRUNE_GRACE_SECONDS,
STALE_SESSION_TIMEOUT,
TICKS_PER_SECOND,
)
from .coordinator import EmbyCoordinator, EmbySession
_LOGGER = logging.getLogger(__name__)
# Supported features for Emby media player
_EMBY_ID_RE = re.compile(EMBY_ID_PATTERN)
SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PLAY
@@ -56,8 +71,37 @@ SUPPORTED_FEATURES = (
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.REPEAT_SET
)
# HA RepeatMode <-> Emby repeat mode
_HA_TO_EMBY_REPEAT = {
RepeatMode.OFF: REPEAT_MODE_NONE,
RepeatMode.ONE: REPEAT_MODE_ONE,
RepeatMode.ALL: REPEAT_MODE_ALL,
}
_EMBY_TO_HA_REPEAT = {v: k for k, v in _HA_TO_EMBY_REPEAT.items()}
# Explicit HA enqueue → Emby play-command mapping. Anything not in this map
# falls back to "PlayNow".
_ENQUEUE_TO_PLAY_COMMAND: dict[MediaPlayerEnqueue | None, str] = {
None: PLAY_COMMAND_PLAY_NOW,
MediaPlayerEnqueue.PLAY: PLAY_COMMAND_PLAY_NOW,
MediaPlayerEnqueue.REPLACE: PLAY_COMMAND_PLAY_NOW,
MediaPlayerEnqueue.NEXT: PLAY_COMMAND_PLAY_NEXT,
MediaPlayerEnqueue.ADD: PLAY_COMMAND_PLAY_LAST,
}
def _device_class_for_client(client_name: str) -> MediaPlayerDeviceClass | None:
"""Pick a sensible device class from the Emby client identifier."""
name = (client_name or "").lower()
if any(token in name for token in ("android tv", "fire tv", "roku", "kodi", "tv")):
return MediaPlayerDeviceClass.TV
if any(token in name for token in ("speaker", "music")):
return MediaPlayerDeviceClass.SPEAKER
return None
async def async_setup_entry(
hass: HomeAssistant,
@@ -68,18 +112,18 @@ async def async_setup_entry(
runtime_data: EmbyRuntimeData = entry.runtime_data
coordinator = runtime_data.coordinator
# Track which sessions we've already created entities for
tracked_sessions: set[str] = set()
setup_started = utcnow()
@callback
def async_update_entities() -> None:
"""Add new entities for new sessions."""
"""Add new entities for new sessions and prune stale ones."""
if coordinator.data is None:
return
current_sessions = set(coordinator.data.keys())
new_sessions = current_sessions - tracked_sessions
new_sessions = current_sessions - tracked_sessions
if new_sessions:
new_entities = [
EmbyMediaPlayer(coordinator, entry, session_id)
@@ -87,42 +131,89 @@ async def async_setup_entry(
]
async_add_entities(new_entities)
tracked_sessions.update(new_sessions)
_LOGGER.debug("Added %d new Emby media player entities", len(new_entities))
_LOGGER.debug(
"Added %d new Emby media player entities", len(new_entities)
)
_prune_stale_devices(
hass, entry, coordinator, tracked_sessions, setup_started
)
# Register listener for coordinator updates
entry.async_on_unload(coordinator.async_add_listener(async_update_entities))
# Add entities for existing sessions
async_update_entities()
@callback
def _prune_stale_devices(
hass: HomeAssistant,
entry: EmbyConfigEntry,
coordinator: EmbyCoordinator,
tracked_sessions: set[str],
setup_started: datetime,
) -> None:
"""Remove device registry entries for sessions absent for too long.
We use the coordinator's per-session ``last_seen`` map as the source of
truth, and skip pruning entirely for the first ``STALE_PRUNE_GRACE_SECONDS``
after setup so that sessions that haven't come online yet aren't wiped.
"""
if coordinator.data is None:
return
now = utcnow()
if (now - setup_started).total_seconds() < STALE_PRUNE_GRACE_SECONDS:
return
device_registry = dr.async_get(hass)
current_ids = set(coordinator.data.keys())
stale = tracked_sessions - current_ids
if not stale:
return
for session_id in list(stale):
last_seen = coordinator.get_session_last_seen(session_id)
if last_seen is None:
# Never seen, but we tracked it (created an entity) — likely
# added during this HA boot but already gone. Use setup_started
# as the floor.
last_seen = setup_started
if (now - last_seen).total_seconds() <= STALE_SESSION_TIMEOUT:
continue
device = device_registry.async_get_device(
identifiers={(DOMAIN, session_id)}
)
if device is not None:
_LOGGER.debug("Removing stale Emby device %s", session_id)
device_registry.async_remove_device(device.id)
tracked_sessions.discard(session_id)
coordinator.forget_session(session_id)
class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
"""Representation of an Emby media player."""
_attr_has_entity_name = True
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_name = None # use device name only
_attr_supported_features = SUPPORTED_FEATURES
def __init__(
self,
coordinator: EmbyCoordinator,
entry: ConfigEntry,
entry: EmbyConfigEntry,
session_id: str,
) -> None:
"""Initialize the Emby media player."""
super().__init__(coordinator)
self._entry = entry
self._session_id = session_id
self._last_position_update: datetime | None = None
# Get initial session info for naming
session = self._session
device_name = session.device_name if session else "Unknown"
client_name = session.client_name if session else "Unknown"
# Set unique ID and entity ID
self._attr_unique_id = f"{entry.entry_id}_{session_id}"
self._attr_name = f"{device_name} ({client_name})"
session = self._session
client_name = session.client_name if session else ""
device_class = _device_class_for_client(client_name)
if device_class is not None:
self._attr_device_class = device_class
@property
def _session(self) -> EmbySession | None:
@@ -139,7 +230,9 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.coordinator.last_update_success and self._session is not None
ws_ok = self.coordinator.websocket_connected
polling_ok = self.coordinator.last_update_success
return (ws_ok or polling_ok) and self._session is not None
@property
def device_info(self) -> DeviceInfo:
@@ -150,11 +243,12 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
return DeviceInfo(
identifiers={(DOMAIN, self._session_id)},
name=f"{device_name}",
name=device_name,
manufacturer="Emby",
model=client_name,
sw_version=session.app_version if session else None,
entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
)
@property
@@ -163,7 +257,6 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
session = self._session
if session is None:
return MediaPlayerState.OFF
if session.is_playing:
return MediaPlayerState.PLAYING
if session.is_paused:
@@ -198,32 +291,33 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
def media_content_type(self) -> MediaType | str | None:
"""Return the content type of current playing media."""
session = self._session
if session and session.now_playing:
media_type = session.now_playing.media_type
if media_type == MEDIA_TYPE_AUDIO:
return MediaType.MUSIC
if media_type == MEDIA_TYPE_VIDEO:
item_type = session.now_playing.item_type
if item_type == ITEM_TYPE_MOVIE:
return MediaType.MOVIE
if item_type == ITEM_TYPE_EPISODE:
return MediaType.TVSHOW
return MediaType.VIDEO
if not session or not session.now_playing:
return None
np = session.now_playing
if np.media_type == MEDIA_TYPE_AUDIO:
return MediaType.MUSIC
if np.media_type == MEDIA_TYPE_VIDEO:
if np.item_type == ITEM_TYPE_MOVIE:
return MediaType.MOVIE
if np.item_type == ITEM_TYPE_EPISODE:
return MediaType.TVSHOW
return MediaType.VIDEO
return None
@property
def media_title(self) -> str | None:
"""Return the title of current playing media."""
session = self._session
if session and session.now_playing:
np = session.now_playing
# For TV episodes, include series and episode info
if np.item_type == ITEM_TYPE_EPISODE and np.series_name:
season = f"S{np.parent_index_number:02d}" if np.parent_index_number else ""
episode = f"E{np.index_number:02d}" if np.index_number else ""
return f"{np.series_name} {season}{episode} - {np.name}"
return np.name
return None
if not session or not session.now_playing:
return None
np = session.now_playing
if np.item_type == ITEM_TYPE_EPISODE and np.series_name:
season = (
f"S{np.parent_index_number:02d}" if np.parent_index_number else ""
)
episode = f"E{np.index_number:02d}" if np.index_number else ""
return f"{np.series_name} {season}{episode} - {np.name}"
return np.name
@property
def media_artist(self) -> str | None:
@@ -291,26 +385,18 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
@property
def media_position_updated_at(self) -> datetime | None:
"""Return when position was last updated."""
"""Return when position was last updated by the coordinator."""
session = self._session
if session and session.play_state and session.now_playing:
return utcnow()
if session and session.play_state:
return session.play_state.updated_at
return None
@property
def media_image_url(self) -> str | None:
"""Return the image URL of current playing media."""
def repeat(self) -> RepeatMode | None:
"""Return current repeat mode."""
session = self._session
if session and session.now_playing:
np = session.now_playing
item_id = np.primary_image_item_id or np.item_id
if item_id:
return self._runtime_data.api.get_image_url(
item_id,
image_type="Primary",
max_width=500,
max_height=500,
)
if session and session.play_state:
return _EMBY_TO_HA_REPEAT.get(session.play_state.repeat_mode)
return None
@property
@@ -320,60 +406,106 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
if session is None:
return {}
attrs = {
attrs: dict[str, Any] = {
ATTR_SESSION_ID: session.session_id,
ATTR_DEVICE_ID: session.device_id,
ATTR_DEVICE_NAME: session.device_name,
ATTR_CLIENT_NAME: session.client_name,
ATTR_USER_NAME: session.user_name,
}
if session.user_name:
attrs[ATTR_USER_NAME] = session.user_name
if session.now_playing:
attrs[ATTR_ITEM_ID] = session.now_playing.item_id
attrs[ATTR_ITEM_TYPE] = session.now_playing.item_type
if session.play_state and session.play_state.play_method:
attrs[ATTR_PLAY_METHOD] = session.play_state.play_method
return attrs
@property
def media_image_remotely_accessible(self) -> bool:
"""The image is fetched server-side via our proxy."""
return False
# -------------------------------------------------------------------------
# Playback Control Methods
# Image proxying — keeps the API key off the client browser
# -------------------------------------------------------------------------
async def async_get_media_image(
self,
) -> tuple[bytes | None, str | None]:
"""Fetch the current media image server-side using the API key in a header."""
session = self._session
if not session or not session.now_playing:
return None, None
np = session.now_playing
item_id = np.primary_image_item_id or np.item_id
if not item_id:
return None, None
try:
return await self._runtime_data.api.fetch_image(
item_id,
image_type="Primary",
max_width=500,
max_height=500,
)
except EmbyApiError as err:
_LOGGER.debug("Failed to fetch media image: %s", err)
return None, None
async def async_get_browse_image(
self,
media_content_type: MediaType | str,
media_content_id: str,
media_image_id: str | None = None,
) -> tuple[bytes | None, str | None]:
"""Fetch a browse-tree image."""
if not media_content_id or not _EMBY_ID_RE.fullmatch(media_content_id):
return None, None
try:
return await self._runtime_data.api.fetch_image(
media_content_id,
image_type="Primary",
max_width=300,
)
except EmbyApiError as err:
_LOGGER.debug("Failed to fetch browse image: %s", err)
return None, None
# -------------------------------------------------------------------------
# Playback Control
# -------------------------------------------------------------------------
async def async_media_play(self) -> None:
"""Resume playback."""
await self._runtime_data.api.play(self._session_id)
await self.coordinator.async_request_refresh()
async def async_media_pause(self) -> None:
"""Pause playback."""
await self._runtime_data.api.pause(self._session_id)
await self.coordinator.async_request_refresh()
async def async_media_stop(self) -> None:
"""Stop playback."""
await self._runtime_data.api.stop(self._session_id)
await self.coordinator.async_request_refresh()
async def async_media_next_track(self) -> None:
"""Skip to next track."""
await self._runtime_data.api.next_track(self._session_id)
await self.coordinator.async_request_refresh()
async def async_media_previous_track(self) -> None:
"""Skip to previous track."""
await self._runtime_data.api.previous_track(self._session_id)
await self.coordinator.async_request_refresh()
async def async_media_seek(self, position: float) -> None:
"""Seek to position."""
position_ticks = int(position * TICKS_PER_SECOND)
position_ticks = max(0, round(position * TICKS_PER_SECOND))
await self._runtime_data.api.seek(self._session_id, position_ticks)
await self.coordinator.async_request_refresh()
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level (0.0-1.0)."""
volume_percent = int(volume * 100)
volume_percent = int(max(0.0, min(1.0, volume)) * 100)
await self._runtime_data.api.set_volume(self._session_id, volume_percent)
await self.coordinator.async_request_refresh()
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute."""
@@ -381,7 +513,11 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
await self._runtime_data.api.mute(self._session_id)
else:
await self._runtime_data.api.unmute(self._session_id)
await self.coordinator.async_request_refresh()
async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set repeat mode."""
emby_mode = _HA_TO_EMBY_REPEAT.get(repeat, REPEAT_MODE_NONE)
await self._runtime_data.api.set_repeat_mode(self._session_id, emby_mode)
# -------------------------------------------------------------------------
# Media Browsing & Playing
@@ -394,17 +530,39 @@ class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
**kwargs: Any,
) -> None:
"""Play a piece of media."""
_LOGGER.debug(
"async_play_media called: session_id=%s, media_type=%s, media_id=%s",
self._session_id,
media_type,
media_id,
if not isinstance(media_id, str) or not media_id.strip():
raise ServiceValidationError("media_id must be a non-empty string")
item_id = media_id.strip()
if not _EMBY_ID_RE.fullmatch(item_id):
raise ServiceValidationError(
f"media_id is not a valid Emby item id: {media_id!r}"
)
# Map HA enqueue semantics to Emby play commands.
enqueue = kwargs.get("enqueue")
play_command = _ENQUEUE_TO_PLAY_COMMAND.get(
enqueue, PLAY_COMMAND_PLAY_NOW
)
await self._runtime_data.api.play_media(
self._session_id,
item_ids=[media_id],
)
await self.coordinator.async_request_refresh()
position = kwargs.get("position")
if position is not None:
if not isinstance(position, (int, float)) or position < 0:
raise ServiceValidationError(
"position must be a non-negative number"
)
start_position_ticks = round(position * TICKS_PER_SECOND)
else:
start_position_ticks = 0
try:
await self._runtime_data.api.play_media(
self._session_id,
item_ids=[item_id],
play_command=play_command,
start_position_ticks=start_position_ticks,
)
except EmbyApiError as err:
raise ServiceValidationError(str(err)) from err
async def async_browse_media(
self,
+205
View File
@@ -0,0 +1,205 @@
"""Services for the Emby Media Player integration."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import entity_registry as er
from .api import EmbyApiError
from .const import (
ATTR_HEADER,
ATTR_MESSAGE,
ATTR_REPEAT_MODE,
ATTR_TIMEOUT_MS,
DOMAIN,
REPEAT_MODE_ALL,
REPEAT_MODE_NONE,
REPEAT_MODE_ONE,
)
if TYPE_CHECKING:
from . import EmbyRuntimeData
from .api import EmbyApiClient
_LOGGER = logging.getLogger(__name__)
SERVICE_SEND_MESSAGE = "send_message"
SERVICE_SET_REPEAT = "set_repeat"
SERVICE_REFRESH_LIBRARY = "refresh_library"
_REPEAT_MODES = (REPEAT_MODE_NONE, REPEAT_MODE_ONE, REPEAT_MODE_ALL)
_SEND_MESSAGE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(ATTR_MESSAGE): cv.string,
vol.Optional(ATTR_HEADER): cv.string,
vol.Optional(ATTR_TIMEOUT_MS): vol.All(
cv.positive_int, vol.Range(min=100, max=60000)
),
}
)
_SET_REPEAT_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(ATTR_REPEAT_MODE): vol.In(_REPEAT_MODES),
}
)
_REFRESH_LIBRARY_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
}
)
def _resolve_sessions(
hass: HomeAssistant, entity_ids: list[str]
) -> list[tuple["EmbyRuntimeData", str]]:
"""Resolve entity_ids into (runtime_data, session_id) pairs."""
entity_registry = er.async_get(hass)
resolved: list[tuple[EmbyRuntimeData, str]] = []
for entity_id in entity_ids:
entry = entity_registry.async_get(entity_id)
if (
entry is None
or entry.platform != DOMAIN
or entry.domain != MEDIA_PLAYER_DOMAIN
):
raise ServiceValidationError(
f"{entity_id} is not an Emby media player"
)
config_entry = hass.config_entries.async_get_entry(entry.config_entry_id)
if (
config_entry is None
or config_entry.state is not ConfigEntryState.LOADED
):
raise HomeAssistantError(
f"Emby integration for {entity_id} is not loaded"
)
# unique_id is "{entry_id}_{session_id}"
prefix = f"{config_entry.entry_id}_"
unique_id = entry.unique_id or ""
if not unique_id.startswith(prefix):
raise HomeAssistantError(
f"Cannot resolve session for {entity_id}"
)
session_id = unique_id.removeprefix(prefix)
resolved.append((config_entry.runtime_data, session_id))
return resolved
def _resolve_apis(
hass: HomeAssistant, entity_ids: list[str] | None
) -> list["EmbyApiClient"]:
"""Resolve unique API clients for the supplied entities, or all entries."""
if entity_ids:
return [rd.api for rd, _ in _resolve_sessions(hass, entity_ids)]
return [
entry.runtime_data.api
for entry in hass.config_entries.async_entries(DOMAIN)
if entry.state is ConfigEntryState.LOADED
]
async def async_setup_services(hass: HomeAssistant) -> None:
"""Register integration-level services (idempotent)."""
if hass.services.has_service(DOMAIN, SERVICE_SEND_MESSAGE):
return
async def _send_message(call: ServiceCall) -> None:
targets = _resolve_sessions(hass, call.data[ATTR_ENTITY_ID])
message = call.data[ATTR_MESSAGE]
header = call.data.get(ATTR_HEADER)
timeout_ms = call.data.get(ATTR_TIMEOUT_MS)
errors: list[str] = []
for runtime_data, session_id in targets:
try:
await runtime_data.api.display_message(
session_id, message, header=header, timeout_ms=timeout_ms
)
except EmbyApiError as err:
errors.append(f"{session_id}: {err}")
if errors:
raise HomeAssistantError(
"Failed to send message to: " + "; ".join(errors)
)
async def _set_repeat(call: ServiceCall) -> None:
targets = _resolve_sessions(hass, call.data[ATTR_ENTITY_ID])
mode = call.data[ATTR_REPEAT_MODE]
errors: list[str] = []
for runtime_data, session_id in targets:
try:
await runtime_data.api.set_repeat_mode(session_id, mode)
except EmbyApiError as err:
errors.append(f"{session_id}: {err}")
if errors:
raise HomeAssistantError(
"Failed to set repeat mode on: " + "; ".join(errors)
)
async def _refresh_library(call: ServiceCall) -> None:
apis = _resolve_apis(hass, call.data.get(ATTR_ENTITY_ID))
# De-duplicate API instances (multiple entities can share one server).
seen: set[int] = set()
errors: list[str] = []
for api in apis:
if id(api) in seen:
continue
seen.add(id(api))
try:
await api.refresh_library()
except EmbyApiError as err:
errors.append(str(err))
if errors:
raise HomeAssistantError(
"Failed to refresh library on one or more servers: "
+ "; ".join(errors)
)
hass.services.async_register(
DOMAIN, SERVICE_SEND_MESSAGE, _send_message, schema=_SEND_MESSAGE_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_SET_REPEAT, _set_repeat, schema=_SET_REPEAT_SCHEMA
)
hass.services.async_register(
DOMAIN,
SERVICE_REFRESH_LIBRARY,
_refresh_library,
schema=_REFRESH_LIBRARY_SCHEMA,
)
def async_unload_services(hass: HomeAssistant) -> None:
"""Remove integration-level services."""
for service in (
SERVICE_SEND_MESSAGE,
SERVICE_SET_REPEAT,
SERVICE_REFRESH_LIBRARY,
):
if hass.services.has_service(DOMAIN, service):
hass.services.async_remove(DOMAIN, service)
@@ -0,0 +1,63 @@
send_message:
name: Send Message
description: >-
Display a message on the Emby client device.
target:
entity:
integration: emby_player
domain: media_player
fields:
message:
name: Message
description: The text to display on the client.
required: true
example: "Pizza is here!"
selector:
text:
header:
name: Header
description: Optional header/title for the message.
example: "Doorbell"
selector:
text:
timeout_ms:
name: Timeout (ms)
description: How long to display the message, in milliseconds.
example: 5000
default: 5000
selector:
number:
min: 100
max: 60000
step: 100
mode: box
unit_of_measurement: ms
set_repeat:
name: Set Repeat Mode
description: Set the repeat mode of the current playback.
target:
entity:
integration: emby_player
domain: media_player
fields:
repeat_mode:
name: Repeat Mode
description: One of RepeatNone, RepeatOne, RepeatAll.
required: true
selector:
select:
options:
- RepeatNone
- RepeatOne
- RepeatAll
refresh_library:
name: Refresh Library
description: >-
Trigger an Emby server library scan. If no entity is provided, all
configured Emby servers are refreshed.
target:
entity:
integration: emby_player
domain: media_player
+45 -2
View File
@@ -8,7 +8,8 @@
"host": "Host",
"port": "Port",
"api_key": "API Key",
"ssl": "Use SSL"
"ssl": "Use SSL",
"verify_ssl": "Verify SSL certificate"
}
},
"user_select": {
@@ -17,6 +18,13 @@
"data": {
"user_id": "User"
}
},
"reauth_confirm": {
"title": "Re-authenticate Emby",
"description": "Provide a new API key for {host}. The previous key is no longer accepted by the server.",
"data": {
"api_key": "API Key"
}
}
},
"error": {
@@ -26,7 +34,8 @@
"unknown": "An unexpected error occurred."
},
"abort": {
"already_configured": "This Emby server and user combination is already configured."
"already_configured": "This Emby server and user combination is already configured.",
"reauth_successful": "Re-authentication was successful."
}
},
"options": {
@@ -39,5 +48,39 @@
}
}
}
},
"services": {
"send_message": {
"name": "Send message",
"description": "Display a message on the Emby client.",
"fields": {
"message": {
"name": "Message",
"description": "The text to display."
},
"header": {
"name": "Header",
"description": "Optional header/title."
},
"timeout_ms": {
"name": "Timeout (ms)",
"description": "How long to display the message in milliseconds."
}
}
},
"set_repeat": {
"name": "Set repeat mode",
"description": "Change the current Emby session's repeat mode.",
"fields": {
"repeat_mode": {
"name": "Repeat mode",
"description": "RepeatNone, RepeatOne or RepeatAll."
}
}
},
"refresh_library": {
"name": "Refresh library",
"description": "Trigger an Emby library scan."
}
}
}
@@ -8,7 +8,8 @@
"host": "Host",
"port": "Port",
"api_key": "API Key",
"ssl": "Use SSL"
"ssl": "Use SSL",
"verify_ssl": "Verify SSL certificate"
}
},
"user_select": {
@@ -17,6 +18,13 @@
"data": {
"user_id": "User"
}
},
"reauth_confirm": {
"title": "Re-authenticate Emby",
"description": "Provide a new API key for {host}. The previous key is no longer accepted by the server.",
"data": {
"api_key": "API Key"
}
}
},
"error": {
@@ -26,7 +34,8 @@
"unknown": "An unexpected error occurred."
},
"abort": {
"already_configured": "This Emby server and user combination is already configured."
"already_configured": "This Emby server and user combination is already configured.",
"reauth_successful": "Re-authentication was successful."
}
},
"options": {
@@ -39,5 +48,39 @@
}
}
}
},
"services": {
"send_message": {
"name": "Send message",
"description": "Display a message on the Emby client.",
"fields": {
"message": {
"name": "Message",
"description": "The text to display."
},
"header": {
"name": "Header",
"description": "Optional header/title."
},
"timeout_ms": {
"name": "Timeout (ms)",
"description": "How long to display the message in milliseconds."
}
}
},
"set_repeat": {
"name": "Set repeat mode",
"description": "Change the current Emby session's repeat mode.",
"fields": {
"repeat_mode": {
"name": "Repeat mode",
"description": "RepeatNone, RepeatOne or RepeatAll."
}
}
},
"refresh_library": {
"name": "Refresh library",
"description": "Trigger an Emby library scan."
}
}
}
+183 -96
View File
@@ -3,129 +3,181 @@
from __future__ import annotations
import asyncio
import inspect
import json
import logging
from collections.abc import Callable
import random
from collections.abc import Awaitable, Callable
from typing import Any
import aiohttp
from .const import (
DEVICE_ID,
DEFAULT_DEVICE_VERSION,
DEVICE_NAME,
DEVICE_VERSION,
WEBSOCKET_PATH,
WS_HEARTBEAT,
WS_MESSAGE_FORCE_KEEP_ALIVE,
WS_MESSAGE_KEEP_ALIVE,
WS_MESSAGE_PLAYBACK_PROGRESS,
WS_MESSAGE_PLAYBACK_START,
WS_MESSAGE_PLAYBACK_STOP,
WS_MESSAGE_SESSIONS,
WS_MESSAGE_SESSIONS_START,
WS_MESSAGE_SESSIONS_STOP,
WS_RECONNECT_MAX_DELAY,
WS_RECONNECT_MIN_DELAY,
)
_LOGGER = logging.getLogger(__name__)
# Message types we're interested in
TRACKED_MESSAGE_TYPES = {
WS_MESSAGE_SESSIONS,
WS_MESSAGE_PLAYBACK_START,
WS_MESSAGE_PLAYBACK_STOP,
WS_MESSAGE_PLAYBACK_PROGRESS,
}
# Message types we surface to subscribers
TRACKED_MESSAGE_TYPES = frozenset(
{
WS_MESSAGE_SESSIONS,
WS_MESSAGE_PLAYBACK_START,
WS_MESSAGE_PLAYBACK_STOP,
WS_MESSAGE_PLAYBACK_PROGRESS,
}
)
# Callbacks may be sync or async; both forms are supported.
WSCallback = Callable[[str, Any], Awaitable[None] | None]
# Bound exponent so we don't overflow on long outages.
_MAX_BACKOFF_EXPONENT = 6
class EmbyWebSocket:
"""WebSocket client for real-time Emby updates."""
"""WebSocket client for real-time Emby updates.
The aiohttp session is owned by Home Assistant and is never closed here.
"""
def __init__(
self,
host: str,
port: int,
api_key: str,
device_id: str,
session: aiohttp.ClientSession,
ssl: bool = False,
session: aiohttp.ClientSession | None = None,
verify_ssl: bool = True,
client_version: str = DEFAULT_DEVICE_VERSION,
) -> None:
"""Initialize the WebSocket client."""
self._host = host
if not host or not host.strip():
raise ValueError("host must not be empty")
if not api_key:
raise ValueError("api_key must not be empty")
if not device_id:
raise ValueError("device_id must not be empty")
self._host = host.strip().rstrip("/")
self._port = port
self._api_key = api_key
self._device_id = device_id
self._ssl = ssl
self._verify_ssl = verify_ssl
self._session = session
self._owns_session = session is None
self._client_version = client_version
protocol = "wss" if ssl else "ws"
self._url = f"{protocol}://{host}:{port}{WEBSOCKET_PATH}"
self._url = f"{protocol}://{self._host}:{port}{WEBSOCKET_PATH}"
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._callbacks: list[Callable[[str, Any], None]] = []
self._listen_task: asyncio.Task | None = None
self._callbacks: list[WSCallback] = []
self._listen_task: asyncio.Task[None] | None = None
self._reconnect_task: asyncio.Task[None] | None = None
self._running = False
self._reconnect_interval = 30 # seconds
self._reconnect_attempts = 0
@property
def connected(self) -> bool:
"""Return True if connected to WebSocket."""
return self._ws is not None and not self._ws.closed
async def _ensure_session(self) -> aiohttp.ClientSession:
"""Ensure an aiohttp session exists."""
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
self._owns_session = True
return self._session
def _ssl_kwarg(self) -> dict[str, Any]:
"""Return ssl kwarg for aiohttp depending on config."""
if not self._ssl:
return {}
return {"ssl": self._verify_ssl}
def _backoff_delay(self) -> float:
"""Compute exponential backoff with jitter for reconnects."""
exponent = min(self._reconnect_attempts, _MAX_BACKOFF_EXPONENT)
base = min(
WS_RECONNECT_MAX_DELAY,
WS_RECONNECT_MIN_DELAY * (2**exponent),
)
jitter = random.uniform(0, base * 0.2) # noqa: S311 - non-crypto jitter
return base + jitter
async def connect(self) -> bool:
"""Connect to Emby WebSocket."""
"""Connect to Emby WebSocket. Returns True on success."""
if self.connected:
return True
session = await self._ensure_session()
# Build WebSocket URL with authentication params
params = {
"api_key": self._api_key,
"deviceId": DEVICE_ID,
# API token in headers (not query string) keeps it out of proxy logs.
headers = {
"X-Emby-Token": self._api_key,
"X-Emby-Client": DEVICE_NAME,
"X-Emby-Device-Name": DEVICE_NAME,
"X-Emby-Device-Id": self._device_id,
"X-Emby-Client-Version": self._client_version,
}
# deviceId is also required as a query param by some Emby versions.
params = {"deviceId": self._device_id}
try:
self._ws = await session.ws_connect(
self._ws = await self._session.ws_connect(
self._url,
params=params,
heartbeat=30,
headers=headers,
heartbeat=WS_HEARTBEAT,
timeout=aiohttp.ClientTimeout(total=10),
**self._ssl_kwarg(),
)
self._running = True
_LOGGER.debug("Connected to Emby WebSocket at %s", self._url)
# Start listening for messages
self._listen_task = asyncio.create_task(self._listen())
return True
except aiohttp.WSServerHandshakeError as err:
if err.status in (401, 403):
_LOGGER.warning("WebSocket auth failed: %s", err)
self._running = False
return False
_LOGGER.warning("WebSocket handshake failed: %s", err)
return False
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to connect to Emby WebSocket: %s", err)
return False
except Exception as err:
_LOGGER.exception("Unexpected error connecting to WebSocket: %s", err)
except TimeoutError:
_LOGGER.warning("Timeout connecting to Emby WebSocket")
return False
self._running = True
self._reconnect_attempts = 0
_LOGGER.debug("Connected to Emby WebSocket at %s", self._url)
self._listen_task = asyncio.create_task(
self._listen(), name="emby_ws_listen"
)
return True
async def _listen(self) -> None:
"""Listen for WebSocket messages."""
if not self._ws:
ws = self._ws
if ws is None:
return
try:
async for msg in self._ws:
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
await self._handle_message(data)
await self._handle_message(json.loads(msg.data))
except json.JSONDecodeError:
_LOGGER.warning("Invalid JSON received: %s", msg.data)
_LOGGER.debug("Invalid JSON received: %s", msg.data)
elif msg.type == aiohttp.WSMsgType.ERROR:
_LOGGER.error(
"WebSocket error: %s", self._ws.exception() if self._ws else "Unknown"
_LOGGER.debug(
"WebSocket error: %s",
ws.exception() if ws else "Unknown",
)
break
@@ -139,50 +191,78 @@ class EmbyWebSocket:
except asyncio.CancelledError:
_LOGGER.debug("WebSocket listener cancelled")
except Exception as err:
_LOGGER.exception("Error in WebSocket listener: %s", err)
raise
except Exception: # noqa: BLE001 - log and reconnect
_LOGGER.exception("Unexpected error in WebSocket listener")
finally:
self._ws = None
self._schedule_reconnect()
# Attempt reconnection if still running
if self._running:
_LOGGER.info(
"WebSocket disconnected, will reconnect in %d seconds",
self._reconnect_interval,
)
asyncio.create_task(self._reconnect())
def _schedule_reconnect(self) -> None:
"""Schedule a reconnect attempt unless one is already pending."""
if not self._running:
return
if self._reconnect_task is not None and not self._reconnect_task.done():
# Already scheduled; do not stack reconnects.
return
async def _reconnect(self) -> None:
"""Attempt to reconnect to WebSocket."""
await asyncio.sleep(self._reconnect_interval)
self._reconnect_attempts += 1
delay = self._backoff_delay()
_LOGGER.info(
"WebSocket disconnected, reconnecting in %.1fs (attempt %d)",
delay,
self._reconnect_attempts,
)
self._reconnect_task = asyncio.create_task(
self._reconnect(delay), name="emby_ws_reconnect"
)
if self._running and not self.connected:
_LOGGER.debug("Attempting WebSocket reconnection...")
if await self.connect():
await self.subscribe_to_sessions()
async def _reconnect(self, delay: float) -> None:
"""Attempt to reconnect to WebSocket after a delay."""
try:
await asyncio.sleep(delay)
except asyncio.CancelledError:
return
if not self._running or self.connected:
return
_LOGGER.debug("Attempting WebSocket reconnection...")
if await self.connect():
await self.subscribe_to_sessions()
async def _handle_message(self, message: dict[str, Any]) -> None:
"""Handle an incoming WebSocket message."""
msg_type = message.get("MessageType", "")
data = message.get("Data")
_LOGGER.debug("Received WebSocket message: %s", msg_type)
# Echo ForceKeepAlive so Emby doesn't drop the connection.
if msg_type == WS_MESSAGE_FORCE_KEEP_ALIVE:
await self._send_message(WS_MESSAGE_KEEP_ALIVE, "")
return
if msg_type in TRACKED_MESSAGE_TYPES:
# Notify all callbacks
for callback in self._callbacks:
try:
callback(msg_type, data)
except Exception:
_LOGGER.exception("Error in WebSocket callback")
if msg_type not in TRACKED_MESSAGE_TYPES:
return
for cb in list(self._callbacks):
try:
result = cb(msg_type, data)
if inspect.isawaitable(result):
# Detach so a slow async callback doesn't block the reader.
asyncio.create_task(
_swallow_callback(result),
name="emby_ws_callback",
)
except Exception: # noqa: BLE001 - never let a cb kill us
_LOGGER.exception("Error in WebSocket callback")
async def subscribe_to_sessions(self) -> None:
"""Subscribe to session updates."""
if not self.connected:
_LOGGER.warning("Cannot subscribe: WebSocket not connected")
_LOGGER.debug("Cannot subscribe: WebSocket not connected")
return
# Request session updates every 1500ms
# Request session updates roughly every 1500ms.
await self._send_message(WS_MESSAGE_SESSIONS_START, "0,1500")
_LOGGER.debug("Subscribed to session updates")
@@ -193,23 +273,19 @@ class EmbyWebSocket:
async def _send_message(self, message_type: str, data: Any) -> None:
"""Send a message through the WebSocket."""
if not self._ws or self._ws.closed:
ws = self._ws
if ws is None or ws.closed:
return
message = {
"MessageType": message_type,
"Data": data,
}
try:
await self._ws.send_json(message)
except Exception as err:
_LOGGER.warning("Failed to send WebSocket message: %s", err)
await ws.send_json({"MessageType": message_type, "Data": data})
except aiohttp.ClientError as err:
_LOGGER.debug("Failed to send WebSocket message: %s", err)
def add_callback(self, callback: Callable[[str, Any], None]) -> Callable[[], None]:
"""Add a callback for WebSocket messages.
def add_callback(self, callback: WSCallback) -> Callable[[], None]:
"""Register a callback for tracked WebSocket messages.
Returns a function to remove the callback.
Returns a function that removes the callback when called.
"""
self._callbacks.append(callback)
@@ -219,26 +295,37 @@ class EmbyWebSocket:
return remove_callback
async def disconnect(self) -> None:
"""Disconnect from WebSocket."""
async def close(self) -> None:
"""Close the WebSocket and cancel any pending reconnect."""
self._running = False
if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
try:
await self._reconnect_task
except asyncio.CancelledError:
pass
self._reconnect_task = None
if self._listen_task and not self._listen_task.done():
self._listen_task.cancel()
try:
await self._listen_task
except asyncio.CancelledError:
pass
self._listen_task = None
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
self._callbacks.clear()
_LOGGER.debug("Disconnected from Emby WebSocket")
async def close(self) -> None:
"""Close the WebSocket and session."""
await self.disconnect()
if self._owns_session and self._session and not self._session.closed:
await self._session.close()
async def _swallow_callback(awaitable: Awaitable[None]) -> None:
"""Run an async callback and log any exception."""
try:
await awaitable
except Exception: # noqa: BLE001
_LOGGER.exception("Error in async WebSocket callback")