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
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
MEDIA_TYPE_MAP: Mapping[str, str] = {
"object": _mp_const.MEDIA_TYPE_URL,

View file

@ -5,6 +5,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"requirements": ["async-upnp-client==0.23.5"],
"dependencies": ["ssdp"],
"after_dependencies": ["media_source"],
"ssdp": [
{
"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.profiles.dlna import DmrDevice, PlayMode, TransportState
from async_upnp_client.utils import async_get_local_ip
from didl_lite import didl_lite
from typing_extensions import Concatenate, ParamSpec
from homeassistant import config_entries
from homeassistant.components import ssdp
from homeassistant.components.media_player import MediaPlayerEntity
from homeassistant.components import media_source, ssdp
from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerEntity,
async_process_play_media_url,
)
from homeassistant.components.media_player.const import (
ATTR_MEDIA_EXTRA,
REPEAT_MODE_ALL,
REPEAT_MODE_OFF,
REPEAT_MODE_ONE,
SUPPORT_BROWSE_MEDIA,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
@ -61,6 +67,7 @@ from .const import (
MEDIA_UPNP_CLASS_MAP,
REPEAT_PLAY_MODES,
SHUFFLE_PLAY_MODES,
STREAMABLE_PROTOCOLS,
)
from .data import EventListenAddr, get_domain_data
@ -512,7 +519,7 @@ class DlnaDmrEntity(MediaPlayerEntity):
if self._device.can_next:
supported_features |= SUPPORT_NEXT_TRACK
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:
supported_features |= SUPPORT_SEEK
@ -586,10 +593,30 @@ class DlnaDmrEntity(MediaPlayerEntity):
"""Play a piece of media."""
_LOGGER.debug("Playing media: %s, %s, %s", media_type, media_id, kwargs)
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 {}
metadata: dict[str, Any] = extra.get("metadata") or {}
title = extra.get("title") or metadata.get("title") or "Home Assistant"
if not title:
title = extra.get("title") or metadata.get("title") or "Home Assistant"
if thumb := extra.get("thumb"):
metadata["album_art_uri"] = thumb
@ -598,15 +625,16 @@ class DlnaDmrEntity(MediaPlayerEntity):
if hass_key in metadata:
metadata[didl_key] = metadata.pop(hass_key)
# Create metadata specific to the given media type; different fields are
# available depending on what the upnp_class is.
upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type)
didl_metadata = await self._device.construct_play_media_metadata(
media_url=media_id,
media_title=title,
override_upnp_class=upnp_class,
meta_data=metadata,
)
if not didl_metadata:
# Create metadata specific to the given media type; different fields are
# available depending on what the upnp_class is.
upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type)
didl_metadata = await self._device.construct_play_media_metadata(
media_url=media_id,
media_title=title,
override_upnp_class=upnp_class,
meta_data=metadata,
)
# Stop current playing media
if self._device.can_stop:
@ -726,6 +754,54 @@ class DlnaDmrEntity(MediaPlayerEntity):
assert self._device is not None
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
def media_title(self) -> str | None:
"""Title of current playing media."""

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncIterable, Mapping
from dataclasses import dataclass
from datetime import timedelta
from types import MappingProxyType
from typing import Any
@ -15,6 +16,7 @@ from async_upnp_client.exceptions import (
UpnpResponseError,
)
from async_upnp_client.profiles.dlna import PlayMode, TransportState
from didl_lite import didl_lite
import pytest
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.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_source.const import DOMAIN as MS_DOMAIN
from homeassistant.components.media_source.models import PlayMedia
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
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_previous", mp_const.SUPPORT_PREVIOUS_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),
("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(
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
) -> None:
@ -844,6 +931,88 @@ async def test_shuffle_repeat_modes(
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(
hass: HomeAssistant, dmr_device_mock: Mock, mock_entity_id: str
) -> None: