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
    )
Example #3
0
    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()
Example #4
0
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"])
    ]
Example #7
0
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
    )
Example #8
0
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")
Example #13
0
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
Example #14
0
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
Example #16
0
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}"
Example #19
0
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)
Example #20
0
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")