async def async_browse_media( self, item: MediaSourceItem, ) -> BrowseMediaSource: """Return library media for Music Assistent instance.""" mass = self.get_mass() if mass is None: raise Unresolvable("MusicAssistant is not initialized") if item is None or item.identifier is None: return self._build_main_listing() if item.identifier == LIBRARY_ARTISTS: return await self._build_artists_listing(mass) if item.identifier == LIBRARY_ALBUMS: return await self._build_albums_listing(mass) if item.identifier == LIBRARY_TRACKS: return await self._build_tracks_listing(mass) if item.identifier == LIBRARY_PLAYLISTS: return await self._build_playlists_listing(mass) if item.identifier == LIBRARY_RADIO: return await self._build_radio_listing(mass) if "artist" in item.identifier: return await self._build_artist_items_listing( mass, item.identifier) if "album" in item.identifier: return await self._build_album_items_listing(mass, item.identifier) if "playlist" in item.identifier: return await self._build_playlist_items_listing( mass, item.identifier) raise Unresolvable(f"Unknown identifier: {item.identifier}")
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" parsed = URL(item.identifier) if "message" not in parsed.query: raise Unresolvable("No message specified.") options = dict(parsed.query) kwargs = { "engine": parsed.name, "message": options.pop("message"), "language": options.pop("language", None), "options": options, } manager: SpeechManager = self.hass.data[DOMAIN] try: url = await manager.async_get_url_path(**kwargs ) # type: ignore[arg-type] except HomeAssistantError as err: raise Unresolvable(str(err)) from err mime_type = mimetypes.guess_type(url)[0] or "audio/mpeg" return PlayMedia(url, mime_type)
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" parsed = URL(item.identifier) if "message" not in parsed.query: raise Unresolvable("No message specified.") options = dict(parsed.query) kwargs: dict[str, Any] = { "engine": parsed.name, "message": options.pop("message"), "language": options.pop("language", None), "options": options, } if "cache" in options: kwargs["cache"] = options.pop("cache") == "true" manager: SpeechManager = self.hass.data[DOMAIN] try: url = await manager.async_get_url_path(**kwargs) except HomeAssistantError as err: raise Unresolvable(str(err)) from err mime_type = mimetypes.guess_type(url)[0] or "audio/mpeg" if manager.base_url and manager.base_url != get_url(self.hass): url = f"{manager.base_url}{url}" return PlayMedia(url, mime_type)
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media identifier to a url.""" media_id: MediaId | None = parse_media_id(item.identifier) if not media_id: raise Unresolvable("No identifier specified for MediaSourceItem") devices = await self.devices() if not (device := devices.get(media_id.device_id)): raise Unresolvable("Unable to find device with identifier: %s" % item.identifier)
async def async_resolve_path(self, path: str) -> str: """Return an Object ID resolved from a path string.""" assert self._device # Iterate through the path, searching for a matching title within the # DLNA object hierarchy. object_id = ROOT_OBJECT_ID for node in path.split(PATH_SEP): if not node: # Skip empty names, for when multiple slashes are involved, e.g // continue criteria = ( f'@parentID="{_esc_quote(object_id)}" and dc:title="{_esc_quote(node)}"' ) try: result = await self._device.async_search_directory( object_id, search_criteria=criteria, metadata_filter=DLNA_PATH_FILTER, requested_count=1, ) except UpnpActionError as err: LOGGER.debug("Error in call to async_search_directory: %r", err) if err.error_code == ContentDirectoryErrorCode.NO_SUCH_CONTAINER: raise Unresolvable( f"No such container: {object_id}") from err # Search failed, but can still try browsing children else: if result.total_matches > 1: raise Unresolvable( f"Too many items found for {node} in {path}") if result.result: object_id = result.result[0].id continue # Nothing was found via search, fall back to iterating children result = await self._device.async_browse_direct_children( object_id, metadata_filter=DLNA_PATH_FILTER) if result.total_matches == 0 or not result.result: raise Unresolvable(f"No contents for {node} in {path}") node_lower = node.lower() for child in result.result: if child.title.lower() == node_lower: object_id = child.id break else: # Examining all direct children failed too raise Unresolvable(f"Nothing found for {node} in {path}") return object_id
def async_parse_identifier(self, item: MediaSourceItem) -> Tuple[str, str]: """Parse identifier.""" if not item.identifier: # Empty source_dir_id and location return "", "" source_dir_id, location = item.identifier.split("/", 1) if source_dir_id not in self.hass.config.media_dirs: raise Unresolvable("Unknown source directory.") if location != sanitize_path(location): raise Unresolvable("Invalid path.") return source_dir_id, location
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve selected Radio station to a streaming URL.""" radios = self.radios if radios is None: raise Unresolvable("Radio Browser not initialized") station = await radios.station(uuid=item.identifier) if not station: raise Unresolvable("Radio station is no longer available") if not (mime_type := self._async_get_station_mime_type(station)): raise Unresolvable( "Could not determine stream type of radio station")
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" config_id, device_id, kind, path = self._parse_identifier( item.identifier) if not config_id or not device_id or not kind or not path: raise Unresolvable( f"Incomplete media identifier specified: {item.identifier}") config = self._get_config_or_raise(config_id) device = self._get_device_or_raise(device_id) camera_id = self._get_camera_id_or_raise(config, device) self._verify_kind_or_raise(kind) path = self._get_path_or_raise(path) client = self.hass.data[DOMAIN][config.entry_id][CONF_CLIENT] try: if kind == "movies": url = client.get_movie_url(camera_id, path) else: url = client.get_image_url(camera_id, path) except MotionEyeClientPathError as exc: raise Unresolvable from exc return PlayMedia(url, MIME_TYPE_MAP[kind])
def async_parse_identifier(item: MediaSourceItem) -> Tuple[str, str]: """Parse identifier.""" if not item.identifier: source_dir_id = "media" location = "" else: source_dir_id, location = item.identifier.lstrip("/").split("/", 1) if source_dir_id != "media": raise Unresolvable("Unknown source directory.") if location != sanitize_path(location): raise Unresolvable("Invalid path.") return source_dir_id, location
def async_parse_identifier(self, item: MediaSourceItem) -> tuple[str, str]: """Parse identifier.""" if not item.identifier: # Empty source_dir_id and location return "", "" source_dir_id, location = item.identifier.split("/", 1) if source_dir_id not in self.hass.config.media_dirs: raise Unresolvable("Unknown source directory.") try: raise_if_invalid_path(location) except ValueError as err: raise Unresolvable("Invalid path.") from err return source_dir_id, location
class NestMediaSource(MediaSource): """Provide Nest Media Sources for Nest Cameras. The media source generates a directory tree of devices and media associated with events for each device (e.g. motion, person, etc). Each node in the tree has a unique MediaId. The lifecycle for event media is handled outside of NestMediaSource, and instead it just asks the device for all events it knows about. """ name: str = MEDIA_SOURCE_TITLE def __init__(self, hass: HomeAssistant) -> None: """Initialize NestMediaSource.""" super().__init__(DOMAIN) self.hass = hass async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media identifier to a url.""" media_id: MediaId | None = parse_media_id(item.identifier) if not media_id: raise Unresolvable("No identifier specified for MediaSourceItem") devices = await self.devices() if not (device := devices.get(media_id.device_id)): raise Unresolvable("Unable to find device with identifier: %s" % item.identifier) if not media_id.event_token: # The device resolves to the most recent event if available if not (last_event_id := await _async_get_recent_event_id( media_id, device)): raise Unresolvable( "Unable to resolve recent event for device: %s" % item.identifier) media_id = last_event_id
async def async_resolve_media(self, item: MediaSourceItem) -> str: """Resolve media to a url.""" source_dir_id, location = self.async_parse_identifier(item) if source_dir_id == "" or source_dir_id not in self.hass.config.media_dirs: raise Unresolvable("Unknown source directory.") mime_type, _ = mimetypes.guess_type( str(self.async_full_path(source_dir_id, location))) return PlayMedia(f"/media/{item.identifier}", mime_type)
async def async_resolve_media(self, item: MediaSourceItem) -> DidlPlayMedia: """Resolve a media item to a playable item.""" dms_data = get_domain_data(self.hass) if not dms_data.sources: raise Unresolvable("No sources have been configured") source_id, media_id = _parse_identifier(item) if not source_id: raise Unresolvable(f"No source ID in {item.identifier}") if not media_id: raise Unresolvable(f"No media ID in {item.identifier}") try: source = dms_data.sources[source_id] except KeyError as err: raise Unresolvable(f"Unknown source ID: {source_id}") from err return await source.async_resolve_media(media_id)
async def async_resolve_search(self, query: str) -> DidlPlayMedia: """Return first playable media item found by the query string.""" assert self._device result = await self._device.async_search_directory( container_id=ROOT_OBJECT_ID, search_criteria=query, metadata_filter=DLNA_RESOLVE_FILTER, requested_count=1, ) if result.total_matches == 0 or not result.result: raise Unresolvable(f"Nothing found for {query}") # Use the first result, even if it doesn't have a playable resource item = result.result[0] if not isinstance(item, didl_lite.DidlObject): raise Unresolvable(f"{item} is not a DidlObject") return self._didl_to_play_media(item)
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" component: EntityComponent = self.hass.data[DOMAIN] camera = cast(Optional[Camera], component.get_entity(item.identifier)) if not camera: raise Unresolvable( f"Could not resolve media item: {item.identifier}") if (stream_type := camera.frontend_stream_type) is None: return PlayMedia(f"/api/camera_proxy_stream/{camera.entity_id}", camera.content_type)
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" identifier = Identifier.from_str( item.identifier, default_frigate_instance_id=self._get_default_frigate_instance_id(), ) if identifier: server_path = identifier.get_integration_proxy_path() return PlayMedia( f"/api/frigate/{identifier.frigate_instance_id}/{server_path}", identifier.mime_type, ) raise Unresolvable("Unknown identifier: %s" % item.identifier)
def async_parse_identifier(self, item: MediaSourceItem) -> ItemInfo: """Parse identifier.""" if not item.identifier: raise Unresolvable("Invalid path.") split = item.identifier.split("/") entry_id = split[0] api = self.hass.data[DOMAIN].get(entry_id) if api is None: raise Unresolvable(f"Missing {DOMAIN} configuration entry.") iteminfo = ItemInfo(entry_id, api.client.target_dir, split[1] if len(split) > 1 else None) try: raise_if_invalid_path(str(iteminfo.path)) except ValueError as err: raise Unresolvable("Invalid path.") from err return iteminfo
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" config_id, device_id, kind, path = self._parse_identifier(item.identifier) if not config_id or not device_id or not kind or not path: raise Unresolvable( f"Incomplete media identifier specified: {item.identifier}" ) config = self._get_config_or_raise(config_id) device = self._get_device_or_raise(device_id) self._verify_kind_or_raise(kind) url = get_media_url( self.hass.data[DOMAIN][config.entry_id][CONF_CLIENT], self._get_camera_id_or_raise(config, device), self._get_path_or_raise(path), kind == "images", ) if not url: raise Unresolvable(f"Could not resolve media item: {item.identifier}") return PlayMedia(url, MIME_TYPE_MAP[kind])
class CameraMediaSource(MediaSource): """Provide camera feeds as media sources.""" name: str = "Camera" def __init__(self, hass: HomeAssistant) -> None: """Initialize CameraMediaSource.""" super().__init__(DOMAIN) self.hass = hass async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" component: EntityComponent = self.hass.data[DOMAIN] camera = cast(Optional[Camera], component.get_entity(item.identifier)) if not camera: raise Unresolvable( f"Could not resolve media item: {item.identifier}") if (stream_type := camera.frontend_stream_type) is None: return PlayMedia(f"/api/camera_proxy_stream/{camera.entity_id}", camera.content_type) if stream_type != STREAM_TYPE_HLS: raise Unresolvable( "Camera does not support MJPEG or HLS streaming.") if "stream" not in self.hass.config.components: raise Unresolvable("Stream integration not loaded") try: url = await _async_stream_endpoint_url(self.hass, camera, HLS_PROVIDER) except HomeAssistantError as err: raise Unresolvable(str(err)) from err return PlayMedia(url, FORMAT_CONTENT_TYPE[HLS_PROVIDER])
def _didl_to_play_media(self, item: didl_lite.DidlObject) -> DidlPlayMedia: """Return the first playable resource from a DIDL-Lite object.""" assert self._device if not item.res: LOGGER.debug("Object %s has no resources", item.id) raise Unresolvable("Object has no resources") for resource in item.res: if not resource.uri: continue if mime_type := _resource_mime_type(resource): url = self._device.get_absolute_url(resource.uri) LOGGER.debug("Resolved to url %s MIME %s", url, mime_type) return DidlPlayMedia(url, mime_type, item)
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" mass = self.get_mass() if mass is None: raise Unresolvable("MusicAssistant is not initialized") if item.target_media_player is None: # TODO: How to intercept a play request for the 'webbrowser' player # or at least hide our source for the webbrowser player ? raise Unresolvable("Playback not supported on the device.") # get/create mass player instance attached to this entity id player = await async_register_player_control(self.hass, mass, item.target_media_player) if not player: return PlayMedia(item.identifier, MEDIA_TYPE_MUSIC) # send the mass library uri to the player(queue) await player.active_queue.play_media(item.identifier, passive=True) # tell the actual player to play the stream url content_type = player.active_queue.settings.stream_type.value return PlayMedia(player.active_queue.stream.url, f"audio/{content_type}")
def async_parse_identifier( item: MediaSourceItem, ) -> tuple[str, str, int | None]: """Parse identifier.""" if not item.identifier: return "events", "", None source, path = item.identifier.lstrip("/").split("/", 1) if source != "events": raise Unresolvable("Unknown source directory.") if "/" in path: camera_id, event_id = path.split("/", 1) return source, camera_id, int(event_id) return source, path, None
async def async_resolve_media(self, identifier: str) -> DidlPlayMedia: """Resolve a media item to a playable item.""" LOGGER.debug("async_resolve_media(%s)", identifier) action, parameters = _parse_identifier(identifier) if action is Action.OBJECT: return await self.async_resolve_object(parameters) if action is Action.PATH: object_id = await self.async_resolve_path(parameters) return await self.async_resolve_object(object_id) if action is Action.SEARCH: return await self.async_resolve_search(parameters) LOGGER.debug("Invalid identifier %s", identifier) raise Unresolvable(f"Invalid identifier {identifier}")
class NestMediaSource(MediaSource): """Provide Nest Media Sources for Nest Cameras. The media source generates a directory tree of devices and media associated with events for each device (e.g. motion, person, etc). Each node in the tree has a unique MediaId. The lifecycle for event media is handled outside of NestMediaSource, and instead it just asks the device for all events it knows about. """ name: str = MEDIA_SOURCE_TITLE def __init__(self, hass: HomeAssistant) -> None: """Initialize NestMediaSource.""" super().__init__(DOMAIN) self.hass = hass async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media identifier to a url.""" media_id: MediaId | None = parse_media_id(item.identifier) if not media_id: raise Unresolvable("No identifier specified for MediaSourceItem") if not media_id.event_id: raise Unresolvable("Identifier missing an event_id: %s" % item.identifier) devices = await self.devices() if not (device := devices.get(media_id.device_id)): raise Unresolvable( "Unable to find device with identifier: %s" % item.identifier ) events = await _get_events(device) if media_id.event_id not in events: raise Unresolvable( "Unable to find event with identifier: %s" % item.identifier ) event = events[media_id.event_id] return PlayMedia( EVENT_MEDIA_API_URL_FORMAT.format( device_id=media_id.device_id, event_id=media_id.event_id ), event.event_image_type.content_type, )
class DmsDeviceSource: """DMS Device wrapper, providing media files as a media_source.""" hass: HomeAssistant config_entry: ConfigEntry # Unique slug used for media-source URIs source_id: str # Last known URL for the device, used when adding this wrapper to hass to # try to connect before SSDP has rediscovered it, or when SSDP discovery # fails. location: str | None _device_lock: asyncio.Lock # Held when connecting or disconnecting the device _device: DmsDevice | None = None # Only try to connect once when an ssdp:alive advertisement is received _ssdp_connect_failed: bool = False # Track BOOTID in SSDP advertisements for device changes _bootid: int | None = None def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, source_id: str) -> None: """Initialize a DMS Source.""" self.hass = hass self.config_entry = config_entry self.source_id = source_id self.location = self.config_entry.data[CONF_URL] self._device_lock = asyncio.Lock() # Callbacks and events async def async_added_to_hass(self) -> None: """Handle addition of this source.""" # Try to connect to the last known location, but don't worry if not available if not self._device and self.location: try: await self.device_connect() except UpnpError as err: LOGGER.debug("Couldn't connect immediately: %r", err) # Get SSDP notifications for only this device self.config_entry.async_on_unload(await ssdp.async_register_callback( self.hass, self.async_ssdp_callback, {"USN": self.usn})) # async_upnp_client.SsdpListener only reports byebye once for each *UDN* # (device name) which often is not the USN (service within the device) # that we're interested in. So also listen for byebye advertisements for # the UDN, which is reported in the _udn field of the combined_headers. self.config_entry.async_on_unload(await ssdp.async_register_callback( self.hass, self.async_ssdp_callback, { "_udn": self.udn, "NTS": NotificationSubType.SSDP_BYEBYE }, )) async def async_will_remove_from_hass(self) -> None: """Handle removal of this source.""" await self.device_disconnect() async def async_ssdp_callback(self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange) -> None: """Handle notification from SSDP of device state change.""" LOGGER.debug( "SSDP %s notification of device %s at %s", change, info.ssdp_usn, info.ssdp_location, ) try: bootid_str = info.ssdp_headers[ssdp.ATTR_SSDP_BOOTID] bootid: int | None = int(bootid_str, 10) except (KeyError, ValueError): bootid = None if change == ssdp.SsdpChange.UPDATE: # This is an announcement that bootid is about to change if self._bootid is not None and self._bootid == bootid: # Store the new value (because our old value matches) so that we # can ignore subsequent ssdp:alive messages try: next_bootid_str = info.ssdp_headers[ ssdp.ATTR_SSDP_NEXTBOOTID] self._bootid = int(next_bootid_str, 10) except (KeyError, ValueError): pass # Nothing left to do until ssdp:alive comes through return if self._bootid is not None and self._bootid != bootid: # Device has rebooted # Maybe connection will succeed now self._ssdp_connect_failed = False if self._device: # Drop existing connection and maybe reconnect await self.device_disconnect() self._bootid = bootid if change == ssdp.SsdpChange.BYEBYE: # Device is going away if self._device: # Disconnect from gone device await self.device_disconnect() # Maybe the next alive message will result in a successful connection self._ssdp_connect_failed = False if (change == ssdp.SsdpChange.ALIVE and not self._device and not self._ssdp_connect_failed): assert info.ssdp_location self.location = info.ssdp_location try: await self.device_connect() except UpnpError as err: self._ssdp_connect_failed = True LOGGER.warning( "Failed connecting to recently alive device at %s: %r", self.location, err, ) # Device connection/disconnection async def device_connect(self) -> None: """Connect to the device now that it's available.""" LOGGER.debug("Connecting to device at %s", self.location) async with self._device_lock: if self._device: LOGGER.debug("Trying to connect when device already connected") return if not self.location: LOGGER.debug("Not connecting because location is not known") return domain_data = get_domain_data(self.hass) # Connect to the base UPNP device upnp_device = await domain_data.upnp_factory.async_create_device( self.location) # Create profile wrapper self._device = DmsDevice(upnp_device, domain_data.event_handler) # Update state variables. We don't care if they change, so this is # only done once, here. await self._device.async_update() async def device_disconnect(self) -> None: """Destroy connections to the device now that it's not available. Also call when removing this device wrapper from hass to clean up connections. """ async with self._device_lock: if not self._device: LOGGER.debug("Disconnecting from device that's not connected") return LOGGER.debug("Disconnecting from %s", self._device.name) self._device = None # Device properties @property def available(self) -> bool: """Device is available when we have a connection to it.""" return self._device is not None and self._device.profile_device.available @property def usn(self) -> str: """Get the USN (Unique Service Name) for the wrapped UPnP device end-point.""" return self.config_entry.data[CONF_DEVICE_ID] @property def udn(self) -> str: """Get the UDN (Unique Device Name) based on the USN.""" return self.usn.partition("::")[0] @property def name(self) -> str: """Return a name for the media server.""" return self.config_entry.title @property def icon(self) -> str | None: """Return an URL to an icon for the media server.""" if not self._device: return None return self._device.icon # MediaSource methods async def async_resolve_media(self, identifier: str) -> DidlPlayMedia: """Resolve a media item to a playable item.""" LOGGER.debug("async_resolve_media(%s)", identifier) action, parameters = _parse_identifier(identifier) if action is Action.OBJECT: return await self.async_resolve_object(parameters) if action is Action.PATH: object_id = await self.async_resolve_path(parameters) return await self.async_resolve_object(object_id) if action is Action.SEARCH: return await self.async_resolve_search(parameters) LOGGER.debug("Invalid identifier %s", identifier) raise Unresolvable(f"Invalid identifier {identifier}") async def async_browse_media(self, identifier: str | None) -> BrowseMediaSource: """Browse media.""" LOGGER.debug("async_browse_media(%s)", identifier) action, parameters = _parse_identifier(identifier) if action is Action.OBJECT: return await self.async_browse_object(parameters) if action is Action.PATH: object_id = await self.async_resolve_path(parameters) return await self.async_browse_object(object_id) if action is Action.SEARCH: return await self.async_browse_search(parameters) return await self.async_browse_object(ROOT_OBJECT_ID) # DMS methods @catch_request_errors async def async_resolve_object(self, object_id: str) -> DidlPlayMedia: """Return a playable media item specified by ObjectID.""" assert self._device item = await self._device.async_browse_metadata( object_id, metadata_filter=DLNA_RESOLVE_FILTER) # Use the first playable resource return self._didl_to_play_media(item) @catch_request_errors async def async_resolve_path(self, path: str) -> str: """Return an Object ID resolved from a path string.""" assert self._device # Iterate through the path, searching for a matching title within the # DLNA object hierarchy. object_id = ROOT_OBJECT_ID for node in path.split(PATH_SEP): if not node: # Skip empty names, for when multiple slashes are involved, e.g // continue criteria = ( f'@parentID="{_esc_quote(object_id)}" and dc:title="{_esc_quote(node)}"' ) try: result = await self._device.async_search_directory( object_id, search_criteria=criteria, metadata_filter=DLNA_PATH_FILTER, requested_count=1, ) except UpnpActionError as err: LOGGER.debug("Error in call to async_search_directory: %r", err) if err.error_code == ContentDirectoryErrorCode.NO_SUCH_CONTAINER: raise Unresolvable( f"No such container: {object_id}") from err # Search failed, but can still try browsing children else: if result.total_matches > 1: raise Unresolvable( f"Too many items found for {node} in {path}") if result.result: object_id = result.result[0].id continue # Nothing was found via search, fall back to iterating children result = await self._device.async_browse_direct_children( object_id, metadata_filter=DLNA_PATH_FILTER) if result.total_matches == 0 or not result.result: raise Unresolvable(f"No contents for {node} in {path}") node_lower = node.lower() for child in result.result: if child.title.lower() == node_lower: object_id = child.id break else: # Examining all direct children failed too raise Unresolvable(f"Nothing found for {node} in {path}") return object_id @catch_request_errors async def async_resolve_search(self, query: str) -> DidlPlayMedia: """Return first playable media item found by the query string.""" assert self._device result = await self._device.async_search_directory( container_id=ROOT_OBJECT_ID, search_criteria=query, metadata_filter=DLNA_RESOLVE_FILTER, requested_count=1, ) if result.total_matches == 0 or not result.result: raise Unresolvable(f"Nothing found for {query}") # Use the first result, even if it doesn't have a playable resource item = result.result[0] if not isinstance(item, didl_lite.DidlObject): raise Unresolvable(f"{item} is not a DidlObject") return self._didl_to_play_media(item) @catch_request_errors async def async_browse_object(self, object_id: str) -> BrowseMediaSource: """Return the contents of a DLNA container by ObjectID.""" assert self._device base_object = await self._device.async_browse_metadata( object_id, metadata_filter=DLNA_BROWSE_FILTER) children = await self._device.async_browse_direct_children( object_id, metadata_filter=DLNA_BROWSE_FILTER, sort_criteria=self._sort_criteria, ) return self._didl_to_media_source(base_object, children) @catch_request_errors async def async_browse_search(self, query: str) -> BrowseMediaSource: """Return all media items found by the query string.""" assert self._device result = await self._device.async_search_directory( container_id=ROOT_OBJECT_ID, search_criteria=query, metadata_filter=DLNA_BROWSE_FILTER, ) children = [ self._didl_to_media_source(child) for child in result.result if isinstance(child, didl_lite.DidlObject) ] media_source = BrowseMediaSource( domain=DOMAIN, identifier=self._make_identifier(Action.SEARCH, query), media_class=MEDIA_CLASS_DIRECTORY, media_content_type="", title="Search results", can_play=False, can_expand=True, children=children, ) if media_source.children: media_source.calculate_children_class() return media_source def _didl_to_play_media(self, item: didl_lite.DidlObject) -> DidlPlayMedia: """Return the first playable resource from a DIDL-Lite object.""" assert self._device if not item.res: LOGGER.debug("Object %s has no resources", item.id) raise Unresolvable("Object has no resources") for resource in item.res: if not resource.uri: continue if mime_type := _resource_mime_type(resource): url = self._device.get_absolute_url(resource.uri) LOGGER.debug("Resolved to url %s MIME %s", url, mime_type) return DidlPlayMedia(url, mime_type, item) LOGGER.debug("Object %s has no playable resources", item.id) raise Unresolvable("Object has no playable resources")