Initial commit for Emby Media Player HAOS HACS integration
All checks were successful
Validate / Hassfest (push) Successful in 9s
All checks were successful
Validate / Hassfest (push) Successful in 9s
This commit is contained in:
135
custom_components/emby_player/__init__.py
Normal file
135
custom_components/emby_player/__init__.py
Normal 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
|
||||
392
custom_components/emby_player/api.py
Normal file
392
custom_components/emby_player/api.py
Normal 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
|
||||
231
custom_components/emby_player/browse_media.py
Normal file
231
custom_components/emby_player/browse_media.py
Normal 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,
|
||||
)
|
||||
230
custom_components/emby_player/config_flow.py
Normal file
230
custom_components/emby_player/config_flow.py
Normal 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",
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
89
custom_components/emby_player/const.py
Normal file
89
custom_components/emby_player/const.py
Normal 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"
|
||||
283
custom_components/emby_player/coordinator.py
Normal file
283
custom_components/emby_player/coordinator.py
Normal 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()
|
||||
12
custom_components/emby_player/manifest.json
Normal file
12
custom_components/emby_player/manifest.json
Normal 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"
|
||||
}
|
||||
421
custom_components/emby_player/media_player.py
Normal file
421
custom_components/emby_player/media_player.py
Normal 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,
|
||||
)
|
||||
43
custom_components/emby_player/strings.json
Normal file
43
custom_components/emby_player/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
custom_components/emby_player/translations/en.json
Normal file
43
custom_components/emby_player/translations/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
244
custom_components/emby_player/websocket.py
Normal file
244
custom_components/emby_player/websocket.py
Normal 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()
|
||||
Reference in New Issue
Block a user