From eebf3acb93507f8f706f8043d57fdfd09942a750 Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Thu, 5 May 2022 15:22:15 +1000 Subject: [PATCH] Relax dlna_dmr filtering when browsing media (#69576) * Fix incorrect types of test data structures * Loosen MIME-type filtering for async_browse_media * Add option to not filter results when browsing media Some devices do not report all that they support, and in this case filtering will hide media that's actually playable. Most devices are OK, though, and it's better to hide what they can't play. Add an option, off by default, to show all media. * Fix linting issues --- .../components/dlna_dmr/config_flow.py | 21 ++-- homeassistant/components/dlna_dmr/const.py | 1 + .../components/dlna_dmr/media_player.py | 39 +++++-- .../components/dlna_dmr/strings.json | 3 +- .../components/dlna_dmr/translations/en.json | 1 + tests/components/dlna_dmr/test_config_flow.py | 14 ++- .../components/dlna_dmr/test_media_player.py | 102 ++++++++++++++++++ 7 files changed, 157 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index ac7e3c83253..c7b37537e2b 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -21,6 +21,7 @@ from homeassistant.exceptions import IntegrationError import homeassistant.helpers.config_validation as cv from .const import ( + CONF_BROWSE_UNFILTERED, CONF_CALLBACK_URL_OVERRIDE, CONF_LISTEN_PORT, CONF_POLL_AVAILABILITY, @@ -328,6 +329,7 @@ class DlnaDmrOptionsFlowHandler(config_entries.OptionsFlow): options[CONF_LISTEN_PORT] = listen_port options[CONF_CALLBACK_URL_OVERRIDE] = callback_url_override options[CONF_POLL_AVAILABILITY] = user_input[CONF_POLL_AVAILABILITY] + options[CONF_BROWSE_UNFILTERED] = user_input[CONF_BROWSE_UNFILTERED] # Save if there's no errors, else fall through and show the form again if not errors: @@ -335,9 +337,14 @@ class DlnaDmrOptionsFlowHandler(config_entries.OptionsFlow): fields = {} - def _add_with_suggestion(key: str, validator: Callable) -> None: - """Add a field to with a suggested, not default, value.""" - if (suggested_value := options.get(key)) is None: + def _add_with_suggestion(key: str, validator: Callable | type[bool]) -> None: + """Add a field to with a suggested value. + + For bools, use the existing value as default, or fallback to False. + """ + if validator is bool: + fields[vol.Required(key, default=options.get(key, False))] = validator + elif (suggested_value := options.get(key)) is None: fields[vol.Optional(key)] = validator else: fields[ @@ -347,12 +354,8 @@ class DlnaDmrOptionsFlowHandler(config_entries.OptionsFlow): # listen_port can be blank or 0 for "bind any free port" _add_with_suggestion(CONF_LISTEN_PORT, cv.port) _add_with_suggestion(CONF_CALLBACK_URL_OVERRIDE, str) - fields[ - vol.Required( - CONF_POLL_AVAILABILITY, - default=options.get(CONF_POLL_AVAILABILITY, False), - ) - ] = bool + _add_with_suggestion(CONF_POLL_AVAILABILITY, bool) + _add_with_suggestion(CONF_BROWSE_UNFILTERED, bool) return self.async_show_form( step_id="init", diff --git a/homeassistant/components/dlna_dmr/const.py b/homeassistant/components/dlna_dmr/const.py index a4118a0ce78..4f9982061fb 100644 --- a/homeassistant/components/dlna_dmr/const.py +++ b/homeassistant/components/dlna_dmr/const.py @@ -16,6 +16,7 @@ DOMAIN: Final = "dlna_dmr" CONF_LISTEN_PORT: Final = "listen_port" CONF_CALLBACK_URL_OVERRIDE: Final = "callback_url_override" CONF_POLL_AVAILABILITY: Final = "poll_availability" +CONF_BROWSE_UNFILTERED: Final = "browse_unfiltered" DEFAULT_NAME: Final = "DLNA Digital Media Renderer" diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 9a7eef4bf89..fd1fc9b2bab 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -45,6 +45,7 @@ from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + CONF_BROWSE_UNFILTERED, CONF_CALLBACK_URL_OVERRIDE, CONF_LISTEN_PORT, CONF_POLL_AVAILABILITY, @@ -108,6 +109,7 @@ async def async_setup_entry( event_callback_url=entry.options.get(CONF_CALLBACK_URL_OVERRIDE), poll_availability=entry.options.get(CONF_POLL_AVAILABILITY, False), location=entry.data[CONF_URL], + browse_unfiltered=entry.options.get(CONF_BROWSE_UNFILTERED, False), ) async_add_entities([entity]) @@ -124,6 +126,8 @@ class DlnaDmrEntity(MediaPlayerEntity): # Last known URL for the device, used when adding this entity to hass to try # to connect before SSDP has rediscovered it, or when SSDP discovery fails. location: str + # Should the async_browse_media function *not* filter out incompatible media? + browse_unfiltered: bool _device_lock: asyncio.Lock # Held when connecting or disconnecting the device _device: DmrDevice | None = None @@ -146,6 +150,7 @@ class DlnaDmrEntity(MediaPlayerEntity): event_callback_url: str | None, poll_availability: bool, location: str, + browse_unfiltered: bool, ) -> None: """Initialize DLNA DMR entity.""" self.udn = udn @@ -154,6 +159,7 @@ class DlnaDmrEntity(MediaPlayerEntity): self._event_addr = EventListenAddr(None, event_port, event_callback_url) self.poll_availability = poll_availability self.location = location + self.browse_unfiltered = browse_unfiltered self._device_lock = asyncio.Lock() async def async_added_to_hass(self) -> None: @@ -275,6 +281,7 @@ class DlnaDmrEntity(MediaPlayerEntity): ) self.location = entry.data[CONF_URL] self.poll_availability = entry.options.get(CONF_POLL_AVAILABILITY, False) + self.browse_unfiltered = entry.options.get(CONF_BROWSE_UNFILTERED, False) new_port = entry.options.get(CONF_LISTEN_PORT) or 0 new_callback_url = entry.options.get(CONF_CALLBACK_URL_OVERRIDE) @@ -762,14 +769,21 @@ class DlnaDmrEntity(MediaPlayerEntity): # 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() + if self.browse_unfiltered: + content_filter = None + else: + 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.""" + """Return a function that filters media based on what the renderer can play. + + The filtering is pretty loose; it's better to show something that can't + be played than hide something that can. + """ 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") @@ -780,18 +794,25 @@ class DlnaDmrEntity(MediaPlayerEntity): # 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] = [] + # Convert list of things like "http-get:*:audio/mpeg;codecs=mp3:*" + # to just "audio/mpeg" + content_types = set[str]() for protocol_info in self._device.sink_protocol_info: protocol, _, content_format, _ = protocol_info.split(":", 3) + # Transform content_format for better generic matching + content_format = content_format.lower().replace("/x-", "/", 1) + content_format = content_format.partition(";")[0] + if protocol in STREAMABLE_PROTOCOLS: - content_types.append(content_format) + content_types.add(content_format) - def _content_type_filter(item: BrowseMedia) -> bool: - """Filter media items by their content_type.""" - return item.media_content_type in content_types + def _content_filter(item: BrowseMedia) -> bool: + """Filter media items by their media_content_type.""" + content_type = item.media_content_type + content_type = content_type.lower().replace("/x-", "/", 1).partition(";")[0] + return content_type in content_types - return _content_type_filter + return _content_filter @property def media_title(self) -> str | None: diff --git a/homeassistant/components/dlna_dmr/strings.json b/homeassistant/components/dlna_dmr/strings.json index ac77009e0cb..d646f20f7a1 100644 --- a/homeassistant/components/dlna_dmr/strings.json +++ b/homeassistant/components/dlna_dmr/strings.json @@ -44,7 +44,8 @@ "data": { "listen_port": "Event listener port (random if not set)", "callback_url_override": "Event listener callback URL", - "poll_availability": "Poll for device availability" + "poll_availability": "Poll for device availability", + "browse_unfiltered": "Show incompatible media when browsing" } } }, diff --git a/homeassistant/components/dlna_dmr/translations/en.json b/homeassistant/components/dlna_dmr/translations/en.json index 512dfe7f11c..51cbd875211 100644 --- a/homeassistant/components/dlna_dmr/translations/en.json +++ b/homeassistant/components/dlna_dmr/translations/en.json @@ -47,6 +47,7 @@ "step": { "init": { "data": { + "browse_unfiltered": "Show incompatible media when browsing", "callback_url_override": "Event listener callback URL", "listen_port": "Event listener port (random if not set)", "poll_availability": "Poll for device availability" diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index 6112c7c5ed5..7ec25906f99 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -11,6 +11,7 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import ssdp from homeassistant.components.dlna_dmr.const import ( + CONF_BROWSE_UNFILTERED, CONF_CALLBACK_URL_OVERRIDE, CONF_LISTEN_PORT, CONF_POLL_AVAILABILITY, @@ -74,7 +75,7 @@ MOCK_DISCOVERY = ssdp.SsdpServiceInfo( ] }, }, - x_homeassistant_matching_domains=(DLNA_DOMAIN,), + x_homeassistant_matching_domains={DLNA_DOMAIN}, ) @@ -390,7 +391,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: """Test SSDP ignores devices that are missing required services.""" # No service list at all discovery = dataclasses.replace(MOCK_DISCOVERY) - discovery.upnp = discovery.upnp.copy() + discovery.upnp = dict(discovery.upnp) del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, @@ -414,7 +415,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: # AVTransport service is missing discovery = dataclasses.replace(MOCK_DISCOVERY) - discovery.upnp = discovery.upnp.copy() + discovery.upnp = dict(discovery.upnp) discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = { "service": [ service @@ -465,7 +466,7 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: assert result["reason"] == "alternative_integration" discovery = dataclasses.replace(MOCK_DISCOVERY) - discovery.upnp = discovery.upnp.copy() + discovery.upnp = dict(discovery.upnp) discovery.upnp[ ssdp.ATTR_UPNP_DEVICE_TYPE ] = "urn:schemas-upnp-org:device:ZonePlayer:1" @@ -484,7 +485,7 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: ("Royal Philips Electronics", "Philips TV DMR"), ]: discovery = dataclasses.replace(MOCK_DISCOVERY) - discovery.upnp = discovery.upnp.copy() + discovery.upnp = dict(discovery.upnp) discovery.upnp[ssdp.ATTR_UPNP_MANUFACTURER] = manufacturer discovery.upnp[ssdp.ATTR_UPNP_MODEL_NAME] = model result = await hass.config_entries.flow.async_init( @@ -592,6 +593,7 @@ async def test_options_flow( user_input={ CONF_CALLBACK_URL_OVERRIDE: "Bad url", CONF_POLL_AVAILABILITY: False, + CONF_BROWSE_UNFILTERED: False, }, ) @@ -606,6 +608,7 @@ async def test_options_flow( CONF_LISTEN_PORT: 2222, CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", CONF_POLL_AVAILABILITY: True, + CONF_BROWSE_UNFILTERED: True, }, ) @@ -614,4 +617,5 @@ async def test_options_flow( CONF_LISTEN_PORT: 2222, CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", CONF_POLL_AVAILABILITY: True, + CONF_BROWSE_UNFILTERED: True, } diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index bed89f3db9d..eef5d936396 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -23,6 +23,7 @@ from homeassistant import const as ha_const from homeassistant.components import ssdp from homeassistant.components.dlna_dmr import media_player from homeassistant.components.dlna_dmr.const import ( + CONF_BROWSE_UNFILTERED, CONF_CALLBACK_URL_OVERRIDE, CONF_LISTEN_PORT, CONF_POLL_AVAILABILITY, @@ -997,6 +998,26 @@ async def test_browse_media( # Audio file should appear assert expected_child_audio in response["result"]["children"] + # Device specifies extra parameters in MIME type, uses non-standard "x-" + # prefix, and capitilizes things, all of which should be ignored + dmr_device_mock.sink_protocol_info = [ + "http-get:*:audio/X-MPEG;codecs=mp3:*", + ] + 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() @@ -1014,6 +1035,87 @@ async def test_browse_media( assert expected_child_audio in response["result"]["children"] +async def test_browse_media_unfiltered( + hass: HomeAssistant, + hass_ws_client, + config_entry_mock: MockConfigEntry, + dmr_device_mock: Mock, + mock_entity_id: str, +) -> None: + """Test the async_browse_media method with filtering turned off and on.""" + # Based on cast's test_entity_browse_media + await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}}) + await hass.async_block_till_done() + + 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, + "thumbnail": None, + "children_media_class": None, + } + 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, + "thumbnail": None, + "children_media_class": None, + } + + # 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:*", + ] + + # Filtering turned on by default + assert CONF_BROWSE_UNFILTERED not in config_entry_mock.options + + 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"] + + # Filtering turned off via config entry + hass.config_entries.async_update_entry( + config_entry_mock, + options={ + CONF_BROWSE_UNFILTERED: True, + }, + ) + await hass.async_block_till_done() + + 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: