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:
parent
db08c04da6
commit
eebf3acb93
7 changed files with 157 additions and 24 deletions
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Reference in a new issue