Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support for the Roku media player."""
2 
3 from __future__ import annotations
4 
5 import datetime as dt
6 import logging
7 import mimetypes
8 from typing import Any
9 
10 from rokuecp.helpers import guess_stream_format
11 import voluptuous as vol
12 import yarl
13 
14 from homeassistant.components import media_source
16  ATTR_MEDIA_EXTRA,
17  BrowseMedia,
18  MediaPlayerDeviceClass,
19  MediaPlayerEntity,
20  MediaPlayerEntityFeature,
21  MediaPlayerState,
22  MediaType,
23  async_process_play_media_url,
24 )
25 from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER
26 from homeassistant.config_entries import ConfigEntry
27 from homeassistant.const import ATTR_NAME
28 from homeassistant.core import HomeAssistant
29 from homeassistant.helpers import entity_platform
30 from homeassistant.helpers.entity_platform import AddEntitiesCallback
31 from homeassistant.helpers.typing import VolDictType
32 
33 from .browse_media import async_browse_media
34 from .const import (
35  ATTR_ARTIST_NAME,
36  ATTR_CONTENT_ID,
37  ATTR_FORMAT,
38  ATTR_KEYWORD,
39  ATTR_MEDIA_TYPE,
40  ATTR_THUMBNAIL,
41  DOMAIN,
42  SERVICE_SEARCH,
43 )
44 from .coordinator import RokuDataUpdateCoordinator
45 from .entity import RokuEntity
46 from .helpers import format_channel_name, roku_exception_handler
47 
48 _LOGGER = logging.getLogger(__name__)
49 
50 
51 STREAM_FORMAT_TO_MEDIA_TYPE = {
52  "dash": MediaType.VIDEO,
53  "hls": MediaType.VIDEO,
54  "ism": MediaType.VIDEO,
55  "m4a": MediaType.MUSIC,
56  "m4v": MediaType.VIDEO,
57  "mka": MediaType.MUSIC,
58  "mkv": MediaType.VIDEO,
59  "mks": MediaType.VIDEO,
60  "mp3": MediaType.MUSIC,
61  "mp4": MediaType.VIDEO,
62 }
63 
64 ATTRS_TO_LAUNCH_PARAMS = {
65  ATTR_CONTENT_ID: "contentID",
66  ATTR_MEDIA_TYPE: "mediaType",
67 }
68 
69 ATTRS_TO_PLAY_ON_ROKU_PARAMS = {
70  ATTR_NAME: "videoName",
71  ATTR_FORMAT: "videoFormat",
72  ATTR_THUMBNAIL: "k",
73 }
74 
75 ATTRS_TO_PLAY_ON_ROKU_AUDIO_PARAMS = {
76  ATTR_NAME: "songName",
77  ATTR_FORMAT: "songFormat",
78  ATTR_ARTIST_NAME: "artistName",
79  ATTR_THUMBNAIL: "albumArtUrl",
80 }
81 
82 SEARCH_SCHEMA: VolDictType = {vol.Required(ATTR_KEYWORD): str}
83 
84 
86  hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
87 ) -> None:
88  """Set up the Roku config entry."""
89  coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
90 
92  [
94  coordinator=coordinator,
95  )
96  ],
97  True,
98  )
99 
100  platform = entity_platform.async_get_current_platform()
101 
102  platform.async_register_entity_service(
103  SERVICE_SEARCH,
104  SEARCH_SCHEMA,
105  "search",
106  )
107 
108 
110  """Representation of a Roku media player on the network."""
111 
112  _attr_name = None
113  _attr_supported_features = (
114  MediaPlayerEntityFeature.PREVIOUS_TRACK
115  | MediaPlayerEntityFeature.NEXT_TRACK
116  | MediaPlayerEntityFeature.VOLUME_STEP
117  | MediaPlayerEntityFeature.VOLUME_MUTE
118  | MediaPlayerEntityFeature.SELECT_SOURCE
119  | MediaPlayerEntityFeature.PAUSE
120  | MediaPlayerEntityFeature.PLAY
121  | MediaPlayerEntityFeature.PLAY_MEDIA
122  | MediaPlayerEntityFeature.TURN_ON
123  | MediaPlayerEntityFeature.TURN_OFF
124  | MediaPlayerEntityFeature.BROWSE_MEDIA
125  )
126 
127  def __init__(self, coordinator: RokuDataUpdateCoordinator) -> None:
128  """Initialize the Roku device."""
129  super().__init__(coordinator=coordinator)
130  if coordinator.data.info.device_type == "tv":
131  self._attr_device_class_attr_device_class = MediaPlayerDeviceClass.TV
132  else:
133  self._attr_device_class_attr_device_class = MediaPlayerDeviceClass.RECEIVER
134 
135  def _media_playback_trackable(self) -> bool:
136  """Detect if we have enough media data to track playback."""
137  if self.coordinator.data.media is None or self.coordinator.data.media.live:
138  return False
139 
140  return self.coordinator.data.media.duration > 0
141 
142  @property
143  def state(self) -> MediaPlayerState | None:
144  """Return the state of the device."""
145  if self.coordinator.data.state.standby:
146  return MediaPlayerState.STANDBY
147 
148  if self.coordinator.data.app is None:
149  return None
150 
151  if (
152  self.coordinator.data.app.name in {"Power Saver", "Roku"}
153  or self.coordinator.data.app.screensaver
154  ):
155  return MediaPlayerState.IDLE
156 
157  if self.coordinator.data.media:
158  if self.coordinator.data.media.paused:
159  return MediaPlayerState.PAUSED
160  return MediaPlayerState.PLAYING
161 
162  if self.coordinator.data.app.name:
163  return MediaPlayerState.ON
164 
165  return None
166 
167  @property
168  def media_content_type(self) -> MediaType | None:
169  """Content type of current playing media."""
170  if self.app_idapp_idapp_idapp_id is None or self.app_nameapp_nameapp_name in ("Power Saver", "Roku"):
171  return None
172 
173  if self.app_idapp_idapp_idapp_id == "tvinput.dtv" and self.coordinator.data.channel is not None:
174  return MediaType.CHANNEL
175 
176  return MediaType.APP
177 
178  @property
179  def media_image_url(self) -> str | None:
180  """Image url of current playing media."""
181  if self.app_idapp_idapp_idapp_id is None or self.app_nameapp_nameapp_name in ("Power Saver", "Roku"):
182  return None
183 
184  return self.coordinator.roku.app_icon_url(self.app_idapp_idapp_idapp_id)
185 
186  @property
187  def app_name(self) -> str | None:
188  """Name of the current running app."""
189  if self.coordinator.data.app is not None:
190  return self.coordinator.data.app.name
191 
192  return None
193 
194  @property
195  def app_id(self) -> str | None:
196  """Return the ID of the current running app."""
197  if self.coordinator.data.app is not None:
198  return self.coordinator.data.app.app_id
199 
200  return None
201 
202  @property
203  def media_channel(self) -> str | None:
204  """Return the TV channel currently tuned."""
205  if self.app_idapp_idapp_idapp_id != "tvinput.dtv" or self.coordinator.data.channel is None:
206  return None
207 
208  channel = self.coordinator.data.channel
209 
210  return format_channel_name(channel.number, channel.name)
211 
212  @property
213  def media_title(self) -> str | None:
214  """Return the title of current playing media."""
215  if self.app_idapp_idapp_idapp_id != "tvinput.dtv" or self.coordinator.data.channel is None:
216  return None
217 
218  if self.coordinator.data.channel.program_title is not None:
219  return self.coordinator.data.channel.program_title
220 
221  return None
222 
223  @property
224  def media_duration(self) -> int | None:
225  """Duration of current playing media in seconds."""
226  if self.coordinator.data.media is not None and self._media_playback_trackable_media_playback_trackable():
227  return self.coordinator.data.media.duration
228 
229  return None
230 
231  @property
232  def media_position(self) -> int | None:
233  """Position of current playing media in seconds."""
234  if self.coordinator.data.media is not None and self._media_playback_trackable_media_playback_trackable():
235  return self.coordinator.data.media.position
236 
237  return None
238 
239  @property
240  def media_position_updated_at(self) -> dt.datetime | None:
241  """When was the position of the current playing media valid."""
242  if self.coordinator.data.media is not None and self._media_playback_trackable_media_playback_trackable():
243  return self.coordinator.data.media.at
244 
245  return None
246 
247  @property
248  def source(self) -> str | None:
249  """Return the current input source."""
250  if self.coordinator.data.app is not None:
251  return self.coordinator.data.app.name
252 
253  return None
254 
255  @property
256  def source_list(self) -> list[str]:
257  """List of available input sources."""
258  return [
259  "Home",
260  *sorted(
261  app.name for app in self.coordinator.data.apps if app.name is not None
262  ),
263  ]
264 
265  @roku_exception_handler()
266  async def search(self, keyword: str) -> None:
267  """Emulate opening the search screen and entering the search keyword."""
268  await self.coordinator.roku.search(keyword)
269 
271  self,
272  media_content_type: MediaType | str,
273  media_content_id: str,
274  media_image_id: str | None = None,
275  ) -> tuple[bytes | None, str | None]:
276  """Fetch media browser image to serve via proxy."""
277  if media_content_type == MediaType.APP and media_content_id:
278  image_url = self.coordinator.roku.app_icon_url(media_content_id)
279  return await self._async_fetch_image_async_fetch_image(image_url)
280 
281  return (None, None)
282 
284  self,
285  media_content_type: MediaType | str | None = None,
286  media_content_id: str | None = None,
287  ) -> BrowseMedia:
288  """Implement the websocket media browsing helper."""
289  return await async_browse_media(
290  self.hasshass,
291  self.coordinator,
292  self.get_browse_image_urlget_browse_image_url,
293  media_content_id,
294  media_content_type,
295  )
296 
297  @roku_exception_handler()
298  async def async_turn_on(self) -> None:
299  """Turn on the Roku."""
300  await self.coordinator.roku.remote("poweron")
301  await self.coordinator.async_request_refresh()
302 
303  @roku_exception_handler(ignore_timeout=True)
304  async def async_turn_off(self) -> None:
305  """Turn off the Roku."""
306  await self.coordinator.roku.remote("poweroff")
307  await self.coordinator.async_request_refresh()
308 
309  @roku_exception_handler()
310  async def async_media_pause(self) -> None:
311  """Send pause command."""
312  if self.statestatestatestatestate not in {MediaPlayerState.STANDBY, MediaPlayerState.PAUSED}:
313  await self.coordinator.roku.remote("play")
314  await self.coordinator.async_request_refresh()
315 
316  @roku_exception_handler()
317  async def async_media_play(self) -> None:
318  """Send play command."""
319  if self.statestatestatestatestate not in {MediaPlayerState.STANDBY, MediaPlayerState.PLAYING}:
320  await self.coordinator.roku.remote("play")
321  await self.coordinator.async_request_refresh()
322 
323  @roku_exception_handler()
324  async def async_media_play_pause(self) -> None:
325  """Send play/pause command."""
326  if self.statestatestatestatestate != MediaPlayerState.STANDBY:
327  await self.coordinator.roku.remote("play")
328  await self.coordinator.async_request_refresh()
329 
330  @roku_exception_handler()
331  async def async_media_previous_track(self) -> None:
332  """Send previous track command."""
333  await self.coordinator.roku.remote("reverse")
334  await self.coordinator.async_request_refresh()
335 
336  @roku_exception_handler()
337  async def async_media_next_track(self) -> None:
338  """Send next track command."""
339  await self.coordinator.roku.remote("forward")
340  await self.coordinator.async_request_refresh()
341 
342  @roku_exception_handler()
343  async def async_mute_volume(self, mute: bool) -> None:
344  """Mute the volume."""
345  await self.coordinator.roku.remote("volume_mute")
346  await self.coordinator.async_request_refresh()
347 
348  @roku_exception_handler()
349  async def async_volume_up(self) -> None:
350  """Volume up media player."""
351  await self.coordinator.roku.remote("volume_up")
352 
353  @roku_exception_handler()
354  async def async_volume_down(self) -> None:
355  """Volume down media player."""
356  await self.coordinator.roku.remote("volume_down")
357 
358  @roku_exception_handler()
359  async def async_play_media(
360  self, media_type: MediaType | str, media_id: str, **kwargs: Any
361  ) -> None:
362  """Play media from a URL or file, launch an application, or tune to a channel."""
363  extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {}
364  original_media_type: str = media_type
365  original_media_id: str = media_id
366  mime_type: str | None = None
367  stream_name: str | None = None
368  stream_format: str | None = extra.get(ATTR_FORMAT)
369 
370  # Handle media_source
371  if media_source.is_media_source_id(media_id):
372  sourced_media = await media_source.async_resolve_media(
373  self.hasshass, media_id, self.entity_identity_id
374  )
375  media_type = MediaType.URL
376  media_id = sourced_media.url
377  mime_type = sourced_media.mime_type
378  stream_name = original_media_id
379  stream_format = guess_stream_format(media_id, mime_type)
380 
381  if media_type == FORMAT_CONTENT_TYPE[HLS_PROVIDER]:
382  media_type = MediaType.VIDEO
383  mime_type = FORMAT_CONTENT_TYPE[HLS_PROVIDER]
384  stream_name = "Camera Stream"
385  stream_format = "hls"
386 
387  if media_type in {MediaType.MUSIC, MediaType.URL, MediaType.VIDEO}:
388  # If media ID is a relative URL, we serve it from HA.
389  media_id = async_process_play_media_url(self.hasshass, media_id)
390 
391  parsed = yarl.URL(media_id)
392 
393  if mime_type is None:
394  mime_type, _ = mimetypes.guess_type(parsed.path)
395 
396  if stream_format is None:
397  stream_format = guess_stream_format(media_id, mime_type)
398 
399  if extra.get(ATTR_FORMAT) is None:
400  extra[ATTR_FORMAT] = stream_format
401 
402  if extra[ATTR_FORMAT] not in STREAM_FORMAT_TO_MEDIA_TYPE:
403  _LOGGER.error(
404  "Media type %s is not supported with format %s (mime: %s)",
405  original_media_type,
406  extra[ATTR_FORMAT],
407  mime_type,
408  )
409  return
410 
411  if (
412  media_type == MediaType.URL
413  and STREAM_FORMAT_TO_MEDIA_TYPE[extra[ATTR_FORMAT]] == MediaType.MUSIC
414  ):
415  media_type = MediaType.MUSIC
416 
417  if media_type == MediaType.MUSIC and "tts_proxy" in media_id:
418  stream_name = "Text to Speech"
419  elif stream_name is None:
420  if stream_format == "ism":
421  stream_name = parsed.parts[-2]
422  else:
423  stream_name = parsed.name
424 
425  if extra.get(ATTR_NAME) is None:
426  extra[ATTR_NAME] = stream_name
427 
428  if media_type == MediaType.APP:
429  params = {
430  param: extra[attr]
431  for attr, param in ATTRS_TO_LAUNCH_PARAMS.items()
432  if attr in extra
433  }
434 
435  await self.coordinator.roku.launch(media_id, params)
436  elif media_type == MediaType.CHANNEL:
437  await self.coordinator.roku.tune(media_id)
438  elif media_type == MediaType.MUSIC:
439  if extra.get(ATTR_ARTIST_NAME) is None:
440  extra[ATTR_ARTIST_NAME] = "Home Assistant"
441 
442  params = {
443  param: extra[attr]
444  for (attr, param) in ATTRS_TO_PLAY_ON_ROKU_AUDIO_PARAMS.items()
445  if attr in extra
446  }
447 
448  params = {"u": media_id, "t": "a", **params}
449 
450  await self.coordinator.roku.launch(
451  self.coordinator.play_media_app_id,
452  params,
453  )
454  elif media_type in {MediaType.URL, MediaType.VIDEO}:
455  params = {
456  param: extra[attr]
457  for (attr, param) in ATTRS_TO_PLAY_ON_ROKU_PARAMS.items()
458  if attr in extra
459  }
460  params["u"] = media_id
461  params["t"] = "v"
462 
463  await self.coordinator.roku.launch(
464  self.coordinator.play_media_app_id,
465  params,
466  )
467  else:
468  _LOGGER.error("Media type %s is not supported", original_media_type)
469  return
470 
471  await self.coordinator.async_request_refresh()
472 
473  @roku_exception_handler()
474  async def async_select_source(self, source: str) -> None:
475  """Select input source."""
476  if source == "Home":
477  await self.coordinator.roku.remote("home")
478 
479  appl = next(
480  (
481  app
482  for app in self.coordinator.data.apps
483  if source in (app.name, app.app_id)
484  ),
485  None,
486  )
487 
488  if appl is not None and appl.app_id is not None:
489  await self.coordinator.roku.launch(appl.app_id)
490  await self.coordinator.async_request_refresh()
str get_browse_image_url(self, str media_content_type, str media_content_id, str|None media_image_id=None)
Definition: __init__.py:1199
tuple[bytes|None, str|None] _async_fetch_image(self, str url)
Definition: __init__.py:1190
BrowseMedia async_browse_media(self, MediaType|str|None media_content_type=None, str|None media_content_id=None)
tuple[bytes|None, str|None] async_get_browse_image(self, MediaType|str media_content_type, str media_content_id, str|None media_image_id=None)
None __init__(self, RokuDataUpdateCoordinator coordinator)
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
str async_process_play_media_url(HomeAssistant hass, str media_content_id, *bool allow_relative_url=False, bool for_supervisor_network=False)
Definition: browse_media.py:36
str format_channel_name(str channel_number, str|None channel_name=None)
Definition: helpers.py:21
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: media_player.py:87