From 13a6aaa6ff5572344759985cd5736d3c5ed2a153 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sun, 6 Sep 2020 07:53:45 -0500 Subject: [PATCH] Add media browser support to roku (#39652) Co-authored-by: Paulus Schoutsen --- .../components/media_player/const.py | 2 + homeassistant/components/roku/media_player.py | 103 ++++++++++++- tests/components/roku/test_media_player.py | 143 ++++++++++++++++++ 3 files changed, 245 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 64d77a4889e..4c8fdace9d7 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -35,11 +35,13 @@ MEDIA_TYPE_MOVIE = "movie" MEDIA_TYPE_VIDEO = "video" MEDIA_TYPE_EPISODE = "episode" MEDIA_TYPE_CHANNEL = "channel" +MEDIA_TYPE_CHANNELS = "channels" MEDIA_TYPE_PLAYLIST = "playlist" MEDIA_TYPE_IMAGE = "image" MEDIA_TYPE_URL = "url" MEDIA_TYPE_GAME = "game" MEDIA_TYPE_APP = "app" +MEDIA_TYPE_APPS = "apps" MEDIA_TYPE_ALBUM = "album" MEDIA_TYPE_TRACK = "track" MEDIA_TYPE_ARTIST = "artist" diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 1aa4fc3e9bb..c25145ae2d7 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -11,7 +11,10 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.media_player.const import ( MEDIA_TYPE_APP, + MEDIA_TYPE_APPS, MEDIA_TYPE_CHANNEL, + MEDIA_TYPE_CHANNELS, + SUPPORT_BROWSE_MEDIA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -23,6 +26,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, ) +from homeassistant.components.media_player.errors import BrowseError from homeassistant.const import ( STATE_HOME, STATE_IDLE, @@ -49,6 +53,7 @@ SUPPORT_ROKU = ( | SUPPORT_PLAY_MEDIA | SUPPORT_TURN_ON | SUPPORT_TURN_OFF + | SUPPORT_BROWSE_MEDIA ) SEARCH_SCHEMA = {vol.Required(ATTR_KEYWORD): str} @@ -69,6 +74,41 @@ async def async_setup_entry(hass, entry, async_add_entities): ) +def browse_media_library(channels: bool = False) -> dict: + """Create response payload to describe contents of a specific library.""" + library_info = { + "title": "Media Library", + "media_content_id": "library", + "media_content_type": "library", + "can_play": False, + "can_expand": True, + "children": [], + } + + library_info["children"].append( + { + "title": "Apps", + "media_content_id": "apps", + "media_content_type": MEDIA_TYPE_APPS, + "can_expand": True, + "can_play": False, + } + ) + + if channels: + library_info["children"].append( + { + "title": "Channels", + "media_content_id": "channels", + "media_content_type": MEDIA_TYPE_CHANNELS, + "can_expand": True, + "can_play": False, + } + ) + + return library_info + + class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): """Representation of a Roku media player on the network.""" @@ -234,6 +274,58 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): """Emulate opening the search screen and entering the search keyword.""" await self.coordinator.roku.search(keyword) + async def async_browse_media(self, media_content_type=None, media_content_id=None): + """Implement the websocket media browsing helper.""" + if media_content_type in [None, "library"]: + is_tv = self.coordinator.data.info.device_type == "tv" + return browse_media_library(channels=is_tv) + + response = None + + if media_content_type == MEDIA_TYPE_APPS: + response = { + "title": "Apps", + "media_content_id": "apps", + "media_content_type": MEDIA_TYPE_APPS, + "can_expand": True, + "can_play": False, + "children": [ + { + "title": app.name, + "thumbnail": self.coordinator.roku.app_icon_url(app.app_id), + "media_content_id": app.app_id, + "media_content_type": MEDIA_TYPE_APP, + "can_play": True, + } + for app in self.coordinator.data.apps + ], + } + + if media_content_type == MEDIA_TYPE_CHANNELS: + response = { + "title": "Channels", + "media_content_id": "channels", + "media_content_type": MEDIA_TYPE_CHANNELS, + "can_expand": True, + "can_play": False, + "children": [ + { + "title": channel.name, + "media_content_id": channel.number, + "media_content_type": MEDIA_TYPE_CHANNEL, + "can_play": True, + } + for channel in self.coordinator.data.channels + ], + } + + if response is None: + raise BrowseError( + f"Media not found: {media_content_type} / {media_content_id}" + ) + + return response + @roku_exception_handler async def async_turn_on(self) -> None: """Turn on the Roku.""" @@ -298,15 +390,20 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): @roku_exception_handler async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None: """Tune to channel.""" - if media_type != MEDIA_TYPE_CHANNEL: + if media_type not in (MEDIA_TYPE_APP, MEDIA_TYPE_CHANNEL): _LOGGER.error( - "Invalid media type %s. Only %s is supported", + "Invalid media type %s. Only %s and %s are supported", media_type, + MEDIA_TYPE_APP, MEDIA_TYPE_CHANNEL, ) return - await self.coordinator.roku.tune(media_id) + if media_type == MEDIA_TYPE_APP: + await self.coordinator.roku.launch(media_id) + elif media_type == MEDIA_TYPE_CHANNEL: + await self.coordinator.roku.tune(media_id) + await self.coordinator.async_request_refresh() @roku_exception_handler diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index b355e6178ff..312770a873a 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -17,9 +17,12 @@ from homeassistant.components.media_player.const import ( ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MP_DOMAIN, MEDIA_TYPE_APP, + MEDIA_TYPE_APPS, MEDIA_TYPE_CHANNEL, + MEDIA_TYPE_CHANNELS, SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, + SUPPORT_BROWSE_MEDIA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -32,6 +35,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_STEP, ) from homeassistant.components.roku.const import ATTR_KEYWORD, DOMAIN, SERVICE_SEARCH +from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_MEDIA_NEXT_TRACK, @@ -158,6 +162,7 @@ async def test_supported_features( | SUPPORT_PLAY_MEDIA | SUPPORT_TURN_ON | SUPPORT_TURN_OFF + | SUPPORT_BROWSE_MEDIA == state.attributes.get("supported_features") ) @@ -187,6 +192,7 @@ async def test_tv_supported_features( | SUPPORT_PLAY_MEDIA | SUPPORT_TURN_ON | SUPPORT_TURN_OFF + | SUPPORT_BROWSE_MEDIA == state.attributes.get("supported_features") ) @@ -364,6 +370,20 @@ async def test_services( remote_mock.assert_called_once_with("reverse") + with patch("homeassistant.components.roku.Roku.launch") as launch_mock: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MAIN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_APP, + ATTR_MEDIA_CONTENT_ID: "11", + }, + blocking=True, + ) + + launch_mock.assert_called_once_with("11") + with patch("homeassistant.components.roku.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, @@ -450,6 +470,129 @@ async def test_tv_services( tune_mock.assert_called_once_with("55") +async def test_media_browse(hass, aioclient_mock, hass_ws_client): + """Test browsing media.""" + await setup_integration( + hass, + aioclient_mock, + device="rokutv", + app="tvinput-dtv", + host=TV_HOST, + unique_id=TV_SERIAL, + ) + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": TV_ENTITY_ID, + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + assert msg["result"] + assert msg["result"]["title"] == "Media Library" + assert msg["result"]["media_content_type"] == "library" + assert msg["result"]["can_expand"] + assert not msg["result"]["can_play"] + assert len(msg["result"]["children"]) == 2 + + # test apps + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": TV_ENTITY_ID, + "media_content_type": MEDIA_TYPE_APPS, + "media_content_id": "apps", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 2 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + assert msg["result"] + assert msg["result"]["title"] == "Apps" + assert msg["result"]["media_content_type"] == MEDIA_TYPE_APPS + assert msg["result"]["can_expand"] + assert not msg["result"]["can_play"] + assert len(msg["result"]["children"]) == 11 + + assert msg["result"]["children"][0]["title"] == "Satellite TV" + assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_APP + assert msg["result"]["children"][0]["media_content_id"] == "tvinput.hdmi2" + assert ( + msg["result"]["children"][0]["thumbnail"] + == "http://192.168.1.161:8060/query/icon/tvinput.hdmi2" + ) + assert msg["result"]["children"][0]["can_play"] + + assert msg["result"]["children"][3]["title"] == "Roku Channel Store" + assert msg["result"]["children"][3]["media_content_type"] == MEDIA_TYPE_APP + assert msg["result"]["children"][3]["media_content_id"] == "11" + assert ( + msg["result"]["children"][3]["thumbnail"] + == "http://192.168.1.161:8060/query/icon/11" + ) + assert msg["result"]["children"][3]["can_play"] + + # test channels + await client.send_json( + { + "id": 3, + "type": "media_player/browse_media", + "entity_id": TV_ENTITY_ID, + "media_content_type": MEDIA_TYPE_CHANNELS, + "media_content_id": "channels", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 3 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + assert msg["result"] + assert msg["result"]["title"] == "Channels" + assert msg["result"]["media_content_type"] == MEDIA_TYPE_CHANNELS + assert msg["result"]["can_expand"] + assert not msg["result"]["can_play"] + assert len(msg["result"]["children"]) == 2 + + assert msg["result"]["children"][0]["title"] == "WhatsOn" + assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_CHANNEL + assert msg["result"]["children"][0]["media_content_id"] == "1.1" + assert msg["result"]["children"][0]["can_play"] + + # test invalid media type + await client.send_json( + { + "id": 4, + "type": "media_player/browse_media", + "entity_id": TV_ENTITY_ID, + "media_content_type": "invalid", + "media_content_id": "invalid", + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 4 + assert msg["type"] == TYPE_RESULT + assert not msg["success"] + + async def test_integration_services( hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker ) -> None: