Improve roku play media handling (#66429)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
parent
cdd5d22b38
commit
9d5dc2ce24
6 changed files with 266 additions and 75 deletions
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
},
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Reference in a new issue