Initial commit for Emby Media Player HAOS HACS integration
All checks were successful
Validate / Hassfest (push) Successful in 9s

This commit is contained in:
2026-02-05 00:15:04 +03:00
commit 46cb2fbac2
20 changed files with 2603 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
"""Emby Media Player integration for Home Assistant."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .api import EmbyApiClient, EmbyConnectionError
from .const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SCAN_INTERVAL,
CONF_SSL,
CONF_USER_ID,
DEFAULT_SCAN_INTERVAL,
DEFAULT_SSL,
DOMAIN,
)
from .coordinator import EmbyCoordinator
from .websocket import EmbyWebSocket
if TYPE_CHECKING:
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.MEDIA_PLAYER]
@dataclass
class EmbyRuntimeData:
"""Runtime data for Emby integration."""
coordinator: EmbyCoordinator
api: EmbyApiClient
websocket: EmbyWebSocket
user_id: str
type EmbyConfigEntry = ConfigEntry[EmbyRuntimeData]
async def async_setup_entry(hass: HomeAssistant, entry: EmbyConfigEntry) -> bool:
"""Set up Emby Media Player from a config entry."""
host = entry.data[CONF_HOST]
port = int(entry.data[CONF_PORT])
api_key = entry.data[CONF_API_KEY]
ssl = entry.data.get(CONF_SSL, DEFAULT_SSL)
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
session = async_get_clientsession(hass)
# Create API client
api = EmbyApiClient(
host=host,
port=port,
api_key=api_key,
ssl=ssl,
session=session,
)
# Test connection
try:
await api.test_connection()
except EmbyConnectionError as 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,
session=session,
)
# Create coordinator
coordinator = EmbyCoordinator(
hass=hass,
api=api,
websocket=websocket,
scan_interval=scan_interval,
)
# Set up WebSocket connection
await coordinator.async_setup()
# Fetch initial data
await coordinator.async_config_entry_first_refresh()
# Store runtime data
entry.runtime_data = EmbyRuntimeData(
coordinator=coordinator,
api=api,
websocket=websocket,
user_id=user_id,
)
# Set up platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# 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:
"""Handle options update."""
scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
entry.runtime_data.coordinator.update_scan_interval(scan_interval)
_LOGGER.debug("Updated Emby scan interval to %d seconds", scan_interval)
async def async_unload_entry(hass: HomeAssistant, entry: EmbyConfigEntry) -> bool:
"""Unload a config entry."""
# Unload 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()
return unload_ok

View File

@@ -0,0 +1,392 @@
"""Emby REST API client."""
from __future__ import annotations
import logging
from typing import Any
import aiohttp
from .const import (
COMMAND_MUTE,
COMMAND_SET_VOLUME,
COMMAND_UNMUTE,
DEFAULT_PORT,
DEVICE_ID,
DEVICE_NAME,
DEVICE_VERSION,
ENDPOINT_ITEMS,
ENDPOINT_SESSIONS,
ENDPOINT_SYSTEM_INFO,
ENDPOINT_USERS,
PLAY_COMMAND_PLAY_NOW,
PLAYBACK_COMMAND_NEXT_TRACK,
PLAYBACK_COMMAND_PAUSE,
PLAYBACK_COMMAND_PREVIOUS_TRACK,
PLAYBACK_COMMAND_SEEK,
PLAYBACK_COMMAND_STOP,
PLAYBACK_COMMAND_UNPAUSE,
)
_LOGGER = logging.getLogger(__name__)
class EmbyApiError(Exception):
"""Base exception for Emby API errors."""
class EmbyConnectionError(EmbyApiError):
"""Exception for connection errors."""
class EmbyAuthenticationError(EmbyApiError):
"""Exception for authentication errors."""
class EmbyApiClient:
"""Emby REST API client."""
def __init__(
self,
host: str,
api_key: str,
port: int = DEFAULT_PORT,
ssl: bool = False,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Initialize the Emby API client."""
self._host = host
self._port = port
self._api_key = api_key
self._ssl = ssl
self._session = session
self._owns_session = session is None
protocol = "https" if ssl else "http"
self._base_url = f"{protocol}://{host}:{port}"
@property
def base_url(self) -> str:
"""Return the base URL."""
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
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()
def _get_headers(self) -> dict[str, str]:
"""Get headers for API requests."""
return {
"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",
"Accept": "application/json",
}
async def _request(
self,
method: str,
endpoint: str,
params: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
) -> Any:
"""Make an API request."""
session = await self._ensure_session()
url = f"{self._base_url}{endpoint}"
_LOGGER.debug("Making %s request to %s", method, url)
try:
async with 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
) as response:
_LOGGER.debug("Response status: %s", response.status)
if response.status == 401:
raise EmbyAuthenticationError("Invalid API key")
if response.status == 403:
raise EmbyAuthenticationError("Access forbidden")
if response.status >= 400:
text = await response.text()
_LOGGER.error("API error %s: %s", response.status, text)
raise EmbyApiError(f"API error {response.status}: {text}")
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)
raise EmbyConnectionError(f"Connection error: {err}") from err
except TimeoutError as err:
_LOGGER.error("Timeout connecting to %s", url)
raise EmbyConnectionError(f"Connection timeout: {err}") from err
async def _get(
self, endpoint: str, params: dict[str, Any] | None = None
) -> Any:
"""Make a GET request."""
return await self._request("GET", endpoint, params=params)
async def _post(
self,
endpoint: str,
params: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
) -> Any:
"""Make a POST request."""
return await self._request("POST", endpoint, params=params, data=data)
# -------------------------------------------------------------------------
# Authentication & System
# -------------------------------------------------------------------------
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.
"""
# 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)
# 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
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)
# -------------------------------------------------------------------------
# Sessions
# -------------------------------------------------------------------------
async def get_sessions(self) -> list[dict[str, Any]]:
"""Get all active sessions."""
return await self._get(ENDPOINT_SESSIONS)
async def get_controllable_sessions(
self, user_id: str | None = None
) -> list[dict[str, Any]]:
"""Get sessions that can be remotely controlled."""
params = {}
if user_id:
params["ControllableByUserId"] = user_id
sessions = await self._get(ENDPOINT_SESSIONS, params=params)
return [s for s in sessions if s.get("SupportsRemoteControl")]
# -------------------------------------------------------------------------
# Playback Control
# -------------------------------------------------------------------------
async def play_media(
self,
session_id: str,
item_ids: list[str],
play_command: str = PLAY_COMMAND_PLAY_NOW,
start_position_ticks: int = 0,
) -> None:
"""Send play command to a session."""
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing"
params = {
"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."""
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing/{command}"
await self._post(endpoint)
async def play(self, session_id: str) -> None:
"""Resume playback."""
await self._playback_command(session_id, PLAYBACK_COMMAND_UNPAUSE)
async def pause(self, session_id: str) -> None:
"""Pause playback."""
await self._playback_command(session_id, PLAYBACK_COMMAND_PAUSE)
async def stop(self, session_id: str) -> None:
"""Stop playback."""
await self._playback_command(session_id, PLAYBACK_COMMAND_STOP)
async def next_track(self, session_id: str) -> None:
"""Skip to next track."""
await self._playback_command(session_id, PLAYBACK_COMMAND_NEXT_TRACK)
async def previous_track(self, session_id: str) -> None:
"""Skip to previous track."""
await self._playback_command(session_id, PLAYBACK_COMMAND_PREVIOUS_TRACK)
async def seek(self, session_id: str, position_ticks: int) -> None:
"""Seek to a position."""
endpoint = f"{ENDPOINT_SESSIONS}/{session_id}/Playing/{PLAYBACK_COMMAND_SEEK}"
await self._post(endpoint, params={"SeekPositionTicks": position_ticks})
# -------------------------------------------------------------------------
# Volume Control
# -------------------------------------------------------------------------
async def _send_command(
self, session_id: str, command: str, arguments: dict[str, Any] | None = None
) -> None:
"""Send a general command to a session."""
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})
async def mute(self, session_id: str) -> None:
"""Mute the session."""
await self._send_command(session_id, COMMAND_MUTE)
async def unmute(self, session_id: str) -> None:
"""Unmute the session."""
await self._send_command(session_id, COMMAND_UNMUTE)
# -------------------------------------------------------------------------
# Library Browsing
# -------------------------------------------------------------------------
async def get_views(self, user_id: str) -> list[dict[str, Any]]:
"""Get user's library views (top-level folders)."""
endpoint = f"{ENDPOINT_USERS}/{user_id}/Views"
result = await self._get(endpoint)
return result.get("Items", [])
async def get_items(
self,
user_id: str,
parent_id: str | None = None,
include_item_types: list[str] | None = None,
recursive: bool = False,
sort_by: str = "SortName",
sort_order: str = "Ascending",
start_index: int = 0,
limit: int = 100,
search_term: str | None = None,
fields: list[str] | None = None,
) -> dict[str, Any]:
"""Get items from the library."""
endpoint = f"{ENDPOINT_USERS}/{user_id}/Items"
params: dict[str, Any] = {
"SortBy": sort_by,
"SortOrder": sort_order,
"StartIndex": start_index,
"Limit": limit,
"Recursive": str(recursive).lower(),
}
if parent_id:
params["ParentId"] = parent_id
if include_item_types:
params["IncludeItemTypes"] = ",".join(include_item_types)
if search_term:
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."""
endpoint = f"{ENDPOINT_USERS}/{user_id}/Items/{item_id}"
return await self._get(endpoint)
async def get_artists(
self,
user_id: str,
parent_id: str | None = None,
start_index: int = 0,
limit: int = 100,
) -> dict[str, Any]:
"""Get artists."""
endpoint = "/emby/Artists"
params: dict[str, Any] = {
"UserId": user_id,
"StartIndex": start_index,
"Limit": limit,
"SortBy": "SortName",
"SortOrder": "Ascending",
}
if parent_id:
params["ParentId"] = parent_id
return await self._get(endpoint, params=params)
def get_image_url(
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 = []
if max_width:
params.append(f"maxWidth={max_width}")
if max_height:
params.append(f"maxHeight={max_height}")
params.append(f"api_key={self._api_key}")
if params:
url += "?" + "&".join(params)
return url

View File

@@ -0,0 +1,231 @@
"""Media browser for Emby Media Player integration."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType
from homeassistant.core import HomeAssistant
from .api import EmbyApiClient
from .const import (
ITEM_TYPE_AUDIO,
ITEM_TYPE_COLLECTION_FOLDER,
ITEM_TYPE_EPISODE,
ITEM_TYPE_FOLDER,
ITEM_TYPE_MOVIE,
ITEM_TYPE_MUSIC_ALBUM,
ITEM_TYPE_MUSIC_ARTIST,
ITEM_TYPE_PLAYLIST,
ITEM_TYPE_SEASON,
ITEM_TYPE_SERIES,
ITEM_TYPE_USER_VIEW,
)
_LOGGER = logging.getLogger(__name__)
# Map Emby item types to Home Assistant media classes
ITEM_TYPE_TO_MEDIA_CLASS: dict[str, MediaClass] = {
ITEM_TYPE_MOVIE: MediaClass.MOVIE,
ITEM_TYPE_SERIES: MediaClass.TV_SHOW,
ITEM_TYPE_SEASON: MediaClass.SEASON,
ITEM_TYPE_EPISODE: MediaClass.EPISODE,
ITEM_TYPE_AUDIO: MediaClass.TRACK,
ITEM_TYPE_MUSIC_ALBUM: MediaClass.ALBUM,
ITEM_TYPE_MUSIC_ARTIST: MediaClass.ARTIST,
ITEM_TYPE_PLAYLIST: MediaClass.PLAYLIST,
ITEM_TYPE_FOLDER: MediaClass.DIRECTORY,
ITEM_TYPE_COLLECTION_FOLDER: MediaClass.DIRECTORY,
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,
ITEM_TYPE_SEASON: MediaType.SEASON,
ITEM_TYPE_EPISODE: MediaType.EPISODE,
ITEM_TYPE_AUDIO: MediaType.TRACK,
ITEM_TYPE_MUSIC_ALBUM: MediaType.ALBUM,
ITEM_TYPE_MUSIC_ARTIST: MediaType.ARTIST,
ITEM_TYPE_PLAYLIST: MediaType.PLAYLIST,
}
# Item types that can be played directly
PLAYABLE_ITEM_TYPES = {
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,
}
async def async_browse_media(
hass: HomeAssistant,
api: EmbyApiClient,
user_id: str,
media_content_type: MediaType | str | None,
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)
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)
children = []
for view in views:
item_id = view.get("Id")
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":
media_class = MediaClass.TV_SHOW
elif collection_type == "music":
media_class = MediaClass.MUSIC
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
title=name,
can_play=False,
can_expand=True,
thumbnail=thumbnail,
)
)
return BrowseMedia(
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type=MediaType.CHANNELS,
title="Emby Library",
can_play=False,
can_expand=True,
children=children,
)
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"],
)
children = []
for child in children_data.get("Items", []):
child_media = _build_browse_media_item(api, 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,
media_content_type=media_type,
title=item_name,
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:
"""Build a BrowseMedia item from Emby item data."""
item_id = item.get("Id")
if not item_id:
return None
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")
if season_num is not None and episode_num is not None:
name = f"S{season_num:02d}E{episode_num:02d} - {name}"
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", [])
if track_num is not None:
name = f"{track_num}. {name}"
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,
)

View File

@@ -0,0 +1,230 @@
"""Config flow for Emby Media Player integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.core import callback
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .api import EmbyApiClient, EmbyAuthenticationError, EmbyConnectionError
from .const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SCAN_INTERVAL,
CONF_SSL,
CONF_USER_ID,
DEFAULT_PORT,
DEFAULT_SCAN_INTERVAL,
DEFAULT_SSL,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
class EmbyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Emby Media Player."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._host: str | None = None
self._port: int = DEFAULT_PORT
self._api_key: str | None = None
self._ssl: bool = DEFAULT_SSL
self._users: list[dict[str, Any]] = []
self._server_info: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step - server connection."""
errors: dict[str, str] = {}
if user_input is not None:
self._host = user_input[CONF_HOST].strip()
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)
_LOGGER.debug(
"Testing connection to %s:%s (SSL: %s)",
self._host,
self._port,
self._ssl,
)
# 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()
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(
NumberSelectorConfig(
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,
}
),
errors=errors,
)
async def async_step_user_select(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user selection step."""
errors: dict[str, str] = {}
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()
server_name = self._server_info.get("ServerName", self._host)
return self.async_create_entry(
title=f"{server_name} ({user_name})",
data={
CONF_HOST: self._host,
CONF_PORT: self._port,
CONF_API_KEY: self._api_key,
CONF_SSL: self._ssl,
CONF_USER_ID: user_id,
},
options={
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
},
)
# Build user selection options
user_options = [
{"value": user["Id"], "label": user["Name"]} for user in self._users
]
return self.async_show_form(
step_id="user_select",
data_schema=vol.Schema(
{
vol.Required(CONF_USER_ID): SelectSelector(
SelectSelectorConfig(
options=user_options,
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> EmbyOptionsFlow:
"""Get the options flow for this handler."""
return EmbyOptionsFlow(config_entry)
class EmbyOptionsFlow(OptionsFlow):
"""Handle options flow for Emby Media Player."""
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize options flow."""
self._config_entry = config_entry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
current_interval = self._config_entry.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_SCAN_INTERVAL, default=current_interval
): NumberSelector(
NumberSelectorConfig(
min=5,
max=60,
step=1,
mode=NumberSelectorMode.SLIDER,
unit_of_measurement="seconds",
)
),
}
),
)

View File

@@ -0,0 +1,89 @@
"""Constants for the Emby Media Player integration."""
from typing import Final
DOMAIN: Final = "emby_player"
# Configuration keys
CONF_HOST: Final = "host"
CONF_PORT: Final = "port"
CONF_API_KEY: Final = "api_key"
CONF_SSL: Final = "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
# 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"
# WebSocket
WEBSOCKET_PATH: Final = "/embywebsocket"
# Device identification for Home Assistant
DEVICE_ID: Final = "homeassistant_emby_player"
DEVICE_NAME: Final = "Home Assistant"
DEVICE_VERSION: Final = "1.0.0"
# Media types
MEDIA_TYPE_VIDEO: Final = "Video"
MEDIA_TYPE_AUDIO: Final = "Audio"
# Item types
ITEM_TYPE_MOVIE: Final = "Movie"
ITEM_TYPE_EPISODE: Final = "Episode"
ITEM_TYPE_SERIES: Final = "Series"
ITEM_TYPE_SEASON: Final = "Season"
ITEM_TYPE_AUDIO: Final = "Audio"
ITEM_TYPE_MUSIC_ALBUM: Final = "MusicAlbum"
ITEM_TYPE_MUSIC_ARTIST: Final = "MusicArtist"
ITEM_TYPE_PLAYLIST: Final = "Playlist"
ITEM_TYPE_FOLDER: Final = "Folder"
ITEM_TYPE_COLLECTION_FOLDER: Final = "CollectionFolder"
ITEM_TYPE_USER_VIEW: Final = "UserView"
# Play commands
PLAY_COMMAND_PLAY_NOW: Final = "PlayNow"
PLAY_COMMAND_PLAY_NEXT: Final = "PlayNext"
PLAY_COMMAND_PLAY_LAST: Final = "PlayLast"
# Playback state commands
PLAYBACK_COMMAND_STOP: Final = "Stop"
PLAYBACK_COMMAND_PAUSE: Final = "Pause"
PLAYBACK_COMMAND_UNPAUSE: Final = "Unpause"
PLAYBACK_COMMAND_NEXT_TRACK: Final = "NextTrack"
PLAYBACK_COMMAND_PREVIOUS_TRACK: Final = "PreviousTrack"
PLAYBACK_COMMAND_SEEK: Final = "Seek"
# General commands
COMMAND_SET_VOLUME: Final = "SetVolume"
COMMAND_MUTE: Final = "Mute"
COMMAND_UNMUTE: Final = "Unmute"
COMMAND_TOGGLE_MUTE: Final = "ToggleMute"
# WebSocket message types
WS_MESSAGE_SESSIONS_START: Final = "SessionsStart"
WS_MESSAGE_SESSIONS_STOP: Final = "SessionsStop"
WS_MESSAGE_SESSIONS: Final = "Sessions"
WS_MESSAGE_PLAYBACK_START: Final = "PlaybackStart"
WS_MESSAGE_PLAYBACK_STOP: Final = "PlaybackStopped"
WS_MESSAGE_PLAYBACK_PROGRESS: Final = "PlaybackProgress"
# Attributes for extra state
ATTR_ITEM_ID: Final = "item_id"
ATTR_ITEM_TYPE: Final = "item_type"
ATTR_SESSION_ID: Final = "session_id"
ATTR_DEVICE_ID: Final = "device_id"
ATTR_DEVICE_NAME: Final = "device_name"
ATTR_CLIENT_NAME: Final = "client_name"
ATTR_USER_NAME: Final = "user_name"

View File

@@ -0,0 +1,283 @@
"""Data coordinator for Emby Media Player integration."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import EmbyApiClient, EmbyApiError
from .const import (
DOMAIN,
TICKS_PER_SECOND,
WS_MESSAGE_PLAYBACK_PROGRESS,
WS_MESSAGE_PLAYBACK_START,
WS_MESSAGE_PLAYBACK_STOP,
WS_MESSAGE_SESSIONS,
)
from .websocket import EmbyWebSocket
_LOGGER = logging.getLogger(__name__)
@dataclass
class EmbyNowPlaying:
"""Currently playing media information."""
item_id: str
name: str
media_type: str # Audio, Video
item_type: str # Movie, Episode, Audio, etc.
artist: str | None = None
album: str | None = None
album_artist: str | None = None
series_name: str | None = None
season_name: str | None = None
index_number: int | None = None # Episode number
parent_index_number: int | None = None # Season number
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)
production_year: int | None = None
overview: str | None = None
@property
def duration_seconds(self) -> float:
"""Get duration in seconds."""
return self.duration_ticks / TICKS_PER_SECOND if self.duration_ticks else 0
@dataclass
class EmbyPlayState:
"""Playback state information."""
is_paused: bool = False
is_muted: bool = False
volume_level: int = 100 # 0-100
position_ticks: int = 0
can_seek: bool = True
repeat_mode: str = "RepeatNone"
shuffle_mode: str = "Sorted"
play_method: str | None = None # DirectPlay, DirectStream, Transcode
@property
def position_seconds(self) -> float:
"""Get position in seconds."""
return self.position_ticks / TICKS_PER_SECOND if self.position_ticks else 0
@dataclass
class EmbySession:
"""Represents an Emby client session."""
session_id: str
device_id: str
device_name: str
client_name: str
app_version: str | None = None
user_id: str | None = None
user_name: str | None = None
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)
@property
def is_playing(self) -> bool:
"""Return True if media is currently playing (not paused)."""
return (
self.now_playing is not None
and self.play_state is not None
and not self.play_state.is_paused
)
@property
def is_paused(self) -> bool:
"""Return True if media is paused."""
return (
self.now_playing is not None
and self.play_state is not None
and self.play_state.is_paused
)
@property
def is_idle(self) -> bool:
"""Return True if session is idle (no media playing)."""
return self.now_playing is None
class EmbyCoordinator(DataUpdateCoordinator[dict[str, EmbySession]]):
"""Coordinator for Emby data with WebSocket + polling fallback."""
def __init__(
self,
hass: HomeAssistant,
api: EmbyApiClient,
websocket: EmbyWebSocket,
scan_interval: int,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=scan_interval),
)
self.api = api
self._websocket = websocket
self._ws_connected = False
self._remove_ws_callback: callable | None = 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
_LOGGER.info("Emby WebSocket connected, using real-time updates")
else:
_LOGGER.warning(
"Emby WebSocket connection failed, using polling fallback"
)
@callback
def _handle_ws_message(self, message_type: str, data: Any) -> None:
"""Handle incoming WebSocket message."""
_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.async_set_updated_data(sessions)
elif 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())
async def _async_update_data(self) -> dict[str, EmbySession]:
"""Fetch sessions from Emby API (polling fallback)."""
try:
sessions_data = await self.api.get_sessions()
return self._parse_sessions(sessions_data)
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]:
"""Parse session data into EmbySession objects."""
sessions: dict[str, EmbySession] = {}
for session_data in sessions_data:
# Only include sessions that support remote control
if not session_data.get("SupportsRemoteControl", False):
continue
session_id = session_data.get("Id")
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)
# 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)
session = EmbySession(
session_id=session_id,
device_id=session_data.get("DeviceId", ""),
device_name=session_data.get("DeviceName", "Unknown Device"),
client_name=session_data.get("Client", "Unknown Client"),
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),
now_playing=now_playing,
play_state=play_state,
playable_media_types=session_data.get("PlayableMediaTypes", []),
supported_commands=session_data.get("SupportedCommands", []),
)
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", [])
artist = ", ".join(artists) if artists else data.get("AlbumArtist")
# Get the image item ID (for series/seasons, might be different from item ID)
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
return EmbyNowPlaying(
item_id=data.get("Id", ""),
name=data.get("Name", ""),
media_type=data.get("MediaType", ""),
item_type=data.get("Type", ""),
artist=artist,
album=data.get("Album"),
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),
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"),
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"),
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)
async def async_shutdown(self) -> None:
"""Shut down the coordinator."""
if self._remove_ws_callback:
self._remove_ws_callback()
await self._websocket.close()

View File

@@ -0,0 +1,12 @@
{
"domain": "emby_player",
"name": "Emby Media Player",
"codeowners": [],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/your-repo/haos-integration-emby",
"iot_class": "local_push",
"issue_tracker": "https://github.com/your-repo/haos-integration-emby/issues",
"requirements": [],
"version": "1.0.0"
}

View File

@@ -0,0 +1,421 @@
"""Media player platform for Emby Media Player integration."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
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 .browse_media import async_browse_media
from .const import (
ATTR_CLIENT_NAME,
ATTR_DEVICE_ID,
ATTR_DEVICE_NAME,
ATTR_ITEM_ID,
ATTR_ITEM_TYPE,
ATTR_SESSION_ID,
ATTR_USER_NAME,
DOMAIN,
ITEM_TYPE_AUDIO,
ITEM_TYPE_EPISODE,
ITEM_TYPE_MOVIE,
MEDIA_TYPE_AUDIO,
MEDIA_TYPE_VIDEO,
TICKS_PER_SECOND,
)
from .coordinator import EmbyCoordinator, EmbySession
_LOGGER = logging.getLogger(__name__)
# Supported features for Emby media player
SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
)
async def async_setup_entry(
hass: HomeAssistant,
entry: EmbyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Emby media player entities."""
runtime_data: EmbyRuntimeData = entry.runtime_data
coordinator = runtime_data.coordinator
# Track which sessions we've already created entities for
tracked_sessions: set[str] = set()
@callback
def async_update_entities() -> None:
"""Add new entities for new sessions."""
if coordinator.data is None:
return
current_sessions = set(coordinator.data.keys())
new_sessions = current_sessions - tracked_sessions
if new_sessions:
new_entities = [
EmbyMediaPlayer(coordinator, entry, session_id)
for session_id in new_sessions
]
async_add_entities(new_entities)
tracked_sessions.update(new_sessions)
_LOGGER.debug("Added %d new Emby media player entities", len(new_entities))
# Register listener for coordinator updates
entry.async_on_unload(coordinator.async_add_listener(async_update_entities))
# Add entities for existing sessions
async_update_entities()
class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
"""Representation of an Emby media player."""
_attr_has_entity_name = True
_attr_device_class = MediaPlayerDeviceClass.TV
_attr_supported_features = SUPPORTED_FEATURES
def __init__(
self,
coordinator: EmbyCoordinator,
entry: ConfigEntry,
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})"
@property
def _session(self) -> EmbySession | None:
"""Get current session data."""
if self.coordinator.data is None:
return None
return self.coordinator.data.get(self._session_id)
@property
def _runtime_data(self) -> EmbyRuntimeData:
"""Get runtime data."""
return self._entry.runtime_data
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.coordinator.last_update_success and self._session is not None
@property
def device_info(self) -> DeviceInfo:
"""Return device info."""
session = self._session
device_name = session.device_name if session else "Unknown Device"
client_name = session.client_name if session else "Unknown"
return DeviceInfo(
identifiers={(DOMAIN, self._session_id)},
name=f"{device_name}",
manufacturer="Emby",
model=client_name,
sw_version=session.app_version if session else None,
entry_type=DeviceEntryType.SERVICE,
)
@property
def state(self) -> MediaPlayerState:
"""Return the state of the player."""
session = self._session
if session is None:
return MediaPlayerState.OFF
if session.is_playing:
return MediaPlayerState.PLAYING
if session.is_paused:
return MediaPlayerState.PAUSED
return MediaPlayerState.IDLE
@property
def volume_level(self) -> float | None:
"""Return volume level (0.0-1.0)."""
session = self._session
if session and session.play_state:
return session.play_state.volume_level / 100
return None
@property
def is_volume_muted(self) -> bool | None:
"""Return True if volume is muted."""
session = self._session
if session and session.play_state:
return session.play_state.is_muted
return None
@property
def media_content_id(self) -> str | None:
"""Return the content ID of current playing media."""
session = self._session
if session and session.now_playing:
return session.now_playing.item_id
return None
@property
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
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
@property
def media_artist(self) -> str | None:
"""Return the artist of current playing media."""
session = self._session
if session and session.now_playing:
return session.now_playing.artist
return None
@property
def media_album_name(self) -> str | None:
"""Return the album name of current playing media."""
session = self._session
if session and session.now_playing:
return session.now_playing.album
return None
@property
def media_album_artist(self) -> str | None:
"""Return the album artist of current playing media."""
session = self._session
if session and session.now_playing:
return session.now_playing.album_artist
return None
@property
def media_series_title(self) -> str | None:
"""Return the series title for TV shows."""
session = self._session
if session and session.now_playing:
return session.now_playing.series_name
return None
@property
def media_season(self) -> str | None:
"""Return the season for TV shows."""
session = self._session
if session and session.now_playing and session.now_playing.parent_index_number:
return str(session.now_playing.parent_index_number)
return None
@property
def media_episode(self) -> str | None:
"""Return the episode for TV shows."""
session = self._session
if session and session.now_playing and session.now_playing.index_number:
return str(session.now_playing.index_number)
return None
@property
def media_duration(self) -> int | None:
"""Return the duration of current playing media in seconds."""
session = self._session
if session and session.now_playing:
return int(session.now_playing.duration_seconds)
return None
@property
def media_position(self) -> int | None:
"""Return the position of current playing media in seconds."""
session = self._session
if session and session.play_state:
return int(session.play_state.position_seconds)
return None
@property
def media_position_updated_at(self) -> datetime | None:
"""Return when position was last updated."""
session = self._session
if session and session.play_state and session.now_playing:
return utcnow()
return None
@property
def media_image_url(self) -> str | None:
"""Return the image URL of current playing media."""
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,
)
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes."""
session = self._session
if session is None:
return {}
attrs = {
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.now_playing:
attrs[ATTR_ITEM_ID] = session.now_playing.item_id
attrs[ATTR_ITEM_TYPE] = session.now_playing.item_type
return attrs
# -------------------------------------------------------------------------
# Playback Control Methods
# -------------------------------------------------------------------------
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)
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)
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."""
if mute:
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()
# -------------------------------------------------------------------------
# Media Browsing & Playing
# -------------------------------------------------------------------------
async def async_play_media(
self,
media_type: MediaType | str,
media_id: str,
**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,
)
await self._runtime_data.api.play_media(
self._session_id,
item_ids=[media_id],
)
await self.coordinator.async_request_refresh()
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Browse media."""
return await async_browse_media(
self.hass,
self._runtime_data.api,
self._runtime_data.user_id,
media_content_type,
media_content_id,
)

View File

@@ -0,0 +1,43 @@
{
"config": {
"step": {
"user": {
"title": "Connect to Emby Server",
"description": "Enter your Emby server connection details. You can find your API key in Emby Server Dashboard > Extended > API Keys.",
"data": {
"host": "Host",
"port": "Port",
"api_key": "API Key",
"ssl": "Use SSL"
}
},
"user_select": {
"title": "Select User",
"description": "Select the Emby user account to use for browsing and playback.",
"data": {
"user_id": "User"
}
}
},
"error": {
"cannot_connect": "Cannot connect to Emby server. Please check the host and port.",
"invalid_auth": "Invalid API key. Please check your credentials.",
"no_users": "No users found on the Emby server.",
"unknown": "An unexpected error occurred."
},
"abort": {
"already_configured": "This Emby server and user combination is already configured."
}
},
"options": {
"step": {
"init": {
"title": "Emby Media Player Options",
"description": "Configure polling interval for session updates (used as fallback when WebSocket is unavailable).",
"data": {
"scan_interval": "Scan Interval"
}
}
}
}
}

View File

@@ -0,0 +1,43 @@
{
"config": {
"step": {
"user": {
"title": "Connect to Emby Server",
"description": "Enter your Emby server connection details. You can find your API key in Emby Server Dashboard > Extended > API Keys.",
"data": {
"host": "Host",
"port": "Port",
"api_key": "API Key",
"ssl": "Use SSL"
}
},
"user_select": {
"title": "Select User",
"description": "Select the Emby user account to use for browsing and playback.",
"data": {
"user_id": "User"
}
}
},
"error": {
"cannot_connect": "Cannot connect to Emby server. Please check the host and port.",
"invalid_auth": "Invalid API key. Please check your credentials.",
"no_users": "No users found on the Emby server.",
"unknown": "An unexpected error occurred."
},
"abort": {
"already_configured": "This Emby server and user combination is already configured."
}
},
"options": {
"step": {
"init": {
"title": "Emby Media Player Options",
"description": "Configure polling interval for session updates (used as fallback when WebSocket is unavailable).",
"data": {
"scan_interval": "Scan Interval"
}
}
}
}
}

View File

@@ -0,0 +1,244 @@
"""Emby WebSocket client for real-time updates."""
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Callable
from typing import Any
import aiohttp
from .const import (
DEVICE_ID,
DEVICE_NAME,
DEVICE_VERSION,
WEBSOCKET_PATH,
WS_MESSAGE_PLAYBACK_PROGRESS,
WS_MESSAGE_PLAYBACK_START,
WS_MESSAGE_PLAYBACK_STOP,
WS_MESSAGE_SESSIONS,
WS_MESSAGE_SESSIONS_START,
WS_MESSAGE_SESSIONS_STOP,
)
_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,
}
class EmbyWebSocket:
"""WebSocket client for real-time Emby updates."""
def __init__(
self,
host: str,
port: int,
api_key: str,
ssl: bool = False,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Initialize the WebSocket client."""
self._host = host
self._port = port
self._api_key = api_key
self._ssl = ssl
self._session = session
self._owns_session = session is None
protocol = "wss" if ssl else "ws"
self._url = f"{protocol}://{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._running = False
self._reconnect_interval = 30 # seconds
@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
async def connect(self) -> bool:
"""Connect to Emby WebSocket."""
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,
}
try:
self._ws = await session.ws_connect(
self._url,
params=params,
heartbeat=30,
timeout=aiohttp.ClientTimeout(total=10),
)
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.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)
return False
async def _listen(self) -> None:
"""Listen for WebSocket messages."""
if not self._ws:
return
try:
async for msg in self._ws:
if msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
await self._handle_message(data)
except json.JSONDecodeError:
_LOGGER.warning("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"
)
break
elif msg.type in (
aiohttp.WSMsgType.CLOSE,
aiohttp.WSMsgType.CLOSED,
aiohttp.WSMsgType.CLOSING,
):
_LOGGER.debug("WebSocket connection closed")
break
except asyncio.CancelledError:
_LOGGER.debug("WebSocket listener cancelled")
except Exception as err:
_LOGGER.exception("Error in WebSocket listener: %s", err)
finally:
self._ws = None
# 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())
async def _reconnect(self) -> None:
"""Attempt to reconnect to WebSocket."""
await asyncio.sleep(self._reconnect_interval)
if self._running and not self.connected:
_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)
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")
async def subscribe_to_sessions(self) -> None:
"""Subscribe to session updates."""
if not self.connected:
_LOGGER.warning("Cannot subscribe: WebSocket not connected")
return
# Request session updates every 1500ms
await self._send_message(WS_MESSAGE_SESSIONS_START, "0,1500")
_LOGGER.debug("Subscribed to session updates")
async def unsubscribe_from_sessions(self) -> None:
"""Unsubscribe from session updates."""
if self.connected:
await self._send_message(WS_MESSAGE_SESSIONS_STOP, "")
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:
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)
def add_callback(self, callback: Callable[[str, Any], None]) -> Callable[[], None]:
"""Add a callback for WebSocket messages.
Returns a function to remove the callback.
"""
self._callbacks.append(callback)
def remove_callback() -> None:
if callback in self._callbacks:
self._callbacks.remove(callback)
return remove_callback
async def disconnect(self) -> None:
"""Disconnect from WebSocket."""
self._running = False
if self._listen_task and not self._listen_task.done():
self._listen_task.cancel()
try:
await self._listen_task
except asyncio.CancelledError:
pass
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
_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()