async def test_resolve_path_browsed_nothing( device_source_mock: DmsDeviceSource, dms_device_mock: Mock ) -> None: """Test async_resolve_path: action error results in browsing, but nothing found.""" dms_device_mock.async_search_directory.side_effect = UpnpActionError() # No children dms_device_mock.async_browse_direct_children.side_effect = [ DmsDevice.BrowseResult([], 0, 0, 0) ] with pytest.raises(Unresolvable, match="No contents for thing in thing/other"): await device_source_mock.async_resolve_path(r"thing/other") # There are children, but they don't match dms_device_mock.async_browse_direct_children.side_effect = [ DmsDevice.BrowseResult( [ didl_lite.Item( id="nothingid", restricted="false", title="not thing", res=[] ) ], 1, 1, 0, ) ] with pytest.raises(Unresolvable, match="Nothing found for thing in thing/other"): await device_source_mock.async_resolve_path(r"thing/other")
async def test_browse_media_path( device_source_mock: DmsDeviceSource, dms_device_mock: Mock ) -> None: """Test async_browse_media with a path.""" title = "folder" con_id = "123" container = didl_lite.Container(id=con_id, restricted="false", title=title) dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( [container], 1, 1, 0 ) dms_device_mock.async_browse_metadata.return_value = container dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult( [], 0, 0, 0 ) result = await device_source_mock.async_browse_media(f"{title}") assert result.identifier == f"{MOCK_SOURCE_ID}/:{con_id}" assert result.title == title dms_device_mock.async_search_directory.assert_awaited_once_with( "0", search_criteria=f'@parentID="0" and dc:title="{title}"', metadata_filter=ANY, requested_count=1, ) dms_device_mock.async_browse_metadata.assert_awaited_once_with( con_id, metadata_filter=ANY ) dms_device_mock.async_browse_direct_children.assert_awaited_once_with( con_id, metadata_filter=ANY, sort_criteria=ANY )
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 test_resolve_path_browsed(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test async_resolve_path: action error results in browsing.""" path: Final = "path/to/thing" object_ids: Final = ["path_id", "to_id", "thing_id"] res_url: Final = "foo/bar" res_mime: Final = "audio/mpeg" # Setup expected calls search_directory_result = [] for ob_id, ob_title in zip(object_ids, path.split("/")): didl_item = didl_lite.Item( id=ob_id, restricted="false", title=ob_title, res=[], ) search_directory_result.append(DmsDevice.BrowseResult([didl_item], 1, 1, 0)) dms_device_mock.async_search_directory.side_effect = [ search_directory_result[0], # 2nd level can't be searched (this happens with Kodi) UpnpActionError(), search_directory_result[2], ] browse_children_result: BrowseResultList = [] for title in ("Irrelevant", "to", "Ignored"): browse_children_result.append( didl_lite.Item(id=f"{title}_id", restricted="false", title=title, res=[]) ) dms_device_mock.async_browse_direct_children.side_effect = [ DmsDevice.BrowseResult(browse_children_result, 3, 3, 0) ] dms_device_mock.async_browse_metadata.return_value = didl_lite.Item( id=object_ids[-1], restricted="false", title="thing", res=[didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:")], ) # Perform the action to test result = await async_resolve_media(hass, path) # All levels should have an attempted search assert dms_device_mock.async_search_directory.await_args_list == [ call( parent_id, search_criteria=f'@parentID="{parent_id}" and dc:title="{title}"', metadata_filter=["id", "upnp:class", "dc:title"], requested_count=1, ) for parent_id, title in zip(["0"] + object_ids[:-1], path.split("/")) ] assert result.didl_metadata.id == object_ids[-1] # 2nd level should also be browsed assert dms_device_mock.async_browse_direct_children.await_args_list == [ call("path_id", metadata_filter=["id", "upnp:class", "dc:title"]) ]
async def test_resolve_media_search( device_source_mock: DmsDeviceSource, dms_device_mock: Mock ) -> None: """Test the async_resolve_search method via async_resolve_media.""" res_url: Final = "foo/bar" res_abs_url: Final = f"{MOCK_DEVICE_BASE_URL}/{res_url}" res_mime: Final = "audio/mpeg" # No results dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( [], 0, 0, 0 ) with pytest.raises(Unresolvable, match='Nothing found for dc:title="thing"'): await device_source_mock.async_resolve_media('?dc:title="thing"') assert dms_device_mock.async_search_directory.await_args_list == [ call( container_id="0", search_criteria='dc:title="thing"', metadata_filter="*", requested_count=1, ) ] # One result dms_device_mock.async_search_directory.reset_mock() didl_item = didl_lite.Item( id="thing's id", restricted="false", title="thing", res=[didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:")], ) dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( [didl_item], 1, 1, 0 ) result = await device_source_mock.async_resolve_media('?dc:title="thing"') assert result.url == res_abs_url assert result.mime_type == res_mime assert result.didl_metadata is didl_item assert dms_device_mock.async_search_directory.await_count == 1 # Values should be taken from search result, not querying the item's metadata assert dms_device_mock.async_browse_metadata.await_count == 0 # Two results - uses the first dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( [didl_item], 1, 2, 0 ) result = await device_source_mock.async_resolve_media('?dc:title="thing"') assert result.url == res_abs_url assert result.mime_type == res_mime assert result.didl_metadata is didl_item # Bad result dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( [didl_lite.Descriptor("id", "namespace")], 1, 1, 0 ) with pytest.raises(Unresolvable, match="Descriptor.* is not a DidlObject"): await device_source_mock.async_resolve_media('?dc:title="thing"')
async def test_resolve_path_browsed(device_source_mock: DmsDeviceSource, dms_device_mock: Mock) -> None: """Test async_resolve_path: action error results in browsing.""" path: Final = "path/to/thing" object_ids: Final = ["path_id", "to_id", "thing_id"] search_directory_result = [] for ob_id, ob_title in zip(object_ids, path.split("/")): didl_item = didl_lite.Item( id=ob_id, restricted="false", title=ob_title, res=[], ) search_directory_result.append( DmsDevice.BrowseResult([didl_item], 1, 1, 0)) dms_device_mock.async_search_directory.side_effect = [ search_directory_result[0], # 2nd level can't be searched (this happens with Kodi) UpnpActionError(), search_directory_result[2], ] browse_children_result: BrowseResultList = [] for title in ("Irrelevant", "to", "Ignored"): browse_children_result.append( didl_lite.Item(id=f"{title}_id", restricted="false", title=title, res=[])) dms_device_mock.async_browse_direct_children.side_effect = [ DmsDevice.BrowseResult(browse_children_result, 3, 3, 0) ] result = await device_source_mock.async_resolve_path(path) # All levels should have an attempted search assert dms_device_mock.async_search_directory.await_args_list == [ call( parent_id, search_criteria=f'@parentID="{parent_id}" and dc:title="{title}"', metadata_filter=["id", "upnp:class", "dc:title"], requested_count=1, ) for parent_id, title in zip(["0"] + object_ids[:-1], path.split("/")) ] assert result == object_ids[-1] # 2nd level should also be browsed assert dms_device_mock.async_browse_direct_children.await_args_list == [ call("path_id", metadata_filter=["id", "upnp:class", "dc:title"]) ]
async def test_browse_object_sort_superset( hass: HomeAssistant, dms_device_mock: Mock ) -> None: """Test sorting where device allows superset of integration's criteria.""" dms_device_mock.sort_capabilities = [ "dc:title", "upnp:originalTrackNumber", "upnp:class", "upnp:artist", "dc:creator", "upnp:genre", ] object_id = "0" dms_device_mock.async_browse_metadata.return_value = didl_lite.Container( id="0", restricted="false", title="root" ) dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult( [], 0, 0, 0 ) await async_browse_media(hass, ":0") # Sort criteria should be dlna_dms's default dms_device_mock.async_browse_direct_children.assert_awaited_once_with( object_id, metadata_filter=ANY, sort_criteria=DLNA_SORT_CRITERIA )
async def test_resolve_path_quoted(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test async_resolve_path: quotes and backslashes in the path get escaped correctly.""" dms_device_mock.async_search_directory.side_effect = [ DmsDevice.BrowseResult( [ didl_lite.Item( id=r'id_with quote" and back\slash', restricted="false", title="path", res=[], ) ], 1, 1, 0, ), UpnpError("Quick abort"), ] with pytest.raises(Unresolvable): await async_resolve_media(hass, r'path/quote"back\slash') assert dms_device_mock.async_search_directory.await_args_list == [ call( "0", search_criteria='@parentID="0" and dc:title="path"', metadata_filter=["id", "upnp:class", "dc:title"], requested_count=1, ), call( r'id_with quote" and back\slash', search_criteria=r'@parentID="id_with quote\" and back\\slash" and dc:title="quote\"back\\slash"', metadata_filter=["id", "upnp:class", "dc:title"], requested_count=1, ), ]
async def test_resolve_path_simple( device_source_mock: DmsDeviceSource, dms_device_mock: Mock ) -> None: """Test async_resolve_path for simple success as for test_resolve_media_path.""" path: Final = "path/to/thing" object_ids: Final = ["path_id", "to_id", "thing_id"] search_directory_result = [] for ob_id, ob_title in zip(object_ids, path.split("/")): didl_item = didl_lite.Item( id=ob_id, restricted="false", title=ob_title, res=[], ) search_directory_result.append(DmsDevice.BrowseResult([didl_item], 1, 1, 0)) dms_device_mock.async_search_directory.side_effect = search_directory_result result = await device_source_mock.async_resolve_path(path) assert dms_device_mock.async_search_directory.call_args_list == [ call( parent_id, search_criteria=f'@parentID="{parent_id}" and dc:title="{title}"', metadata_filter=["id", "upnp:class", "dc:title"], requested_count=1, ) for parent_id, title in zip(["0"] + object_ids[:-1], path.split("/")) ] assert result == object_ids[-1] assert not dms_device_mock.async_browse_direct_children.await_count
async def test_browse_media_root( device_source_mock: DmsDeviceSource, dms_device_mock: Mock ) -> None: """Test async_browse_media with no identifier will browse the root of the device.""" dms_device_mock.async_browse_metadata.return_value = didl_lite.DidlObject( id="0", restricted="false", title="root" ) dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult( [], 0, 0, 0 ) # No identifier (first opened in media browser) result = await device_source_mock.async_browse_media(None) assert result.identifier == f"{MOCK_SOURCE_ID}/:0" assert result.title == MOCK_DEVICE_NAME dms_device_mock.async_browse_metadata.assert_awaited_once_with( "0", metadata_filter=ANY ) dms_device_mock.async_browse_direct_children.assert_awaited_once_with( "0", metadata_filter=ANY, sort_criteria=ANY ) dms_device_mock.async_browse_metadata.reset_mock() dms_device_mock.async_browse_direct_children.reset_mock() # Empty string identifier result = await device_source_mock.async_browse_media("") assert result.identifier == f"{MOCK_SOURCE_ID}/:0" assert result.title == MOCK_DEVICE_NAME dms_device_mock.async_browse_metadata.assert_awaited_once_with( "0", metadata_filter=ANY ) dms_device_mock.async_browse_direct_children.assert_awaited_once_with( "0", metadata_filter=ANY, sort_criteria=ANY )
async def test_browse_media_search( device_source_mock: DmsDeviceSource, dms_device_mock: Mock ) -> None: """Test async_browse_media with a search query.""" query = 'dc:title contains "FooBar"' object_details = (("111", "FooBar baz"), ("432", "Not FooBar"), ("99", "FooBar")) dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( [ didl_lite.DidlObject(id=ob_id, restricted="false", title=title) for ob_id, title in object_details ], 3, 3, 0, ) # Test that descriptors are skipped dms_device_mock.async_search_directory.return_value.result.insert( 1, didl_lite.Descriptor("id", "name_space") ) result = await device_source_mock.async_browse_media(f"?{query}") assert result.identifier == f"{MOCK_SOURCE_ID}/?{query}" assert result.title == "Search results" assert result.children for obj, child in zip(object_details, result.children): assert isinstance(child, BrowseMediaSource) assert child.identifier == f"{MOCK_SOURCE_ID}/:{obj[0]}" assert child.title == obj[1] assert not child.children
async def test_resolve_path_ambiguous( device_source_mock: DmsDeviceSource, dms_device_mock: Mock ) -> None: """Test async_resolve_path: ambiguous results (too many matches) gives error.""" dms_device_mock.async_search_directory.side_effect = [ DmsDevice.BrowseResult( [ didl_lite.Item( id=r"thing 1", restricted="false", title="thing", res=[], ), didl_lite.Item( id=r"thing 2", restricted="false", title="thing", res=[], ), ], 2, 2, 0, ) ] with pytest.raises( Unresolvable, match="Too many items found for thing in thing/other" ): await device_source_mock.async_resolve_path(r"thing/other")
async def test_resolve_media_path(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test the async_resolve_path method via async_resolve_media.""" # Path resolution involves searching each component of the path, then # browsing the metadata of the final object found. path: Final = "path/to/thing" object_ids: Final = ["path_id", "to_id", "thing_id"] res_url: Final = "foo/bar" res_abs_url: Final = f"{MOCK_DEVICE_BASE_URL}/{res_url}" res_mime: Final = "audio/mpeg" search_directory_result = [] for ob_id, ob_title in zip(object_ids, path.split("/")): didl_item = didl_lite.Item( id=ob_id, restricted="false", title=ob_title, res=[], ) search_directory_result.append( DmsDevice.BrowseResult([didl_item], 1, 1, 0)) # Test that path is resolved correctly dms_device_mock.async_search_directory.side_effect = search_directory_result dms_device_mock.async_browse_metadata.return_value = didl_lite.Item( id=object_ids[-1], restricted="false", title="thing", res=[ didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:") ], ) result = await async_resolve_media(hass, f"/{path}") assert dms_device_mock.async_search_directory.await_args_list == [ call( parent_id, search_criteria=f'@parentID="{parent_id}" and dc:title="{title}"', metadata_filter=["id", "upnp:class", "dc:title"], requested_count=1, ) for parent_id, title in zip(["0"] + object_ids[:-1], path.split("/")) ] assert result.url == res_abs_url assert result.mime_type == res_mime # Test a path starting with a / (first / is path action, second / is root of path) dms_device_mock.async_search_directory.reset_mock() dms_device_mock.async_search_directory.side_effect = search_directory_result result = await async_resolve_media(hass, f"//{path}") assert dms_device_mock.async_search_directory.await_args_list == [ call( parent_id, search_criteria=f'@parentID="{parent_id}" and dc:title="{title}"', metadata_filter=["id", "upnp:class", "dc:title"], requested_count=1, ) for parent_id, title in zip(["0"] + object_ids[:-1], path.split("/")) ] assert result.url == res_abs_url assert result.mime_type == res_mime
async def test_browse_search_no_results(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test a search with no results does not give an error.""" query = 'dc:title contains "FooBar"' dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( [], 0, 0, 0) result = await async_browse_media(hass, f"?{query}") assert result.identifier == f"{MOCK_SOURCE_ID}/?{query}" assert result.title == "Search results" assert not result.children
async def test_thumbnail( device_source_mock: DmsDeviceSource, dms_device_mock: Mock ) -> None: """Test getting thumbnails URLs for items.""" # Use browse_search to get multiple items at once for least effort dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( [ # Thumbnail as albumArtURI property didl_lite.MusicAlbum( id="a", restricted="false", title="a", res=[], album_art_uri="a_thumb.jpg", ), # Thumbnail as resource (1st resource is media item, 2nd is missing # a URI, 3rd is thumbnail) didl_lite.MusicTrack( id="b", restricted="false", title="b", res=[ didl_lite.Resource( uri="b_track.mp3", protocol_info="http-get:*:audio/mpeg:" ), didl_lite.Resource(uri="", protocol_info="internal:*::"), didl_lite.Resource( uri="b_thumb.png", protocol_info="http-get:*:image/png:" ), ], ), # No thumbnail didl_lite.MusicTrack( id="c", restricted="false", title="c", res=[ didl_lite.Resource( uri="c_track.mp3", protocol_info="http-get:*:audio/mpeg:" ) ], ), ], 3, 3, 0, ) result = await device_source_mock.async_browse_media("?query") assert result.children assert result.children[0].thumbnail == f"{MOCK_DEVICE_BASE_URL}/a_thumb.jpg" assert result.children[1].thumbnail == f"{MOCK_DEVICE_BASE_URL}/b_thumb.png" assert result.children[2].thumbnail is None
async def test_browse_object_sort_anything(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test sort criteria for children where device allows anything.""" dms_device_mock.sort_capabilities = ["*"] object_id = "0" dms_device_mock.async_browse_metadata.return_value = didl_lite.Container( id="0", restricted="false", title="root") dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult( [], 0, 0, 0) await async_browse_media(hass, ":0") # Sort criteria should be dlna_dms's default dms_device_mock.async_browse_direct_children.assert_awaited_once_with( object_id, metadata_filter=ANY, sort_criteria=DLNA_SORT_CRITERIA)
async def test_browse_media_object( device_source_mock: DmsDeviceSource, dms_device_mock: Mock ) -> None: """Test async_browse_object via async_browse_media.""" object_id = "1234" child_titles = ("Item 1", "Thing", "Item 2") dms_device_mock.async_browse_metadata.return_value = didl_lite.Container( id=object_id, restricted="false", title="subcontainer" ) children_result = DmsDevice.BrowseResult([], 3, 3, 0) for title in child_titles: children_result.result.append( didl_lite.Item( id=title + "_id", restricted="false", title=title, res=[ didl_lite.Resource( uri=title + "_url", protocol_info="http-get:*:audio/mpeg:" ) ], ), ) dms_device_mock.async_browse_direct_children.return_value = children_result result = await device_source_mock.async_browse_media(f":{object_id}") dms_device_mock.async_browse_metadata.assert_awaited_once_with( object_id, metadata_filter=ANY ) dms_device_mock.async_browse_direct_children.assert_awaited_once_with( object_id, metadata_filter=ANY, sort_criteria=ANY ) assert result.domain == DOMAIN assert result.identifier == f"{MOCK_SOURCE_ID}/:{object_id}" assert result.title == "subcontainer" assert not result.can_play assert result.can_expand assert result.children for child, title in zip(result.children, child_titles): assert isinstance(child, BrowseMediaSource) assert child.identifier == f"{MOCK_SOURCE_ID}/:{title}_id" assert child.title == title assert child.can_play assert not child.can_expand assert not child.children
async def test_can_play( device_source_mock: DmsDeviceSource, dms_device_mock: Mock ) -> None: """Test determination of playability for items.""" protocol_infos = [ # No protocol info for resource ("", True), # Protocol info is poorly formatted but can play ("http-get", True), # Protocol info is poorly formatted and can't play ("internal", False), # Protocol is HTTP ("http-get:*:audio/mpeg", True), # Protocol is RTSP ("rtsp-rtp-udp:*:MPA:", True), # Protocol is something else ("internal:*:audio/mpeg:", False), ] search_results: BrowseResultList = [] # No resources search_results.append(didl_lite.DidlObject(id="", restricted="false", title="")) search_results.extend( didl_lite.MusicTrack( id="", restricted="false", title="", res=[didl_lite.Resource(uri="", protocol_info=protocol_info)], ) for protocol_info, _ in protocol_infos ) # Use browse_search to get multiple items at once for least effort dms_device_mock.async_search_directory.return_value = DmsDevice.BrowseResult( search_results, len(search_results), len(search_results), 0 ) result = await device_source_mock.async_browse_media("?query") assert result.children assert not result.children[0].can_play for idx, info_can_play in enumerate(protocol_infos): protocol_info, can_play = info_can_play assert result.children[idx + 1].can_play is can_play, f"Checked {protocol_info}"
async def test_browse_object_sort_subset(hass: HomeAssistant, dms_device_mock: Mock) -> None: """Test sorting where device allows subset of integration's criteria.""" dms_device_mock.sort_capabilities = [ "dc:title", "upnp:class", ] object_id = "0" dms_device_mock.async_browse_metadata.return_value = didl_lite.Container( id="0", restricted="false", title="root") dms_device_mock.async_browse_direct_children.return_value = DmsDevice.BrowseResult( [], 0, 0, 0) await async_browse_media(hass, ":0") # Sort criteria should be reduced to only those allowed, # and in the order specified by DLNA_SORT_CRITERIA expected_criteria = ["+upnp:class", "+dc:title"] dms_device_mock.async_browse_direct_children.assert_awaited_once_with( object_id, metadata_filter=ANY, sort_criteria=expected_criteria)
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")