Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Provide functionality to interact with Cast devices on the network."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 from contextlib import suppress
7 from datetime import datetime
8 from functools import wraps
9 import json
10 import logging
11 from typing import TYPE_CHECKING, Any, Concatenate
12 
13 import pychromecast
14 from pychromecast.controllers.homeassistant import HomeAssistantController
16  MEDIA_PLAYER_ERROR_CODES,
17  MEDIA_PLAYER_STATE_BUFFERING,
18  MEDIA_PLAYER_STATE_PLAYING,
19  MEDIA_PLAYER_STATE_UNKNOWN,
20 )
21 from pychromecast.controllers.multizone import MultizoneManager
22 from pychromecast.controllers.receiver import VOLUME_CONTROL_TYPE_FIXED
23 from pychromecast.error import PyChromecastError
24 from pychromecast.quick_play import quick_play
25 from pychromecast.socket_client import (
26  CONNECTION_STATUS_CONNECTED,
27  CONNECTION_STATUS_DISCONNECTED,
28 )
29 import yarl
30 
31 from homeassistant.components import media_source, zeroconf
33  ATTR_MEDIA_EXTRA,
34  BrowseError,
35  BrowseMedia,
36  MediaClass,
37  MediaPlayerDeviceClass,
38  MediaPlayerEntity,
39  MediaPlayerEntityFeature,
40  MediaPlayerState,
41  MediaType,
42  async_process_play_media_url,
43 )
44 from homeassistant.config_entries import ConfigEntry
45 from homeassistant.const import (
46  CAST_APP_ID_HOMEASSISTANT_LOVELACE,
47  CONF_UUID,
48  EVENT_HOMEASSISTANT_STOP,
49 )
50 from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
51 from homeassistant.exceptions import HomeAssistantError
52 from homeassistant.helpers.device_registry import DeviceInfo
53 from homeassistant.helpers.dispatcher import async_dispatcher_connect
54 from homeassistant.helpers.entity_platform import AddEntitiesCallback
55 from homeassistant.helpers.network import NoURLAvailableError, get_url, is_hass_url
56 import homeassistant.util.dt as dt_util
57 from homeassistant.util.logging import async_create_catching_coro
58 
59 from .const import (
60  ADDED_CAST_DEVICES_KEY,
61  CAST_MULTIZONE_MANAGER_KEY,
62  CONF_IGNORE_CEC,
63  DOMAIN as CAST_DOMAIN,
64  SIGNAL_CAST_DISCOVERED,
65  SIGNAL_CAST_REMOVED,
66  SIGNAL_HASS_CAST_SHOW_VIEW,
67  HomeAssistantControllerData,
68 )
69 from .discovery import setup_internal_discovery
70 from .helpers import (
71  CastStatusListener,
72  ChromecastInfo,
73  ChromeCastZeroconf,
74  PlaylistError,
75  PlaylistSupported,
76  parse_playlist,
77 )
78 
79 if TYPE_CHECKING:
80  from . import CastProtocol
81 
82 _LOGGER = logging.getLogger(__name__)
83 
84 APP_IDS_UNRELIABLE_MEDIA_INFO = ("Netflix",)
85 
86 CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png"
87 
88 type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R]
89 
90 
91 def api_error[_CastDeviceT: CastDevice, **_P, _R](
92  func: _FuncType[_CastDeviceT, _P, _R],
93 ) -> _FuncType[_CastDeviceT, _P, _R]:
94  """Handle PyChromecastError and reraise a HomeAssistantError."""
95 
96  @wraps(func)
97  def wrapper(self: _CastDeviceT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
98  """Wrap a CastDevice method."""
99  try:
100  return_value = func(self, *args, **kwargs)
101  except PyChromecastError as err:
102  raise HomeAssistantError(
103  f"{self.__class__.__name__}.{func.__name__} Failed: {err}"
104  ) from err
105 
106  return return_value
107 
108  return wrapper
109 
110 
111 @callback
112 def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
113  """Create a CastDevice entity or dynamic group from the chromecast object.
114 
115  Returns None if the cast device has already been added.
116  """
117  _LOGGER.debug("_async_create_cast_device: %s", info)
118  if info.uuid is None:
119  _LOGGER.error("_async_create_cast_device uuid none: %s", info)
120  return None
121 
122  # Found a cast with UUID
123  added_casts = hass.data[ADDED_CAST_DEVICES_KEY]
124  if info.uuid in added_casts:
125  # Already added this one, the entity will take care of moved hosts
126  # itself
127  return None
128  # -> New cast device
129  added_casts.add(info.uuid)
130 
131  if info.is_dynamic_group:
132  # This is a dynamic group, do not add it but connect to the service.
133  group = DynamicCastGroup(hass, info)
134  group.async_setup()
135  return None
136 
137  return CastMediaPlayerEntity(hass, info)
138 
139 
141  hass: HomeAssistant,
142  config_entry: ConfigEntry,
143  async_add_entities: AddEntitiesCallback,
144 ) -> None:
145  """Set up Cast from a config entry."""
146  hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set())
147 
148  # Import CEC IGNORE attributes
149  pychromecast.IGNORE_CEC += config_entry.data.get(CONF_IGNORE_CEC) or []
150 
151  wanted_uuids = config_entry.data.get(CONF_UUID) or None
152 
153  @callback
154  def async_cast_discovered(discover: ChromecastInfo) -> None:
155  """Handle discovery of a new chromecast."""
156  # If wanted_uuids is set, we're only accepting specific cast devices identified
157  # by UUID
158  if wanted_uuids is not None and str(discover.uuid) not in wanted_uuids:
159  # UUID not matching, ignore.
160  return
161 
162  cast_device = _async_create_cast_device(hass, discover)
163  if cast_device is not None:
164  async_add_entities([cast_device])
165 
166  async_dispatcher_connect(hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered)
167  ChromeCastZeroconf.set_zeroconf(await zeroconf.async_get_instance(hass))
168  hass.async_add_executor_job(setup_internal_discovery, hass, config_entry)
169 
170 
172  """Representation of a Cast device or dynamic group on the network.
173 
174  This class is the holder of the pychromecast.Chromecast object and its
175  socket client. It therefore handles all reconnects and audio groups changing
176  "elected leader" itself.
177  """
178 
179  _mz_only: bool
180 
181  def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
182  """Initialize the cast device."""
183 
184  self.hass: HomeAssistant = hass
185  self._cast_info_cast_info = cast_info
186  self._chromecast_chromecast: pychromecast.Chromecast | None = None
187  self.mz_mgrmz_mgr = None
188  self._status_listener_status_listener: CastStatusListener | None = None
189  self._add_remove_handler_add_remove_handler: Callable[[], None] | None = None
190  self._del_remove_handler_del_remove_handler: Callable[[], None] | None = None
191  self._name_name: str | None = None
192 
193  def _async_setup(self, name: str) -> None:
194  """Create chromecast object."""
195  self._name_name = name
197  self.hass, SIGNAL_CAST_DISCOVERED, self._async_cast_discovered_async_cast_discovered
198  )
200  self.hass, SIGNAL_CAST_REMOVED, self._async_cast_removed_async_cast_removed
201  )
202  self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop_async_stop)
203  # async_create_background_task is used to avoid delaying startup wrapup if the device
204  # is discovered already during startup but then fails to respond
205  self.hass.async_create_background_task(
206  async_create_catching_coro(self._async_connect_to_chromecast_async_connect_to_chromecast()),
207  "cast-connect",
208  )
209 
210  async def _async_tear_down(self) -> None:
211  """Disconnect chromecast object and remove listeners."""
212  await self._async_disconnect_async_disconnect()
213  if self._cast_info_cast_info.uuid is not None:
214  # Remove the entity from the added casts so that it can dynamically
215  # be re-added again.
216  self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info_cast_info.uuid)
217  if self._add_remove_handler_add_remove_handler:
218  self._add_remove_handler_add_remove_handler()
219  self._add_remove_handler_add_remove_handler = None
220  if self._del_remove_handler_del_remove_handler:
221  self._del_remove_handler_del_remove_handler()
222  self._del_remove_handler_del_remove_handler = None
223 
225  """Set up the chromecast object."""
226  _LOGGER.debug(
227  "[%s %s] Connecting to cast device by service %s",
228  self._name_name,
229  self._cast_info_cast_info.friendly_name,
230  self._cast_info_cast_info.cast_info.services,
231  )
232  chromecast = await self.hass.async_add_executor_job(
233  pychromecast.get_chromecast_from_cast_info,
234  self._cast_info_cast_info.cast_info,
235  ChromeCastZeroconf.get_zeroconf(),
236  )
237  self._chromecast_chromecast = chromecast
238 
239  if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
240  self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
241 
242  self.mz_mgrmz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
243 
244  self._status_listener_status_listener = CastStatusListener(
245  self, chromecast, self.mz_mgrmz_mgr, self._mz_only
246  )
247  chromecast.start()
248 
249  async def _async_disconnect(self) -> None:
250  """Disconnect Chromecast object if it is set."""
251  if self._chromecast_chromecast is not None:
252  _LOGGER.debug(
253  "[%s %s] Disconnecting from chromecast socket",
254  self._name_name,
255  self._cast_info_cast_info.friendly_name,
256  )
257  await self.hass.async_add_executor_job(self._chromecast_chromecast.disconnect)
258 
259  self._invalidate_invalidate()
260 
261  def _invalidate(self) -> None:
262  """Invalidate some attributes."""
263  self._chromecast_chromecast = None
264  self.mz_mgrmz_mgr = None
265  if self._status_listener_status_listener is not None:
266  self._status_listener_status_listener.invalidate()
267  self._status_listener_status_listener = None
268 
269  @callback
270  def _async_cast_discovered(self, discover: ChromecastInfo) -> None:
271  """Handle discovery of new Chromecast."""
272  if self._cast_info_cast_info.uuid != discover.uuid:
273  # Discovered is not our device.
274  return
275 
276  _LOGGER.debug("Discovered chromecast with same UUID: %s", discover)
277  self._cast_info_cast_info = discover
278 
279  async def _async_cast_removed(self, discover: ChromecastInfo) -> None:
280  """Handle removal of Chromecast."""
281 
282  async def _async_stop(self, event: Event) -> None:
283  """Disconnect socket on Home Assistant stop."""
284  await self._async_disconnect_async_disconnect()
285 
286  def _get_chromecast(self) -> pychromecast.Chromecast:
287  """Ensure chromecast is available, to facilitate type checking."""
288  if self._chromecast_chromecast is None:
289  raise HomeAssistantError("Chromecast is not available.")
290  return self._chromecast_chromecast
291 
292 
294  """Representation of a Cast device on the network."""
295 
296  _attr_has_entity_name = True
297  _attr_name = None
298  _attr_should_poll = False
299  _attr_media_image_remotely_accessible = True
300  _mz_only = False
301 
302  def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
303  """Initialize the cast device."""
304 
305  CastDevice.__init__(self, hass, cast_info)
306 
307  self.cast_statuscast_status = None
308  self.media_statusmedia_status = None
309  self.media_status_receivedmedia_status_received = None
310  self.mz_media_statusmz_media_status: dict[str, pychromecast.controllers.media.MediaStatus] = {}
311  self.mz_media_status_receivedmz_media_status_received: dict[str, datetime] = {}
312  self._attr_available_attr_available = False
313  self._hass_cast_controller_hass_cast_controller: HomeAssistantController | None = None
314 
315  self._cast_view_remove_handler_cast_view_remove_handler: CALLBACK_TYPE | None = None
316  self._attr_unique_id_attr_unique_id = str(cast_info.uuid)
317  self._attr_device_info_attr_device_info = DeviceInfo(
318  identifiers={(CAST_DOMAIN, str(cast_info.uuid).replace("-", ""))},
319  manufacturer=str(cast_info.cast_info.manufacturer),
320  model=cast_info.cast_info.model_name,
321  name=str(cast_info.friendly_name),
322  )
323 
324  if cast_info.cast_info.cast_type in [
325  pychromecast.const.CAST_TYPE_AUDIO,
326  pychromecast.const.CAST_TYPE_GROUP,
327  ]:
328  self._attr_device_class_attr_device_class = MediaPlayerDeviceClass.SPEAKER
329 
330  async def async_added_to_hass(self) -> None:
331  """Create chromecast object when added to hass."""
332  self._async_setup_async_setup(self.entity_identity_id)
333 
334  self._cast_view_remove_handler_cast_view_remove_handler = async_dispatcher_connect(
335  self.hasshass, SIGNAL_HASS_CAST_SHOW_VIEW, self._handle_signal_show_view_handle_signal_show_view
336  )
337 
338  async def async_will_remove_from_hass(self) -> None:
339  """Disconnect Chromecast object when removed."""
340  await self._async_tear_down_async_tear_down()
341 
342  if self._cast_view_remove_handler_cast_view_remove_handler:
343  self._cast_view_remove_handler_cast_view_remove_handler()
344  self._cast_view_remove_handler_cast_view_remove_handler = None
345 
347  """Set up the chromecast object."""
348  await super()._async_connect_to_chromecast()
349 
350  self._attr_available_attr_available = False
351  self.cast_statuscast_status = self._chromecast_chromecast.status
352  self.media_statusmedia_status = self._chromecast_chromecast.media_controller.status
353  self.async_write_ha_stateasync_write_ha_state()
354 
355  async def _async_disconnect(self):
356  """Disconnect Chromecast object if it is set."""
357  await super()._async_disconnect()
358 
359  self._attr_available_attr_available = False
360  self.async_write_ha_stateasync_write_ha_state()
361 
362  def _invalidate(self):
363  """Invalidate some attributes."""
364  super()._invalidate()
365 
366  self.cast_statuscast_status = None
367  self.media_statusmedia_status = None
368  self.media_status_receivedmedia_status_received = None
369  self.mz_media_statusmz_media_status = {}
370  self.mz_media_status_receivedmz_media_status_received = {}
371  self._hass_cast_controller_hass_cast_controller = None
372 
373  # ========== Callbacks ==========
374  def new_cast_status(self, cast_status):
375  """Handle updates of the cast status."""
376  self.cast_statuscast_status = cast_status
377  self._attr_volume_level_attr_volume_level = cast_status.volume_level if cast_status else None
378  self._attr_is_volume_muted_attr_is_volume_muted = (
379  cast_status.volume_muted if self.cast_statuscast_status else None
380  )
381  self.schedule_update_ha_stateschedule_update_ha_state()
382 
383  def new_media_status(self, media_status):
384  """Handle updates of the media status."""
385  if (
386  media_status
387  and media_status.player_is_idle
388  and media_status.idle_reason == "ERROR"
389  ):
390  external_url = None
391  internal_url = None
392  url_description = ""
393  with suppress(NoURLAvailableError): # external_url not configured
394  external_url = get_url(self.hasshass, allow_internal=False)
395 
396  with suppress(NoURLAvailableError): # internal_url not configured
397  internal_url = get_url(self.hasshass, allow_external=False)
398 
399  if media_status.content_id:
400  if external_url and media_status.content_id.startswith(external_url):
401  url_description = f" from external_url ({external_url})"
402  if internal_url and media_status.content_id.startswith(internal_url):
403  url_description = f" from internal_url ({internal_url})"
404 
405  _LOGGER.error(
406  (
407  "Failed to cast media %s%s. Please make sure the URL is: "
408  "Reachable from the cast device and either a publicly resolvable "
409  "hostname or an IP address"
410  ),
411  media_status.content_id,
412  url_description,
413  )
414 
415  self.media_statusmedia_status = media_status
416  self.media_status_receivedmedia_status_received = dt_util.utcnow()
417  self.schedule_update_ha_stateschedule_update_ha_state()
418 
419  def load_media_failed(self, queue_item_id, error_code):
420  """Handle load media failed."""
421  _LOGGER.debug(
422  "[%s %s] Load media failed with code %s(%s) for queue_item_id %s",
423  self.entity_identity_id,
424  self._cast_info_cast_info.friendly_name,
425  error_code,
426  MEDIA_PLAYER_ERROR_CODES.get(error_code, "unknown code"),
427  queue_item_id,
428  )
429 
430  def new_connection_status(self, connection_status):
431  """Handle updates of connection status."""
432  _LOGGER.debug(
433  "[%s %s] Received cast device connection status: %s",
434  self.entity_identity_id,
435  self._cast_info_cast_info.friendly_name,
436  connection_status.status,
437  )
438  if connection_status.status == CONNECTION_STATUS_DISCONNECTED:
439  self._attr_available_attr_available = False
440  self._invalidate_invalidate_invalidate()
441  self.schedule_update_ha_stateschedule_update_ha_state()
442  return
443 
444  new_available = connection_status.status == CONNECTION_STATUS_CONNECTED
445  if new_available != self.availableavailable:
446  # Connection status callbacks happen often when disconnected.
447  # Only update state when availability changed to put less pressure
448  # on state machine.
449  _LOGGER.debug(
450  "[%s %s] Cast device availability changed: %s",
451  self.entity_identity_id,
452  self._cast_info_cast_info.friendly_name,
453  connection_status.status,
454  )
455  self._attr_available_attr_available = new_available
456  if new_available and not self._cast_info_cast_info.is_audio_group:
457  # Poll current group status
458  for group_uuid in self.mz_mgrmz_mgr.get_multizone_memberships(
459  self._cast_info_cast_info.uuid
460  ):
461  group_media_controller = self.mz_mgrmz_mgr.get_multizone_mediacontroller(
462  group_uuid
463  )
464  if not group_media_controller:
465  continue
466  self.multizone_new_media_statusmultizone_new_media_status(
467  group_uuid, group_media_controller.status
468  )
469  self.schedule_update_ha_stateschedule_update_ha_state()
470 
471  def multizone_new_media_status(self, group_uuid, media_status):
472  """Handle updates of audio group media status."""
473  _LOGGER.debug(
474  "[%s %s] Multizone %s media status: %s",
475  self.entity_identity_id,
476  self._cast_info_cast_info.friendly_name,
477  group_uuid,
478  media_status,
479  )
480  self.mz_media_statusmz_media_status[group_uuid] = media_status
481  self.mz_media_status_receivedmz_media_status_received[group_uuid] = dt_util.utcnow()
482  self.schedule_update_ha_stateschedule_update_ha_state()
483 
484  # ========== Service Calls ==========
485  def _media_controller(self):
486  """Return media controller.
487 
488  First try from our own cast, then groups which our cast is a member in.
489  """
490  media_status = self.media_statusmedia_status
491  media_controller = self._chromecast_chromecast.media_controller
492 
493  if (
494  media_status is None
495  or media_status.player_state == MEDIA_PLAYER_STATE_UNKNOWN
496  ):
497  groups = self.mz_media_statusmz_media_status
498  for k, val in groups.items():
499  if val and val.player_state != MEDIA_PLAYER_STATE_UNKNOWN:
500  media_controller = self.mz_mgrmz_mgr.get_multizone_mediacontroller(k)
501  break
502 
503  return media_controller
504 
505  @api_error
506  def _quick_play(self, app_name: str, data: dict[str, Any]) -> None:
507  """Launch the app `app_name` and start playing media defined by `data`."""
508  quick_play(self._get_chromecast_get_chromecast(), app_name, data)
509 
510  @api_error
511  def _quit_app(self) -> None:
512  """Quit the currently running app."""
513  self._get_chromecast_get_chromecast().quit_app()
514 
515  @api_error
516  def _start_app(self, app_id: str) -> None:
517  """Start an app."""
518  self._get_chromecast_get_chromecast().start_app(app_id)
519 
520  def turn_on(self) -> None:
521  """Turn on the cast device."""
522 
523  chromecast = self._get_chromecast_get_chromecast()
524  if not chromecast.is_idle:
525  # Already turned on
526  return
527 
528  if chromecast.app_id is not None:
529  # Quit the previous app before starting splash screen or media player
530  self._quit_app_quit_app()
531 
532  # The only way we can turn the Chromecast is on is by launching an app
533  if chromecast.cast_type == pychromecast.const.CAST_TYPE_CHROMECAST:
534  app_data = {"media_id": CAST_SPLASH, "media_type": "image/png"}
535  self._quick_play_quick_play("default_media_receiver", app_data)
536  else:
537  self._start_app_start_app(pychromecast.config.APP_MEDIA_RECEIVER)
538 
539  @api_error
540  def turn_off(self) -> None:
541  """Turn off the cast device."""
542  self._get_chromecast_get_chromecast().quit_app()
543 
544  @api_error
545  def mute_volume(self, mute: bool) -> None:
546  """Mute the volume."""
547  self._get_chromecast_get_chromecast().set_volume_muted(mute)
548 
549  @api_error
550  def set_volume_level(self, volume: float) -> None:
551  """Set volume level, range 0..1."""
552  self._get_chromecast_get_chromecast().set_volume(volume)
553 
554  @api_error
555  def media_play(self) -> None:
556  """Send play command."""
557  media_controller = self._media_controller_media_controller()
558  media_controller.play()
559 
560  @api_error
561  def media_pause(self) -> None:
562  """Send pause command."""
563  media_controller = self._media_controller_media_controller()
564  media_controller.pause()
565 
566  @api_error
567  def media_stop(self) -> None:
568  """Send stop command."""
569  media_controller = self._media_controller_media_controller()
570  media_controller.stop()
571 
572  @api_error
573  def media_previous_track(self) -> None:
574  """Send previous track command."""
575  media_controller = self._media_controller_media_controller()
576  media_controller.queue_prev()
577 
578  @api_error
579  def media_next_track(self) -> None:
580  """Send next track command."""
581  media_controller = self._media_controller_media_controller()
582  media_controller.queue_next()
583 
584  @api_error
585  def media_seek(self, position: float) -> None:
586  """Seek the media to a specific location."""
587  media_controller = self._media_controller_media_controller()
588  media_controller.seek(position)
589 
590  async def _async_root_payload(self, content_filter):
591  """Generate root node."""
592  children = []
593  # Add media browsers
594  for platform in self.hasshass.data[CAST_DOMAIN]["cast_platform"].values():
595  children.extend(
596  await platform.async_get_media_browser_root_object(
597  self.hasshass, self._chromecast_chromecast.cast_type
598  )
599  )
600 
601  # Add media sources
602  try:
603  result = await media_source.async_browse_media(
604  self.hasshass, None, content_filter=content_filter
605  )
606  children.extend(result.children)
607  except BrowseError:
608  if not children:
609  raise
610 
611  # If there's only one media source, resolve it
612  if len(children) == 1 and children[0].can_expand:
613  return await self.async_browse_mediaasync_browse_mediaasync_browse_media(
614  children[0].media_content_type,
615  children[0].media_content_id,
616  )
617 
618  return BrowseMedia(
619  title="Cast",
620  media_class=MediaClass.DIRECTORY,
621  media_content_id="",
622  media_content_type="",
623  can_play=False,
624  can_expand=True,
625  children=sorted(children, key=lambda c: c.title),
626  )
627 
629  self,
630  media_content_type: MediaType | str | None = None,
631  media_content_id: str | None = None,
632  ) -> BrowseMedia:
633  """Implement the websocket media browsing helper."""
634  content_filter = None
635 
636  chromecast = self._get_chromecast_get_chromecast()
637  if chromecast.cast_type in (
638  pychromecast.const.CAST_TYPE_AUDIO,
639  pychromecast.const.CAST_TYPE_GROUP,
640  ):
641 
642  def audio_content_filter(item):
643  """Filter non audio content."""
644  return item.media_content_type.startswith("audio/")
645 
646  content_filter = audio_content_filter
647 
648  if media_content_id is None:
649  return await self._async_root_payload_async_root_payload(content_filter)
650 
651  platform: CastProtocol
652  assert media_content_type is not None
653  for platform in self.hasshass.data[CAST_DOMAIN]["cast_platform"].values():
654  browse_media = await platform.async_browse_media(
655  self.hasshass,
656  media_content_type,
657  media_content_id,
658  chromecast.cast_type,
659  )
660  if browse_media:
661  return browse_media
662 
663  return await media_source.async_browse_media(
664  self.hasshass, media_content_id, content_filter=content_filter
665  )
666 
667  async def async_play_media(
668  self, media_type: MediaType | str, media_id: str, **kwargs: Any
669  ) -> None:
670  """Play a piece of media."""
671  chromecast = self._get_chromecast_get_chromecast()
672  # Handle media_source
673  if media_source.is_media_source_id(media_id):
674  sourced_media = await media_source.async_resolve_media(
675  self.hasshass, media_id, self.entity_identity_id
676  )
677  media_type = sourced_media.mime_type
678  media_id = sourced_media.url
679 
680  extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
681 
682  # Handle media supported by a known cast app
683  if media_type == CAST_DOMAIN:
684  try:
685  app_data = json.loads(media_id)
686  if metadata := extra.get("metadata"):
687  app_data["metadata"] = metadata
688  except json.JSONDecodeError:
689  _LOGGER.error("Invalid JSON in media_content_id")
690  raise
691 
692  # Special handling for passed `app_id` parameter. This will only launch
693  # an arbitrary cast app, generally for UX.
694  if "app_id" in app_data:
695  app_id = app_data.pop("app_id")
696  _LOGGER.debug("Starting Cast app by ID %s", app_id)
697  await self.hasshass.async_add_executor_job(self._start_app_start_app, app_id)
698  if app_data:
699  _LOGGER.warning(
700  "Extra keys %s were ignored. Please use app_name to cast media",
701  app_data.keys(),
702  )
703  return
704 
705  app_name = app_data.pop("app_name")
706  try:
707  await self.hasshass.async_add_executor_job(
708  self._quick_play_quick_play, app_name, app_data
709  )
710  except NotImplementedError:
711  _LOGGER.error("App %s not supported", app_name)
712  return
713 
714  # Try the cast platforms
715  for platform in self.hasshass.data[CAST_DOMAIN]["cast_platform"].values():
716  result = await platform.async_play_media(
717  self.hasshass, self.entity_identity_id, chromecast, media_type, media_id
718  )
719  if result:
720  return
721 
722  # If media ID is a relative URL, we serve it from HA.
723  media_id = async_process_play_media_url(self.hasshass, media_id)
724 
725  # Configure play command for when playing a HLS stream
726  if is_hass_url(self.hasshass, media_id):
727  parsed = yarl.URL(media_id)
728  if parsed.path.startswith("/api/hls/"):
729  extra = {
730  **extra,
731  "stream_type": "LIVE",
732  "media_info": {
733  "hlsVideoSegmentFormat": "fmp4",
734  },
735  }
736  elif media_id.endswith((".m3u", ".m3u8", ".pls")):
737  try:
738  playlist = await parse_playlist(self.hasshass, media_id)
739  _LOGGER.debug(
740  "[%s %s] Playing item %s from playlist %s",
741  self.entity_identity_id,
742  self._cast_info_cast_info.friendly_name,
743  playlist[0].url,
744  media_id,
745  )
746  media_id = playlist[0].url
747  if title := playlist[0].title:
748  extra = {
749  **extra,
750  "metadata": {"title": title},
751  }
752  except PlaylistSupported as err:
753  _LOGGER.debug(
754  "[%s %s] Playlist %s is supported: %s",
755  self.entity_identity_id,
756  self._cast_info_cast_info.friendly_name,
757  media_id,
758  err,
759  )
760  except PlaylistError as err:
761  _LOGGER.warning(
762  "[%s %s] Failed to parse playlist %s: %s",
763  self.entity_identity_id,
764  self._cast_info_cast_info.friendly_name,
765  media_id,
766  err,
767  )
768 
769  # Default to play with the default media receiver
770  app_data = {"media_id": media_id, "media_type": media_type, **extra}
771  _LOGGER.debug(
772  "[%s %s] Playing %s with default_media_receiver",
773  self.entity_identity_id,
774  self._cast_info_cast_info.friendly_name,
775  app_data,
776  )
777  await self.hasshass.async_add_executor_job(
778  self._quick_play_quick_play, "default_media_receiver", app_data
779  )
780 
781  def _media_status(self):
782  """Return media status.
783 
784  First try from our own cast, then groups which our cast is a member in.
785  """
786  media_status = self.media_statusmedia_status
787  media_status_received = self.media_status_receivedmedia_status_received
788 
789  if (
790  media_status is None
791  or media_status.player_state == MEDIA_PLAYER_STATE_UNKNOWN
792  ):
793  groups = self.mz_media_statusmz_media_status
794  for k, val in groups.items():
795  if val and val.player_state != MEDIA_PLAYER_STATE_UNKNOWN:
796  media_status = val
797  media_status_received = self.mz_media_status_receivedmz_media_status_received[k]
798  break
799 
800  return (media_status, media_status_received)
801 
802  @property
803  def state(self) -> MediaPlayerState | None:
804  """Return the state of the player."""
805  # The lovelace app loops media to prevent timing out, don't show that
806  if self.app_idapp_idapp_idapp_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE:
807  return MediaPlayerState.PLAYING
808  if (media_status := self._media_status_media_status()[0]) is not None:
809  if media_status.player_state == MEDIA_PLAYER_STATE_PLAYING:
810  return MediaPlayerState.PLAYING
811  if media_status.player_state == MEDIA_PLAYER_STATE_BUFFERING:
812  return MediaPlayerState.BUFFERING
813  if media_status.player_is_paused:
814  return MediaPlayerState.PAUSED
815  if media_status.player_is_idle:
816  return MediaPlayerState.IDLE
817  if self.app_idapp_idapp_idapp_id is not None and self.app_idapp_idapp_idapp_id != pychromecast.IDLE_APP_ID:
818  if self.app_idapp_idapp_idapp_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
819  # Some apps don't report media status, show the player as playing
820  return MediaPlayerState.PLAYING
821  return MediaPlayerState.IDLE
822  if self._chromecast_chromecast is not None and self._chromecast_chromecast.is_idle:
823  return MediaPlayerState.OFF
824  return None
825 
826  @property
827  def media_content_id(self) -> str | None:
828  """Content ID of current playing media."""
829  # The lovelace app loops media to prevent timing out, don't show that
830  if self.app_idapp_idapp_idapp_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE:
831  return None
832  media_status = self._media_status_media_status()[0]
833  return media_status.content_id if media_status else None
834 
835  @property
836  def media_content_type(self) -> MediaType | None:
837  """Content type of current playing media."""
838  # The lovelace app loops media to prevent timing out, don't show that
839  if self.app_idapp_idapp_idapp_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE:
840  return None
841  if (media_status := self._media_status_media_status()[0]) is None:
842  return None
843  if media_status.media_is_tvshow:
844  return MediaType.TVSHOW
845  if media_status.media_is_movie:
846  return MediaType.MOVIE
847  if media_status.media_is_musictrack:
848  return MediaType.MUSIC
849 
850  chromecast = self._get_chromecast_get_chromecast()
851  if chromecast.cast_type in (
852  pychromecast.const.CAST_TYPE_AUDIO,
853  pychromecast.const.CAST_TYPE_GROUP,
854  ):
855  return MediaType.MUSIC
856 
857  return MediaType.VIDEO
858 
859  @property
860  def media_duration(self):
861  """Duration of current playing media in seconds."""
862  # The lovelace app loops media to prevent timing out, don't show that
863  if self.app_idapp_idapp_idapp_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE:
864  return None
865  media_status = self._media_status_media_status()[0]
866  return media_status.duration if media_status else None
867 
868  @property
869  def media_image_url(self):
870  """Image url of current playing media."""
871  if (media_status := self._media_status_media_status()[0]) is None:
872  return None
873 
874  images = media_status.images
875 
876  return images[0].url if images and images[0].url else None
877 
878  @property
879  def media_title(self):
880  """Title of current playing media."""
881  media_status = self._media_status_media_status()[0]
882  return media_status.title if media_status else None
883 
884  @property
885  def media_artist(self):
886  """Artist of current playing media (Music track only)."""
887  media_status = self._media_status_media_status()[0]
888  return media_status.artist if media_status else None
889 
890  @property
891  def media_album_name(self):
892  """Album of current playing media (Music track only)."""
893  media_status = self._media_status_media_status()[0]
894  return media_status.album_name if media_status else None
895 
896  @property
898  """Album artist of current playing media (Music track only)."""
899  media_status = self._media_status_media_status()[0]
900  return media_status.album_artist if media_status else None
901 
902  @property
903  def media_track(self):
904  """Track number of current playing media (Music track only)."""
905  media_status = self._media_status_media_status()[0]
906  return media_status.track if media_status else None
907 
908  @property
910  """Return the title of the series of current playing media."""
911  media_status = self._media_status_media_status()[0]
912  return media_status.series_title if media_status else None
913 
914  @property
915  def media_season(self):
916  """Season of current playing media (TV Show only)."""
917  media_status = self._media_status_media_status()[0]
918  return media_status.season if media_status else None
919 
920  @property
921  def media_episode(self):
922  """Episode of current playing media (TV Show only)."""
923  media_status = self._media_status_media_status()[0]
924  return media_status.episode if media_status else None
925 
926  @property
927  def app_id(self):
928  """Return the ID of the current running app."""
929  return self._chromecast_chromecast.app_id if self._chromecast_chromecast else None
930 
931  @property
932  def app_name(self):
933  """Name of the current running app."""
934  return self._chromecast_chromecast.app_display_name if self._chromecast_chromecast else None
935 
936  @property
937  def supported_features(self) -> MediaPlayerEntityFeature:
938  """Flag media player features that are supported."""
939  support = (
940  MediaPlayerEntityFeature.PLAY_MEDIA
941  | MediaPlayerEntityFeature.TURN_OFF
942  | MediaPlayerEntityFeature.TURN_ON
943  )
944  media_status = self._media_status_media_status()[0]
945 
946  if (
947  self.cast_statuscast_status
948  and self.cast_statuscast_status.volume_control_type != VOLUME_CONTROL_TYPE_FIXED
949  ):
950  support |= (
951  MediaPlayerEntityFeature.VOLUME_MUTE
952  | MediaPlayerEntityFeature.VOLUME_SET
953  )
954 
955  if media_status and self.app_idapp_idapp_idapp_id != CAST_APP_ID_HOMEASSISTANT_LOVELACE:
956  support |= (
957  MediaPlayerEntityFeature.PAUSE
958  | MediaPlayerEntityFeature.PLAY
959  | MediaPlayerEntityFeature.STOP
960  )
961  if media_status.supports_queue_next:
962  support |= (
963  MediaPlayerEntityFeature.PREVIOUS_TRACK
964  | MediaPlayerEntityFeature.NEXT_TRACK
965  )
966  if media_status.supports_seek:
967  support |= MediaPlayerEntityFeature.SEEK
968 
969  if "media_source" in self.hasshass.config.components:
970  support |= MediaPlayerEntityFeature.BROWSE_MEDIA
971 
972  return support
973 
974  @property
975  def media_position(self):
976  """Position of current playing media in seconds."""
977  # The lovelace app loops media to prevent timing out, don't show that
978  if self.app_idapp_idapp_idapp_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE:
979  return None
980  media_status = self._media_status_media_status()[0]
981  if media_status is None or not (
982  media_status.player_is_playing
983  or media_status.player_is_paused
984  or media_status.player_is_idle
985  ):
986  return None
987  return media_status.current_time
988 
989  @property
991  """When was the position of the current playing media valid.
992 
993  Returns value from homeassistant.util.dt.utcnow().
994  """
995  if self.app_idapp_idapp_idapp_id == CAST_APP_ID_HOMEASSISTANT_LOVELACE:
996  return None
997  return self._media_status_media_status()[1]
998 
1000  self,
1001  controller_data: HomeAssistantControllerData,
1002  entity_id: str,
1003  view_path: str,
1004  url_path: str | None,
1005  ):
1006  """Handle a show view signal."""
1007  if entity_id != self.entity_identity_id or self._chromecast_chromecast is None:
1008  return
1009 
1010  if self._hass_cast_controller_hass_cast_controller is None:
1011 
1012  def unregister() -> None:
1013  """Handle request to unregister the handler."""
1014  if not self._hass_cast_controller_hass_cast_controller or not self._chromecast_chromecast:
1015  return
1016  _LOGGER.debug(
1017  "[%s %s] Unregistering HomeAssistantController",
1018  self.entity_identity_id,
1019  self._cast_info_cast_info.friendly_name,
1020  )
1021 
1022  self._chromecast_chromecast.unregister_handler(self._hass_cast_controller_hass_cast_controller)
1023  self._hass_cast_controller_hass_cast_controller = None
1024 
1025  controller = HomeAssistantController(
1026  **controller_data, unregister=unregister
1027  )
1028  self._hass_cast_controller_hass_cast_controller = controller
1029  self._chromecast_chromecast.register_handler(controller)
1030 
1031  self._hass_cast_controller_hass_cast_controller.show_lovelace_view(view_path, url_path)
1032 
1033 
1035  """Representation of a Cast device on the network - for dynamic cast groups."""
1036 
1037  _mz_only = True
1038 
1039  def async_setup(self):
1040  """Create chromecast object."""
1041  self._async_setup_async_setup("Dynamic group")
1042 
1043  async def _async_cast_removed(self, discover: ChromecastInfo):
1044  """Handle removal of Chromecast."""
1045  if self._cast_info_cast_info.uuid != discover.uuid:
1046  # Removed is not our device.
1047  return
1048 
1049  if not discover.cast_info.services:
1050  # Clean up the dynamic group
1051  _LOGGER.debug("Clean up dynamic group: %s", discover)
1052  await self._async_tear_down_async_tear_down()
None _async_cast_removed(self, ChromecastInfo discover)
None __init__(self, HomeAssistant hass, ChromecastInfo cast_info)
None _async_cast_discovered(self, ChromecastInfo discover)
def _handle_signal_show_view(self, HomeAssistantControllerData controller_data, str entity_id, str view_path, str|None url_path)
None _quick_play(self, str app_name, dict[str, Any] data)
None __init__(self, HomeAssistant hass, ChromecastInfo cast_info)
BrowseMedia async_browse_media(self, MediaType|str|None media_content_type=None, str|None media_content_id=None)
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
def multizone_new_media_status(self, group_uuid, media_status)
def _async_cast_removed(self, ChromecastInfo discover)
BrowseMedia async_browse_media(self, MediaType|str|None media_content_type=None, str|None media_content_id=None)
Definition: __init__.py:1137
None schedule_update_ha_state(self, bool force_refresh=False)
Definition: entity.py:1244
bool remove(self, _T matcher)
Definition: match.py:214
def _async_create_cast_device(HomeAssistant hass, ChromecastInfo info)
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
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
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
str get_url(HomeAssistant hass, *bool require_current_request=False, bool require_ssl=False, bool require_standard_port=False, bool require_cloud=False, bool allow_internal=True, bool allow_external=True, bool allow_cloud=True, bool|None allow_ip=None, bool|None prefer_external=None, bool prefer_cloud=False)
Definition: network.py:131
bool is_hass_url(HomeAssistant hass, str url)
Definition: network.py:67