Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions homeassistant/components/dlna_dmr/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import homeassistant.helpers.config_validation as cv

from .const import (
CONF_BROWSE_UNFILTERED,
CONF_CALLBACK_URL_OVERRIDE,
CONF_LISTEN_PORT,
CONF_POLL_AVAILABILITY,
Expand Down Expand Up @@ -322,16 +323,22 @@ async def async_step_init(
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:
return self.async_create_entry(title="", data=options)

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[
Expand All @@ -341,12 +348,8 @@ def _add_with_suggestion(key: str, validator: Callable) -> None:
# 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",
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/dlna_dmr/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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"

Expand Down
39 changes: 30 additions & 9 deletions homeassistant/components/dlna_dmr/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .const import (
CONF_BROWSE_UNFILTERED,
CONF_CALLBACK_URL_OVERRIDE,
CONF_LISTEN_PORT,
CONF_POLL_AVAILABILITY,
Expand Down Expand Up @@ -106,6 +107,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])
Expand All @@ -122,6 +124,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
Expand All @@ -144,6 +148,7 @@ def __init__(
event_callback_url: str | None,
poll_availability: bool,
location: str,
browse_unfiltered: bool,
) -> None:
"""Initialize DLNA DMR entity."""
self.udn = udn
Expand All @@ -152,6 +157,7 @@ def __init__(
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:
Expand Down Expand Up @@ -273,6 +279,7 @@ async def async_config_update_listener(
)
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)
Expand Down Expand Up @@ -760,14 +767,21 @@ async def async_browse_media(
# 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")
Expand All @@ -778,18 +792,25 @@ def _get_content_filter(self) -> Callable[[BrowseMedia], bool]:
# 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:
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/dlna_dmr/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
},
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/dlna_dmr/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 9 additions & 5 deletions tests/components/dlna_dmr/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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,
Expand Down Expand Up @@ -74,7 +75,7 @@
]
},
},
x_homeassistant_matching_domains=(DLNA_DOMAIN,),
x_homeassistant_matching_domains={DLNA_DOMAIN},
)


Expand Down Expand Up @@ -390,7 +391,7 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None:
"""Test SSDP ignores devices that are missing required services."""
# No services defined 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,
Expand All @@ -402,7 +403,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
Expand Down Expand Up @@ -431,7 +432,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"
Expand All @@ -450,7 +451,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(
Expand Down Expand Up @@ -558,6 +559,7 @@ async def test_options_flow(
user_input={
CONF_CALLBACK_URL_OVERRIDE: "Bad url",
CONF_POLL_AVAILABILITY: False,
CONF_BROWSE_UNFILTERED: False,
},
)

Expand All @@ -572,6 +574,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,
},
)

Expand All @@ -580,4 +583,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,
}
102 changes: 102 additions & 0 deletions tests/components/dlna_dmr/test_media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
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,
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand Down