Files
haos-hacs-emby-media-player/custom_components/emby_player/media_player.py
T
alexei.dolgolyov 6ae0ed1787
Release / release (push) Successful in 2s
chore: release v0.2.0
Production-readiness pass: security hardening, performance improvements,
new services (send_message, set_repeat, refresh_library), diagnostics,
reauth flow, image proxy, per-instance device IDs, exponential WS
reconnect backoff, ID validation, stale device cleanup, and supporting
integration plumbing. Three rounds of independent code review applied.

See RELEASE_NOTES.md for the full changelog.
2026-05-26 13:16:36 +03:00

580 lines
20 KiB
Python

"""Media player platform for Emby Media Player integration."""
from __future__ import annotations
import logging
import re
from datetime import datetime
from typing import Any
from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerDeviceClass,
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
RepeatMode,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utcnow
from . import EmbyConfigEntry, EmbyRuntimeData
from .api import EmbyApiError
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_PLAY_METHOD,
ATTR_SESSION_ID,
ATTR_USER_NAME,
DOMAIN,
EMBY_ID_PATTERN,
ITEM_TYPE_AUDIO,
ITEM_TYPE_EPISODE,
ITEM_TYPE_MOVIE,
MEDIA_TYPE_AUDIO,
MEDIA_TYPE_VIDEO,
PLAY_COMMAND_PLAY_LAST,
PLAY_COMMAND_PLAY_NEXT,
PLAY_COMMAND_PLAY_NOW,
REPEAT_MODE_ALL,
REPEAT_MODE_NONE,
REPEAT_MODE_ONE,
STALE_PRUNE_GRACE_SECONDS,
STALE_SESSION_TIMEOUT,
TICKS_PER_SECOND,
)
from .coordinator import EmbyCoordinator, EmbySession
_LOGGER = logging.getLogger(__name__)
_EMBY_ID_RE = re.compile(EMBY_ID_PATTERN)
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
| MediaPlayerEntityFeature.REPEAT_SET
)
# HA RepeatMode <-> Emby repeat mode
_HA_TO_EMBY_REPEAT = {
RepeatMode.OFF: REPEAT_MODE_NONE,
RepeatMode.ONE: REPEAT_MODE_ONE,
RepeatMode.ALL: REPEAT_MODE_ALL,
}
_EMBY_TO_HA_REPEAT = {v: k for k, v in _HA_TO_EMBY_REPEAT.items()}
# Explicit HA enqueue → Emby play-command mapping. Anything not in this map
# falls back to "PlayNow".
_ENQUEUE_TO_PLAY_COMMAND: dict[MediaPlayerEnqueue | None, str] = {
None: PLAY_COMMAND_PLAY_NOW,
MediaPlayerEnqueue.PLAY: PLAY_COMMAND_PLAY_NOW,
MediaPlayerEnqueue.REPLACE: PLAY_COMMAND_PLAY_NOW,
MediaPlayerEnqueue.NEXT: PLAY_COMMAND_PLAY_NEXT,
MediaPlayerEnqueue.ADD: PLAY_COMMAND_PLAY_LAST,
}
def _device_class_for_client(client_name: str) -> MediaPlayerDeviceClass | None:
"""Pick a sensible device class from the Emby client identifier."""
name = (client_name or "").lower()
if any(token in name for token in ("android tv", "fire tv", "roku", "kodi", "tv")):
return MediaPlayerDeviceClass.TV
if any(token in name for token in ("speaker", "music")):
return MediaPlayerDeviceClass.SPEAKER
return None
async def async_setup_entry(
hass: HomeAssistant,
entry: EmbyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Emby media player entities."""
runtime_data: EmbyRuntimeData = entry.runtime_data
coordinator = runtime_data.coordinator
tracked_sessions: set[str] = set()
setup_started = utcnow()
@callback
def async_update_entities() -> None:
"""Add new entities for new sessions and prune stale ones."""
if coordinator.data is None:
return
current_sessions = set(coordinator.data.keys())
new_sessions = current_sessions - tracked_sessions
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)
)
_prune_stale_devices(
hass, entry, coordinator, tracked_sessions, setup_started
)
entry.async_on_unload(coordinator.async_add_listener(async_update_entities))
async_update_entities()
@callback
def _prune_stale_devices(
hass: HomeAssistant,
entry: EmbyConfigEntry,
coordinator: EmbyCoordinator,
tracked_sessions: set[str],
setup_started: datetime,
) -> None:
"""Remove device registry entries for sessions absent for too long.
We use the coordinator's per-session ``last_seen`` map as the source of
truth, and skip pruning entirely for the first ``STALE_PRUNE_GRACE_SECONDS``
after setup so that sessions that haven't come online yet aren't wiped.
"""
if coordinator.data is None:
return
now = utcnow()
if (now - setup_started).total_seconds() < STALE_PRUNE_GRACE_SECONDS:
return
device_registry = dr.async_get(hass)
current_ids = set(coordinator.data.keys())
stale = tracked_sessions - current_ids
if not stale:
return
for session_id in list(stale):
last_seen = coordinator.get_session_last_seen(session_id)
if last_seen is None:
# Never seen, but we tracked it (created an entity) — likely
# added during this HA boot but already gone. Use setup_started
# as the floor.
last_seen = setup_started
if (now - last_seen).total_seconds() <= STALE_SESSION_TIMEOUT:
continue
device = device_registry.async_get_device(
identifiers={(DOMAIN, session_id)}
)
if device is not None:
_LOGGER.debug("Removing stale Emby device %s", session_id)
device_registry.async_remove_device(device.id)
tracked_sessions.discard(session_id)
coordinator.forget_session(session_id)
class EmbyMediaPlayer(CoordinatorEntity[EmbyCoordinator], MediaPlayerEntity):
"""Representation of an Emby media player."""
_attr_has_entity_name = True
_attr_name = None # use device name only
_attr_supported_features = SUPPORTED_FEATURES
def __init__(
self,
coordinator: EmbyCoordinator,
entry: EmbyConfigEntry,
session_id: str,
) -> None:
"""Initialize the Emby media player."""
super().__init__(coordinator)
self._entry = entry
self._session_id = session_id
self._attr_unique_id = f"{entry.entry_id}_{session_id}"
session = self._session
client_name = session.client_name if session else ""
device_class = _device_class_for_client(client_name)
if device_class is not None:
self._attr_device_class = device_class
@property
def _session(self) -> EmbySession | None:
"""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."""
ws_ok = self.coordinator.websocket_connected
polling_ok = self.coordinator.last_update_success
return (ws_ok or polling_ok) and self._session is not None
@property
def device_info(self) -> DeviceInfo:
"""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=device_name,
manufacturer="Emby",
model=client_name,
sw_version=session.app_version if session else None,
entry_type=DeviceEntryType.SERVICE,
via_device=(DOMAIN, self._entry.entry_id),
)
@property
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 not session or not session.now_playing:
return None
np = session.now_playing
if np.media_type == MEDIA_TYPE_AUDIO:
return MediaType.MUSIC
if np.media_type == MEDIA_TYPE_VIDEO:
if np.item_type == ITEM_TYPE_MOVIE:
return MediaType.MOVIE
if np.item_type == ITEM_TYPE_EPISODE:
return MediaType.TVSHOW
return MediaType.VIDEO
return None
@property
def media_title(self) -> str | None:
"""Return the title of current playing media."""
session = self._session
if not session or not session.now_playing:
return None
np = session.now_playing
if np.item_type == ITEM_TYPE_EPISODE and np.series_name:
season = (
f"S{np.parent_index_number:02d}" if np.parent_index_number else ""
)
episode = f"E{np.index_number:02d}" if np.index_number else ""
return f"{np.series_name} {season}{episode} - {np.name}"
return np.name
@property
def media_artist(self) -> str | None:
"""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 by the coordinator."""
session = self._session
if session and session.play_state:
return session.play_state.updated_at
return None
@property
def repeat(self) -> RepeatMode | None:
"""Return current repeat mode."""
session = self._session
if session and session.play_state:
return _EMBY_TO_HA_REPEAT.get(session.play_state.repeat_mode)
return None
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes."""
session = self._session
if session is None:
return {}
attrs: dict[str, Any] = {
ATTR_SESSION_ID: session.session_id,
ATTR_DEVICE_ID: session.device_id,
ATTR_DEVICE_NAME: session.device_name,
ATTR_CLIENT_NAME: session.client_name,
}
if session.user_name:
attrs[ATTR_USER_NAME] = session.user_name
if session.now_playing:
attrs[ATTR_ITEM_ID] = session.now_playing.item_id
attrs[ATTR_ITEM_TYPE] = session.now_playing.item_type
if session.play_state and session.play_state.play_method:
attrs[ATTR_PLAY_METHOD] = session.play_state.play_method
return attrs
@property
def media_image_remotely_accessible(self) -> bool:
"""The image is fetched server-side via our proxy."""
return False
# -------------------------------------------------------------------------
# Image proxying — keeps the API key off the client browser
# -------------------------------------------------------------------------
async def async_get_media_image(
self,
) -> tuple[bytes | None, str | None]:
"""Fetch the current media image server-side using the API key in a header."""
session = self._session
if not session or not session.now_playing:
return None, None
np = session.now_playing
item_id = np.primary_image_item_id or np.item_id
if not item_id:
return None, None
try:
return await self._runtime_data.api.fetch_image(
item_id,
image_type="Primary",
max_width=500,
max_height=500,
)
except EmbyApiError as err:
_LOGGER.debug("Failed to fetch media image: %s", err)
return None, None
async def async_get_browse_image(
self,
media_content_type: MediaType | str,
media_content_id: str,
media_image_id: str | None = None,
) -> tuple[bytes | None, str | None]:
"""Fetch a browse-tree image."""
if not media_content_id or not _EMBY_ID_RE.fullmatch(media_content_id):
return None, None
try:
return await self._runtime_data.api.fetch_image(
media_content_id,
image_type="Primary",
max_width=300,
)
except EmbyApiError as err:
_LOGGER.debug("Failed to fetch browse image: %s", err)
return None, None
# -------------------------------------------------------------------------
# Playback Control
# -------------------------------------------------------------------------
async def async_media_play(self) -> None:
"""Resume playback."""
await self._runtime_data.api.play(self._session_id)
async def async_media_pause(self) -> None:
"""Pause playback."""
await self._runtime_data.api.pause(self._session_id)
async def async_media_stop(self) -> None:
"""Stop playback."""
await self._runtime_data.api.stop(self._session_id)
async def async_media_next_track(self) -> None:
"""Skip to next track."""
await self._runtime_data.api.next_track(self._session_id)
async def async_media_previous_track(self) -> None:
"""Skip to previous track."""
await self._runtime_data.api.previous_track(self._session_id)
async def async_media_seek(self, position: float) -> None:
"""Seek to position."""
position_ticks = max(0, round(position * TICKS_PER_SECOND))
await self._runtime_data.api.seek(self._session_id, position_ticks)
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level (0.0-1.0)."""
volume_percent = int(max(0.0, min(1.0, volume)) * 100)
await self._runtime_data.api.set_volume(self._session_id, volume_percent)
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)
async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set repeat mode."""
emby_mode = _HA_TO_EMBY_REPEAT.get(repeat, REPEAT_MODE_NONE)
await self._runtime_data.api.set_repeat_mode(self._session_id, emby_mode)
# -------------------------------------------------------------------------
# Media Browsing & Playing
# -------------------------------------------------------------------------
async def async_play_media(
self,
media_type: MediaType | str,
media_id: str,
**kwargs: Any,
) -> None:
"""Play a piece of media."""
if not isinstance(media_id, str) or not media_id.strip():
raise ServiceValidationError("media_id must be a non-empty string")
item_id = media_id.strip()
if not _EMBY_ID_RE.fullmatch(item_id):
raise ServiceValidationError(
f"media_id is not a valid Emby item id: {media_id!r}"
)
# Map HA enqueue semantics to Emby play commands.
enqueue = kwargs.get("enqueue")
play_command = _ENQUEUE_TO_PLAY_COMMAND.get(
enqueue, PLAY_COMMAND_PLAY_NOW
)
position = kwargs.get("position")
if position is not None:
if not isinstance(position, (int, float)) or position < 0:
raise ServiceValidationError(
"position must be a non-negative number"
)
start_position_ticks = round(position * TICKS_PER_SECOND)
else:
start_position_ticks = 0
try:
await self._runtime_data.api.play_media(
self._session_id,
item_ids=[item_id],
play_command=play_command,
start_position_ticks=start_position_ticks,
)
except EmbyApiError as err:
raise ServiceValidationError(str(err)) from err
async def async_browse_media(
self,
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,
)