Add media browser support to roku (#39652)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
parent
405311e89f
commit
13a6aaa6ff
3 changed files with 245 additions and 3 deletions
|
@ -35,11 +35,13 @@ MEDIA_TYPE_MOVIE = "movie"
|
||||||
MEDIA_TYPE_VIDEO = "video"
|
MEDIA_TYPE_VIDEO = "video"
|
||||||
MEDIA_TYPE_EPISODE = "episode"
|
MEDIA_TYPE_EPISODE = "episode"
|
||||||
MEDIA_TYPE_CHANNEL = "channel"
|
MEDIA_TYPE_CHANNEL = "channel"
|
||||||
|
MEDIA_TYPE_CHANNELS = "channels"
|
||||||
MEDIA_TYPE_PLAYLIST = "playlist"
|
MEDIA_TYPE_PLAYLIST = "playlist"
|
||||||
MEDIA_TYPE_IMAGE = "image"
|
MEDIA_TYPE_IMAGE = "image"
|
||||||
MEDIA_TYPE_URL = "url"
|
MEDIA_TYPE_URL = "url"
|
||||||
MEDIA_TYPE_GAME = "game"
|
MEDIA_TYPE_GAME = "game"
|
||||||
MEDIA_TYPE_APP = "app"
|
MEDIA_TYPE_APP = "app"
|
||||||
|
MEDIA_TYPE_APPS = "apps"
|
||||||
MEDIA_TYPE_ALBUM = "album"
|
MEDIA_TYPE_ALBUM = "album"
|
||||||
MEDIA_TYPE_TRACK = "track"
|
MEDIA_TYPE_TRACK = "track"
|
||||||
MEDIA_TYPE_ARTIST = "artist"
|
MEDIA_TYPE_ARTIST = "artist"
|
||||||
|
|
|
@ -11,7 +11,10 @@ from homeassistant.components.media_player import (
|
||||||
)
|
)
|
||||||
from homeassistant.components.media_player.const import (
|
from homeassistant.components.media_player.const import (
|
||||||
MEDIA_TYPE_APP,
|
MEDIA_TYPE_APP,
|
||||||
|
MEDIA_TYPE_APPS,
|
||||||
MEDIA_TYPE_CHANNEL,
|
MEDIA_TYPE_CHANNEL,
|
||||||
|
MEDIA_TYPE_CHANNELS,
|
||||||
|
SUPPORT_BROWSE_MEDIA,
|
||||||
SUPPORT_NEXT_TRACK,
|
SUPPORT_NEXT_TRACK,
|
||||||
SUPPORT_PAUSE,
|
SUPPORT_PAUSE,
|
||||||
SUPPORT_PLAY,
|
SUPPORT_PLAY,
|
||||||
|
@ -23,6 +26,7 @@ from homeassistant.components.media_player.const import (
|
||||||
SUPPORT_VOLUME_MUTE,
|
SUPPORT_VOLUME_MUTE,
|
||||||
SUPPORT_VOLUME_STEP,
|
SUPPORT_VOLUME_STEP,
|
||||||
)
|
)
|
||||||
|
from homeassistant.components.media_player.errors import BrowseError
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
STATE_HOME,
|
STATE_HOME,
|
||||||
STATE_IDLE,
|
STATE_IDLE,
|
||||||
|
@ -49,6 +53,7 @@ SUPPORT_ROKU = (
|
||||||
| SUPPORT_PLAY_MEDIA
|
| SUPPORT_PLAY_MEDIA
|
||||||
| SUPPORT_TURN_ON
|
| SUPPORT_TURN_ON
|
||||||
| SUPPORT_TURN_OFF
|
| SUPPORT_TURN_OFF
|
||||||
|
| SUPPORT_BROWSE_MEDIA
|
||||||
)
|
)
|
||||||
|
|
||||||
SEARCH_SCHEMA = {vol.Required(ATTR_KEYWORD): str}
|
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):
|
class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||||
"""Representation of a Roku media player on the network."""
|
"""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."""
|
"""Emulate opening the search screen and entering the search keyword."""
|
||||||
await self.coordinator.roku.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
|
@roku_exception_handler
|
||||||
async def async_turn_on(self) -> None:
|
async def async_turn_on(self) -> None:
|
||||||
"""Turn on the Roku."""
|
"""Turn on the Roku."""
|
||||||
|
@ -298,15 +390,20 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||||
@roku_exception_handler
|
@roku_exception_handler
|
||||||
async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None:
|
async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None:
|
||||||
"""Tune to channel."""
|
"""Tune to channel."""
|
||||||
if media_type != MEDIA_TYPE_CHANNEL:
|
if media_type not in (MEDIA_TYPE_APP, MEDIA_TYPE_CHANNEL):
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Invalid media type %s. Only %s is supported",
|
"Invalid media type %s. Only %s and %s are supported",
|
||||||
media_type,
|
media_type,
|
||||||
|
MEDIA_TYPE_APP,
|
||||||
MEDIA_TYPE_CHANNEL,
|
MEDIA_TYPE_CHANNEL,
|
||||||
)
|
)
|
||||||
return
|
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()
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
@roku_exception_handler
|
@roku_exception_handler
|
||||||
|
|
|
@ -17,9 +17,12 @@ from homeassistant.components.media_player.const import (
|
||||||
ATTR_MEDIA_VOLUME_MUTED,
|
ATTR_MEDIA_VOLUME_MUTED,
|
||||||
DOMAIN as MP_DOMAIN,
|
DOMAIN as MP_DOMAIN,
|
||||||
MEDIA_TYPE_APP,
|
MEDIA_TYPE_APP,
|
||||||
|
MEDIA_TYPE_APPS,
|
||||||
MEDIA_TYPE_CHANNEL,
|
MEDIA_TYPE_CHANNEL,
|
||||||
|
MEDIA_TYPE_CHANNELS,
|
||||||
SERVICE_PLAY_MEDIA,
|
SERVICE_PLAY_MEDIA,
|
||||||
SERVICE_SELECT_SOURCE,
|
SERVICE_SELECT_SOURCE,
|
||||||
|
SUPPORT_BROWSE_MEDIA,
|
||||||
SUPPORT_NEXT_TRACK,
|
SUPPORT_NEXT_TRACK,
|
||||||
SUPPORT_PAUSE,
|
SUPPORT_PAUSE,
|
||||||
SUPPORT_PLAY,
|
SUPPORT_PLAY,
|
||||||
|
@ -32,6 +35,7 @@ from homeassistant.components.media_player.const import (
|
||||||
SUPPORT_VOLUME_STEP,
|
SUPPORT_VOLUME_STEP,
|
||||||
)
|
)
|
||||||
from homeassistant.components.roku.const import ATTR_KEYWORD, DOMAIN, SERVICE_SEARCH
|
from homeassistant.components.roku.const import ATTR_KEYWORD, DOMAIN, SERVICE_SEARCH
|
||||||
|
from homeassistant.components.websocket_api.const import TYPE_RESULT
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ENTITY_ID,
|
ATTR_ENTITY_ID,
|
||||||
SERVICE_MEDIA_NEXT_TRACK,
|
SERVICE_MEDIA_NEXT_TRACK,
|
||||||
|
@ -158,6 +162,7 @@ async def test_supported_features(
|
||||||
| SUPPORT_PLAY_MEDIA
|
| SUPPORT_PLAY_MEDIA
|
||||||
| SUPPORT_TURN_ON
|
| SUPPORT_TURN_ON
|
||||||
| SUPPORT_TURN_OFF
|
| SUPPORT_TURN_OFF
|
||||||
|
| SUPPORT_BROWSE_MEDIA
|
||||||
== state.attributes.get("supported_features")
|
== state.attributes.get("supported_features")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -187,6 +192,7 @@ async def test_tv_supported_features(
|
||||||
| SUPPORT_PLAY_MEDIA
|
| SUPPORT_PLAY_MEDIA
|
||||||
| SUPPORT_TURN_ON
|
| SUPPORT_TURN_ON
|
||||||
| SUPPORT_TURN_OFF
|
| SUPPORT_TURN_OFF
|
||||||
|
| SUPPORT_BROWSE_MEDIA
|
||||||
== state.attributes.get("supported_features")
|
== state.attributes.get("supported_features")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -364,6 +370,20 @@ async def test_services(
|
||||||
|
|
||||||
remote_mock.assert_called_once_with("reverse")
|
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:
|
with patch("homeassistant.components.roku.Roku.remote") as remote_mock:
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
|
@ -450,6 +470,129 @@ async def test_tv_services(
|
||||||
tune_mock.assert_called_once_with("55")
|
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(
|
async def test_integration_services(
|
||||||
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue