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_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_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_resolve_media_success(hass: HomeAssistant, dms_device_mock: Mock, device_source_mock: None) -> None: """Test resolving an item via a DmsDeviceSource.""" object_id = "123" res_url = "foo/bar" res_mime = "audio/mpeg" didl_item = didl_lite.Item( id=object_id, restricted=False, title="Object", res=[ didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:") ], ) dms_device_mock.async_browse_metadata.return_value = didl_item result = await media_source.async_resolve_media( hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:{object_id}") assert isinstance(result, DidlPlayMedia) assert result.url == f"{MOCK_DEVICE_BASE_URL}/{res_url}" assert result.mime_type == res_mime assert result.didl_metadata is didl_item
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_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_become_unavailable( hass: HomeAssistant, connected_source_mock: DmsDeviceSource, dms_device_mock: Mock, ) -> None: """Test a device becoming unavailable.""" # Mock a good resolve result dms_device_mock.async_browse_metadata.return_value = didl_lite.Item( id="object_id", restricted=False, title="Object", res=[didl_lite.Resource(uri="foo", protocol_info="http-get:*:audio/mpeg:")], ) # Check async_resolve_object currently works await connected_source_mock.async_resolve_media(":object_id") # Now break the network connection dms_device_mock.async_browse_metadata.side_effect = UpnpConnectionError # The device should be considered available until next contacted assert connected_source_mock.available # async_resolve_object should fail with pytest.raises(Unresolvable): await connected_source_mock.async_resolve_media(":object_id") # The device should now be unavailable assert not connected_source_mock.available
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_catch_upnp_connection_error( hass: HomeAssistant, dms_device_mock: Mock ) -> None: """Test UpnpConnectionError causes the device source to disconnect from the device.""" # First check the source can be used object_id = "foo" didl_item = didl_lite.Item( id=object_id, restricted="false", title="Object", res=[didl_lite.Resource(uri="foo", protocol_info="http-get:*:audio/mpeg")], ) dms_device_mock.async_browse_metadata.return_value = didl_item await async_browse_media(hass, f":{object_id}") dms_device_mock.async_browse_metadata.assert_awaited_once_with( object_id, metadata_filter=ANY ) # Cause a UpnpConnectionError when next browsing dms_device_mock.async_browse_metadata.side_effect = UpnpConnectionError with pytest.raises( BrowseError, match="Server disconnected: UpnpConnectionError(.*)" ): await async_browse_media(hass, f":{object_id}") # Clear the error, but the device should be disconnected dms_device_mock.async_browse_metadata.side_effect = None with pytest.raises(BrowseError, match="DMS is not connected"): await async_browse_media(hass, f":{object_id}")
async def test_become_unavailable( hass: HomeAssistant, connected_source_mock: None, dms_device_mock: Mock, ) -> None: """Test a device becoming unavailable.""" # Mock a good resolve result dms_device_mock.async_browse_metadata.return_value = didl_lite.Item( id="object_id", restricted=False, title="Object", res=[didl_lite.Resource(uri="foo", protocol_info="http-get:*:audio/mpeg:")], ) # Check async_resolve_object currently works assert await media_source.async_resolve_media( hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id", None ) # Now break the network connection dms_device_mock.async_browse_metadata.side_effect = UpnpConnectionError # async_resolve_object should fail with pytest.raises(Unresolvable): await media_source.async_resolve_media( hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id", None ) # The device should now be unavailable await assert_source_unavailable(hass)
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 connected_source_mock( dms_device_mock: Mock, device_source_mock: None ) -> None: """Fixture to set up a mock DmsDeviceSource in a connected state.""" # Make async_browse_metadata work for assert_source_available didl_item = didl_lite.Item( id=DUMMY_OBJECT_ID, restricted=False, title="Object", res=[didl_lite.Resource(uri="foo/bar", protocol_info="http-get:*:audio/mpeg:")], ) dms_device_mock.async_browse_metadata.return_value = didl_item
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 disconnected_source_mock( hass: HomeAssistant, upnp_factory_mock: Mock, config_entry_mock: MockConfigEntry, ssdp_scanner_mock: Mock, dms_device_mock: Mock, ) -> AsyncIterable[None]: """Fixture to set up a mock DmsDeviceSource in a disconnected state.""" # Cause the connection attempt to fail upnp_factory_mock.async_create_device.side_effect = UpnpConnectionError config_entry_mock.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry_mock.entry_id) await hass.async_block_till_done() # Check the DmsDeviceSource has registered all needed listeners assert len(config_entry_mock.update_listeners) == 0 assert ssdp_scanner_mock.async_register_callback.await_count == 2 assert ssdp_scanner_mock.async_register_callback.return_value.call_count == 0 # Make async_browse_metadata work for assert_source_available when this # source is connected didl_item = didl_lite.Item( id=DUMMY_OBJECT_ID, restricted=False, title="Object", res=[ didl_lite.Resource(uri="foo/bar", protocol_info="http-get:*:audio/mpeg:") ], ) dms_device_mock.async_browse_metadata.return_value = didl_item # Run the test yield # Unload config entry to clean up assert await hass.config_entries.async_remove(config_entry_mock.entry_id ) == { "require_restart": False } # Check device source has cleaned up its resources assert not config_entry_mock.update_listeners assert (ssdp_scanner_mock.async_register_callback.await_count == ssdp_scanner_mock.async_register_callback.return_value.call_count)
async def test_resolve_media_object( device_source_mock: DmsDeviceSource, dms_device_mock: Mock ) -> None: """Test the async_resolve_object method via async_resolve_media.""" object_id: Final = "123" res_url: Final = "foo/bar" res_abs_url: Final = f"{MOCK_DEVICE_BASE_URL}/{res_url}" res_mime: Final = "audio/mpeg" # Success case: one resource didl_item = didl_lite.Item( id=object_id, restricted="false", title="Object", res=[didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:")], ) dms_device_mock.async_browse_metadata.return_value = didl_item result = await device_source_mock.async_resolve_media(f":{object_id}") dms_device_mock.async_browse_metadata.assert_awaited_once_with( object_id, metadata_filter="*" ) assert result.url == res_abs_url assert result.mime_type == res_mime assert result.didl_metadata is didl_item # Success case: two resources, first is playable didl_item = didl_lite.Item( id=object_id, restricted="false", title="Object", res=[ didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:"), didl_lite.Resource( uri="thumbnail.png", protocol_info="http-get:*:image/png:" ), ], ) dms_device_mock.async_browse_metadata.return_value = didl_item result = await device_source_mock.async_resolve_media(f":{object_id}") assert result.url == res_abs_url assert result.mime_type == res_mime assert result.didl_metadata is didl_item # Success case: three resources, only third is playable didl_item = didl_lite.Item( id=object_id, restricted="false", title="Object", res=[ didl_lite.Resource(uri="", protocol_info=""), didl_lite.Resource(uri="internal:thing", protocol_info="internal:*::"), didl_lite.Resource(uri=res_url, protocol_info=f"http-get:*:{res_mime}:"), ], ) dms_device_mock.async_browse_metadata.return_value = didl_item result = await device_source_mock.async_resolve_media(f":{object_id}") assert result.url == res_abs_url assert result.mime_type == res_mime assert result.didl_metadata is didl_item # Failure case: no resources didl_item = didl_lite.Item( id=object_id, restricted="false", title="Object", res=[], ) dms_device_mock.async_browse_metadata.return_value = didl_item with pytest.raises(Unresolvable, match="Object has no resources"): await device_source_mock.async_resolve_media(f":{object_id}") # Failure case: resources are not playable didl_item = didl_lite.Item( id=object_id, restricted="false", title="Object", res=[didl_lite.Resource(uri="internal:thing", protocol_info="internal:*::")], ) dms_device_mock.async_browse_metadata.return_value = didl_item with pytest.raises(Unresolvable, match="Object has no playable resources"): await device_source_mock.async_resolve_media(f":{object_id}")