Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support for DLNA DMR (Device Media Renderer)."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Awaitable, Callable, Coroutine, Sequence
7 import contextlib
8 from datetime import datetime, timedelta
9 import functools
10 from typing import Any, Concatenate
11 
12 from async_upnp_client.client import UpnpService, UpnpStateVariable
13 from async_upnp_client.const import NotificationSubType
14 from async_upnp_client.exceptions import UpnpError, UpnpResponseError
15 from async_upnp_client.profiles.dlna import DmrDevice, PlayMode, TransportState
16 from async_upnp_client.utils import async_get_local_ip
17 from didl_lite import didl_lite
18 
19 from homeassistant import config_entries
20 from homeassistant.components import media_source, ssdp
22  ATTR_MEDIA_EXTRA,
23  DOMAIN as MEDIA_PLAYER_DOMAIN,
24  BrowseMedia,
25  MediaPlayerEntity,
26  MediaPlayerEntityFeature,
27  MediaPlayerState,
28  MediaType,
29  RepeatMode,
30  async_process_play_media_url,
31 )
32 from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL
33 from homeassistant.core import CoreState, HomeAssistant
34 from homeassistant.helpers import device_registry as dr, entity_registry as er
35 from homeassistant.helpers.entity_platform import AddEntitiesCallback
36 
37 from .const import (
38  CONF_BROWSE_UNFILTERED,
39  CONF_CALLBACK_URL_OVERRIDE,
40  CONF_LISTEN_PORT,
41  CONF_POLL_AVAILABILITY,
42  DOMAIN,
43  LOGGER as _LOGGER,
44  MEDIA_METADATA_DIDL,
45  MEDIA_TYPE_MAP,
46  MEDIA_UPNP_CLASS_MAP,
47  REPEAT_PLAY_MODES,
48  SHUFFLE_PLAY_MODES,
49  STREAMABLE_PROTOCOLS,
50 )
51 from .data import EventListenAddr, get_domain_data
52 
53 PARALLEL_UPDATES = 0
54 
55 _TRANSPORT_STATE_TO_MEDIA_PLAYER_STATE = {
56  TransportState.PLAYING: MediaPlayerState.PLAYING,
57  TransportState.TRANSITIONING: MediaPlayerState.PLAYING,
58  TransportState.PAUSED_PLAYBACK: MediaPlayerState.PAUSED,
59  TransportState.PAUSED_RECORDING: MediaPlayerState.PAUSED,
60  # Unable to map this state to anything reasonable, so it's "Unknown"
61  TransportState.VENDOR_DEFINED: None,
62  None: MediaPlayerState.ON,
63 }
64 
65 
66 def catch_request_errors[_DlnaDmrEntityT: DlnaDmrEntity, **_P, _R](
67  func: Callable[Concatenate[_DlnaDmrEntityT, _P], Awaitable[_R]],
68 ) -> Callable[Concatenate[_DlnaDmrEntityT, _P], Coroutine[Any, Any, _R | None]]:
69  """Catch UpnpError errors."""
70 
71  @functools.wraps(func)
72  async def wrapper(
73  self: _DlnaDmrEntityT, *args: _P.args, **kwargs: _P.kwargs
74  ) -> _R | None:
75  """Catch UpnpError errors and check availability before and after request."""
76  if not self.available:
77  _LOGGER.warning(
78  "Device disappeared when trying to call service %s", func.__name__
79  )
80  return None
81  try:
82  return await func(self, *args, **kwargs)
83  except UpnpError as err:
84  self.check_available = True
85  _LOGGER.error("Error during call %s: %r", func.__name__, err)
86  return None
87 
88  return wrapper
89 
90 
91 async def async_setup_entry(
92  hass: HomeAssistant,
94  async_add_entities: AddEntitiesCallback,
95 ) -> None:
96  """Set up the DlnaDmrEntity from a config entry."""
97  _LOGGER.debug("media_player.async_setup_entry %s (%s)", entry.entry_id, entry.title)
98 
99  udn = entry.data[CONF_DEVICE_ID]
100  ent_reg = er.async_get(hass)
101  dev_reg = dr.async_get(hass)
102 
103  if (
104  (
105  existing_entity_id := ent_reg.async_get_entity_id(
106  domain=MEDIA_PLAYER_DOMAIN, platform=DOMAIN, unique_id=udn
107  )
108  )
109  and (existing_entry := ent_reg.async_get(existing_entity_id))
110  and (device_id := existing_entry.device_id)
111  and (device_entry := dev_reg.async_get(device_id))
112  and (dr.CONNECTION_UPNP, udn) not in device_entry.connections
113  ):
114  # If the existing device is missing the udn connection, add it
115  # now to ensure that when the entity gets added it is linked to
116  # the correct device.
117  dev_reg.async_update_device(
118  device_id,
119  merge_connections={(dr.CONNECTION_UPNP, udn)},
120  )
121 
122  # Create our own device-wrapping entity
123  entity = DlnaDmrEntity(
124  udn=udn,
125  device_type=entry.data[CONF_TYPE],
126  name=entry.title,
127  event_port=entry.options.get(CONF_LISTEN_PORT) or 0,
128  event_callback_url=entry.options.get(CONF_CALLBACK_URL_OVERRIDE),
129  poll_availability=entry.options.get(CONF_POLL_AVAILABILITY, False),
130  location=entry.data[CONF_URL],
131  mac_address=entry.data.get(CONF_MAC),
132  browse_unfiltered=entry.options.get(CONF_BROWSE_UNFILTERED, False),
133  config_entry=entry,
134  )
135 
136  async_add_entities([entity])
137 
138 
139 class DlnaDmrEntity(MediaPlayerEntity):
140  """Representation of a DLNA DMR device as a HA entity."""
141 
142  udn: str
143  device_type: str
144 
145  _event_addr: EventListenAddr
146  poll_availability: bool
147  # Last known URL for the device, used when adding this entity to hass to try
148  # to connect before SSDP has rediscovered it, or when SSDP discovery fails.
149  location: str
150  # Should the async_browse_media function *not* filter out incompatible media?
151  browse_unfiltered: bool
152 
153  _device_lock: asyncio.Lock # Held when connecting or disconnecting the device
154  _device: DmrDevice | None = None
155  check_available: bool = False
156  _ssdp_connect_failed: bool = False
157 
158  # Track BOOTID in SSDP advertisements for device changes
159  _bootid: int | None = None
160 
161  # DMR devices need polling for track position information. async_update will
162  # determine whether further device polling is required.
163  _attr_should_poll = True
164 
165  # Name of the current sound mode, not supported by DLNA
166  _attr_sound_mode = None
167 
168  def __init__(
169  self,
170  udn: str,
171  device_type: str,
172  name: str,
173  event_port: int,
174  event_callback_url: str | None,
175  poll_availability: bool,
176  location: str,
177  mac_address: str | None,
178  browse_unfiltered: bool,
179  config_entry: config_entries.ConfigEntry,
180  ) -> None:
181  """Initialize DLNA DMR entity."""
182  self.udn = udn
183  self.device_type = device_type
184  self._attr_name = name
185  self._event_addr = EventListenAddr(None, event_port, event_callback_url)
186  self.poll_availability = poll_availability
187  self.location = location
188  self.mac_address = mac_address
189  self.browse_unfiltered = browse_unfiltered
190  self._device_lock = asyncio.Lock()
191  self._background_setup_task: asyncio.Task[None] | None = None
192  self._updated_registry: bool = False
193  self._config_entry = config_entry
194  self._attr_device_info = dr.DeviceInfo(connections={(dr.CONNECTION_UPNP, udn)})
195  self._attr_supported_features = self._supported_features()
196 
197  async def async_added_to_hass(self) -> None:
198  """Handle addition."""
199  # Update this entity when the associated config entry is modified
200  self.async_on_remove(
201  self._config_entry.add_update_listener(self.async_config_update_listener)
202  )
203 
204  # Get SSDP notifications for only this device
205  self.async_on_remove(
206  await ssdp.async_register_callback(
207  self.hass, self.async_ssdp_callback, {"USN": self.usn}
208  )
209  )
210 
211  # async_upnp_client.SsdpListener only reports byebye once for each *UDN*
212  # (device name) which often is not the USN (service within the device)
213  # that we're interested in. So also listen for byebye advertisements for
214  # the UDN, which is reported in the _udn field of the combined_headers.
215  self.async_on_remove(
216  await ssdp.async_register_callback(
217  self.hass,
218  self.async_ssdp_callback,
219  {"_udn": self.udn, "NTS": NotificationSubType.SSDP_BYEBYE},
220  )
221  )
222 
223  if not self._device:
224  if self.hass.state is CoreState.running:
225  await self._async_setup()
226  else:
227  self._background_setup_task = self.hass.async_create_background_task(
228  self._async_setup(), f"dlna_dmr {self.name} setup"
229  )
230 
231  async def _async_setup(self) -> None:
232  # Try to connect to the last known location, but don't worry if not available
233  try:
234  await self._device_connect(self.location)
235  except UpnpError as err:
236  _LOGGER.debug("Couldn't connect immediately: %r", err)
237 
238  async def async_will_remove_from_hass(self) -> None:
239  """Handle removal."""
240  if self._background_setup_task:
241  self._background_setup_task.cancel()
242  with contextlib.suppress(asyncio.CancelledError):
243  await self._background_setup_task
244  self._background_setup_task = None
245 
246  await self._device_disconnect()
247 
248  async def async_ssdp_callback(
249  self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange
250  ) -> None:
251  """Handle notification from SSDP of device state change."""
252  _LOGGER.debug(
253  "SSDP %s notification of device %s at %s",
254  change,
255  info.ssdp_usn,
256  info.ssdp_location,
257  )
258 
259  try:
260  bootid_str = info.ssdp_headers[ssdp.ATTR_SSDP_BOOTID]
261  bootid: int | None = int(bootid_str, 10)
262  except (KeyError, ValueError):
263  bootid = None
264 
265  if change == ssdp.SsdpChange.UPDATE:
266  # This is an announcement that bootid is about to change
267  if self._bootid is not None and self._bootid == bootid:
268  # Store the new value (because our old value matches) so that we
269  # can ignore subsequent ssdp:alive messages
270  with contextlib.suppress(KeyError, ValueError):
271  next_bootid_str = info.ssdp_headers[ssdp.ATTR_SSDP_NEXTBOOTID]
272  self._bootid = int(next_bootid_str, 10)
273  # Nothing left to do until ssdp:alive comes through
274  return
275 
276  if self._bootid is not None and self._bootid != bootid:
277  # Device has rebooted
278  # Maybe connection will succeed now
279  self._ssdp_connect_failed = False
280  if self._device:
281  # Drop existing connection and maybe reconnect
282  await self._device_disconnect()
283  self._bootid = bootid
284 
285  if change == ssdp.SsdpChange.BYEBYE:
286  # Device is going away
287  if self._device:
288  # Disconnect from gone device
289  await self._device_disconnect()
290  # Maybe the next alive message will result in a successful connection
291  self._ssdp_connect_failed = False
292 
293  if (
294  change == ssdp.SsdpChange.ALIVE
295  and not self._device
296  and not self._ssdp_connect_failed
297  ):
298  assert info.ssdp_location
299  location = info.ssdp_location
300  try:
301  await self._device_connect(location)
302  except UpnpError as err:
303  self._ssdp_connect_failed = True
304  _LOGGER.warning(
305  "Failed connecting to recently alive device at %s: %r",
306  location,
307  err,
308  )
309 
310  # Device could have been de/re-connected, state probably changed
311  self.async_write_ha_state()
312 
313  async def async_config_update_listener(
314  self, hass: HomeAssistant, entry: config_entries.ConfigEntry
315  ) -> None:
316  """Handle options update by modifying self in-place."""
317  _LOGGER.debug(
318  "Updating: %s with data=%s and options=%s",
319  self.name,
320  entry.data,
321  entry.options,
322  )
323  self.location = entry.data[CONF_URL]
324  self.poll_availability = entry.options.get(CONF_POLL_AVAILABILITY, False)
325  self.browse_unfiltered = entry.options.get(CONF_BROWSE_UNFILTERED, False)
326 
327  new_mac_address = entry.data.get(CONF_MAC)
328  if new_mac_address != self.mac_address:
329  self.mac_address = new_mac_address
330  self._update_device_registry(set_mac=True)
331 
332  new_port = entry.options.get(CONF_LISTEN_PORT) or 0
333  new_callback_url = entry.options.get(CONF_CALLBACK_URL_OVERRIDE)
334 
335  if (
336  new_port == self._event_addr.port
337  and new_callback_url == self._event_addr.callback_url
338  ):
339  return
340 
341  # Changes to eventing requires a device reconnect for it to update correctly
342  await self._device_disconnect()
343  # Update _event_addr after disconnecting, to stop the right event listener
344  self._event_addr = self._event_addr._replace(
345  port=new_port, callback_url=new_callback_url
346  )
347  try:
348  await self._device_connect(self.location)
349  except UpnpError as err:
350  _LOGGER.warning("Couldn't (re)connect after config change: %r", err)
351 
352  # Device was de/re-connected, state might have changed
353  self.async_write_ha_state()
354 
355  def async_write_ha_state(self) -> None:
356  """Write the state."""
357  self._attr_supported_features = self._supported_features()
358  super().async_write_ha_state()
359 
360  async def _device_connect(self, location: str) -> None:
361  """Connect to the device now that it's available."""
362  _LOGGER.debug("Connecting to device at %s", location)
363 
364  async with self._device_lock:
365  if self._device:
366  _LOGGER.debug("Trying to connect when device already connected")
367  return
368 
369  domain_data = get_domain_data(self.hass)
370 
371  # Connect to the base UPNP device
372  upnp_device = await domain_data.upnp_factory.async_create_device(location)
373 
374  # Create/get event handler that is reachable by the device, using
375  # the connection's local IP to listen only on the relevant interface
376  _, event_ip = await async_get_local_ip(location, self.hass.loop)
377  self._event_addr = self._event_addr._replace(host=event_ip)
378  event_handler = await domain_data.async_get_event_notifier(
379  self._event_addr, self.hass
380  )
381 
382  # Create profile wrapper
383  self._device = DmrDevice(upnp_device, event_handler)
384 
385  self.location = location
386 
387  # Subscribe to event notifications
388  try:
389  self._device.on_event = self._on_event
390  await self._device.async_subscribe_services(auto_resubscribe=True)
391  except UpnpResponseError as err:
392  # Device rejected subscription request. This is OK, variables
393  # will be polled instead.
394  _LOGGER.debug("Device rejected subscription: %r", err)
395  except UpnpError as err:
396  # Don't leave the device half-constructed
397  self._device.on_event = None
398  self._device = None
399  await domain_data.async_release_event_notifier(self._event_addr)
400  _LOGGER.debug("Error while subscribing during device connect: %r", err)
401  raise
402 
403  self._update_device_registry()
404 
405  def _update_device_registry(self, set_mac: bool = False) -> None:
406  """Update the device registry with new information about the DMR."""
407  if (
408  # Can't get all the required information without a connection
409  not self._device
410  or
411  # No new information
412  (not set_mac and self._updated_registry)
413  ):
414  return
415 
416  # Connections based on the root device's UDN, and the DMR embedded
417  # device's UDN. They may be the same, if the DMR is the root device.
418  connections = {
419  (
420  dr.CONNECTION_UPNP,
421  self._device.profile_device.root_device.udn,
422  ),
423  (dr.CONNECTION_UPNP, self._device.udn),
424  (
425  dr.CONNECTION_UPNP,
426  self.udn,
427  ),
428  }
429 
430  if self.mac_address:
431  # Connection based on MAC address, if known
432  connections.add(
433  # Device MAC is obtained from the config entry, which uses getmac
434  (dr.CONNECTION_NETWORK_MAC, self.mac_address)
435  )
436 
437  device_info = dr.DeviceInfo(
438  connections=connections,
439  default_manufacturer=self._device.manufacturer,
440  default_model=self._device.model_name,
441  default_name=self._device.name,
442  )
443  self._attr_device_info = device_info
444 
445  self._updated_registry = True
446  # Create linked HA DeviceEntry now the information is known.
447  device_entry = dr.async_get(self.hass).async_get_or_create(
448  config_entry_id=self._config_entry.entry_id, **device_info
449  )
450 
451  # Update entity registry to link to the device
452  er.async_get(self.hass).async_get_or_create(
453  MEDIA_PLAYER_DOMAIN,
454  DOMAIN,
455  self.unique_id,
456  device_id=device_entry.id,
457  config_entry=self._config_entry,
458  )
459 
460  async def _device_disconnect(self) -> None:
461  """Destroy connections to the device now that it's not available.
462 
463  Also call when removing this entity from hass to clean up connections.
464  """
465  async with self._device_lock:
466  if not self._device:
467  _LOGGER.debug("Disconnecting from device that's not connected")
468  return
469 
470  _LOGGER.debug("Disconnecting from %s", self._device.name)
471 
472  self._device.on_event = None
473  old_device = self._device
474  self._device = None
475  await old_device.async_unsubscribe_services()
476 
477  domain_data = get_domain_data(self.hass)
478  await domain_data.async_release_event_notifier(self._event_addr)
479 
480  async def async_update(self) -> None:
481  """Retrieve the latest data."""
482  if self._background_setup_task:
483  await self._background_setup_task
484  self._background_setup_task = None
485 
486  if not self._device:
487  if not self.poll_availability:
488  return
489  try:
490  await self._device_connect(self.location)
491  except UpnpError:
492  return
493 
494  assert self._device is not None
495 
496  try:
497  do_ping = self.poll_availability or self.check_available
498  await self._device.async_update(do_ping=do_ping)
499  except UpnpError as err:
500  _LOGGER.debug("Device unavailable: %r", err)
501  await self._device_disconnect()
502  return
503  finally:
504  self.check_available = False
505 
506  # Supported features may have changed
507  self._attr_supported_features = self._supported_features()
508 
509  def _on_event(
510  self, service: UpnpService, state_variables: Sequence[UpnpStateVariable]
511  ) -> None:
512  """State variable(s) changed, let home-assistant know."""
513  if not state_variables:
514  # Indicates a failure to resubscribe, check if device is still available
515  self.check_available = True
516 
517  force_refresh = False
518 
519  if service.service_id == "urn:upnp-org:serviceId:AVTransport":
520  for state_variable in state_variables:
521  # Force a state refresh when player begins or pauses playback
522  # to update the position info.
523  if state_variable.name == "TransportState" and state_variable.value in (
524  TransportState.PLAYING,
525  TransportState.PAUSED_PLAYBACK,
526  ):
527  force_refresh = True
528  break
529 
530  if force_refresh:
531  self.async_schedule_update_ha_state(force_refresh)
532  else:
533  self.async_write_ha_state()
534 
535  @property
536  def available(self) -> bool:
537  """Device is available when we have a connection to it."""
538  return self._device is not None and self._device.profile_device.available
539 
540  @property
541  def unique_id(self) -> str:
542  """Report the UDN (Unique Device Name) as this entity's unique ID."""
543  return self.udn
544 
545  @property
546  def usn(self) -> str:
547  """Get the USN based on the UDN (Unique Device Name) and device type."""
548  return f"{self.udn}::{self.device_type}"
549 
550  @property
551  def state(self) -> MediaPlayerState | None:
552  """State of the player."""
553  if not self._device:
554  return MediaPlayerState.OFF
555  return _TRANSPORT_STATE_TO_MEDIA_PLAYER_STATE.get(
556  self._device.transport_state, MediaPlayerState.IDLE
557  )
558 
559  def _supported_features(self) -> MediaPlayerEntityFeature:
560  """Flag media player features that are supported at this moment.
561 
562  Supported features may change as the device enters different states.
563  """
564  if not self._device:
565  return MediaPlayerEntityFeature(0)
566 
567  supported_features = MediaPlayerEntityFeature(0)
568 
569  if self._device.has_volume_level:
570  supported_features |= MediaPlayerEntityFeature.VOLUME_SET
571  if self._device.has_volume_mute:
572  supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
573  if self._device.can_play:
574  supported_features |= MediaPlayerEntityFeature.PLAY
575  if self._device.can_pause:
576  supported_features |= MediaPlayerEntityFeature.PAUSE
577  if self._device.can_stop:
578  supported_features |= MediaPlayerEntityFeature.STOP
579  if self._device.can_previous:
580  supported_features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
581  if self._device.can_next:
582  supported_features |= MediaPlayerEntityFeature.NEXT_TRACK
583  if self._device.has_play_media:
584  supported_features |= (
585  MediaPlayerEntityFeature.PLAY_MEDIA
586  | MediaPlayerEntityFeature.BROWSE_MEDIA
587  )
588  if self._device.can_seek_rel_time:
589  supported_features |= MediaPlayerEntityFeature.SEEK
590 
591  play_modes = self._device.valid_play_modes
592  if play_modes & {PlayMode.RANDOM, PlayMode.SHUFFLE}:
593  supported_features |= MediaPlayerEntityFeature.SHUFFLE_SET
594  if play_modes & {PlayMode.REPEAT_ONE, PlayMode.REPEAT_ALL}:
595  supported_features |= MediaPlayerEntityFeature.REPEAT_SET
596 
597  if self._device.has_presets:
598  supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
599 
600  return supported_features
601 
602  @property
603  def volume_level(self) -> float | None:
604  """Volume level of the media player (0..1)."""
605  if not self._device or not self._device.has_volume_level:
606  return None
607  return self._device.volume_level
608 
609  @catch_request_errors
610  async def async_set_volume_level(self, volume: float) -> None:
611  """Set volume level, range 0..1."""
612  assert self._device is not None
613  await self._device.async_set_volume_level(volume)
614 
615  @property
616  def is_volume_muted(self) -> bool | None:
617  """Boolean if volume is currently muted."""
618  if not self._device:
619  return None
620  return self._device.is_volume_muted
621 
622  @catch_request_errors
623  async def async_mute_volume(self, mute: bool) -> None:
624  """Mute the volume."""
625  assert self._device is not None
626  desired_mute = bool(mute)
627  await self._device.async_mute_volume(desired_mute)
628 
629  @catch_request_errors
630  async def async_media_pause(self) -> None:
631  """Send pause command."""
632  assert self._device is not None
633  await self._device.async_pause()
634 
635  @catch_request_errors
636  async def async_media_play(self) -> None:
637  """Send play command."""
638  assert self._device is not None
639  await self._device.async_play()
640 
641  @catch_request_errors
642  async def async_media_stop(self) -> None:
643  """Send stop command."""
644  assert self._device is not None
645  await self._device.async_stop()
646 
647  @catch_request_errors
648  async def async_media_seek(self, position: float) -> None:
649  """Send seek command."""
650  assert self._device is not None
651  time = timedelta(seconds=position)
652  await self._device.async_seek_rel_time(time)
653 
654  @catch_request_errors
655  async def async_play_media(
656  self, media_type: MediaType | str, media_id: str, **kwargs: Any
657  ) -> None:
658  """Play a piece of media."""
659  _LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs)
660  assert self._device is not None
661 
662  didl_metadata: str | None = None
663  title: str = ""
664 
665  # If media is media_source, resolve it to url and MIME type, and maybe metadata
666  if media_source.is_media_source_id(media_id):
667  sourced_media = await media_source.async_resolve_media(
668  self.hass, media_id, self.entity_id
669  )
670  media_type = sourced_media.mime_type
671  media_id = sourced_media.url
672  _LOGGER.debug("sourced_media is %s", sourced_media)
673  if sourced_metadata := getattr(sourced_media, "didl_metadata", None):
674  didl_metadata = didl_lite.to_xml_string(sourced_metadata).decode(
675  "utf-8"
676  )
677  title = sourced_metadata.title
678 
679  # If media ID is a relative URL, we serve it from HA.
680  media_id = async_process_play_media_url(self.hass, media_id)
681 
682  extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {}
683  metadata: dict[str, Any] = extra.get("metadata") or {}
684 
685  if not title:
686  title = extra.get("title") or metadata.get("title") or "Home Assistant"
687  if thumb := extra.get("thumb"):
688  metadata["album_art_uri"] = thumb
689 
690  # Translate metadata keys from HA names to DIDL-Lite names
691  for hass_key, didl_key in MEDIA_METADATA_DIDL.items():
692  if hass_key in metadata:
693  metadata[didl_key] = metadata.pop(hass_key)
694 
695  if not didl_metadata:
696  # Create metadata specific to the given media type; different fields are
697  # available depending on what the upnp_class is.
698  upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type)
699  didl_metadata = await self._device.construct_play_media_metadata(
700  media_url=media_id,
701  media_title=title,
702  override_upnp_class=upnp_class,
703  meta_data=metadata,
704  )
705 
706  # Stop current playing media
707  if self._device.can_stop:
708  await self.async_media_stop()
709 
710  # Queue media
711  await self._device.async_set_transport_uri(media_id, title, didl_metadata)
712 
713  # If already playing, or don't want to autoplay, no need to call Play
714  autoplay = extra.get("autoplay", True)
715  if self._device.transport_state == TransportState.PLAYING or not autoplay:
716  return
717 
718  # Play it
719  await self._device.async_wait_for_can_play()
720  await self.async_media_play()
721 
722  @catch_request_errors
723  async def async_media_previous_track(self) -> None:
724  """Send previous track command."""
725  assert self._device is not None
726  await self._device.async_previous()
727 
728  @catch_request_errors
729  async def async_media_next_track(self) -> None:
730  """Send next track command."""
731  assert self._device is not None
732  await self._device.async_next()
733 
734  @property
735  def shuffle(self) -> bool | None:
736  """Boolean if shuffle is enabled."""
737  if not self._device:
738  return None
739 
740  if not (play_mode := self._device.play_mode):
741  return None
742 
743  if play_mode == PlayMode.VENDOR_DEFINED:
744  return None
745 
746  return play_mode in (PlayMode.SHUFFLE, PlayMode.RANDOM)
747 
748  @catch_request_errors
749  async def async_set_shuffle(self, shuffle: bool) -> None:
750  """Enable/disable shuffle mode."""
751  assert self._device is not None
752 
753  repeat = self.repeat or RepeatMode.OFF
754  potential_play_modes = SHUFFLE_PLAY_MODES[(shuffle, repeat)]
755 
756  valid_play_modes = self._device.valid_play_modes
757 
758  for mode in potential_play_modes:
759  if mode in valid_play_modes:
760  await self._device.async_set_play_mode(mode)
761  return
762 
763  _LOGGER.debug(
764  "Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat
765  )
766 
767  @property
768  def repeat(self) -> RepeatMode | None:
769  """Return current repeat mode."""
770  if not self._device:
771  return None
772 
773  if not (play_mode := self._device.play_mode):
774  return None
775 
776  if play_mode == PlayMode.VENDOR_DEFINED:
777  return None
778 
779  if play_mode == PlayMode.REPEAT_ONE:
780  return RepeatMode.ONE
781 
782  if play_mode in (PlayMode.REPEAT_ALL, PlayMode.RANDOM):
783  return RepeatMode.ALL
784 
785  return RepeatMode.OFF
786 
787  @catch_request_errors
788  async def async_set_repeat(self, repeat: RepeatMode) -> None:
789  """Set repeat mode."""
790  assert self._device is not None
791 
792  shuffle = self.shuffle or False
793  potential_play_modes = REPEAT_PLAY_MODES[(shuffle, repeat)]
794 
795  valid_play_modes = self._device.valid_play_modes
796 
797  for mode in potential_play_modes:
798  if mode in valid_play_modes:
799  await self._device.async_set_play_mode(mode)
800  return
801 
802  _LOGGER.debug(
803  "Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat
804  )
805 
806  @property
807  def sound_mode_list(self) -> list[str] | None:
808  """List of available sound modes."""
809  if not self._device:
810  return None
811  return self._device.preset_names
812 
813  @catch_request_errors
814  async def async_select_sound_mode(self, sound_mode: str) -> None:
815  """Select sound mode."""
816  assert self._device is not None
817  await self._device.async_select_preset(sound_mode)
818 
819  async def async_browse_media(
820  self,
821  media_content_type: MediaType | str | None = None,
822  media_content_id: str | None = None,
823  ) -> BrowseMedia:
824  """Implement the websocket media browsing helper.
825 
826  Browses all available media_sources by default. Filters content_type
827  based on the DMR's sink_protocol_info.
828  """
829  _LOGGER.debug(
830  "async_browse_media(%s, %s)", media_content_type, media_content_id
831  )
832 
833  # media_content_type is ignored; it's the content_type of the current
834  # media_content_id, not the desired content_type of whomever is calling.
835 
836  if self.browse_unfiltered:
837  content_filter = None
838  else:
839  content_filter = self._get_content_filter()
840 
841  return await media_source.async_browse_media(
842  self.hass, media_content_id, content_filter=content_filter
843  )
844 
845  def _get_content_filter(self) -> Callable[[BrowseMedia], bool]:
846  """Return a function that filters media based on what the renderer can play.
847 
848  The filtering is pretty loose; it's better to show something that can't
849  be played than hide something that can.
850  """
851  if not self._device or not self._device.sink_protocol_info:
852  # Nothing is specified by the renderer, so show everything
853  _LOGGER.debug("Get content filter with no device or sink protocol info")
854  return lambda _: True
855 
856  _LOGGER.debug("Get content filter for %s", self._device.sink_protocol_info)
857  if self._device.sink_protocol_info[0] == "*":
858  # Renderer claims it can handle everything, so show everything
859  return lambda _: True
860 
861  # Convert list of things like "http-get:*:audio/mpeg;codecs=mp3:*"
862  # to just "audio/mpeg"
863  content_types = set[str]()
864  for protocol_info in self._device.sink_protocol_info:
865  protocol, _, content_format, _ = protocol_info.split(":", 3)
866  # Transform content_format for better generic matching
867  content_format = content_format.lower().replace("/x-", "/", 1)
868  content_format = content_format.partition(";")[0]
869 
870  if protocol in STREAMABLE_PROTOCOLS:
871  content_types.add(content_format)
872 
873  def _content_filter(item: BrowseMedia) -> bool:
874  """Filter media items by their media_content_type."""
875  content_type = item.media_content_type
876  content_type = content_type.lower().replace("/x-", "/", 1).partition(";")[0]
877  return content_type in content_types
878 
879  return _content_filter
880 
881  @property
882  def media_title(self) -> str | None:
883  """Title of current playing media."""
884  if not self._device:
885  return None
886  # Use the best available title
887  return self._device.media_program_title or self._device.media_title
888 
889  @property
890  def media_image_url(self) -> str | None:
891  """Image url of current playing media."""
892  if not self._device:
893  return None
894  return self._device.media_image_url
895 
896  @property
897  def media_content_id(self) -> str | None:
898  """Content ID of current playing media."""
899  if not self._device:
900  return None
901  return self._device.current_track_uri
902 
903  @property
904  def media_content_type(self) -> MediaType | None:
905  """Content type of current playing media."""
906  if not self._device or not self._device.media_class:
907  return None
908  return MEDIA_TYPE_MAP.get(self._device.media_class)
909 
910  @property
911  def media_duration(self) -> int | None:
912  """Duration of current playing media in seconds."""
913  if not self._device:
914  return None
915  return self._device.media_duration
916 
917  @property
918  def media_position(self) -> int | None:
919  """Position of current playing media in seconds."""
920  if not self._device:
921  return None
922  return self._device.media_position
923 
924  @property
925  def media_position_updated_at(self) -> datetime | None:
926  """When was the position of the current playing media valid.
927 
928  Returns value from homeassistant.util.dt.utcnow().
929  """
930  if not self._device:
931  return None
932  return self._device.media_position_updated_at
933 
934  @property
935  def media_artist(self) -> str | None:
936  """Artist of current playing media, music track only."""
937  if not self._device:
938  return None
939  return self._device.media_artist
940 
941  @property
942  def media_album_name(self) -> str | None:
943  """Album name of current playing media, music track only."""
944  if not self._device:
945  return None
946  return self._device.media_album_name
947 
948  @property
949  def media_album_artist(self) -> str | None:
950  """Album artist of current playing media, music track only."""
951  if not self._device:
952  return None
953  return self._device.media_album_artist
954 
955  @property
956  def media_track(self) -> int | None:
957  """Track number of current playing media, music track only."""
958  if not self._device:
959  return None
960  return self._device.media_track_number
961 
962  @property
963  def media_series_title(self) -> str | None:
964  """Title of series of current playing media, TV show only."""
965  if not self._device:
966  return None
967  return self._device.media_series_title
968 
969  @property
970  def media_season(self) -> str | None:
971  """Season number, starting at 1, of current playing media, TV show only."""
972  if not self._device:
973  return None
974  # Some DMRs, like Kodi, leave this as 0 and encode the season & episode
975  # in the episode_number metadata, as {season:d}{episode:02d}
976  if (
977  not self._device.media_season_number
978  or self._device.media_season_number == "0"
979  ) and self._device.media_episode_number:
980  with contextlib.suppress(ValueError):
981  episode = int(self._device.media_episode_number, 10)
982  if episode > 100:
983  return str(episode // 100)
984  return self._device.media_season_number
985 
986  @property
987  def media_episode(self) -> str | None:
988  """Episode number of current playing media, TV show only."""
989  if not self._device:
990  return None
991  # Complement to media_season math above
992  if (
993  not self._device.media_season_number
994  or self._device.media_season_number == "0"
995  ) and self._device.media_episode_number:
996  with contextlib.suppress(ValueError):
997  episode = int(self._device.media_episode_number, 10)
998  if episode > 100:
999  return str(episode % 100)
1000  return self._device.media_episode_number
1001 
1002  @property
1003  def media_channel(self) -> str | None:
1004  """Channel name currently playing."""
1005  if not self._device:
1006  return None
1007  return self._device.media_channel_name
1008 
1009  @property
1010  def media_playlist(self) -> str | None:
1011  """Title of Playlist currently playing."""
1012  if not self._device:
1013  return None
1014  return self._device.media_playlist_title
None __init__(self, _AOSmithCoordinatorT coordinator, str junction_id)
Definition: entity.py:20
DlnaDmrData get_domain_data(HomeAssistant hass)
Definition: data.py:120
bool async_setup_entry(HomeAssistant hass, config_entries.ConfigEntry entry)
Definition: __init__.py:16
BrowseMedia|None async_browse_media(HomeAssistant hass, MediaType|str media_content_type, str media_content_id, str cast_type)
Definition: cast.py:56
bool async_play_media(HomeAssistant hass, str cast_entity_id, Chromecast chromecast, MediaType|str media_type, str media_id)
Definition: cast.py:123
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
None async_stop(HomeAssistant hass)
Definition: discovery.py:694
bool state(HomeAssistant hass, str|State|None entity, Any req_state, timedelta|None for_period=None, str|None attribute=None, TemplateVarsType variables=None)
Definition: condition.py:551