Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add media browser support to dlna_dmr #66425

Merged
merged 1 commit into from Feb 20, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions homeassistant/components/dlna_dmr/const.py
Expand Up @@ -21,6 +21,11 @@

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,
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/dlna_dmr/manifest.json
Expand Up @@ -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",
Expand Down
102 changes: 89 additions & 13 deletions homeassistant/components/dlna_dmr/media_player.py
Expand Up @@ -13,16 +13,22 @@
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,
Expand Down Expand Up @@ -61,6 +67,7 @@
MEDIA_UPNP_CLASS_MAP,
REPEAT_PLAY_MODES,
SHUFFLE_PLAY_MODES,
STREAMABLE_PROTOCOLS,
)
from .data import EventListenAddr, get_domain_data

Expand Down Expand Up @@ -512,7 +519,7 @@ def supported_features(self) -> int:
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

Expand Down Expand Up @@ -586,10 +593,30 @@ async def async_play_media(
"""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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use isinstance to detect if you're browsing UPNP media?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could (and did originally), but that then needs an explicit import of the dlna_dms component, and therefore a dependency within the dlna_dmr manifest. This current approach is also a bit more flexible because it can handle other integrations extending PlayMedia with a didl_metadata attribute, and they don't have to depend on dlna_dms for it to work.

This has also led me to think about how media sources can provide metadata to media players more generally. I'll work up an architecture discussion for that next week.

didl_metadata = didl_lite.to_xml_string(sourced_metadata).decode(
ctalkington marked this conversation as resolved.
Show resolved Hide resolved
"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

Expand All @@ -598,15 +625,16 @@ async def async_play_media(
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:
Expand Down Expand Up @@ -726,6 +754,54 @@ async def async_select_sound_mode(self, sound_mode: str) -> None:
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."""
Expand Down
171 changes: 170 additions & 1 deletion tests/components/dlna_dmr/test_media_player.py
Expand Up @@ -3,6 +3,7 @@

import asyncio
from collections.abc import AsyncIterable, Mapping
from dataclasses import dataclass
from datetime import timedelta
from types import MappingProxyType
from typing import Any
Expand All @@ -15,6 +16,7 @@
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
Expand All @@ -29,6 +31,8 @@
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
Expand Down Expand Up @@ -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),
]
Expand Down Expand Up @@ -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]
chishm marked this conversation as resolved.
Show resolved Hide resolved

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:
Expand Down Expand Up @@ -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:
Expand Down