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
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",

View file

@ -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"

View file

@ -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:

View file

@ -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"
}
}
},

View file

@ -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"

View file

@ -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,
}

View file

@ -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: