From 9d5dc2ce246a46a285aa8066971bdf8131a18053 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Thu, 17 Feb 2022 12:19:01 -0600 Subject: [PATCH] Improve roku play media handling (#66429) Co-authored-by: Paulus Schoutsen --- homeassistant/components/roku/const.py | 2 + homeassistant/components/roku/manifest.json | 2 +- homeassistant/components/roku/media_player.py | 128 ++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roku/test_media_player.py | 205 ++++++++++++++---- 6 files changed, 266 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/roku/const.py b/homeassistant/components/roku/const.py index e399f6e0558..f098483e0c6 100644 --- a/homeassistant/components/roku/const.py +++ b/homeassistant/components/roku/const.py @@ -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 diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index d68f2b4b242..d03aa9846c3 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -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"] }, diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index ff9e034e5d4..9cf17d890a4 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -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() diff --git a/requirements_all.txt b/requirements_all.txt index e0c0e4c960c..38629f5e8bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6d7d898eb3..4b082aa6009 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 2686a281dba..79b996530e3 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -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)