Improve roku play media handling (#66429)

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
Chris Talkington 2022-02-17 12:19:01 -06:00 committed by GitHub
parent cdd5d22b38
commit 9d5dc2ce24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 266 additions and 75 deletions

View file

@ -2,10 +2,12 @@
DOMAIN = "roku"
# Attributes
ATTR_ARTIST_NAME = "artist_name"
ATTR_CONTENT_ID = "content_id"
ATTR_FORMAT = "format"
ATTR_KEYWORD = "keyword"
ATTR_MEDIA_TYPE = "media_type"
ATTR_THUMBNAIL = "thumbnail"
# Default Values
DEFAULT_PORT = 8060

View file

@ -2,7 +2,7 @@
"domain": "roku",
"name": "Roku",
"documentation": "https://www.home-assistant.io/integrations/roku",
"requirements": ["rokuecp==0.13.2"],
"requirements": ["rokuecp==0.14.0"],
"homekit": {
"models": ["3810X", "4660X", "7820X", "C105X", "C135X"]
},

View file

@ -3,9 +3,12 @@ from __future__ import annotations
import datetime as dt
import logging
import mimetypes
from typing import Any
from rokuecp.helpers import guess_stream_format
import voluptuous as vol
import yarl
from homeassistant.components import media_source
from homeassistant.components.media_player import (
@ -18,7 +21,9 @@ from homeassistant.components.media_player.const import (
ATTR_MEDIA_EXTRA,
MEDIA_TYPE_APP,
MEDIA_TYPE_CHANNEL,
MEDIA_TYPE_MUSIC,
MEDIA_TYPE_URL,
MEDIA_TYPE_VIDEO,
SUPPORT_BROWSE_MEDIA,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
@ -49,10 +54,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import roku_exception_handler
from .browse_media import async_browse_media
from .const import (
ATTR_ARTIST_NAME,
ATTR_CONTENT_ID,
ATTR_FORMAT,
ATTR_KEYWORD,
ATTR_MEDIA_TYPE,
ATTR_THUMBNAIL,
DOMAIN,
SERVICE_SEARCH,
)
@ -76,21 +83,36 @@ SUPPORT_ROKU = (
| SUPPORT_BROWSE_MEDIA
)
ATTRS_TO_LAUNCH_PARAMS = {
ATTR_CONTENT_ID: "contentID",
ATTR_MEDIA_TYPE: "MediaType",
STREAM_FORMAT_TO_MEDIA_TYPE = {
"dash": MEDIA_TYPE_VIDEO,
"hls": MEDIA_TYPE_VIDEO,
"ism": MEDIA_TYPE_VIDEO,
"m4a": MEDIA_TYPE_MUSIC,
"m4v": MEDIA_TYPE_VIDEO,
"mka": MEDIA_TYPE_MUSIC,
"mkv": MEDIA_TYPE_VIDEO,
"mks": MEDIA_TYPE_VIDEO,
"mp3": MEDIA_TYPE_MUSIC,
"mp4": MEDIA_TYPE_VIDEO,
}
PLAY_MEDIA_SUPPORTED_TYPES = (
MEDIA_TYPE_APP,
MEDIA_TYPE_CHANNEL,
MEDIA_TYPE_URL,
FORMAT_CONTENT_TYPE[HLS_PROVIDER],
)
ATTRS_TO_LAUNCH_PARAMS = {
ATTR_CONTENT_ID: "contentID",
ATTR_MEDIA_TYPE: "mediaType",
}
ATTRS_TO_PLAY_VIDEO_PARAMS = {
ATTRS_TO_PLAY_ON_ROKU_PARAMS = {
ATTR_NAME: "videoName",
ATTR_FORMAT: "videoFormat",
ATTR_THUMBNAIL: "k",
}
ATTRS_TO_PLAY_ON_ROKU_AUDIO_PARAMS = {
ATTR_NAME: "songName",
ATTR_FORMAT: "songFormat",
ATTR_ARTIST_NAME: "artistName",
ATTR_THUMBNAIL: "albumArtUrl",
}
SEARCH_SCHEMA = {vol.Required(ATTR_KEYWORD): str}
@ -366,25 +388,67 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
) -> None:
"""Play media from a URL or file, launch an application, or tune to a channel."""
extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {}
original_media_type: str = media_type
original_media_id: str = media_id
mime_type: str | None = None
stream_name: str | None = None
stream_format: str | None = extra.get(ATTR_FORMAT)
# Handle media_source
if media_source.is_media_source_id(media_id):
sourced_media = await media_source.async_resolve_media(self.hass, media_id)
media_type = MEDIA_TYPE_URL
media_id = sourced_media.url
mime_type = sourced_media.mime_type
stream_name = original_media_id
stream_format = guess_stream_format(media_id, mime_type)
# If media ID is a relative URL, we serve it from HA.
media_id = async_process_play_media_url(self.hass, media_id)
if media_type not in PLAY_MEDIA_SUPPORTED_TYPES:
_LOGGER.error(
"Invalid media type %s. Only %s, %s, %s, and camera HLS streams are supported",
media_type,
MEDIA_TYPE_APP,
MEDIA_TYPE_CHANNEL,
MEDIA_TYPE_URL,
)
return
if media_type == FORMAT_CONTENT_TYPE[HLS_PROVIDER]:
media_type = MEDIA_TYPE_VIDEO
mime_type = FORMAT_CONTENT_TYPE[HLS_PROVIDER]
stream_name = "Camera Stream"
stream_format = "hls"
if media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_URL, MEDIA_TYPE_VIDEO):
parsed = yarl.URL(media_id)
if mime_type is None:
mime_type, _ = mimetypes.guess_type(parsed.path)
if stream_format is None:
stream_format = guess_stream_format(media_id, mime_type)
if extra.get(ATTR_FORMAT) is None:
extra[ATTR_FORMAT] = stream_format
if extra[ATTR_FORMAT] not in STREAM_FORMAT_TO_MEDIA_TYPE:
_LOGGER.error(
"Media type %s is not supported with format %s (mime: %s)",
original_media_type,
extra[ATTR_FORMAT],
mime_type,
)
return
if (
media_type == MEDIA_TYPE_URL
and STREAM_FORMAT_TO_MEDIA_TYPE[extra[ATTR_FORMAT]] == MEDIA_TYPE_MUSIC
):
media_type = MEDIA_TYPE_MUSIC
if media_type == MEDIA_TYPE_MUSIC and "tts_proxy" in media_id:
stream_name = "Text to Speech"
elif stream_name is None:
if stream_format == "ism":
stream_name = parsed.parts[-2]
else:
stream_name = parsed.name
if extra.get(ATTR_NAME) is None:
extra[ATTR_NAME] = stream_name
if media_type == MEDIA_TYPE_APP:
params = {
@ -396,20 +460,30 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
await self.coordinator.roku.launch(media_id, params)
elif media_type == MEDIA_TYPE_CHANNEL:
await self.coordinator.roku.tune(media_id)
elif media_type == MEDIA_TYPE_URL:
elif media_type == MEDIA_TYPE_MUSIC:
if extra.get(ATTR_ARTIST_NAME) is None:
extra[ATTR_ARTIST_NAME] = "Home Assistant"
params = {
param: extra[attr]
for (attr, param) in ATTRS_TO_PLAY_VIDEO_PARAMS.items()
for (attr, param) in ATTRS_TO_PLAY_ON_ROKU_AUDIO_PARAMS.items()
if attr in extra
}
params = {"t": "a", **params}
await self.coordinator.roku.play_on_roku(media_id, params)
elif media_type in (MEDIA_TYPE_URL, MEDIA_TYPE_VIDEO):
params = {
param: extra[attr]
for (attr, param) in ATTRS_TO_PLAY_ON_ROKU_PARAMS.items()
if attr in extra
}
await self.coordinator.roku.play_on_roku(media_id, params)
elif media_type == FORMAT_CONTENT_TYPE[HLS_PROVIDER]:
params = {
"MediaType": "hls",
}
await self.coordinator.roku.play_on_roku(media_id, params)
else:
_LOGGER.error("Media type %s is not supported", original_media_type)
return
await self.coordinator.async_request_refresh()

View file

@ -2111,7 +2111,7 @@ rjpl==0.3.6
rocketchat-API==0.6.1
# homeassistant.components.roku
rokuecp==0.13.2
rokuecp==0.14.0
# homeassistant.components.roomba
roombapy==1.6.5

View file

@ -1306,7 +1306,7 @@ rflink==0.0.62
ring_doorbell==0.7.2
# homeassistant.components.roku
rokuecp==0.13.2
rokuecp==0.14.0
# homeassistant.components.roomba
roombapy==1.6.5

View file

@ -27,7 +27,9 @@ from homeassistant.components.media_player.const import (
MEDIA_TYPE_APPS,
MEDIA_TYPE_CHANNEL,
MEDIA_TYPE_CHANNELS,
MEDIA_TYPE_MUSIC,
MEDIA_TYPE_URL,
MEDIA_TYPE_VIDEO,
SERVICE_PLAY_MEDIA,
SERVICE_SELECT_SOURCE,
SUPPORT_BROWSE_MEDIA,
@ -459,50 +461,7 @@ async def test_services(
"291097",
{
"contentID": "8e06a8b7-d667-4e31-939d-f40a6dd78a88",
"MediaType": "movie",
},
)
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MAIN_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_URL,
ATTR_MEDIA_CONTENT_ID: "https://awesome.tld/media.mp4",
ATTR_MEDIA_EXTRA: {
ATTR_NAME: "Sent from HA",
ATTR_FORMAT: "mp4",
},
},
blocking=True,
)
assert mock_roku.play_on_roku.call_count == 1
mock_roku.play_on_roku.assert_called_with(
"https://awesome.tld/media.mp4",
{
"videoName": "Sent from HA",
"videoFormat": "mp4",
},
)
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MAIN_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[HLS_PROVIDER],
ATTR_MEDIA_CONTENT_ID: "https://awesome.tld/api/hls/api_token/master_playlist.m3u8",
},
blocking=True,
)
assert mock_roku.play_on_roku.call_count == 2
mock_roku.play_on_roku.assert_called_with(
"https://awesome.tld/api/hls/api_token/master_playlist.m3u8",
{
"MediaType": "hls",
"mediaType": "movie",
},
)
@ -527,6 +486,158 @@ async def test_services(
mock_roku.launch.assert_called_with("12")
async def test_services_play_media(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_roku: MagicMock,
) -> None:
"""Test the media player services related to playing media."""
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MAIN_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: "blah",
ATTR_MEDIA_CONTENT_ID: "https://localhost/media.m4a",
ATTR_MEDIA_EXTRA: {
ATTR_NAME: "Test",
},
},
blocking=True,
)
assert mock_roku.play_on_roku.call_count == 0
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MAIN_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC,
ATTR_MEDIA_CONTENT_ID: "https://localhost/media.m4a",
ATTR_MEDIA_EXTRA: {ATTR_FORMAT: "blah"},
},
blocking=True,
)
assert mock_roku.play_on_roku.call_count == 0
@pytest.mark.parametrize(
"content_type, content_id, resolved_name, resolved_format",
[
(MEDIA_TYPE_URL, "http://localhost/media.m4a", "media.m4a", "m4a"),
(MEDIA_TYPE_MUSIC, "http://localhost/media.m4a", "media.m4a", "m4a"),
(MEDIA_TYPE_MUSIC, "http://localhost/media.mka", "media.mka", "mka"),
(
MEDIA_TYPE_MUSIC,
"http://localhost/api/tts_proxy/generated.mp3",
"Text to Speech",
"mp3",
),
],
)
async def test_services_play_media_audio(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_roku: MagicMock,
content_type: str,
content_id: str,
resolved_name: str,
resolved_format: str,
) -> None:
"""Test the media player services related to playing media."""
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MAIN_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: content_type,
ATTR_MEDIA_CONTENT_ID: content_id,
},
blocking=True,
)
mock_roku.play_on_roku.assert_called_once_with(
content_id,
{
"t": "a",
"songName": resolved_name,
"songFormat": resolved_format,
"artistName": "Home Assistant",
},
)
@pytest.mark.parametrize(
"content_type, content_id, resolved_name, resolved_format",
[
(MEDIA_TYPE_URL, "http://localhost/media.mp4", "media.mp4", "mp4"),
(MEDIA_TYPE_VIDEO, "http://localhost/media.m4v", "media.m4v", "mp4"),
(MEDIA_TYPE_VIDEO, "http://localhost/media.mov", "media.mov", "mp4"),
(MEDIA_TYPE_VIDEO, "http://localhost/media.mkv", "media.mkv", "mkv"),
(MEDIA_TYPE_VIDEO, "http://localhost/media.mks", "media.mks", "mks"),
(MEDIA_TYPE_VIDEO, "http://localhost/media.m3u8", "media.m3u8", "hls"),
(MEDIA_TYPE_VIDEO, "http://localhost/media.dash", "media.dash", "dash"),
(MEDIA_TYPE_VIDEO, "http://localhost/media.mpd", "media.mpd", "dash"),
(MEDIA_TYPE_VIDEO, "http://localhost/media.ism/manifest", "media.ism", "ism"),
],
)
async def test_services_play_media_video(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_roku: MagicMock,
content_type: str,
content_id: str,
resolved_name: str,
resolved_format: str,
) -> None:
"""Test the media player services related to playing media."""
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MAIN_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: content_type,
ATTR_MEDIA_CONTENT_ID: content_id,
},
blocking=True,
)
mock_roku.play_on_roku.assert_called_once_with(
content_id,
{
"videoName": resolved_name,
"videoFormat": resolved_format,
},
)
async def test_services_camera_play_stream(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_roku: MagicMock,
) -> None:
"""Test the media player services related to playing camera stream."""
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MAIN_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[HLS_PROVIDER],
ATTR_MEDIA_CONTENT_ID: "https://awesome.tld/api/hls/api_token/master_playlist.m3u8",
},
blocking=True,
)
assert mock_roku.play_on_roku.call_count == 1
mock_roku.play_on_roku.assert_called_with(
"https://awesome.tld/api/hls/api_token/master_playlist.m3u8",
{
"videoName": "Camera Stream",
"videoFormat": "hls",
},
)
async def test_services_play_media_local_source(
hass: HomeAssistant,
init_integration: MockConfigEntry,
@ -556,7 +667,11 @@ async def test_services_play_media_local_source(
assert mock_roku.play_on_roku.call_count == 1
assert mock_roku.play_on_roku.call_args
call_args = mock_roku.play_on_roku.call_args.args
assert "/media/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[0]
assert "/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[0]
assert call_args[1] == {
"videoFormat": "mp4",
"videoName": "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4",
}
@pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True)