Home Assistant Unofficial Reference 2024.12.1
dms.py
Go to the documentation of this file.
1 """Wrapper for media_source around async_upnp_client's DmsDevice ."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Callable, Coroutine
7 from dataclasses import dataclass
8 from enum import StrEnum
9 import functools
10 from typing import Any, cast
11 
12 from async_upnp_client.aiohttp import AiohttpSessionRequester
13 from async_upnp_client.client import UpnpRequester
14 from async_upnp_client.client_factory import UpnpFactory
15 from async_upnp_client.const import NotificationSubType
16 from async_upnp_client.exceptions import UpnpActionError, UpnpConnectionError, UpnpError
17 from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice
18 from didl_lite import didl_lite
19 from propcache import cached_property
20 
21 from homeassistant.components import ssdp
22 from homeassistant.components.media_player import BrowseError, MediaClass
24  BrowseMediaSource,
25  PlayMedia,
26  Unresolvable,
27 )
28 from homeassistant.config_entries import ConfigEntry
29 from homeassistant.const import CONF_DEVICE_ID, CONF_URL
30 from homeassistant.core import HomeAssistant, callback
31 from homeassistant.helpers import aiohttp_client
32 
33 from .const import (
34  CONF_SOURCE_ID,
35  DLNA_BROWSE_FILTER,
36  DLNA_PATH_FILTER,
37  DLNA_RESOLVE_FILTER,
38  DLNA_SORT_CRITERIA,
39  DOMAIN,
40  LOGGER,
41  MEDIA_CLASS_MAP,
42  PATH_OBJECT_ID_FLAG,
43  PATH_SEARCH_FLAG,
44  PATH_SEP,
45  ROOT_OBJECT_ID,
46  STREAMABLE_PROTOCOLS,
47 )
48 
49 
51  """Storage class for domain global data."""
52 
53  hass: HomeAssistant
54  requester: UpnpRequester
55  upnp_factory: UpnpFactory
56  devices: dict[str, DmsDeviceSource] # Indexed by config_entry.unique_id
57  sources: dict[str, DmsDeviceSource] # Indexed by source_id
58 
59  def __init__(
60  self,
61  hass: HomeAssistant,
62  ) -> None:
63  """Initialize global data."""
64  self.hasshass = hass
65  session = aiohttp_client.async_get_clientsession(hass, verify_ssl=False)
66  self.requesterrequester = AiohttpSessionRequester(session, with_sleep=True)
67  self.upnp_factoryupnp_factory = UpnpFactory(self.requesterrequester, non_strict=True)
68  self.devicesdevices = {}
69  self.sourcessources = {}
70 
71  async def async_setup_entry(self, config_entry: ConfigEntry) -> bool:
72  """Create a DMS device connection from a config entry."""
73  assert config_entry.unique_id
74  device = DmsDeviceSource(self.hasshass, config_entry)
75  self.devicesdevices[config_entry.unique_id] = device
76  # source_id must be unique, which generate_source_id should guarantee.
77  # Ensure this is the case, for debugging purposes.
78  assert device.source_id not in self.sourcessources
79  self.sourcessources[device.source_id] = device
80  await device.async_added_to_hass()
81  return True
82 
83  async def async_unload_entry(self, config_entry: ConfigEntry) -> bool:
84  """Unload a config entry and disconnect the corresponding DMS device."""
85  assert config_entry.unique_id
86  device = self.devicesdevices.pop(config_entry.unique_id)
87  del self.sourcessources[device.source_id]
88  await device.async_will_remove_from_hass()
89  return True
90 
91 
92 @callback
93 def get_domain_data(hass: HomeAssistant) -> DlnaDmsData:
94  """Obtain this integration's domain data, creating it if needed."""
95  if DOMAIN in hass.data:
96  return cast(DlnaDmsData, hass.data[DOMAIN])
97 
98  data = DlnaDmsData(hass)
99  hass.data[DOMAIN] = data
100  return data
101 
102 
103 @dataclass
104 class DidlPlayMedia(PlayMedia):
105  """Playable media with DIDL metadata."""
106 
107  didl_metadata: didl_lite.DidlObject
108 
109 
110 class DlnaDmsDeviceError(BrowseError, Unresolvable):
111  """Base for errors raised by DmsDeviceSource.
112 
113  Caught by both media_player (BrowseError) and media_source (Unresolvable),
114  so DmsDeviceSource methods can be used for both browse and resolve
115  functionality.
116  """
117 
118 
119 class DeviceConnectionError(DlnaDmsDeviceError):
120  """Error occurred with the connection to the server."""
121 
122 
124  """Error when calling a UPnP Action on the device."""
125 
126 
127 def catch_request_errors[_DlnaDmsDeviceMethod: DmsDeviceSource, _R](
128  func: Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]],
129 ) -> Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]]:
130  """Catch UpnpError errors."""
131 
132  @functools.wraps(func)
133  async def wrapper(self: _DlnaDmsDeviceMethod, req_param: str) -> _R:
134  """Catch UpnpError errors and check availability before and after request."""
135  if not self.available:
136  LOGGER.warning("Device disappeared when trying to call %s", func.__name__)
137  raise DeviceConnectionError("DMS is not connected")
138 
139  try:
140  return await func(self, req_param)
141  except UpnpActionError as err:
142  LOGGER.debug("Server failure", exc_info=err)
143  if err.error_code == ContentDirectoryErrorCode.NO_SUCH_OBJECT:
144  LOGGER.debug("No such object: %s", req_param)
145  raise ActionError(f"No such object: {req_param}") from err
146  if err.error_code == ContentDirectoryErrorCode.INVALID_SEARCH_CRITERIA:
147  LOGGER.debug("Invalid query: %s", req_param)
148  raise ActionError(f"Invalid query: {req_param}") from err
149  raise DeviceConnectionError(f"Server failure: {err!r}") from err
150  except UpnpConnectionError as err:
151  LOGGER.debug("Server disconnected", exc_info=err)
152  await self.device_disconnect()
153  raise DeviceConnectionError(f"Server disconnected: {err!r}") from err
154  except UpnpError as err:
155  LOGGER.debug("Server communication failure", exc_info=err)
156  raise DeviceConnectionError(
157  f"Server communication failure: {err!r}"
158  ) from err
159 
160  return wrapper
161 
162 
164  """DMS Device wrapper, providing media files as a media_source."""
165 
166  # Last known URL for the device, used when adding this wrapper to hass to
167  # try to connect before SSDP has rediscovered it, or when SSDP discovery
168  # fails.
169  location: str | None
170 
171  _device_lock: asyncio.Lock # Held when connecting or disconnecting the device
172  _device: DmsDevice | None = None
173 
174  # Only try to connect once when an ssdp:alive advertisement is received
175  _ssdp_connect_failed: bool = False
176 
177  # Track BOOTID in SSDP advertisements for device changes
178  _bootid: int | None = None
179 
180  def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
181  """Initialize a DMS Source."""
182  self.hasshass = hass
183  self.config_entryconfig_entry = config_entry
184  self.locationlocation = self.config_entryconfig_entry.data[CONF_URL]
185  self._device_lock_device_lock = asyncio.Lock()
186 
187  # Callbacks and events
188 
189  async def async_added_to_hass(self) -> None:
190  """Handle addition of this source."""
191 
192  # Try to connect to the last known location, but don't worry if not available
193  if not self._device_device and self.locationlocation:
194  try:
195  await self.device_connectdevice_connect()
196  except UpnpError as err:
197  LOGGER.debug("Couldn't connect immediately: %r", err)
198 
199  # Get SSDP notifications for only this device
200  self.config_entryconfig_entry.async_on_unload(
201  await ssdp.async_register_callback(
202  self.hasshass, self.async_ssdp_callbackasync_ssdp_callback, {"USN": self.usnusn}
203  )
204  )
205 
206  # async_upnp_client.SsdpListener only reports byebye once for each *UDN*
207  # (device name) which often is not the USN (service within the device)
208  # that we're interested in. So also listen for byebye advertisements for
209  # the UDN, which is reported in the _udn field of the combined_headers.
210  self.config_entryconfig_entry.async_on_unload(
211  await ssdp.async_register_callback(
212  self.hasshass,
213  self.async_ssdp_callbackasync_ssdp_callback,
214  {"_udn": self.udnudn, "NTS": NotificationSubType.SSDP_BYEBYE},
215  )
216  )
217 
218  async def async_will_remove_from_hass(self) -> None:
219  """Handle removal of this source."""
220  await self.device_disconnectdevice_disconnect()
221 
223  self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange
224  ) -> None:
225  """Handle notification from SSDP of device state change."""
226  LOGGER.debug(
227  "SSDP %s notification of device %s at %s",
228  change,
229  info.ssdp_usn,
230  info.ssdp_location,
231  )
232 
233  try:
234  bootid_str = info.ssdp_headers[ssdp.ATTR_SSDP_BOOTID]
235  bootid: int | None = int(bootid_str, 10)
236  except (KeyError, ValueError):
237  bootid = None
238 
239  if change == ssdp.SsdpChange.UPDATE:
240  # This is an announcement that bootid is about to change
241  if self._bootid_bootid is not None and self._bootid_bootid == bootid:
242  # Store the new value (because our old value matches) so that we
243  # can ignore subsequent ssdp:alive messages
244  try:
245  next_bootid_str = info.ssdp_headers[ssdp.ATTR_SSDP_NEXTBOOTID]
246  self._bootid_bootid = int(next_bootid_str, 10)
247  except (KeyError, ValueError):
248  pass
249  # Nothing left to do until ssdp:alive comes through
250  return
251 
252  if self._bootid_bootid is not None and self._bootid_bootid != bootid:
253  # Device has rebooted
254  # Maybe connection will succeed now
255  self._ssdp_connect_failed_ssdp_connect_failed = False
256  if self._device_device:
257  # Drop existing connection and maybe reconnect
258  await self.device_disconnectdevice_disconnect()
259  self._bootid_bootid = bootid
260 
261  if change == ssdp.SsdpChange.BYEBYE:
262  # Device is going away
263  if self._device_device:
264  # Disconnect from gone device
265  await self.device_disconnectdevice_disconnect()
266  # Maybe the next alive message will result in a successful connection
267  self._ssdp_connect_failed_ssdp_connect_failed = False
268 
269  if (
270  change == ssdp.SsdpChange.ALIVE
271  and not self._device_device
272  and not self._ssdp_connect_failed_ssdp_connect_failed
273  ):
274  assert info.ssdp_location
275  self.locationlocation = info.ssdp_location
276  try:
277  await self.device_connectdevice_connect()
278  except UpnpError as err:
279  self._ssdp_connect_failed_ssdp_connect_failed = True
280  LOGGER.warning(
281  "Failed connecting to recently alive device at %s: %r",
282  self.locationlocation,
283  err,
284  )
285 
286  # Device connection/disconnection
287 
288  async def device_connect(self) -> None:
289  """Connect to the device now that it's available."""
290  LOGGER.debug("Connecting to device at %s", self.locationlocation)
291  assert self.locationlocation
292 
293  async with self._device_lock_device_lock:
294  if self._device_device:
295  LOGGER.debug("Trying to connect when device already connected")
296  return
297 
298  domain_data = get_domain_data(self.hasshass)
299 
300  # Connect to the base UPNP device
301  upnp_device = await domain_data.upnp_factory.async_create_device(
302  self.locationlocation
303  )
304 
305  # Create profile wrapper
306  self._device_device = DmsDevice(upnp_device, event_handler=None)
307 
308  # Update state variables. We don't care if they change, so this is
309  # only done once, here.
310  await self._device_device.async_update()
311 
312  async def device_disconnect(self) -> None:
313  """Destroy connections to the device now that it's not available.
314 
315  Also call when removing this device wrapper from hass to clean up connections.
316  """
317  async with self._device_lock_device_lock:
318  if not self._device_device:
319  LOGGER.debug("Disconnecting from device that's not connected")
320  return
321 
322  LOGGER.debug("Disconnecting from %s", self._device_device.name)
323 
324  self._device_device = None
325 
326  # Device properties
327 
328  @property
329  def available(self) -> bool:
330  """Device is available when we have a connection to it."""
331  return self._device_device is not None and self._device_device.profile_device.available
332 
333  @property
334  def usn(self) -> str:
335  """Get the USN (Unique Service Name) for the wrapped UPnP device end-point."""
336  return self.config_entryconfig_entry.data[CONF_DEVICE_ID] # type: ignore[no-any-return]
337 
338  @property
339  def udn(self) -> str:
340  """Get the UDN (Unique Device Name) based on the USN."""
341  return self.usnusn.partition("::")[0]
342 
343  @property
344  def name(self) -> str:
345  """Return a name for the media server."""
346  return self.config_entryconfig_entry.title
347 
348  @property
349  def source_id(self) -> str:
350  """Return a unique ID (slug) for this source for people to use in URLs."""
351  return self.config_entryconfig_entry.data[CONF_SOURCE_ID] # type: ignore[no-any-return]
352 
353  @property
354  def icon(self) -> str | None:
355  """Return an URL to an icon for the media server."""
356  return self._device_device.icon if self._device_device else None
357 
358  # MediaSource methods
359 
360  async def async_resolve_media(self, identifier: str) -> DidlPlayMedia:
361  """Resolve a media item to a playable item."""
362  LOGGER.debug("async_resolve_media(%s)", identifier)
363  action, parameters = _parse_identifier(identifier)
364 
365  assert action is not None, f"Invalid identifier: {identifier}"
366 
367  if action is Action.OBJECT:
368  return await self.async_resolve_objectasync_resolve_object(parameters)
369 
370  if action is Action.PATH:
371  object_id = await self.async_resolve_pathasync_resolve_path(parameters)
372  return await self.async_resolve_objectasync_resolve_object(object_id)
373 
374  assert action is Action.SEARCH
375  return await self.async_resolve_searchasync_resolve_search(parameters)
376 
377  async def async_browse_media(self, identifier: str | None) -> BrowseMediaSource:
378  """Browse media."""
379  LOGGER.debug("async_browse_media(%s)", identifier)
380  action, parameters = _parse_identifier(identifier)
381 
382  if action is Action.OBJECT:
383  return await self.async_browse_objectasync_browse_object(parameters)
384 
385  if action is Action.PATH:
386  object_id = await self.async_resolve_pathasync_resolve_path(parameters)
387  return await self.async_browse_objectasync_browse_object(object_id)
388 
389  if action is Action.SEARCH:
390  return await self.async_browse_searchasync_browse_search(parameters)
391 
392  return await self.async_browse_objectasync_browse_object(ROOT_OBJECT_ID)
393 
394  # DMS methods
395 
396  @catch_request_errors
397  async def async_resolve_object(self, object_id: str) -> DidlPlayMedia:
398  """Return a playable media item specified by ObjectID."""
399  assert self._device_device
400 
401  item = await self._device_device.async_browse_metadata(
402  object_id, metadata_filter=DLNA_RESOLVE_FILTER
403  )
404 
405  # Use the first playable resource
406  return self._didl_to_play_media_didl_to_play_media(item)
407 
408  @catch_request_errors
409  async def async_resolve_path(self, path: str) -> str:
410  """Return an Object ID resolved from a path string."""
411  assert self._device_device
412 
413  # Iterate through the path, searching for a matching title within the
414  # DLNA object hierarchy.
415  object_id = ROOT_OBJECT_ID
416  for node in path.split(PATH_SEP):
417  if not node:
418  # Skip empty names, for when multiple slashes are involved, e.g //
419  continue
420 
421  criteria = (
422  f'@parentID="{_esc_quote(object_id)}" and dc:title="{_esc_quote(node)}"'
423  )
424  try:
425  result = await self._device_device.async_search_directory(
426  object_id,
427  search_criteria=criteria,
428  metadata_filter=DLNA_PATH_FILTER,
429  requested_count=1,
430  )
431  except UpnpActionError as err:
432  LOGGER.debug("Error in call to async_search_directory: %r", err)
433  if err.error_code == ContentDirectoryErrorCode.NO_SUCH_CONTAINER:
434  raise Unresolvable(f"No such container: {object_id}") from err
435  # Search failed, but can still try browsing children
436  else:
437  if result.total_matches > 1:
438  raise Unresolvable(f"Too many items found for {node} in {path}")
439 
440  if result.result:
441  object_id = result.result[0].id
442  continue
443 
444  # Nothing was found via search, fall back to iterating children
445  result = await self._device_device.async_browse_direct_children(
446  object_id, metadata_filter=DLNA_PATH_FILTER
447  )
448 
449  if result.total_matches == 0 or not result.result:
450  raise Unresolvable(f"No contents for {node} in {path}")
451 
452  node_lower = node.lower()
453  for child in result.result:
454  if child.title.lower() == node_lower:
455  object_id = child.id
456  break
457  else:
458  # Examining all direct children failed too
459  raise Unresolvable(f"Nothing found for {node} in {path}")
460  return object_id
461 
462  @catch_request_errors
463  async def async_resolve_search(self, query: str) -> DidlPlayMedia:
464  """Return first playable media item found by the query string."""
465  assert self._device_device
466 
467  result = await self._device_device.async_search_directory(
468  container_id=ROOT_OBJECT_ID,
469  search_criteria=query,
470  metadata_filter=DLNA_RESOLVE_FILTER,
471  requested_count=1,
472  )
473 
474  if result.total_matches == 0 or not result.result:
475  raise Unresolvable(f"Nothing found for {query}")
476 
477  # Use the first result, even if it doesn't have a playable resource
478  item = result.result[0]
479 
480  if not isinstance(item, didl_lite.DidlObject):
481  raise Unresolvable(f"{item} is not a DidlObject")
482 
483  return self._didl_to_play_media_didl_to_play_media(item)
484 
485  @catch_request_errors
486  async def async_browse_object(self, object_id: str) -> BrowseMediaSource:
487  """Return the contents of a DLNA container by ObjectID."""
488  assert self._device_device
489 
490  base_object = await self._device_device.async_browse_metadata(
491  object_id, metadata_filter=DLNA_BROWSE_FILTER
492  )
493 
494  children = await self._device_device.async_browse_direct_children(
495  object_id,
496  metadata_filter=DLNA_BROWSE_FILTER,
497  sort_criteria=self._sort_criteria_sort_criteria,
498  )
499 
500  return self._didl_to_media_source_didl_to_media_source(base_object, children)
501 
502  @catch_request_errors
503  async def async_browse_search(self, query: str) -> BrowseMediaSource:
504  """Return all media items found by the query string."""
505  assert self._device_device
506 
507  result = await self._device_device.async_search_directory(
508  container_id=ROOT_OBJECT_ID,
509  search_criteria=query,
510  metadata_filter=DLNA_BROWSE_FILTER,
511  )
512 
513  children = [
514  self._didl_to_media_source_didl_to_media_source(child)
515  for child in result.result
516  if isinstance(child, didl_lite.DidlObject)
517  ]
518 
519  return BrowseMediaSource(
520  domain=DOMAIN,
521  identifier=self._make_identifier_make_identifier(Action.SEARCH, query),
522  media_class=MediaClass.DIRECTORY,
523  media_content_type="",
524  title="Search results",
525  can_play=False,
526  can_expand=True,
527  children=children,
528  )
529 
530  def _didl_to_play_media(self, item: didl_lite.DidlObject) -> DidlPlayMedia:
531  """Return the first playable resource from a DIDL-Lite object."""
532  assert self._device_device
533 
534  if not item.res:
535  LOGGER.debug("Object %s has no resources", item.id)
536  raise Unresolvable("Object has no resources")
537 
538  for resource in item.res:
539  if not resource.uri:
540  continue
541  if mime_type := _resource_mime_type(resource):
542  url = self._device_device.get_absolute_url(resource.uri)
543  LOGGER.debug("Resolved to url %s MIME %s", url, mime_type)
544  return DidlPlayMedia(url, mime_type, item)
545 
546  LOGGER.debug("Object %s has no playable resources", item.id)
547  raise Unresolvable("Object has no playable resources")
548 
550  self,
551  item: didl_lite.DidlObject,
552  browsed_children: DmsDevice.BrowseResult | None = None,
553  ) -> BrowseMediaSource:
554  """Convert a DIDL-Lite object to a browse media source."""
555  children: list[BrowseMediaSource] | None = None
556 
557  if browsed_children:
558  children = [
559  self._didl_to_media_source_didl_to_media_source(child)
560  for child in browsed_children.result
561  if isinstance(child, didl_lite.DidlObject)
562  ]
563 
564  # Can expand if it has children (even if we don't have them yet), or its
565  # a container type. Otherwise the front-end will try to play it (even if
566  # can_play is False).
567  try:
568  child_count = int(item.child_count)
569  except (AttributeError, TypeError, ValueError):
570  child_count = 0
571  can_expand = (
572  bool(children) or child_count > 0 or isinstance(item, didl_lite.Container)
573  )
574 
575  # Can play if item has any resource that can be streamed over the network
576  can_play = any(_resource_is_streaming(res) for res in item.res)
577 
578  # Use server name for root object, not "root"
579  title = self.namename if item.id == ROOT_OBJECT_ID else item.title
580 
581  mime_type = _resource_mime_type(item.res[0]) if item.res else None
582  media_content_type = mime_type or item.upnp_class
583 
584  return BrowseMediaSource(
585  domain=DOMAIN,
586  identifier=self._make_identifier_make_identifier(Action.OBJECT, item.id),
587  media_class=MEDIA_CLASS_MAP.get(item.upnp_class, ""),
588  media_content_type=media_content_type,
589  title=title,
590  can_play=can_play,
591  can_expand=can_expand,
592  children=children,
593  thumbnail=self._didl_thumbnail_url_didl_thumbnail_url(item),
594  )
595 
596  def _didl_thumbnail_url(self, item: didl_lite.DidlObject) -> str | None:
597  """Return absolute URL of a thumbnail for a DIDL-Lite object.
598 
599  Some objects have the thumbnail in albumArtURI, others in an image
600  resource.
601  """
602  assert self._device_device
603 
604  # Based on DmrDevice.media_image_url from async_upnp_client.
605  if album_art_uri := getattr(item, "album_art_uri", None):
606  return self._device_device.get_absolute_url(album_art_uri)
607 
608  for resource in item.res:
609  if not resource.protocol_info or not resource.uri:
610  continue
611  if resource.protocol_info.startswith("http-get:*:image/"):
612  return self._device_device.get_absolute_url(resource.uri)
613 
614  return None
615 
616  def _make_identifier(self, action: Action, object_id: str) -> str:
617  """Make an identifier for BrowseMediaSource."""
618  return f"{self.source_id}/{action}{object_id}"
619 
620  @cached_property
621  def _sort_criteria(self) -> list[str]:
622  """Return criteria to be used for sorting results.
623 
624  The device must be connected before reading this property.
625  """
626  assert self._device_device
627 
628  if self._device_device.sort_capabilities == ["*"]:
629  return DLNA_SORT_CRITERIA
630 
631  # Filter criteria based on what the device supports. Strings in
632  # DLNA_SORT_CRITERIA are prefixed with a sign, while those in
633  # the device's sort_capabilities are not.
634  return [
635  criterion
636  for criterion in DLNA_SORT_CRITERIA
637  if criterion[1:] in self._device_device.sort_capabilities
638  ]
639 
640 
641 class Action(StrEnum):
642  """Actions that can be specified in a DMS media-source identifier."""
643 
644  OBJECT = PATH_OBJECT_ID_FLAG
645  PATH = PATH_SEP
646  SEARCH = PATH_SEARCH_FLAG
647 
648 
649 def _parse_identifier(identifier: str | None) -> tuple[Action | None, str]:
650  """Parse the media identifier component of a media-source URI."""
651  if not identifier:
652  return None, ""
653  if identifier.startswith(PATH_OBJECT_ID_FLAG):
654  return Action.OBJECT, identifier[1:]
655  if identifier.startswith(PATH_SEP):
656  return Action.PATH, identifier[1:]
657  if identifier.startswith(PATH_SEARCH_FLAG):
658  return Action.SEARCH, identifier[1:]
659  return Action.PATH, identifier
660 
661 
662 def _resource_is_streaming(resource: didl_lite.Resource) -> bool:
663  """Determine if a resource can be streamed across a network."""
664  # Err on the side of "True" if the protocol info is not available
665  if not resource.protocol_info:
666  return True
667  protocol = resource.protocol_info.split(":")[0].lower()
668  return protocol.lower() in STREAMABLE_PROTOCOLS
669 
670 
671 def _resource_mime_type(resource: didl_lite.Resource) -> str | None:
672  """Return the MIME type of a resource, if specified."""
673  # This is the contentFormat portion of the ProtocolInfo for an http-get stream
674  if not resource.protocol_info:
675  return None
676  try:
677  protocol, _, content_format, _ = resource.protocol_info.split(":", 3)
678  except ValueError:
679  return None
680  if protocol.lower() in STREAMABLE_PROTOCOLS:
681  return content_format
682  return None
683 
684 
685 def _esc_quote(contents: str) -> str:
686  """Escape string contents for DLNA search quoted values.
687 
688  See ContentDirectory:v4, section 4.1.2.
689  """
690  return contents.replace("\\", "\\\\").replace('"', '\\"')
None __init__(self, HomeAssistant hass)
Definition: dms.py:62
bool async_setup_entry(self, ConfigEntry config_entry)
Definition: dms.py:71
bool async_unload_entry(self, ConfigEntry config_entry)
Definition: dms.py:83
str _make_identifier(self, Action action, str object_id)
Definition: dms.py:616
str|None _didl_thumbnail_url(self, didl_lite.DidlObject item)
Definition: dms.py:596
None __init__(self, HomeAssistant hass, ConfigEntry config_entry)
Definition: dms.py:180
DidlPlayMedia _didl_to_play_media(self, didl_lite.DidlObject item)
Definition: dms.py:530
DidlPlayMedia async_resolve_object(self, str object_id)
Definition: dms.py:397
BrowseMediaSource async_browse_object(self, str object_id)
Definition: dms.py:486
None async_ssdp_callback(self, ssdp.SsdpServiceInfo info, ssdp.SsdpChange change)
Definition: dms.py:224
BrowseMediaSource _didl_to_media_source(self, didl_lite.DidlObject item, DmsDevice.BrowseResult|None browsed_children=None)
Definition: dms.py:553
BrowseMediaSource async_browse_media(self, str|None identifier)
Definition: dms.py:377
DidlPlayMedia async_resolve_search(self, str query)
Definition: dms.py:463
DidlPlayMedia async_resolve_media(self, str identifier)
Definition: dms.py:360
BrowseMediaSource async_browse_search(self, str query)
Definition: dms.py:503
DlnaDmsData get_domain_data(HomeAssistant hass)
Definition: dms.py:93
str _esc_quote(str contents)
Definition: dms.py:685
tuple[Action|None, str] _parse_identifier(str|None identifier)
Definition: dms.py:649
str|None _resource_mime_type(didl_lite.Resource resource)
Definition: dms.py:671
bool _resource_is_streaming(didl_lite.Resource resource)
Definition: dms.py:662