diff --git a/homeassistant/components/roku/browse_media.py b/homeassistant/components/roku/browse_media.py index 8110174450e..49af4740123 100644 --- a/homeassistant/components/roku/browse_media.py +++ b/homeassistant/components/roku/browse_media.py @@ -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 diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 267e904a980..4d064d5d326 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -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", diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 154bcedded5..d42e06aceb8 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -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,