From 3c15fe85873b03f4c1b4167493bc5f7e7766f170 Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Sun, 20 Feb 2022 16:07:38 +1100 Subject: [PATCH] Add media browser support to dlna_dmr (#66425) --- homeassistant/components/dlna_dmr/const.py | 5 + .../components/dlna_dmr/manifest.json | 1 + .../components/dlna_dmr/media_player.py | 102 +++++++++-- .../components/dlna_dmr/test_media_player.py | 171 +++++++++++++++++- 4 files changed, 265 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/dlna_dmr/const.py b/homeassistant/components/dlna_dmr/const.py index 20a978f9fda..a4118a0ce78 100644 --- a/homeassistant/components/dlna_dmr/const.py +++ b/homeassistant/components/dlna_dmr/const.py @@ -21,6 +21,11 @@ DEFAULT_NAME: Final = "DLNA Digital Media Renderer" CONNECT_TIMEOUT: Final = 10 +PROTOCOL_HTTP: Final = "http-get" +PROTOCOL_RTSP: Final = "rtsp-rtp-udp" +PROTOCOL_ANY: Final = "*" +STREAMABLE_PROTOCOLS: Final = [PROTOCOL_HTTP, PROTOCOL_RTSP, PROTOCOL_ANY] + # Map UPnP class to media_player media_content_type MEDIA_TYPE_MAP: Mapping[str, str] = { "object": _mp_const.MEDIA_TYPE_URL, diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 885b7e3c65a..4001fc9dddc 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -5,6 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "requirements": ["async-upnp-client==0.23.5"], "dependencies": ["ssdp"], + "after_dependencies": ["media_source"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index fd89c5be2d0..265c6e9dde6 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -13,16 +13,22 @@ from async_upnp_client.const import NotificationSubType from async_upnp_client.exceptions import UpnpError, UpnpResponseError from async_upnp_client.profiles.dlna import DmrDevice, PlayMode, TransportState from async_upnp_client.utils import async_get_local_ip +from didl_lite import didl_lite from typing_extensions import Concatenate, ParamSpec from homeassistant import config_entries -from homeassistant.components import ssdp -from homeassistant.components.media_player import MediaPlayerEntity +from homeassistant.components import media_source, ssdp +from homeassistant.components.media_player import ( + BrowseMedia, + MediaPlayerEntity, + async_process_play_media_url, +) from homeassistant.components.media_player.const import ( ATTR_MEDIA_EXTRA, REPEAT_MODE_ALL, REPEAT_MODE_OFF, REPEAT_MODE_ONE, + SUPPORT_BROWSE_MEDIA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -61,6 +67,7 @@ from .const import ( MEDIA_UPNP_CLASS_MAP, REPEAT_PLAY_MODES, SHUFFLE_PLAY_MODES, + STREAMABLE_PROTOCOLS, ) from .data import EventListenAddr, get_domain_data @@ -512,7 +519,7 @@ class DlnaDmrEntity(MediaPlayerEntity): if self._device.can_next: supported_features |= SUPPORT_NEXT_TRACK if self._device.has_play_media: - supported_features |= SUPPORT_PLAY_MEDIA + supported_features |= SUPPORT_PLAY_MEDIA | SUPPORT_BROWSE_MEDIA if self._device.can_seek_rel_time: supported_features |= SUPPORT_SEEK @@ -586,10 +593,30 @@ class DlnaDmrEntity(MediaPlayerEntity): """Play a piece of media.""" _LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs) assert self._device is not None + + didl_metadata: str | None = None + title: str = "" + + # If media is media_source, resolve it to url and MIME type, and maybe metadata + if media_source.is_media_source_id(media_id): + sourced_media = await media_source.async_resolve_media(self.hass, media_id) + media_type = sourced_media.mime_type + media_id = sourced_media.url + _LOGGER.debug("sourced_media is %s", sourced_media) + if sourced_metadata := getattr(sourced_media, "didl_metadata", None): + didl_metadata = didl_lite.to_xml_string(sourced_metadata).decode( + "utf-8" + ) + title = sourced_metadata.title + + # If media ID is a relative URL, we serve it from HA. + media_id = async_process_play_media_url(self.hass, media_id) + extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {} metadata: dict[str, Any] = extra.get("metadata") or {} - title = extra.get("title") or metadata.get("title") or "Home Assistant" + if not title: + title = extra.get("title") or metadata.get("title") or "Home Assistant" if thumb := extra.get("thumb"): metadata["album_art_uri"] = thumb @@ -598,15 +625,16 @@ class DlnaDmrEntity(MediaPlayerEntity): if hass_key in metadata: metadata[didl_key] = metadata.pop(hass_key) - # Create metadata specific to the given media type; different fields are - # available depending on what the upnp_class is. - upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type) - didl_metadata = await self._device.construct_play_media_metadata( - media_url=media_id, - media_title=title, - override_upnp_class=upnp_class, - meta_data=metadata, - ) + if not didl_metadata: + # Create metadata specific to the given media type; different fields are + # available depending on what the upnp_class is. + upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type) + didl_metadata = await self._device.construct_play_media_metadata( + media_url=media_id, + media_title=title, + override_upnp_class=upnp_class, + meta_data=metadata, + ) # Stop current playing media if self._device.can_stop: @@ -726,6 +754,54 @@ class DlnaDmrEntity(MediaPlayerEntity): assert self._device is not None await self._device.async_select_preset(sound_mode) + async def async_browse_media( + self, + media_content_type: str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """Implement the websocket media browsing helper. + + Browses all available media_sources by default. Filters content_type + based on the DMR's sink_protocol_info. + """ + _LOGGER.debug( + "async_browse_media(%s, %s)", media_content_type, media_content_id + ) + + # media_content_type is ignored; it's the content_type of the current + # media_content_id, not the desired content_type of whomever is calling. + + content_filter = self._get_content_filter() + + return await media_source.async_browse_media( + self.hass, media_content_id, content_filter=content_filter + ) + + def _get_content_filter(self) -> Callable[[BrowseMedia], bool]: + """Return a function that filters media based on what the renderer can play.""" + if not self._device or not self._device.sink_protocol_info: + # Nothing is specified by the renderer, so show everything + _LOGGER.debug("Get content filter with no device or sink protocol info") + return lambda _: True + + _LOGGER.debug("Get content filter for %s", self._device.sink_protocol_info) + if self._device.sink_protocol_info[0] == "*": + # Renderer claims it can handle everything, so show everything + return lambda _: True + + # Convert list of things like "http-get:*:audio/mpeg:*" to just "audio/mpeg" + content_types: list[str] = [] + for protocol_info in self._device.sink_protocol_info: + protocol, _, content_format, _ = protocol_info.split(":", 3) + if protocol in STREAMABLE_PROTOCOLS: + content_types.append(content_format) + + def _content_type_filter(item: BrowseMedia) -> bool: + """Filter media items by their content_type.""" + return item.media_content_type in content_types + + return _content_type_filter + @property def media_title(self) -> str | None: """Title of current playing media.""" diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index 3cb4b2a726a..0abda9e1ed3 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterable, Mapping +from dataclasses import dataclass from datetime import timedelta from types import MappingProxyType from typing import Any @@ -15,6 +16,7 @@ from async_upnp_client.exceptions import ( UpnpResponseError, ) from async_upnp_client.profiles.dlna import PlayMode, TransportState +from didl_lite import didl_lite import pytest from homeassistant import const as ha_const @@ -29,6 +31,8 @@ from homeassistant.components.dlna_dmr.const import ( from homeassistant.components.dlna_dmr.data import EventListenAddr from homeassistant.components.media_player import ATTR_TO_PROPERTY, const as mp_const from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN +from homeassistant.components.media_source.const import DOMAIN as MS_DOMAIN +from homeassistant.components.media_source.models import PlayMedia from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import async_get as async_get_dr @@ -418,7 +422,7 @@ async def test_feature_flags( ("can_stop", mp_const.SUPPORT_STOP), ("can_previous", mp_const.SUPPORT_PREVIOUS_TRACK), ("can_next", mp_const.SUPPORT_NEXT_TRACK), - ("has_play_media", mp_const.SUPPORT_PLAY_MEDIA), + ("has_play_media", mp_const.SUPPORT_PLAY_MEDIA | mp_const.SUPPORT_BROWSE_MEDIA), ("can_seek_rel_time", mp_const.SUPPORT_SEEK), ("has_presets", mp_const.SUPPORT_SELECT_SOUND_MODE), ] @@ -760,6 +764,89 @@ async def test_play_media_metadata( ) +async def test_play_media_local_source( + hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str +) -> None: + """Test play_media with a media_id from a local media_source.""" + # Based on roku's test_services_play_media_local_source and cast's + # test_entity_browse_media + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + await hass.async_block_till_done() + + await hass.services.async_call( + MP_DOMAIN, + mp_const.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: mock_entity_id, + mp_const.ATTR_MEDIA_CONTENT_TYPE: "video/mp4", + mp_const.ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4", + }, + blocking=True, + ) + + assert dmr_device_mock.construct_play_media_metadata.await_count == 1 + assert ( + "/media/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" + in dmr_device_mock.construct_play_media_metadata.call_args.kwargs["media_url"] + ) + assert dmr_device_mock.async_set_transport_uri.await_count == 1 + assert dmr_device_mock.async_play.await_count == 1 + call_args = dmr_device_mock.async_set_transport_uri.call_args.args + assert "/media/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[0] + + +async def test_play_media_didl_metadata( + hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str +) -> None: + """Test play_media passes available DIDL-Lite metadata to the DMR.""" + + @dataclass + class DidlPlayMedia(PlayMedia): + """Playable media with DIDL metadata.""" + + didl_metadata: didl_lite.DidlObject + + didl_metadata = didl_lite.VideoItem( + id="120$22$33", + restricted="false", + title="Epic Sax Guy 10 Hours", + res=[ + didl_lite.Resource(uri="unused-URI", protocol_info="http-get:*:video/mp4:") + ], + ) + + play_media = DidlPlayMedia( + url="/media/local/Epic Sax Guy 10 Hours.mp4", + mime_type="video/mp4", + didl_metadata=didl_metadata, + ) + + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + await hass.async_block_till_done() + local_source = hass.data[MS_DOMAIN][MS_DOMAIN] + + with patch.object(local_source, "async_resolve_media", return_value=play_media): + + await hass.services.async_call( + MP_DOMAIN, + mp_const.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: mock_entity_id, + mp_const.ATTR_MEDIA_CONTENT_TYPE: "video/mp4", + mp_const.ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4", + }, + blocking=True, + ) + + assert dmr_device_mock.construct_play_media_metadata.await_count == 0 + assert dmr_device_mock.async_set_transport_uri.await_count == 1 + assert dmr_device_mock.async_play.await_count == 1 + call_args = dmr_device_mock.async_set_transport_uri.call_args.args + assert "/media/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[0] + assert call_args[1] == "Epic Sax Guy 10 Hours" + assert call_args[2] == didl_lite.to_xml_string(didl_metadata).decode() + + async def test_shuffle_repeat_modes( hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str ) -> None: @@ -844,6 +931,88 @@ async def test_shuffle_repeat_modes( dmr_device_mock.async_set_play_mode.assert_not_awaited() +async def test_browse_media( + hass: HomeAssistant, hass_ws_client, dmr_device_mock: Mock, mock_entity_id: str +) -> None: + """Test the async_browse_media method.""" + # Based on cast's test_entity_browse_media + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + await hass.async_block_till_done() + + # DMR can play all media types + dmr_device_mock.sink_protocol_info = ["*"] + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": mock_entity_id, + } + ) + response = await client.receive_json() + assert response["success"] + expected_child_video = { + "title": "Epic Sax Guy 10 Hours.mp4", + "media_class": "video", + "media_content_type": "video/mp4", + "media_content_id": "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4", + "can_play": True, + "can_expand": False, + "children_media_class": None, + "thumbnail": None, + } + assert expected_child_video in response["result"]["children"] + + expected_child_audio = { + "title": "test.mp3", + "media_class": "music", + "media_content_type": "audio/mpeg", + "media_content_id": "media-source://media_source/local/test.mp3", + "can_play": True, + "can_expand": False, + "children_media_class": None, + "thumbnail": None, + } + assert expected_child_audio in response["result"]["children"] + + # Device can only play MIME type audio/mpeg and audio/vorbis + dmr_device_mock.sink_protocol_info = [ + "http-get:*:audio/mpeg:*", + "http-get:*:audio/vorbis:*", + ] + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": mock_entity_id, + } + ) + response = await client.receive_json() + assert response["success"] + # Video file should not be shown + assert expected_child_video not in response["result"]["children"] + # Audio file should appear + assert expected_child_audio in response["result"]["children"] + + # Device does not specify what it can play + dmr_device_mock.sink_protocol_info = [] + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": mock_entity_id, + } + ) + response = await client.receive_json() + assert response["success"] + # All files should be returned + assert expected_child_video in response["result"]["children"] + assert expected_child_audio in response["result"]["children"] + + async def test_playback_update_state( hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str ) -> None: