Add media browser support to dlna_dmr (#66425)
This commit is contained in:
parent
6a7872fc1b
commit
3c15fe8587
4 changed files with 265 additions and 14 deletions
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Reference in a new issue