| name | ha-emby-media-player |
| description | Use when implementing Emby MediaPlayerEntity - covers all supported features, state management, playback control, media browsing, image handling, and Emby-specific API mappings for Home Assistant media player integration. |
Home Assistant Emby Media Player Entity
Overview
Implement MediaPlayerEntity following HA patterns with Emby API mappings.
This covers supported features, state machine, properties, async methods, and Emby-specific implementation details.
Entity Structure
"""Emby media player entity."""
from __future__ import annotations
from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from .coordinator import EmbyDataUpdateCoordinator
from .entity import EmbyEntity
from .models import EmbySession
class EmbyMediaPlayer(EmbyEntity, MediaPlayerEntity):
"""Representation of an Emby media player."""
_attr_device_class = MediaPlayerDeviceClass.TV # or SPEAKER/RECEIVER
_attr_supported_features = (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
)
def __init__(
self,
coordinator: EmbyDataUpdateCoordinator,
session: EmbySession,
) -> None:
"""Initialize the media player."""
super().__init__(coordinator, session.device_id)
self._session_id = session.session_id
Supported Features
Map Emby capabilities to HA features:
| HA Feature | Emby API | Notes |
|---|---|---|
| PLAY | PlayState/Unpause | Resume playback |
| PAUSE | PlayState/Pause | Pause playback |
| STOP | PlayState/Stop | Stop playback |
| SEEK | PlayState/Seek | PositionTicks |
| VOLUME_SET | SetVolume | 0-100 scale |
| VOLUME_MUTE | ToggleMute | Boolean toggle |
| NEXT_TRACK | PlayState/NextTrack | Next in queue |
| PREVIOUS_TRACK | PlayState/PreviousTrack | Previous in queue |
| PLAY_MEDIA | Play items | By item ID |
| BROWSE_MEDIA | Items endpoint | Library browsing |
State Machine
@property
def state(self) -> MediaPlayerState | None:
"""Return current state."""
session = self._get_session()
if session is None:
return MediaPlayerState.OFF
if session.now_playing is None:
return MediaPlayerState.IDLE
if session.play_state.is_paused:
return MediaPlayerState.PAUSED
return MediaPlayerState.PLAYING
State mapping:
- No session → OFF
- Session but nothing playing → IDLE
- Playing and paused → PAUSED
- Playing and not paused → PLAYING
- Buffering (if detectable) → BUFFERING
Properties (Return Cached Data Only)
@property
def media_title(self) -> str | None:
"""Return the title of current media."""
if (item := self._now_playing) is None:
return None
return item.name
@property
def media_artist(self) -> str | None:
"""Return the artist of current media."""
if (item := self._now_playing) is None:
return None
# For music: artist, for TV: series name
if item.media_type == "Audio":
return item.album_artist or item.artists[0] if item.artists else None
if item.media_type == "Episode":
return item.series_name
return None
@property
def media_album_name(self) -> str | None:
"""Return the album name of current media."""
if (item := self._now_playing) is None:
return None
return item.album
@property
def media_duration(self) -> int | None:
"""Return the duration of current media in seconds."""
if (item := self._now_playing) is None:
return None
# Emby uses ticks (100ns units)
return item.run_time_ticks // 10_000_000
@property
def media_position(self) -> int | None:
"""Return the position of current media in seconds."""
if (state := self._play_state) is None:
return None
return state.position_ticks // 10_000_000
@property
def media_position_updated_at(self) -> datetime | None:
"""Return when position was last updated."""
return self._position_updated_at
@property
def volume_level(self) -> float | None:
"""Return the volume level (0.0 to 1.0)."""
if (state := self._play_state) is None:
return None
# Emby uses 0-100 scale
return state.volume_level / 100
@property
def is_volume_muted(self) -> bool | None:
"""Return true if volume is muted."""
if (state := self._play_state) is None:
return None
return state.is_muted
@property
def media_content_type(self) -> MediaType | None:
"""Return the content type of current media."""
if (item := self._now_playing) is None:
return None
return self._map_media_type(item.media_type)
@property
def media_content_id(self) -> str | None:
"""Return the content ID of current media."""
if (item := self._now_playing) is None:
return None
return item.item_id
@property
def media_image_url(self) -> str | None:
"""Return the image URL of current media."""
if (item := self._now_playing) is None:
return None
return self._build_image_url(item.item_id, "Primary")
@property
def media_image_remotely_accessible(self) -> bool:
"""Return if image is accessible outside local network."""
# Emby images require authentication
return False
Helper Property
@property
def _now_playing(self) -> EmbyNowPlayingItem | None:
"""Get the currently playing item from coordinator."""
session = self._get_session()
if session is None:
return None
return session.now_playing
@property
def _play_state(self) -> EmbyPlayState | None:
"""Get the play state from coordinator."""
session = self._get_session()
if session is None:
return None
return session.play_state
def _get_session(self) -> EmbySession | None:
"""Get this player's session from coordinator data."""
return self.coordinator.data.sessions.get(self._session_id)
Async Action Methods
async def async_play_media(
self,
media_type: MediaType,
media_id: str,
enqueue: MediaPlayerEnqueue | None = None,
announce: bool | None = None,
**kwargs: Any, # Required by interface
) -> None:
"""Play a piece of media."""
play_command = "PlayNow"
if enqueue == MediaPlayerEnqueue.ADD:
play_command = "PlayLast"
elif enqueue == MediaPlayerEnqueue.NEXT:
play_command = "PlayNext"
await self.coordinator.client.async_play(
session_id=self._session_id,
item_ids=[media_id],
play_command=play_command,
)
async def async_media_play(self) -> None:
"""Send play command."""
await self.coordinator.client.async_send_play_command(
session_id=self._session_id,
command="Unpause",
)
async def async_media_pause(self) -> None:
"""Send pause command."""
await self.coordinator.client.async_send_play_command(
session_id=self._session_id,
command="Pause",
)
async def async_media_stop(self) -> None:
"""Send stop command."""
await self.coordinator.client.async_send_play_command(
session_id=self._session_id,
command="Stop",
)
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self.coordinator.client.async_send_play_command(
session_id=self._session_id,
command="NextTrack",
)
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
await self.coordinator.client.async_send_play_command(
session_id=self._session_id,
command="PreviousTrack",
)
async def async_media_seek(self, position: float) -> None:
"""Seek to position in seconds."""
position_ticks = int(position * 10_000_000)
await self.coordinator.client.async_seek(
session_id=self._session_id,
position_ticks=position_ticks,
)
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level (0.0 to 1.0)."""
volume_percent = int(volume * 100)
await self.coordinator.client.async_set_volume(
session_id=self._session_id,
volume=volume_percent,
)
async def async_mute_volume(self, mute: bool) -> None:
"""Mute or unmute volume."""
await self.coordinator.client.async_set_mute(
session_id=self._session_id,
mute=mute,
)
Media Browsing
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the media browsing capability."""
if media_content_id is None:
# Return root level
return await self._build_root_browse()
return await self._build_item_browse(media_content_id)
async def _build_root_browse(self) -> BrowseMedia:
"""Build root level browse menu."""
views = await self.coordinator.client.async_get_views()
children = [
BrowseMedia(
title=view.name,
media_class=self._map_collection_type(view.collection_type),
media_content_id=view.item_id,
media_content_type=view.collection_type,
can_play=False,
can_expand=True,
)
for view in views
]
return BrowseMedia(
title="Emby",
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type="root",
can_play=False,
can_expand=True,
children=children,
)
async def _build_item_browse(self, item_id: str) -> BrowseMedia:
"""Build browse for a specific item."""
items = await self.coordinator.client.async_get_items(
parent_id=item_id,
)
children = [
BrowseMedia(
title=item.name,
media_class=self._map_media_class(item.media_type),
media_content_id=item.item_id,
media_content_type=item.media_type,
can_play=item.is_playable,
can_expand=item.is_folder,
thumbnail=self._build_image_url(item.item_id, "Primary"),
)
for item in items
]
return BrowseMedia(
title="Browse",
media_class=MediaClass.DIRECTORY,
media_content_id=item_id,
media_content_type="directory",
can_play=False,
can_expand=True,
children=children,
)
Type Mapping Helpers
def _map_media_type(self, emby_type: str) -> MediaType:
"""Map Emby media type to HA MediaType."""
mapping: dict[str, MediaType] = {
"Movie": MediaType.MOVIE,
"Episode": MediaType.TVSHOW,
"Audio": MediaType.MUSIC,
"MusicVideo": MediaType.VIDEO,
"Video": MediaType.VIDEO,
"Photo": MediaType.IMAGE,
}
return mapping.get(emby_type, MediaType.VIDEO)
def _map_media_class(self, emby_type: str) -> MediaClass:
"""Map Emby type to HA MediaClass."""
mapping: dict[str, MediaClass] = {
"Movie": MediaClass.MOVIE,
"Series": MediaClass.TV_SHOW,
"Season": MediaClass.SEASON,
"Episode": MediaClass.EPISODE,
"Audio": MediaClass.TRACK,
"MusicAlbum": MediaClass.ALBUM,
"MusicArtist": MediaClass.ARTIST,
"Playlist": MediaClass.PLAYLIST,
"CollectionFolder": MediaClass.DIRECTORY,
}
return mapping.get(emby_type, MediaClass.DIRECTORY)
Emby Tick Conversions
Emby uses "ticks" (100-nanosecond intervals):
TICKS_PER_SECOND = 10_000_000
TICKS_PER_MS = 10_000
def ticks_to_seconds(ticks: int) -> int:
"""Convert Emby ticks to seconds."""
return ticks // TICKS_PER_SECOND
def seconds_to_ticks(seconds: float) -> int:
"""Convert seconds to Emby ticks."""
return int(seconds * TICKS_PER_SECOND)
def ticks_to_timedelta(ticks: int) -> timedelta:
"""Convert Emby ticks to timedelta."""
return timedelta(microseconds=ticks // 10)
Image URL Building
def _build_image_url(
self,
item_id: str,
image_type: str = "Primary",
max_width: int = 500,
) -> str:
"""Build URL for Emby item image."""
return (
f"{self.coordinator.client.base_url}"
f"/Items/{item_id}/Images/{image_type}"
f"?maxWidth={max_width}"
f"&api_key={self.coordinator.client.api_key}"
)
Security note: Don't expose image URLs to media_image_url without auth context. Set media_image_remotely_accessible = False and let HA proxy the images.
Platform Setup
"""Platform setup for Emby media player."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import EmbyConfigEntry
from .media_player import EmbyMediaPlayer
async def async_setup_entry(
hass: HomeAssistant,
entry: EmbyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Emby media player entities."""
coordinator = entry.runtime_data
entities = [
EmbyMediaPlayer(coordinator, session)
for session in coordinator.data.sessions.values()
if session.supports_media_control
]
async_add_entities(entities)
The Bottom Line
Properties return cached data. Async methods do I/O. Map Emby concepts to HA correctly.
- Use coordinator data for all state
- Convert ticks to seconds for HA
- Implement browse_media for library access
- Never block in properties