Trigger async_request_refresh() on WebSocket disconnect to restart the polling loop. Without this, the coordinator's polling stays stopped and last_update_success is never set to False. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
651 lines
23 KiB
Python
651 lines
23 KiB
Python
"""Media player platform for Remote Media Player integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
from homeassistant.components.media_player import (
|
|
BrowseMedia,
|
|
MediaPlayerEntity,
|
|
MediaPlayerEntityFeature,
|
|
MediaPlayerState,
|
|
MediaType,
|
|
)
|
|
from homeassistant.components.media_player.const import (
|
|
MediaClass,
|
|
)
|
|
from urllib.parse import quote, unquote
|
|
from homeassistant.config_entries import ConfigEntry
|
|
from homeassistant.const import CONF_NAME
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers.entity import DeviceInfo
|
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|
from homeassistant.helpers.update_coordinator import (
|
|
CoordinatorEntity,
|
|
DataUpdateCoordinator,
|
|
UpdateFailed,
|
|
)
|
|
|
|
from .api_client import MediaServerClient, MediaServerError, MediaServerWebSocket
|
|
from .const import (
|
|
DOMAIN,
|
|
CONF_HOST,
|
|
CONF_PORT,
|
|
CONF_TOKEN,
|
|
CONF_POLL_INTERVAL,
|
|
CONF_USE_WEBSOCKET,
|
|
DEFAULT_POLL_INTERVAL,
|
|
DEFAULT_NAME,
|
|
DEFAULT_USE_WEBSOCKET,
|
|
DEFAULT_RECONNECT_INTERVAL,
|
|
)
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
async def async_setup_entry(
|
|
hass: HomeAssistant,
|
|
entry: ConfigEntry,
|
|
async_add_entities: AddEntitiesCallback,
|
|
) -> None:
|
|
"""Set up the media player platform.
|
|
|
|
Args:
|
|
hass: Home Assistant instance
|
|
entry: Config entry
|
|
async_add_entities: Callback to add entities
|
|
"""
|
|
_LOGGER.debug("Setting up media player platform for %s", entry.entry_id)
|
|
|
|
try:
|
|
client: MediaServerClient = hass.data[DOMAIN][entry.entry_id]["client"]
|
|
except KeyError:
|
|
_LOGGER.error("Client not found in hass.data for entry %s", entry.entry_id)
|
|
return
|
|
|
|
# Get poll interval from options or data
|
|
poll_interval = entry.options.get(
|
|
CONF_POLL_INTERVAL,
|
|
entry.data.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL),
|
|
)
|
|
|
|
# Get WebSocket setting from options or data
|
|
use_websocket = entry.options.get(
|
|
CONF_USE_WEBSOCKET,
|
|
entry.data.get(CONF_USE_WEBSOCKET, DEFAULT_USE_WEBSOCKET),
|
|
)
|
|
|
|
# Create update coordinator with WebSocket support
|
|
coordinator = MediaPlayerCoordinator(
|
|
hass,
|
|
client,
|
|
poll_interval,
|
|
host=entry.data[CONF_HOST],
|
|
port=entry.data[CONF_PORT],
|
|
token=entry.data[CONF_TOKEN],
|
|
use_websocket=use_websocket,
|
|
entry=entry,
|
|
)
|
|
|
|
# Set up WebSocket connection if enabled
|
|
await coordinator.async_setup()
|
|
|
|
# Fetch initial data - don't fail setup if this fails
|
|
try:
|
|
await coordinator.async_config_entry_first_refresh()
|
|
except Exception as err:
|
|
_LOGGER.warning("Initial data fetch failed, will retry: %s", err)
|
|
# Continue anyway - the coordinator will retry
|
|
|
|
# Store coordinator for cleanup
|
|
hass.data[DOMAIN][entry.entry_id]["coordinator"] = coordinator
|
|
|
|
# Create and add entity
|
|
entity = RemoteMediaPlayerEntity(
|
|
coordinator,
|
|
entry,
|
|
)
|
|
_LOGGER.info("Adding media player entity: %s", entity.unique_id)
|
|
async_add_entities([entity])
|
|
|
|
|
|
class MediaPlayerCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|
"""Coordinator for fetching media player data with WebSocket support."""
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
client: MediaServerClient,
|
|
poll_interval: int,
|
|
host: str,
|
|
port: int,
|
|
token: str,
|
|
use_websocket: bool = True,
|
|
entry: ConfigEntry | None = None,
|
|
) -> None:
|
|
"""Initialize the coordinator.
|
|
|
|
Args:
|
|
hass: Home Assistant instance
|
|
client: Media Server API client
|
|
poll_interval: Update interval in seconds
|
|
host: Server hostname
|
|
port: Server port
|
|
token: API token
|
|
use_websocket: Whether to use WebSocket for updates
|
|
entry: Config entry (for integration reload on scripts change)
|
|
"""
|
|
super().__init__(
|
|
hass,
|
|
_LOGGER,
|
|
name="Remote Media Player",
|
|
update_interval=timedelta(seconds=poll_interval),
|
|
)
|
|
self.client = client
|
|
self._host = host
|
|
self._port = port
|
|
self._token = token
|
|
self._use_websocket = use_websocket
|
|
self._entry = entry
|
|
self._ws_client: MediaServerWebSocket | None = None
|
|
self._ws_connected = False
|
|
self._reconnect_task: asyncio.Task | None = None
|
|
self._poll_interval = poll_interval
|
|
|
|
async def async_setup(self) -> None:
|
|
"""Set up the coordinator with WebSocket if enabled."""
|
|
if self._use_websocket:
|
|
await self._connect_websocket()
|
|
|
|
async def _connect_websocket(self) -> None:
|
|
"""Establish WebSocket connection."""
|
|
if self._ws_client:
|
|
await self._ws_client.disconnect()
|
|
|
|
self._ws_client = MediaServerWebSocket(
|
|
host=self._host,
|
|
port=self._port,
|
|
token=self._token,
|
|
on_status_update=self._handle_ws_status_update,
|
|
on_disconnect=self._handle_ws_disconnect,
|
|
on_scripts_changed=self._handle_ws_scripts_changed,
|
|
)
|
|
|
|
if await self._ws_client.connect():
|
|
self._ws_connected = True
|
|
# Disable polling - WebSocket handles all updates including position
|
|
self.update_interval = None
|
|
_LOGGER.info("WebSocket connected, polling disabled")
|
|
else:
|
|
self._ws_connected = False
|
|
# Keep polling as fallback
|
|
self.update_interval = timedelta(seconds=self._poll_interval)
|
|
_LOGGER.warning("WebSocket failed, falling back to polling")
|
|
# Schedule reconnect attempt
|
|
self._schedule_reconnect()
|
|
|
|
@callback
|
|
def _handle_ws_status_update(self, status_data: dict[str, Any]) -> None:
|
|
"""Handle status update from WebSocket."""
|
|
self.async_set_updated_data(status_data)
|
|
|
|
@callback
|
|
def _handle_ws_disconnect(self) -> None:
|
|
"""Handle WebSocket disconnection."""
|
|
self._ws_connected = False
|
|
# Re-enable polling as fallback
|
|
self.update_interval = timedelta(seconds=self._poll_interval)
|
|
_LOGGER.warning("WebSocket disconnected, falling back to polling")
|
|
# Trigger an immediate refresh to restart the polling loop.
|
|
# Without this, the polling loop stays stopped (it was disabled when
|
|
# WebSocket was active) and the entity never becomes unavailable.
|
|
self.hass.async_create_task(self.async_request_refresh())
|
|
# Schedule reconnect attempt
|
|
self._schedule_reconnect()
|
|
|
|
@callback
|
|
def _handle_ws_scripts_changed(self) -> None:
|
|
"""Handle scripts changed notification from WebSocket."""
|
|
if self._entry:
|
|
_LOGGER.info("Scripts changed, reloading integration")
|
|
self.hass.async_create_task(
|
|
self.hass.config_entries.async_reload(self._entry.entry_id)
|
|
)
|
|
else:
|
|
_LOGGER.warning("Cannot reload integration: entry not available")
|
|
|
|
def _schedule_reconnect(self) -> None:
|
|
"""Schedule a WebSocket reconnection attempt."""
|
|
if self._reconnect_task and not self._reconnect_task.done():
|
|
return # Already scheduled
|
|
|
|
async def reconnect() -> None:
|
|
await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
|
|
if self._use_websocket and not self._ws_connected:
|
|
_LOGGER.info("Attempting WebSocket reconnect...")
|
|
await self._connect_websocket()
|
|
|
|
self._reconnect_task = self.hass.async_create_task(reconnect())
|
|
|
|
async def _async_update_data(self) -> dict[str, Any]:
|
|
"""Fetch data from the API (fallback when WebSocket unavailable).
|
|
|
|
Returns:
|
|
Media status data
|
|
|
|
Raises:
|
|
UpdateFailed: On API errors
|
|
"""
|
|
try:
|
|
data = await self.client.get_status()
|
|
_LOGGER.debug("HTTP poll received status: %s", data.get("state"))
|
|
return data
|
|
except MediaServerError as err:
|
|
raise UpdateFailed(f"Error communicating with server: {err}") from err
|
|
except Exception as err:
|
|
_LOGGER.exception("Unexpected error fetching media status")
|
|
raise UpdateFailed(f"Unexpected error: {err}") from err
|
|
|
|
async def async_shutdown(self) -> None:
|
|
"""Clean up resources."""
|
|
if self._reconnect_task:
|
|
self._reconnect_task.cancel()
|
|
try:
|
|
await self._reconnect_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
if self._ws_client:
|
|
await self._ws_client.disconnect()
|
|
|
|
|
|
class RemoteMediaPlayerEntity(CoordinatorEntity[MediaPlayerCoordinator], MediaPlayerEntity):
|
|
"""Representation of a Remote Media Player."""
|
|
|
|
_attr_has_entity_name = True
|
|
_attr_name = None
|
|
|
|
@property
|
|
def available(self) -> bool:
|
|
"""Return True if entity is available."""
|
|
# Use the coordinator's last_update_success to detect server availability
|
|
return self.coordinator.last_update_success
|
|
|
|
def __init__(
|
|
self,
|
|
coordinator: MediaPlayerCoordinator,
|
|
entry: ConfigEntry,
|
|
) -> None:
|
|
"""Initialize the media player entity.
|
|
|
|
Args:
|
|
coordinator: Data update coordinator
|
|
entry: Config entry
|
|
"""
|
|
super().__init__(coordinator)
|
|
self._entry = entry
|
|
self._attr_unique_id = f"{entry.entry_id}_media_player"
|
|
|
|
# Device info - must match button.py identifiers
|
|
self._attr_device_info = DeviceInfo(
|
|
identifiers={(DOMAIN, entry.entry_id)},
|
|
name=entry.title,
|
|
manufacturer="Remote Media Player",
|
|
model="Media Server",
|
|
sw_version="1.0.0",
|
|
configuration_url=f"http://{entry.data[CONF_HOST]}:{int(entry.data[CONF_PORT])}/docs",
|
|
)
|
|
|
|
@property
|
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
|
"""Return the supported features."""
|
|
return (
|
|
MediaPlayerEntityFeature.PAUSE
|
|
| MediaPlayerEntityFeature.PLAY
|
|
| MediaPlayerEntityFeature.STOP
|
|
| MediaPlayerEntityFeature.VOLUME_SET
|
|
| MediaPlayerEntityFeature.VOLUME_MUTE
|
|
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
|
| MediaPlayerEntityFeature.NEXT_TRACK
|
|
| MediaPlayerEntityFeature.SEEK
|
|
| MediaPlayerEntityFeature.TURN_ON
|
|
| MediaPlayerEntityFeature.TURN_OFF
|
|
| MediaPlayerEntityFeature.BROWSE_MEDIA
|
|
| MediaPlayerEntityFeature.PLAY_MEDIA
|
|
)
|
|
|
|
@property
|
|
def state(self) -> MediaPlayerState | None:
|
|
"""Return the state of the player."""
|
|
if self.coordinator.data is None:
|
|
return MediaPlayerState.OFF
|
|
|
|
state = self.coordinator.data.get("state", "idle")
|
|
state_map = {
|
|
"playing": MediaPlayerState.PLAYING,
|
|
"paused": MediaPlayerState.PAUSED,
|
|
"stopped": MediaPlayerState.IDLE,
|
|
"idle": MediaPlayerState.IDLE,
|
|
}
|
|
return state_map.get(state, MediaPlayerState.IDLE)
|
|
|
|
@property
|
|
def volume_level(self) -> float | None:
|
|
"""Return the volume level (0..1)."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
volume = self.coordinator.data.get("volume", 0)
|
|
return volume / 100.0
|
|
|
|
@property
|
|
def is_volume_muted(self) -> bool | None:
|
|
"""Return True if volume is muted."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
return self.coordinator.data.get("muted", False)
|
|
|
|
@property
|
|
def media_content_type(self) -> MediaType | None:
|
|
"""Return the content type of current playing media."""
|
|
return MediaType.MUSIC
|
|
|
|
@property
|
|
def media_title(self) -> str | None:
|
|
"""Return the title of current playing media."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
return self.coordinator.data.get("title")
|
|
|
|
@property
|
|
def media_artist(self) -> str | None:
|
|
"""Return the artist of current playing media."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
return self.coordinator.data.get("artist")
|
|
|
|
@property
|
|
def media_album_name(self) -> str | None:
|
|
"""Return the album name of current playing media."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
return self.coordinator.data.get("album")
|
|
|
|
@property
|
|
def media_image_url(self) -> str | None:
|
|
"""Return the image URL of current playing media."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
return self.coordinator.data.get("album_art_url")
|
|
|
|
@property
|
|
def media_duration(self) -> int | None:
|
|
"""Return the duration of current playing media in seconds."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
duration = self.coordinator.data.get("duration")
|
|
return int(duration) if duration is not None else None
|
|
|
|
@property
|
|
def media_position(self) -> int | None:
|
|
"""Return the position of current playing media in seconds."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
position = self.coordinator.data.get("position")
|
|
return int(position) if position is not None else None
|
|
|
|
@property
|
|
def media_position_updated_at(self) -> datetime | None:
|
|
"""Return when the position was last updated."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
if self.coordinator.data.get("position") is not None:
|
|
# Use last_update_success_time if available, otherwise use current time
|
|
if hasattr(self.coordinator, 'last_update_success_time'):
|
|
return self.coordinator.last_update_success_time
|
|
return datetime.now()
|
|
return None
|
|
|
|
@property
|
|
def source(self) -> str | None:
|
|
"""Return the current media source."""
|
|
if self.coordinator.data is None:
|
|
return None
|
|
return self.coordinator.data.get("source")
|
|
|
|
async def async_media_play(self) -> None:
|
|
"""Send play command."""
|
|
try:
|
|
await self.coordinator.client.play()
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to play: %s", err)
|
|
|
|
async def async_media_pause(self) -> None:
|
|
"""Send pause command."""
|
|
try:
|
|
await self.coordinator.client.pause()
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to pause: %s", err)
|
|
|
|
async def async_media_stop(self) -> None:
|
|
"""Send stop command."""
|
|
try:
|
|
await self.coordinator.client.stop()
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to stop: %s", err)
|
|
|
|
async def async_media_next_track(self) -> None:
|
|
"""Send next track command."""
|
|
try:
|
|
await self.coordinator.client.next_track()
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to skip to next track: %s", err)
|
|
|
|
async def async_media_previous_track(self) -> None:
|
|
"""Send previous track command."""
|
|
try:
|
|
await self.coordinator.client.previous_track()
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to go to previous track: %s", err)
|
|
|
|
async def async_set_volume_level(self, volume: float) -> None:
|
|
"""Set volume level, range 0..1."""
|
|
try:
|
|
await self.coordinator.client.set_volume(int(volume * 100))
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to set volume: %s", err)
|
|
|
|
async def async_mute_volume(self, mute: bool) -> None:
|
|
"""Mute/unmute the volume."""
|
|
try:
|
|
# Toggle mute (API toggles, so call it if state differs)
|
|
if self.is_volume_muted != mute:
|
|
await self.coordinator.client.toggle_mute()
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to toggle mute: %s", err)
|
|
|
|
async def async_media_seek(self, position: float) -> None:
|
|
"""Seek to a position."""
|
|
try:
|
|
await self.coordinator.client.seek(position)
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to seek: %s", err)
|
|
|
|
async def async_turn_on(self) -> None:
|
|
"""Send turn on command."""
|
|
try:
|
|
await self.coordinator.client.turn_on()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to turn on: %s", err)
|
|
|
|
async def async_turn_off(self) -> None:
|
|
"""Send turn off command."""
|
|
try:
|
|
await self.coordinator.client.turn_off()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to turn off: %s", err)
|
|
|
|
async def async_toggle(self) -> None:
|
|
"""Send toggle command."""
|
|
try:
|
|
await self.coordinator.client.toggle()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to toggle: %s", err)
|
|
|
|
# Media Browser support
|
|
|
|
@staticmethod
|
|
def _encode_media_id(folder_id: str, path: str = "") -> str:
|
|
"""Encode folder_id and path into media_content_id.
|
|
|
|
Format: folder_id|encoded_path
|
|
Root folder: folder_id|
|
|
"""
|
|
return f"{folder_id}|{quote(path, safe='')}"
|
|
|
|
@staticmethod
|
|
def _decode_media_id(media_content_id: str) -> tuple[str, str]:
|
|
"""Decode media_content_id into folder_id and path.
|
|
|
|
Returns:
|
|
Tuple of (folder_id, path)
|
|
"""
|
|
if not media_content_id or "|" not in media_content_id:
|
|
return "", ""
|
|
folder_id, encoded_path = media_content_id.split("|", 1)
|
|
path = unquote(encoded_path) if encoded_path else ""
|
|
return folder_id, path
|
|
|
|
async def async_browse_media(
|
|
self,
|
|
media_content_type: str | None = None,
|
|
media_content_id: str | None = None,
|
|
) -> BrowseMedia:
|
|
"""Implement the media browsing.
|
|
|
|
Args:
|
|
media_content_type: Type of media (unused, but required by HA)
|
|
media_content_id: ID in format "folder_id|path" or None for root
|
|
|
|
Returns:
|
|
BrowseMedia object with children
|
|
"""
|
|
_LOGGER.debug("Browse media: type=%s, id=%s", media_content_type, media_content_id)
|
|
|
|
# Root level - list all folders
|
|
if not media_content_id:
|
|
folders = await self.coordinator.client.get_media_folders()
|
|
|
|
children = [
|
|
BrowseMedia(
|
|
title=config["label"],
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=MediaType.MUSIC, # All folders show as music
|
|
media_content_id=self._encode_media_id(folder_id, ""),
|
|
can_play=False,
|
|
can_expand=True,
|
|
)
|
|
for folder_id, config in folders.items()
|
|
if config.get("enabled", True)
|
|
]
|
|
|
|
return BrowseMedia(
|
|
title="Media Folders",
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=MediaType.MUSIC,
|
|
media_content_id="",
|
|
can_play=False,
|
|
can_expand=True,
|
|
children=children,
|
|
)
|
|
|
|
# Browse specific folder
|
|
folder_id, path = self._decode_media_id(media_content_id)
|
|
|
|
if not folder_id:
|
|
raise ValueError("Invalid media_content_id format")
|
|
|
|
# Get folder contents from API
|
|
browse_data = await self.coordinator.client.browse_folder(folder_id, path, offset=0, limit=1000)
|
|
|
|
children = []
|
|
for item in browse_data.get("items", []):
|
|
if item["type"] == "folder":
|
|
# Subfolder
|
|
item_path = f"{path}/{item['name']}" if path else item['name']
|
|
children.append(
|
|
BrowseMedia(
|
|
title=item["name"],
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=MediaType.MUSIC,
|
|
media_content_id=self._encode_media_id(folder_id, item_path),
|
|
can_play=False,
|
|
can_expand=True,
|
|
)
|
|
)
|
|
elif item.get("is_media", False):
|
|
# Media file
|
|
# Build absolute path for playback
|
|
folders = await self.coordinator.client.get_media_folders()
|
|
base_path = folders[folder_id]["path"]
|
|
file_path_in_folder = f"{path}/{item['name']}" if path else item['name']
|
|
# Handle platform path separators
|
|
separator = '\\' if '\\' in base_path else '/'
|
|
# Ensure base_path doesn't end with separator to avoid double separators
|
|
base_path_clean = base_path.rstrip('/\\')
|
|
absolute_path = f"{base_path_clean}{separator}{file_path_in_folder.replace('/', separator)}"
|
|
|
|
children.append(
|
|
BrowseMedia(
|
|
title=item["name"],
|
|
media_class=MediaClass.MUSIC,
|
|
media_content_type=MediaType.MUSIC,
|
|
media_content_id=absolute_path, # Use absolute path as ID for playback
|
|
can_play=True,
|
|
can_expand=False,
|
|
)
|
|
)
|
|
|
|
# Get current folder label
|
|
current_title = path.split("/")[-1] if path else browse_data.get("label", folder_id)
|
|
|
|
return BrowseMedia(
|
|
title=current_title,
|
|
media_class=MediaClass.DIRECTORY,
|
|
media_content_type=MediaType.MUSIC,
|
|
media_content_id=media_content_id,
|
|
can_play=False,
|
|
can_expand=True,
|
|
children=children,
|
|
)
|
|
|
|
async def async_play_media(
|
|
self, media_type: str, media_id: str, **kwargs: Any
|
|
) -> None:
|
|
"""Play a media file.
|
|
|
|
Args:
|
|
media_type: Type of media (unused)
|
|
media_id: Absolute file path to media file
|
|
**kwargs: Additional arguments (unused)
|
|
"""
|
|
_LOGGER.debug("Play media: type=%s, id=%s", media_type, media_id)
|
|
|
|
try:
|
|
# media_id is the absolute file path from browse_media
|
|
await self.coordinator.client.play_media_file(media_id)
|
|
|
|
# Request immediate status update
|
|
await self.coordinator.async_request_refresh()
|
|
except MediaServerError as err:
|
|
_LOGGER.error("Failed to play media file: %s", err)
|