Allow roku to browse and play local media (#64799)

pull/64848/head
Chris Talkington 2022-01-24 10:34:09 -06:00 committed by GitHub
parent 8ea2f865ed
commit 3e29fe5a67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 447 additions and 169 deletions

View File

@ -1,5 +1,11 @@
"""Support for media browsing."""
from __future__ import annotations
from collections.abc import Callable
from functools import partial
from urllib.parse import quote_plus
from homeassistant.components import media_source
from homeassistant.components.media_player import BrowseMedia
from homeassistant.components.media_player.const import (
MEDIA_CLASS_APP,
@ -10,6 +16,11 @@ from homeassistant.components.media_player.const import (
MEDIA_TYPE_CHANNEL,
MEDIA_TYPE_CHANNELS,
)
from homeassistant.components.media_player.errors import BrowseError
from homeassistant.core import HomeAssistant
from homeassistant.helpers.network import is_internal_request
from .coordinator import RokuDataUpdateCoordinator
CONTENT_TYPE_MEDIA_CLASS = {
MEDIA_TYPE_APP: MEDIA_CLASS_APP,
@ -33,8 +44,133 @@ EXPANDABLE_MEDIA_TYPES = [
MEDIA_TYPE_CHANNELS,
]
GetBrowseImageUrlType = Callable[[str, str, "str | None"], str]
def build_item_response(coordinator, payload, get_thumbnail_url=None):
def get_thumbnail_url_full(
coordinator: RokuDataUpdateCoordinator,
is_internal: bool,
get_browse_image_url: GetBrowseImageUrlType,
media_content_type: str,
media_content_id: str,
media_image_id: str | None = None,
) -> str | None:
"""Get thumbnail URL."""
if is_internal:
if media_content_type == MEDIA_TYPE_APP and media_content_id:
return coordinator.roku.app_icon_url(media_content_id)
return None
return get_browse_image_url(
media_content_type,
quote_plus(media_content_id),
media_image_id,
)
async def async_browse_media(
hass,
coordinator: RokuDataUpdateCoordinator,
get_browse_image_url: GetBrowseImageUrlType,
media_content_id: str | None,
media_content_type: str | None,
):
"""Browse media."""
if media_content_id is None:
return await root_payload(
hass,
coordinator,
get_browse_image_url,
)
if media_source.is_media_source_id(media_content_id):
return await media_source.async_browse_media(hass, media_content_id)
payload = {
"search_type": media_content_type,
"search_id": media_content_id,
}
response = await hass.async_add_executor_job(
build_item_response,
coordinator,
payload,
partial(
get_thumbnail_url_full,
coordinator,
is_internal_request(hass),
get_browse_image_url,
),
)
if response is None:
raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}")
return response
async def root_payload(
hass: HomeAssistant,
coordinator: RokuDataUpdateCoordinator,
get_browse_image_url: GetBrowseImageUrlType,
):
"""Return root payload for Roku."""
device = coordinator.data
children = [
item_payload(
{"title": "Apps", "type": MEDIA_TYPE_APPS},
coordinator,
get_browse_image_url,
)
]
if device.info.device_type == "tv" and len(device.channels) > 0:
children.append(
item_payload(
{"title": "TV Channels", "type": MEDIA_TYPE_CHANNELS},
coordinator,
get_browse_image_url,
)
)
try:
browse_item = await media_source.async_browse_media(hass, None)
# If domain is None, it's overview of available sources
if browse_item.domain is None:
if browse_item.children is not None:
children.extend(browse_item.children)
else:
children.append(browse_item)
except media_source.BrowseError:
pass
if len(children) == 1:
return await async_browse_media(
hass,
coordinator,
get_browse_image_url,
children[0].media_content_id,
children[0].media_content_type,
)
return BrowseMedia(
title="Roku",
media_class=MEDIA_CLASS_DIRECTORY,
media_content_id="",
media_content_type="root",
can_play=False,
can_expand=True,
children=children,
)
def build_item_response(
coordinator: RokuDataUpdateCoordinator,
payload: dict,
get_browse_image_url: GetBrowseImageUrlType,
) -> BrowseMedia | None:
"""Create response payload for the provided media query."""
search_id = payload["search_id"]
search_type = payload["search_type"]
@ -52,7 +188,7 @@ def build_item_response(coordinator, payload, get_thumbnail_url=None):
]
children_media_class = MEDIA_CLASS_APP
elif search_type == MEDIA_TYPE_CHANNELS:
title = "Channels"
title = "TV Channels"
media = [
{
"channel_number": item.number,
@ -63,7 +199,7 @@ def build_item_response(coordinator, payload, get_thumbnail_url=None):
]
children_media_class = MEDIA_CLASS_CHANNEL
if media is None:
if title is None or media is None:
return None
return BrowseMedia(
@ -75,13 +211,19 @@ def build_item_response(coordinator, payload, get_thumbnail_url=None):
title=title,
can_play=search_type in PLAYABLE_MEDIA_TYPES and search_id,
can_expand=True,
children=[item_payload(item, coordinator, get_thumbnail_url) for item in media],
children=[
item_payload(item, coordinator, get_browse_image_url) for item in media
],
children_media_class=children_media_class,
thumbnail=thumbnail,
)
def item_payload(item, coordinator, get_thumbnail_url=None):
def item_payload(
item: dict,
coordinator: RokuDataUpdateCoordinator,
get_browse_image_url: GetBrowseImageUrlType,
):
"""
Create response payload for a single media item.
@ -92,8 +234,7 @@ def item_payload(item, coordinator, get_thumbnail_url=None):
if "app_id" in item:
media_content_type = MEDIA_TYPE_APP
media_content_id = item["app_id"]
if get_thumbnail_url:
thumbnail = get_thumbnail_url(media_content_type, media_content_id)
thumbnail = get_browse_image_url(media_content_type, media_content_id, None)
elif "channel_number" in item:
media_content_type = MEDIA_TYPE_CHANNEL
media_content_id = item["channel_number"]
@ -114,52 +255,3 @@ def item_payload(item, coordinator, get_thumbnail_url=None):
can_expand=can_expand,
thumbnail=thumbnail,
)
def library_payload(coordinator, get_thumbnail_url=None):
"""
Create response payload to describe contents of a specific library.
Used by async_browse_media.
"""
library_info = BrowseMedia(
media_class=MEDIA_CLASS_DIRECTORY,
media_content_id="library",
media_content_type="library",
title="Media Library",
can_play=False,
can_expand=True,
children=[],
)
library = {
MEDIA_TYPE_APPS: "Apps",
MEDIA_TYPE_CHANNELS: "Channels",
}
for item in [{"title": name, "type": type_} for type_, name in library.items()]:
if (
item["type"] == MEDIA_TYPE_CHANNELS
and coordinator.data.info.device_type != "tv"
):
continue
library_info.children.append(
item_payload(
{"title": item["title"], "type": item["type"]},
coordinator,
get_thumbnail_url,
)
)
if all(
child.media_content_type == MEDIA_TYPE_APPS for child in library_info.children
):
library_info.children_media_class = MEDIA_CLASS_APP
elif all(
child.media_content_type == MEDIA_TYPE_CHANNELS
for child in library_info.children
):
library_info.children_media_class = MEDIA_CLASS_CHANNEL
return library_info

View File

@ -4,9 +4,12 @@ from __future__ import annotations
import datetime as dt
import logging
from typing import Any
from urllib.parse import quote
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.http.auth import async_sign_path
from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerDeviceClass,
@ -29,7 +32,6 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_MUTE,
SUPPORT_VOLUME_STEP,
)
from homeassistant.components.media_player.errors import BrowseError
from homeassistant.components.stream.const import FORMAT_CONTENT_TYPE, HLS_PROVIDER
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@ -44,10 +46,10 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.network import is_internal_request
from homeassistant.helpers.network import get_url
from . import roku_exception_handler
from .browse_media import build_item_response, library_payload
from .browse_media import async_browse_media
from .const import (
ATTR_CONTENT_ID,
ATTR_FORMAT,
@ -287,35 +289,13 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the websocket media browsing helper."""
is_internal = is_internal_request(self.hass)
def _get_thumbnail_url(
media_content_type, media_content_id, media_image_id=None
):
if is_internal:
if media_content_type == MEDIA_TYPE_APP and media_content_id:
return self.coordinator.roku.app_icon_url(media_content_id)
return None
return self.get_browse_image_url(
media_content_type, media_content_id, media_image_id
)
if media_content_type in [None, "library"]:
return library_payload(self.coordinator, _get_thumbnail_url)
payload = {
"search_type": media_content_type,
"search_id": media_content_id,
}
response = build_item_response(self.coordinator, payload, _get_thumbnail_url)
if response is None:
raise BrowseError(
f"Media not found: {media_content_type} / {media_content_id}"
)
return response
return await async_browse_media(
self.hass,
self.coordinator,
self.get_browse_image_url,
media_content_id,
media_content_type,
)
@roku_exception_handler
async def async_turn_on(self) -> None:
@ -380,9 +360,27 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
@roku_exception_handler
async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None:
"""Tune to channel."""
"""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 {}
# 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
# Sign and prefix with URL if playing a relative URL
if media_id[0] == "/":
media_id = async_sign_path(
self.hass,
quote(media_id),
dt.timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME),
)
# prepend external URL
hass_url = get_url(self.hass)
media_id = f"{hass_url}{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",

View File

@ -22,6 +22,7 @@ from homeassistant.components.media_player.const import (
MEDIA_CLASS_APP,
MEDIA_CLASS_CHANNEL,
MEDIA_CLASS_DIRECTORY,
MEDIA_CLASS_VIDEO,
MEDIA_TYPE_APP,
MEDIA_TYPE_APPS,
MEDIA_TYPE_CHANNEL,
@ -75,6 +76,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, async_fire_time_changed
@ -523,6 +525,38 @@ async def test_services(
mock_roku.launch.assert_called_with("12")
async def test_services_play_media_local_source(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_roku: MagicMock,
) -> None:
"""Test the media player services related to playing media."""
local_media = hass.config.path("media")
await async_process_ha_core_config(
hass, {"media_dirs": {"local": local_media, "recordings": local_media}}
)
await hass.async_block_till_done()
assert await async_setup_component(hass, "media_source", {})
await hass.async_block_till_done()
await hass.services.async_call(
MP_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: MAIN_ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: "video/mp4",
ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4",
},
blocking=True,
)
assert mock_roku.play_video.call_count == 1
assert mock_roku.play_video.call_args
call_args = mock_roku.play_video.call_args.args
assert "/media/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[0]
@pytest.mark.parametrize("mock_roku", ["roku/rokutv-7820x.json"], indirect=True)
async def test_tv_services(
hass: HomeAssistant,
@ -572,7 +606,6 @@ async def test_tv_services(
mock_roku.tune.assert_called_with("55")
@pytest.mark.parametrize("mock_roku", ["roku/rokutv-7820x.json"], indirect=True)
async def test_media_browse(
hass,
init_integration,
@ -582,6 +615,237 @@ async def test_media_browse(
"""Test browsing media."""
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 1,
"type": "media_player/browse_media",
"entity_id": MAIN_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"] == "Apps"
assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY
assert msg["result"]["media_content_type"] == MEDIA_TYPE_APPS
assert msg["result"]["children_media_class"] == MEDIA_CLASS_APP
assert msg["result"]["can_expand"]
assert not msg["result"]["can_play"]
assert len(msg["result"]["children"]) == 8
assert msg["result"]["children_media_class"] == MEDIA_CLASS_APP
assert msg["result"]["children"][0]["title"] == "Roku Channel Store"
assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_APP
assert msg["result"]["children"][0]["media_content_id"] == "11"
assert "/browse_media/app/11" in msg["result"]["children"][0]["thumbnail"]
assert msg["result"]["children"][0]["can_play"]
# test invalid media type
await client.send_json(
{
"id": 2,
"type": "media_player/browse_media",
"entity_id": MAIN_ENTITY_ID,
"media_content_type": "invalid",
"media_content_id": "invalid",
}
)
msg = await client.receive_json()
assert msg["id"] == 2
assert msg["type"] == TYPE_RESULT
assert not msg["success"]
async def test_media_browse_internal(
hass,
init_integration,
mock_roku,
hass_ws_client,
):
"""Test browsing media with internal url."""
await async_process_ha_core_config(
hass,
{"internal_url": "http://example.local:8123"},
)
assert hass.config.internal_url == "http://example.local:8123"
client = await hass_ws_client(hass)
with patch(
"homeassistant.helpers.network._get_request_host", return_value="example.local"
):
await client.send_json(
{
"id": 1,
"type": "media_player/browse_media",
"entity_id": MAIN_ENTITY_ID,
"media_content_type": MEDIA_TYPE_APPS,
"media_content_id": "apps",
}
)
msg = await client.receive_json()
assert msg["id"] == 1
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert msg["result"]
assert msg["result"]["title"] == "Apps"
assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY
assert msg["result"]["media_content_type"] == MEDIA_TYPE_APPS
assert msg["result"]["children_media_class"] == MEDIA_CLASS_APP
assert msg["result"]["can_expand"]
assert not msg["result"]["can_play"]
assert len(msg["result"]["children"]) == 8
assert msg["result"]["children_media_class"] == MEDIA_CLASS_APP
assert msg["result"]["children"][0]["title"] == "Roku Channel Store"
assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_APP
assert msg["result"]["children"][0]["media_content_id"] == "11"
assert "/query/icon/11" in msg["result"]["children"][0]["thumbnail"]
assert msg["result"]["children"][0]["can_play"]
async def test_media_browse_local_source(
hass,
init_integration,
mock_roku,
hass_ws_client,
):
"""Test browsing local media source."""
local_media = hass.config.path("media")
await async_process_ha_core_config(
hass, {"media_dirs": {"local": local_media, "recordings": local_media}}
)
await hass.async_block_till_done()
assert await async_setup_component(hass, "media_source", {})
await hass.async_block_till_done()
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 1,
"type": "media_player/browse_media",
"entity_id": MAIN_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"] == "Roku"
assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY
assert msg["result"]["media_content_type"] == "root"
assert msg["result"]["can_expand"]
assert not msg["result"]["can_play"]
assert len(msg["result"]["children"]) == 2
assert msg["result"]["children"][0]["title"] == "Apps"
assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_APPS
assert msg["result"]["children"][1]["title"] == "Local Media"
assert msg["result"]["children"][1]["media_class"] == MEDIA_CLASS_DIRECTORY
assert msg["result"]["children"][1]["media_content_type"] is None
assert (
msg["result"]["children"][1]["media_content_id"]
== "media-source://media_source"
)
assert not msg["result"]["children"][1]["can_play"]
assert msg["result"]["children"][1]["can_expand"]
# test local media
await client.send_json(
{
"id": 2,
"type": "media_player/browse_media",
"entity_id": MAIN_ENTITY_ID,
"media_content_type": "",
"media_content_id": "media-source://media_source",
}
)
msg = await client.receive_json()
assert msg["id"] == 2
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert msg["result"]
assert msg["result"]["title"] == "Local Media"
assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY
assert msg["result"]["media_content_type"] is None
assert len(msg["result"]["children"]) == 2
assert msg["result"]["children"][0]["title"] == "media"
assert msg["result"]["children"][0]["media_content_type"] == ""
assert (
msg["result"]["children"][0]["media_content_id"]
== "media-source://media_source/local/."
)
assert msg["result"]["children"][1]["title"] == "media"
assert msg["result"]["children"][1]["media_content_type"] == ""
assert (
msg["result"]["children"][1]["media_content_id"]
== "media-source://media_source/recordings/."
)
# test local media directory
await client.send_json(
{
"id": 3,
"type": "media_player/browse_media",
"entity_id": MAIN_ENTITY_ID,
"media_content_type": "",
"media_content_id": "media-source://media_source/local/.",
}
)
msg = await client.receive_json()
assert msg["id"] == 3
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert msg["result"]["title"] == "media"
assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY
assert msg["result"]["media_content_type"] == ""
assert len(msg["result"]["children"]) == 2
assert msg["result"]["children"][0]["title"] == "Epic Sax Guy 10 Hours.mp4"
assert msg["result"]["children"][0]["media_class"] == MEDIA_CLASS_VIDEO
assert msg["result"]["children"][0]["media_content_type"] == "video/mp4"
assert (
msg["result"]["children"][0]["media_content_id"]
== "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4"
)
@pytest.mark.parametrize("mock_roku", ["roku/rokutv-7820x.json"], indirect=True)
async def test_tv_media_browse(
hass,
init_integration,
mock_roku,
hass_ws_client,
):
"""Test browsing media."""
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 1,
@ -597,9 +861,9 @@ async def test_media_browse(
assert msg["success"]
assert msg["result"]
assert msg["result"]["title"] == "Media Library"
assert msg["result"]["title"] == "Roku"
assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY
assert msg["result"]["media_content_type"] == "library"
assert msg["result"]["media_content_type"] == "root"
assert msg["result"]["can_expand"]
assert not msg["result"]["can_play"]
assert len(msg["result"]["children"]) == 2
@ -663,7 +927,7 @@ async def test_media_browse(
assert msg["success"]
assert msg["result"]
assert msg["result"]["title"] == "Channels"
assert msg["result"]["title"] == "TV Channels"
assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY
assert msg["result"]["media_content_type"] == MEDIA_TYPE_CHANNELS
assert msg["result"]["children_media_class"] == MEDIA_CLASS_CHANNEL
@ -677,82 +941,6 @@ async def test_media_browse(
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"]
@pytest.mark.parametrize("mock_roku", ["roku/rokutv-7820x.json"], indirect=True)
async def test_media_browse_internal(
hass,
init_integration,
mock_roku,
hass_ws_client,
):
"""Test browsing media with internal url."""
await async_process_ha_core_config(
hass,
{"internal_url": "http://example.local:8123"},
)
assert hass.config.internal_url == "http://example.local:8123"
client = await hass_ws_client(hass)
with patch(
"homeassistant.helpers.network._get_request_host", return_value="example.local"
):
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_class"] == MEDIA_CLASS_DIRECTORY
assert msg["result"]["media_content_type"] == MEDIA_TYPE_APPS
assert msg["result"]["children_media_class"] == MEDIA_CLASS_APP
assert msg["result"]["can_expand"]
assert not msg["result"]["can_play"]
assert len(msg["result"]["children"]) == 11
assert msg["result"]["children_media_class"] == MEDIA_CLASS_APP
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 "/query/icon/tvinput.hdmi2" in msg["result"]["children"][0]["thumbnail"]
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 "/query/icon/11" in msg["result"]["children"][3]["thumbnail"]
assert msg["result"]["children"][3]["can_play"]
async def test_integration_services(
hass: HomeAssistant,