Add media browser support to dlna_dmr (#66425)

This commit is contained in:
Michael Chisholm 2022-02-20 16:07:38 +11:00 committed by GitHub
parent 6a7872fc1b
commit 3c15fe8587
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 265 additions and 14 deletions

View file

@ -21,6 +21,11 @@ DEFAULT_NAME: Final = "DLNA Digital Media Renderer"
CONNECT_TIMEOUT: Final = 10 CONNECT_TIMEOUT: Final = 10
PROTOCOL_HTTP: Final = "http-get"
PROTOCOL_RTSP: Final = "rtsp-rtp-udp"
PROTOCOL_ANY: Final = "*"
STREAMABLE_PROTOCOLS: Final = [PROTOCOL_HTTP, PROTOCOL_RTSP, PROTOCOL_ANY]
# Map UPnP class to media_player media_content_type # Map UPnP class to media_player media_content_type
MEDIA_TYPE_MAP: Mapping[str, str] = { MEDIA_TYPE_MAP: Mapping[str, str] = {
"object": _mp_const.MEDIA_TYPE_URL, "object": _mp_const.MEDIA_TYPE_URL,

View file

@ -5,6 +5,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"requirements": ["async-upnp-client==0.23.5"], "requirements": ["async-upnp-client==0.23.5"],
"dependencies": ["ssdp"], "dependencies": ["ssdp"],
"after_dependencies": ["media_source"],
"ssdp": [ "ssdp": [
{ {
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View file

@ -13,16 +13,22 @@ from async_upnp_client.const import NotificationSubType
from async_upnp_client.exceptions import UpnpError, UpnpResponseError from async_upnp_client.exceptions import UpnpError, UpnpResponseError
from async_upnp_client.profiles.dlna import DmrDevice, PlayMode, TransportState from async_upnp_client.profiles.dlna import DmrDevice, PlayMode, TransportState
from async_upnp_client.utils import async_get_local_ip from async_upnp_client.utils import async_get_local_ip
from didl_lite import didl_lite
from typing_extensions import Concatenate, ParamSpec from typing_extensions import Concatenate, ParamSpec
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import ssdp from homeassistant.components import media_source, ssdp
from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerEntity,
async_process_play_media_url,
)
from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.const import (
ATTR_MEDIA_EXTRA, ATTR_MEDIA_EXTRA,
REPEAT_MODE_ALL, REPEAT_MODE_ALL,
REPEAT_MODE_OFF, REPEAT_MODE_OFF,
REPEAT_MODE_ONE, REPEAT_MODE_ONE,
SUPPORT_BROWSE_MEDIA,
SUPPORT_NEXT_TRACK, SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE, SUPPORT_PAUSE,
SUPPORT_PLAY, SUPPORT_PLAY,
@ -61,6 +67,7 @@ from .const import (
MEDIA_UPNP_CLASS_MAP, MEDIA_UPNP_CLASS_MAP,
REPEAT_PLAY_MODES, REPEAT_PLAY_MODES,
SHUFFLE_PLAY_MODES, SHUFFLE_PLAY_MODES,
STREAMABLE_PROTOCOLS,
) )
from .data import EventListenAddr, get_domain_data from .data import EventListenAddr, get_domain_data
@ -512,7 +519,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
if self._device.can_next: if self._device.can_next:
supported_features |= SUPPORT_NEXT_TRACK supported_features |= SUPPORT_NEXT_TRACK
if self._device.has_play_media: if self._device.has_play_media:
supported_features |= SUPPORT_PLAY_MEDIA supported_features |= SUPPORT_PLAY_MEDIA | SUPPORT_BROWSE_MEDIA
if self._device.can_seek_rel_time: if self._device.can_seek_rel_time:
supported_features |= SUPPORT_SEEK supported_features |= SUPPORT_SEEK
@ -586,9 +593,29 @@ class DlnaDmrEntity(MediaPlayerEntity):
"""Play a piece of media.""" """Play a piece of media."""
_LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs) _LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs)
assert self._device is not None assert self._device is not None
didl_metadata: str | None = None
title: str = ""
# If media is media_source, resolve it to url and MIME type, and maybe metadata
if media_source.is_media_source_id(media_id):
sourced_media = await media_source.async_resolve_media(self.hass, media_id)
media_type = sourced_media.mime_type
media_id = sourced_media.url
_LOGGER.debug("sourced_media is %s", sourced_media)
if sourced_metadata := getattr(sourced_media, "didl_metadata", None):
didl_metadata = didl_lite.to_xml_string(sourced_metadata).decode(
"utf-8"
)
title = sourced_metadata.title
# If media ID is a relative URL, we serve it from HA.
media_id = async_process_play_media_url(self.hass, media_id)
extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {} extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {}
metadata: dict[str, Any] = extra.get("metadata") or {} metadata: dict[str, Any] = extra.get("metadata") or {}
if not title:
title = extra.get("title") or metadata.get("title") or "Home Assistant" title = extra.get("title") or metadata.get("title") or "Home Assistant"
if thumb := extra.get("thumb"): if thumb := extra.get("thumb"):
metadata["album_art_uri"] = thumb metadata["album_art_uri"] = thumb
@ -598,6 +625,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
if hass_key in metadata: if hass_key in metadata:
metadata[didl_key] = metadata.pop(hass_key) metadata[didl_key] = metadata.pop(hass_key)
if not didl_metadata:
# Create metadata specific to the given media type; different fields are # Create metadata specific to the given media type; different fields are
# available depending on what the upnp_class is. # available depending on what the upnp_class is.
upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type) upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type)
@ -726,6 +754,54 @@ class DlnaDmrEntity(MediaPlayerEntity):
assert self._device is not None assert self._device is not None
await self._device.async_select_preset(sound_mode) await self._device.async_select_preset(sound_mode)
async def async_browse_media(
self,
media_content_type: str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the websocket media browsing helper.
Browses all available media_sources by default. Filters content_type
based on the DMR's sink_protocol_info.
"""
_LOGGER.debug(
"async_browse_media(%s, %s)", media_content_type, media_content_id
)
# 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()
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."""
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")
return lambda _: True
_LOGGER.debug("Get content filter for %s", self._device.sink_protocol_info)
if self._device.sink_protocol_info[0] == "*":
# 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] = []
for protocol_info in self._device.sink_protocol_info:
protocol, _, content_format, _ = protocol_info.split(":", 3)
if protocol in STREAMABLE_PROTOCOLS:
content_types.append(content_format)
def _content_type_filter(item: BrowseMedia) -> bool:
"""Filter media items by their content_type."""
return item.media_content_type in content_types
return _content_type_filter
@property @property
def media_title(self) -> str | None: def media_title(self) -> str | None:
"""Title of current playing media.""" """Title of current playing media."""

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import AsyncIterable, Mapping from collections.abc import AsyncIterable, Mapping
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from types import MappingProxyType from types import MappingProxyType
from typing import Any from typing import Any
@ -15,6 +16,7 @@ from async_upnp_client.exceptions import (
UpnpResponseError, UpnpResponseError,
) )
from async_upnp_client.profiles.dlna import PlayMode, TransportState from async_upnp_client.profiles.dlna import PlayMode, TransportState
from didl_lite import didl_lite
import pytest import pytest
from homeassistant import const as ha_const from homeassistant import const as ha_const
@ -29,6 +31,8 @@ from homeassistant.components.dlna_dmr.const import (
from homeassistant.components.dlna_dmr.data import EventListenAddr from homeassistant.components.dlna_dmr.data import EventListenAddr
from homeassistant.components.media_player import ATTR_TO_PROPERTY, const as mp_const from homeassistant.components.media_player import ATTR_TO_PROPERTY, const as mp_const
from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN
from homeassistant.components.media_source.const import DOMAIN as MS_DOMAIN
from homeassistant.components.media_source.models import PlayMedia
from homeassistant.const import ATTR_ENTITY_ID from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import async_get as async_get_dr from homeassistant.helpers.device_registry import async_get as async_get_dr
@ -418,7 +422,7 @@ async def test_feature_flags(
("can_stop", mp_const.SUPPORT_STOP), ("can_stop", mp_const.SUPPORT_STOP),
("can_previous", mp_const.SUPPORT_PREVIOUS_TRACK), ("can_previous", mp_const.SUPPORT_PREVIOUS_TRACK),
("can_next", mp_const.SUPPORT_NEXT_TRACK), ("can_next", mp_const.SUPPORT_NEXT_TRACK),
("has_play_media", mp_const.SUPPORT_PLAY_MEDIA), ("has_play_media", mp_const.SUPPORT_PLAY_MEDIA | mp_const.SUPPORT_BROWSE_MEDIA),
("can_seek_rel_time", mp_const.SUPPORT_SEEK), ("can_seek_rel_time", mp_const.SUPPORT_SEEK),
("has_presets", mp_const.SUPPORT_SELECT_SOUND_MODE), ("has_presets", mp_const.SUPPORT_SELECT_SOUND_MODE),
] ]
@ -760,6 +764,89 @@ async def test_play_media_metadata(
) )
async def test_play_media_local_source(
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
) -> None:
"""Test play_media with a media_id from a local media_source."""
# Based on roku's test_services_play_media_local_source and cast's
# test_entity_browse_media
await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}})
await hass.async_block_till_done()
await hass.services.async_call(
MP_DOMAIN,
mp_const.SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: mock_entity_id,
mp_const.ATTR_MEDIA_CONTENT_TYPE: "video/mp4",
mp_const.ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4",
},
blocking=True,
)
assert dmr_device_mock.construct_play_media_metadata.await_count == 1
assert (
"/media/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig="
in dmr_device_mock.construct_play_media_metadata.call_args.kwargs["media_url"]
)
assert dmr_device_mock.async_set_transport_uri.await_count == 1
assert dmr_device_mock.async_play.await_count == 1
call_args = dmr_device_mock.async_set_transport_uri.call_args.args
assert "/media/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[0]
async def test_play_media_didl_metadata(
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
) -> None:
"""Test play_media passes available DIDL-Lite metadata to the DMR."""
@dataclass
class DidlPlayMedia(PlayMedia):
"""Playable media with DIDL metadata."""
didl_metadata: didl_lite.DidlObject
didl_metadata = didl_lite.VideoItem(
id="120$22$33",
restricted="false",
title="Epic Sax Guy 10 Hours",
res=[
didl_lite.Resource(uri="unused-URI", protocol_info="http-get:*:video/mp4:")
],
)
play_media = DidlPlayMedia(
url="/media/local/Epic Sax Guy 10 Hours.mp4",
mime_type="video/mp4",
didl_metadata=didl_metadata,
)
await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}})
await hass.async_block_till_done()
local_source = hass.data[MS_DOMAIN][MS_DOMAIN]
with patch.object(local_source, "async_resolve_media", return_value=play_media):
await hass.services.async_call(
MP_DOMAIN,
mp_const.SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: mock_entity_id,
mp_const.ATTR_MEDIA_CONTENT_TYPE: "video/mp4",
mp_const.ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4",
},
blocking=True,
)
assert dmr_device_mock.construct_play_media_metadata.await_count == 0
assert dmr_device_mock.async_set_transport_uri.await_count == 1
assert dmr_device_mock.async_play.await_count == 1
call_args = dmr_device_mock.async_set_transport_uri.call_args.args
assert "/media/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[0]
assert call_args[1] == "Epic Sax Guy 10 Hours"
assert call_args[2] == didl_lite.to_xml_string(didl_metadata).decode()
async def test_shuffle_repeat_modes( async def test_shuffle_repeat_modes(
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
) -> None: ) -> None:
@ -844,6 +931,88 @@ async def test_shuffle_repeat_modes(
dmr_device_mock.async_set_play_mode.assert_not_awaited() dmr_device_mock.async_set_play_mode.assert_not_awaited()
async def test_browse_media(
hass: HomeAssistant, hass_ws_client, dmr_device_mock: Mock, mock_entity_id: str
) -> None:
"""Test the async_browse_media method."""
# Based on cast's test_entity_browse_media
await async_setup_component(hass, MS_DOMAIN, {MS_DOMAIN: {}})
await hass.async_block_till_done()
# DMR can play all media types
dmr_device_mock.sink_protocol_info = ["*"]
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"]
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,
"children_media_class": None,
"thumbnail": None,
}
assert expected_child_video in response["result"]["children"]
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,
"children_media_class": None,
"thumbnail": None,
}
assert expected_child_audio in response["result"]["children"]
# 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:*",
]
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()
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: