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
This commit is contained in:
Michael Chisholm 2022-05-05 15:22:15 +10:00 committed by GitHub
parent db08c04da6
commit eebf3acb93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 157 additions and 24 deletions

View File

@ -21,6 +21,7 @@ from homeassistant.exceptions import IntegrationError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from .const import ( from .const import (
CONF_BROWSE_UNFILTERED,
CONF_CALLBACK_URL_OVERRIDE, CONF_CALLBACK_URL_OVERRIDE,
CONF_LISTEN_PORT, CONF_LISTEN_PORT,
CONF_POLL_AVAILABILITY, CONF_POLL_AVAILABILITY,
@ -328,6 +329,7 @@ class DlnaDmrOptionsFlowHandler(config_entries.OptionsFlow):
options[CONF_LISTEN_PORT] = listen_port options[CONF_LISTEN_PORT] = listen_port
options[CONF_CALLBACK_URL_OVERRIDE] = callback_url_override options[CONF_CALLBACK_URL_OVERRIDE] = callback_url_override
options[CONF_POLL_AVAILABILITY] = user_input[CONF_POLL_AVAILABILITY] 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 # Save if there's no errors, else fall through and show the form again
if not errors: if not errors:
@ -335,9 +337,14 @@ class DlnaDmrOptionsFlowHandler(config_entries.OptionsFlow):
fields = {} fields = {}
def _add_with_suggestion(key: str, validator: Callable) -> None: def _add_with_suggestion(key: str, validator: Callable | type[bool]) -> None:
"""Add a field to with a suggested, not default, value.""" """Add a field to with a suggested value.
if (suggested_value := options.get(key)) is None:
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 fields[vol.Optional(key)] = validator
else: else:
fields[ fields[
@ -347,12 +354,8 @@ class DlnaDmrOptionsFlowHandler(config_entries.OptionsFlow):
# listen_port can be blank or 0 for "bind any free port" # listen_port can be blank or 0 for "bind any free port"
_add_with_suggestion(CONF_LISTEN_PORT, cv.port) _add_with_suggestion(CONF_LISTEN_PORT, cv.port)
_add_with_suggestion(CONF_CALLBACK_URL_OVERRIDE, str) _add_with_suggestion(CONF_CALLBACK_URL_OVERRIDE, str)
fields[ _add_with_suggestion(CONF_POLL_AVAILABILITY, bool)
vol.Required( _add_with_suggestion(CONF_BROWSE_UNFILTERED, bool)
CONF_POLL_AVAILABILITY,
default=options.get(CONF_POLL_AVAILABILITY, False),
)
] = bool
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",

View File

@ -16,6 +16,7 @@ DOMAIN: Final = "dlna_dmr"
CONF_LISTEN_PORT: Final = "listen_port" CONF_LISTEN_PORT: Final = "listen_port"
CONF_CALLBACK_URL_OVERRIDE: Final = "callback_url_override" CONF_CALLBACK_URL_OVERRIDE: Final = "callback_url_override"
CONF_POLL_AVAILABILITY: Final = "poll_availability" CONF_POLL_AVAILABILITY: Final = "poll_availability"
CONF_BROWSE_UNFILTERED: Final = "browse_unfiltered"
DEFAULT_NAME: Final = "DLNA Digital Media Renderer" DEFAULT_NAME: Final = "DLNA Digital Media Renderer"

View File

@ -45,6 +45,7 @@ from homeassistant.helpers import device_registry, entity_registry
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import (
CONF_BROWSE_UNFILTERED,
CONF_CALLBACK_URL_OVERRIDE, CONF_CALLBACK_URL_OVERRIDE,
CONF_LISTEN_PORT, CONF_LISTEN_PORT,
CONF_POLL_AVAILABILITY, CONF_POLL_AVAILABILITY,
@ -108,6 +109,7 @@ async def async_setup_entry(
event_callback_url=entry.options.get(CONF_CALLBACK_URL_OVERRIDE), event_callback_url=entry.options.get(CONF_CALLBACK_URL_OVERRIDE),
poll_availability=entry.options.get(CONF_POLL_AVAILABILITY, False), poll_availability=entry.options.get(CONF_POLL_AVAILABILITY, False),
location=entry.data[CONF_URL], location=entry.data[CONF_URL],
browse_unfiltered=entry.options.get(CONF_BROWSE_UNFILTERED, False),
) )
async_add_entities([entity]) 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 # 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. # to connect before SSDP has rediscovered it, or when SSDP discovery fails.
location: str 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_lock: asyncio.Lock # Held when connecting or disconnecting the device
_device: DmrDevice | None = None _device: DmrDevice | None = None
@ -146,6 +150,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
event_callback_url: str | None, event_callback_url: str | None,
poll_availability: bool, poll_availability: bool,
location: str, location: str,
browse_unfiltered: bool,
) -> None: ) -> None:
"""Initialize DLNA DMR entity.""" """Initialize DLNA DMR entity."""
self.udn = udn self.udn = udn
@ -154,6 +159,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
self._event_addr = EventListenAddr(None, event_port, event_callback_url) self._event_addr = EventListenAddr(None, event_port, event_callback_url)
self.poll_availability = poll_availability self.poll_availability = poll_availability
self.location = location self.location = location
self.browse_unfiltered = browse_unfiltered
self._device_lock = asyncio.Lock() self._device_lock = asyncio.Lock()
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
@ -275,6 +281,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
) )
self.location = entry.data[CONF_URL] self.location = entry.data[CONF_URL]
self.poll_availability = entry.options.get(CONF_POLL_AVAILABILITY, False) 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_port = entry.options.get(CONF_LISTEN_PORT) or 0
new_callback_url = entry.options.get(CONF_CALLBACK_URL_OVERRIDE) 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_type is ignored; it's the content_type of the current
# media_content_id, not the desired content_type of whomever is calling. # 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( return await media_source.async_browse_media(
self.hass, media_content_id, content_filter=content_filter self.hass, media_content_id, content_filter=content_filter
) )
def _get_content_filter(self) -> Callable[[BrowseMedia], bool]: 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: if not self._device or not self._device.sink_protocol_info:
# Nothing is specified by the renderer, so show everything # Nothing is specified by the renderer, so show everything
_LOGGER.debug("Get content filter with no device or sink protocol info") _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 # Renderer claims it can handle everything, so show everything
return lambda _: True return lambda _: True
# Convert list of things like "http-get:*:audio/mpeg:*" to just "audio/mpeg" # Convert list of things like "http-get:*:audio/mpeg;codecs=mp3:*"
content_types: list[str] = [] # to just "audio/mpeg"
content_types = set[str]()
for protocol_info in self._device.sink_protocol_info: for protocol_info in self._device.sink_protocol_info:
protocol, _, content_format, _ = protocol_info.split(":", 3) 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: if protocol in STREAMABLE_PROTOCOLS:
content_types.append(content_format) content_types.add(content_format)
def _content_type_filter(item: BrowseMedia) -> bool: def _content_filter(item: BrowseMedia) -> bool:
"""Filter media items by their content_type.""" """Filter media items by their media_content_type."""
return item.media_content_type in content_types 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 @property
def media_title(self) -> str | None: def media_title(self) -> str | None:

View File

@ -44,7 +44,8 @@
"data": { "data": {
"listen_port": "Event listener port (random if not set)", "listen_port": "Event listener port (random if not set)",
"callback_url_override": "Event listener callback URL", "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"
} }
} }
}, },

View File

@ -47,6 +47,7 @@
"step": { "step": {
"init": { "init": {
"data": { "data": {
"browse_unfiltered": "Show incompatible media when browsing",
"callback_url_override": "Event listener callback URL", "callback_url_override": "Event listener callback URL",
"listen_port": "Event listener port (random if not set)", "listen_port": "Event listener port (random if not set)",
"poll_availability": "Poll for device availability" "poll_availability": "Poll for device availability"

View File

@ -11,6 +11,7 @@ import pytest
from homeassistant import config_entries, data_entry_flow from homeassistant import config_entries, data_entry_flow
from homeassistant.components import ssdp from homeassistant.components import ssdp
from homeassistant.components.dlna_dmr.const import ( from homeassistant.components.dlna_dmr.const import (
CONF_BROWSE_UNFILTERED,
CONF_CALLBACK_URL_OVERRIDE, CONF_CALLBACK_URL_OVERRIDE,
CONF_LISTEN_PORT, CONF_LISTEN_PORT,
CONF_POLL_AVAILABILITY, 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.""" """Test SSDP ignores devices that are missing required services."""
# No service list at all # No service list at all
discovery = dataclasses.replace(MOCK_DISCOVERY) discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = discovery.upnp.copy() discovery.upnp = dict(discovery.upnp)
del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DLNA_DOMAIN, DLNA_DOMAIN,
@ -414,7 +415,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None:
# AVTransport service is missing # AVTransport service is missing
discovery = dataclasses.replace(MOCK_DISCOVERY) discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = discovery.upnp.copy() discovery.upnp = dict(discovery.upnp)
discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = { discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {
"service": [ "service": [
service service
@ -465,7 +466,7 @@ async def test_ssdp_ignore_device(hass: HomeAssistant) -> None:
assert result["reason"] == "alternative_integration" assert result["reason"] == "alternative_integration"
discovery = dataclasses.replace(MOCK_DISCOVERY) discovery = dataclasses.replace(MOCK_DISCOVERY)
discovery.upnp = discovery.upnp.copy() discovery.upnp = dict(discovery.upnp)
discovery.upnp[ discovery.upnp[
ssdp.ATTR_UPNP_DEVICE_TYPE ssdp.ATTR_UPNP_DEVICE_TYPE
] = "urn:schemas-upnp-org:device:ZonePlayer:1" ] = "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"), ("Royal Philips Electronics", "Philips TV DMR"),
]: ]:
discovery = dataclasses.replace(MOCK_DISCOVERY) 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_MANUFACTURER] = manufacturer
discovery.upnp[ssdp.ATTR_UPNP_MODEL_NAME] = model discovery.upnp[ssdp.ATTR_UPNP_MODEL_NAME] = model
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -592,6 +593,7 @@ async def test_options_flow(
user_input={ user_input={
CONF_CALLBACK_URL_OVERRIDE: "Bad url", CONF_CALLBACK_URL_OVERRIDE: "Bad url",
CONF_POLL_AVAILABILITY: False, CONF_POLL_AVAILABILITY: False,
CONF_BROWSE_UNFILTERED: False,
}, },
) )
@ -606,6 +608,7 @@ async def test_options_flow(
CONF_LISTEN_PORT: 2222, CONF_LISTEN_PORT: 2222,
CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", CONF_CALLBACK_URL_OVERRIDE: "http://override/callback",
CONF_POLL_AVAILABILITY: True, CONF_POLL_AVAILABILITY: True,
CONF_BROWSE_UNFILTERED: True,
}, },
) )
@ -614,4 +617,5 @@ async def test_options_flow(
CONF_LISTEN_PORT: 2222, CONF_LISTEN_PORT: 2222,
CONF_CALLBACK_URL_OVERRIDE: "http://override/callback", CONF_CALLBACK_URL_OVERRIDE: "http://override/callback",
CONF_POLL_AVAILABILITY: True, CONF_POLL_AVAILABILITY: True,
CONF_BROWSE_UNFILTERED: True,
} }

View File

@ -23,6 +23,7 @@ from homeassistant import const as ha_const
from homeassistant.components import ssdp from homeassistant.components import ssdp
from homeassistant.components.dlna_dmr import media_player from homeassistant.components.dlna_dmr import media_player
from homeassistant.components.dlna_dmr.const import ( from homeassistant.components.dlna_dmr.const import (
CONF_BROWSE_UNFILTERED,
CONF_CALLBACK_URL_OVERRIDE, CONF_CALLBACK_URL_OVERRIDE,
CONF_LISTEN_PORT, CONF_LISTEN_PORT,
CONF_POLL_AVAILABILITY, CONF_POLL_AVAILABILITY,
@ -997,6 +998,26 @@ async def test_browse_media(
# Audio file should appear # Audio file should appear
assert expected_child_audio in response["result"]["children"] 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 # Device does not specify what it can play
dmr_device_mock.sink_protocol_info = [] dmr_device_mock.sink_protocol_info = []
client = await hass_ws_client() client = await hass_ws_client()
@ -1014,6 +1035,87 @@ async def test_browse_media(
assert expected_child_audio in response["result"]["children"] 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( async def test_playback_update_state(
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
) -> None: ) -> None: