Files
media-player-mixed/custom_components/remote_media_player/media_player.py
alexei.dolgolyov 67a89e8349 Initial commit: Media server and Home Assistant integration
- FastAPI server for Windows media control via WinRT/SMTC
- Home Assistant custom integration with media player entity
- Script button entities for system commands
- Position tracking with grace period for track skip handling
- Server availability detection in HA entity

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 13:08:40 +03:00

346 lines
11 KiB
Python

"""Media player platform for Remote Media Player integration."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Any
from homeassistant.components.media_player import (
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
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
from .const import (
DOMAIN,
CONF_HOST,
CONF_PORT,
CONF_POLL_INTERVAL,
DEFAULT_POLL_INTERVAL,
DEFAULT_NAME,
)
_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),
)
# Create update coordinator
coordinator = MediaPlayerCoordinator(
hass,
client,
poll_interval,
)
# 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
# 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."""
def __init__(
self,
hass: HomeAssistant,
client: MediaServerClient,
poll_interval: int,
) -> None:
"""Initialize the coordinator.
Args:
hass: Home Assistant instance
client: Media Server API client
poll_interval: Update interval in seconds
"""
super().__init__(
hass,
_LOGGER,
name="Remote Media Player",
update_interval=timedelta(seconds=poll_interval),
)
self.client = client
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the API.
Returns:
Media status data
Raises:
UpdateFailed: On API errors
"""
try:
data = await self.client.get_status()
_LOGGER.debug("Received media status: %s", data)
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
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
)
@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)