Add cast platform for extending Google Cast media_player (#65149)

* Add cast platform for extending Google Cast media_player

* Update tests

* Refactor according to review comments

* Add test for playing using a cast platform

* Apply suggestions from code review

Co-authored-by: jjlawren <jjlawren@users.noreply.github.com>

* Pass cast type instead of a filter function when browsing

* Raise on invalid cast platform

* Test media browsing

Co-authored-by: jjlawren <jjlawren@users.noreply.github.com>
This commit is contained in:
Erik Montnemery 2022-01-31 10:50:05 +01:00 committed by Paulus Schoutsen
parent 2eef05eb84
commit 73750d8a25
5 changed files with 353 additions and 100 deletions

View File

@ -1,12 +1,21 @@
"""Component to embed Google Cast.""" """Component to embed Google Cast."""
import logging from __future__ import annotations
import logging
from typing import Protocol
from pychromecast import Chromecast
import voluptuous as vol import voluptuous as vol
from homeassistant.components.media_player import BrowseMedia
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.integration_platform import (
async_process_integration_platforms,
)
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from . import home_assistant_cast from . import home_assistant_cast
@ -49,9 +58,58 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Cast from a config entry.""" """Set up Cast from a config entry."""
await home_assistant_cast.async_setup_ha_cast(hass, entry) await home_assistant_cast.async_setup_ha_cast(hass, entry)
hass.config_entries.async_setup_platforms(entry, PLATFORMS) hass.config_entries.async_setup_platforms(entry, PLATFORMS)
hass.data[DOMAIN] = {}
await async_process_integration_platforms(hass, DOMAIN, _register_cast_platform)
return True return True
class CastProtocol(Protocol):
"""Define the format of cast platforms."""
async def async_get_media_browser_root_object(
self, cast_type: str
) -> list[BrowseMedia]:
"""Create a list of root objects for media browsing."""
async def async_browse_media(
self,
hass: HomeAssistant,
media_content_type: str,
media_content_id: str,
cast_type: str,
) -> BrowseMedia | None:
"""Browse media.
Return a BrowseMedia object or None if the media does not belong to this platform.
"""
async def async_play_media(
self,
hass: HomeAssistant,
cast_entity_id: str,
chromecast: Chromecast,
media_type: str,
media_id: str,
) -> bool:
"""Play media.
Return True if the media is played by the platform, False if not.
"""
async def _register_cast_platform(
hass: HomeAssistant, integration_domain: str, platform: CastProtocol
):
"""Register a cast platform."""
if (
not hasattr(platform, "async_get_media_browser_root_object")
or not hasattr(platform, "async_browse_media")
or not hasattr(platform, "async_play_media")
):
raise HomeAssistantError(f"Invalid cast platform {platform}")
hass.data[DOMAIN][integration_domain] = platform
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove Home Assistant Cast user.""" """Remove Home Assistant Cast user."""
await home_assistant_cast.async_remove_user(hass, entry) await home_assistant_cast.async_remove_user(hass, entry)

View File

@ -11,7 +11,6 @@ from urllib.parse import quote
import pychromecast import pychromecast
from pychromecast.controllers.homeassistant import HomeAssistantController from pychromecast.controllers.homeassistant import HomeAssistantController
from pychromecast.controllers.multizone import MultizoneManager from pychromecast.controllers.multizone import MultizoneManager
from pychromecast.controllers.plex import PlexController
from pychromecast.controllers.receiver import VOLUME_CONTROL_TYPE_FIXED from pychromecast.controllers.receiver import VOLUME_CONTROL_TYPE_FIXED
from pychromecast.quick_play import quick_play from pychromecast.quick_play import quick_play
from pychromecast.socket_client import ( from pychromecast.socket_client import (
@ -20,7 +19,7 @@ from pychromecast.socket_client import (
) )
import voluptuous as vol import voluptuous as vol
from homeassistant.components import media_source, plex, zeroconf from homeassistant.components import media_source, zeroconf
from homeassistant.components.http.auth import async_sign_path from homeassistant.components.http.auth import async_sign_path
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
BrowseError, BrowseError,
@ -29,7 +28,6 @@ from homeassistant.components.media_player import (
) )
from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.const import (
ATTR_MEDIA_EXTRA, ATTR_MEDIA_EXTRA,
MEDIA_CLASS_APP,
MEDIA_CLASS_DIRECTORY, MEDIA_CLASS_DIRECTORY,
MEDIA_TYPE_MOVIE, MEDIA_TYPE_MOVIE,
MEDIA_TYPE_MUSIC, MEDIA_TYPE_MUSIC,
@ -47,8 +45,6 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_MUTE,
SUPPORT_VOLUME_SET, SUPPORT_VOLUME_SET,
) )
from homeassistant.components.plex.const import PLEX_URI_SCHEME
from homeassistant.components.plex.services import lookup_plex_media
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CAST_APP_ID_HOMEASSISTANT_LOVELACE, CAST_APP_ID_HOMEASSISTANT_LOVELACE,
@ -463,21 +459,15 @@ class CastDevice(MediaPlayerEntity):
async def _async_root_payload(self, content_filter): async def _async_root_payload(self, content_filter):
"""Generate root node.""" """Generate root node."""
children = [] children = []
# Add external sources # Add media browsers
if "plex" in self.hass.config.components: for platform in self.hass.data[CAST_DOMAIN].values():
children.append( children.extend(
BrowseMedia( await platform.async_get_media_browser_root_object(
title="Plex", self._chromecast.cast_type
media_class=MEDIA_CLASS_APP,
media_content_id="",
media_content_type="plex",
thumbnail="https://brands.home-assistant.io/_/plex/logo.png",
can_play=False,
can_expand=True,
) )
) )
# Add local media source # Add media sources
try: try:
result = await media_source.async_browse_media( result = await media_source.async_browse_media(
self.hass, None, content_filter=content_filter self.hass, None, content_filter=content_filter
@ -519,14 +509,15 @@ class CastDevice(MediaPlayerEntity):
if media_content_id is None: if media_content_id is None:
return await self._async_root_payload(content_filter) return await self._async_root_payload(content_filter)
if plex.is_plex_media_id(media_content_id): for platform in self.hass.data[CAST_DOMAIN].values():
return await plex.async_browse_media( browse_media = await platform.async_browse_media(
self.hass, media_content_type, media_content_id, platform=CAST_DOMAIN self.hass,
) media_content_type,
if media_content_type == "plex": media_content_id,
return await plex.async_browse_media( self._chromecast.cast_type,
self.hass, None, None, platform=CAST_DOMAIN
) )
if browse_media:
return browse_media
return await media_source.async_browse_media( return await media_source.async_browse_media(
self.hass, media_content_id, content_filter=content_filter self.hass, media_content_id, content_filter=content_filter
@ -556,7 +547,7 @@ class CastDevice(MediaPlayerEntity):
extra = kwargs.get(ATTR_MEDIA_EXTRA, {}) extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
metadata = extra.get("metadata") metadata = extra.get("metadata")
# We do not want this to be forwarded to a group # Handle media supported by a known cast app
if media_type == CAST_DOMAIN: if media_type == CAST_DOMAIN:
try: try:
app_data = json.loads(media_id) app_data = json.loads(media_id)
@ -588,23 +579,21 @@ class CastDevice(MediaPlayerEntity):
) )
except NotImplementedError: except NotImplementedError:
_LOGGER.error("App %s not supported", app_name) _LOGGER.error("App %s not supported", app_name)
return
# Handle plex # Try the cast platforms
elif media_id and media_id.startswith(PLEX_URI_SCHEME): for platform in self.hass.data[CAST_DOMAIN].values():
media_id = media_id[len(PLEX_URI_SCHEME) :] result = await platform.async_play_media(
media = await self.hass.async_add_executor_job( self.hass, self.entity_id, self._chromecast, media_type, media_id
lookup_plex_media, self.hass, media_type, media_id
) )
if media is None: if result:
return return
controller = PlexController()
self._chromecast.register_handler(controller) # Default to play with the default media receiver
await self.hass.async_add_executor_job(controller.play_media, media) app_data = {"media_id": media_id, "media_type": media_type, **extra}
else: await self.hass.async_add_executor_job(
app_data = {"media_id": media_id, "media_type": media_type, **extra} quick_play, self._chromecast, "default_media_receiver", app_data
await self.hass.async_add_executor_job( )
quick_play, self._chromecast, "default_media_receiver", app_data
)
def _media_status(self): def _media_status(self):
""" """

View File

@ -0,0 +1,75 @@
"""Google Cast support for the Plex component."""
from __future__ import annotations
from pychromecast import Chromecast
from pychromecast.controllers.plex import PlexController
from homeassistant.components.cast.const import DOMAIN as CAST_DOMAIN
from homeassistant.components.media_player import BrowseMedia
from homeassistant.components.media_player.const import MEDIA_CLASS_APP
from homeassistant.core import HomeAssistant
from . import async_browse_media as async_browse_plex_media, is_plex_media_id
from .const import PLEX_URI_SCHEME
from .services import lookup_plex_media
async def async_get_media_browser_root_object(cast_type: str) -> list[BrowseMedia]:
"""Create a root object for media browsing."""
return [
BrowseMedia(
title="Plex",
media_class=MEDIA_CLASS_APP,
media_content_id="",
media_content_type="plex",
thumbnail="https://brands.home-assistant.io/_/plex/logo.png",
can_play=False,
can_expand=True,
)
]
async def async_browse_media(
hass: HomeAssistant,
media_content_type: str,
media_content_id: str,
cast_type: str,
) -> BrowseMedia | None:
"""Browse media."""
if is_plex_media_id(media_content_id):
return await async_browse_plex_media(
hass, media_content_type, media_content_id, platform=CAST_DOMAIN
)
if media_content_type == "plex":
return await async_browse_plex_media(hass, None, None, platform=CAST_DOMAIN)
return None
def _play_media(
hass: HomeAssistant, chromecast: Chromecast, media_type: str, media_id: str
) -> None:
"""Play media."""
media_id = media_id[len(PLEX_URI_SCHEME) :]
media = lookup_plex_media(hass, media_type, media_id)
if media is None:
return
controller = PlexController()
chromecast.register_handler(controller)
controller.play_media(media)
async def async_play_media(
hass: HomeAssistant,
cast_entity_id: str,
chromecast: Chromecast,
media_type: str,
media_id: str,
) -> bool:
"""Play media."""
if media_id and media_id.startswith(PLEX_URI_SCHEME):
await hass.async_add_executor_job(
_play_media, hass, chromecast, media_type, media_id
)
return True
return False

View File

@ -26,12 +26,6 @@ def mz_mock():
return MagicMock(spec_set=pychromecast.controllers.multizone.MultizoneManager) return MagicMock(spec_set=pychromecast.controllers.multizone.MultizoneManager)
@pytest.fixture()
def plex_mock():
"""Mock pychromecast PlexController."""
return MagicMock(spec_set=pychromecast.controllers.plex.PlexController)
@pytest.fixture() @pytest.fixture()
def quick_play_mock(): def quick_play_mock():
"""Mock pychromecast quick_play.""" """Mock pychromecast quick_play."""
@ -51,7 +45,6 @@ def cast_mock(
castbrowser_mock, castbrowser_mock,
get_chromecast_mock, get_chromecast_mock,
get_multizone_status_mock, get_multizone_status_mock,
plex_mock,
): ):
"""Mock pychromecast.""" """Mock pychromecast."""
ignore_cec_orig = list(pychromecast.IGNORE_CEC) ignore_cec_orig = list(pychromecast.IGNORE_CEC)
@ -65,9 +58,6 @@ def cast_mock(
), patch( ), patch(
"homeassistant.components.cast.media_player.MultizoneManager", "homeassistant.components.cast.media_player.MultizoneManager",
return_value=mz_mock, return_value=mz_mock,
), patch(
"homeassistant.components.cast.media_player.PlexController",
return_value=plex_mock,
), patch( ), patch(
"homeassistant.components.cast.media_player.zeroconf.async_get_instance", "homeassistant.components.cast.media_player.zeroconf.async_get_instance",
AsyncMock(), AsyncMock(),

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from unittest.mock import ANY, MagicMock, patch from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
from uuid import UUID from uuid import UUID
import attr import attr
@ -14,7 +14,10 @@ import pytest
from homeassistant.components import media_player, tts from homeassistant.components import media_player, tts
from homeassistant.components.cast import media_player as cast from homeassistant.components.cast import media_player as cast
from homeassistant.components.cast.media_player import ChromecastInfo from homeassistant.components.cast.media_player import ChromecastInfo
from homeassistant.components.media_player import BrowseMedia
from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.const import (
MEDIA_CLASS_APP,
MEDIA_CLASS_PLAYLIST,
SUPPORT_NEXT_TRACK, SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE, SUPPORT_PAUSE,
SUPPORT_PLAY, SUPPORT_PLAY,
@ -38,7 +41,7 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, assert_setup_component from tests.common import MockConfigEntry, assert_setup_component, mock_platform
from tests.components.media_player import common from tests.components.media_player import common
# pylint: disable=invalid-name # pylint: disable=invalid-name
@ -844,54 +847,6 @@ async def test_entity_play_media_cast(hass: HomeAssistant, quick_play_mock):
) )
async def test_entity_play_media_plex(hass: HomeAssistant, plex_mock):
"""Test playing media."""
entity_id = "media_player.speaker"
info = get_fake_chromecast_info()
chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, _ = get_status_callbacks(chromecast)
connection_status = MagicMock()
connection_status.status = "CONNECTED"
conn_status_cb(connection_status)
await hass.async_block_till_done()
with patch(
"homeassistant.components.cast.media_player.lookup_plex_media",
return_value=None,
):
await hass.services.async_call(
media_player.DOMAIN,
media_player.SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: entity_id,
media_player.ATTR_MEDIA_CONTENT_TYPE: "music",
media_player.ATTR_MEDIA_CONTENT_ID: 'plex://{"library_name": "Music", "artist.title": "Not an Artist"}',
},
blocking=True,
)
assert not plex_mock.play_media.called
mock_plex_media = MagicMock()
with patch(
"homeassistant.components.cast.media_player.lookup_plex_media",
return_value=mock_plex_media,
):
await hass.services.async_call(
media_player.DOMAIN,
media_player.SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: entity_id,
media_player.ATTR_MEDIA_CONTENT_TYPE: "music",
media_player.ATTR_MEDIA_CONTENT_ID: 'plex://{"library_name": "Music", "artist.title": "Artist"}',
},
blocking=True,
)
plex_mock.play_media.assert_called_once_with(mock_plex_media)
async def test_entity_play_media_cast_invalid(hass, caplog, quick_play_mock): async def test_entity_play_media_cast_invalid(hass, caplog, quick_play_mock):
"""Test playing media.""" """Test playing media."""
entity_id = "media_player.speaker" entity_id = "media_player.speaker"
@ -1578,3 +1533,189 @@ async def test_entry_setup_list_config(hass: HomeAssistant):
assert set(config_entry.data["uuid"]) == {"bla", "blu"} assert set(config_entry.data["uuid"]) == {"bla", "blu"}
assert set(config_entry.data["ignore_cec"]) == {"cast1", "cast2", "cast3"} assert set(config_entry.data["ignore_cec"]) == {"cast1", "cast2", "cast3"}
assert set(pychromecast.IGNORE_CEC) == {"cast1", "cast2", "cast3"} assert set(pychromecast.IGNORE_CEC) == {"cast1", "cast2", "cast3"}
async def test_invalid_cast_platform(hass: HomeAssistant, caplog):
"""Test we can play media through a cast platform."""
cast_platform_mock = Mock()
del cast_platform_mock.async_get_media_browser_root_object
del cast_platform_mock.async_browse_media
del cast_platform_mock.async_play_media
mock_platform(hass, "test.cast", cast_platform_mock)
await async_setup_component(hass, "test", {"test": {}})
await hass.async_block_till_done()
info = get_fake_chromecast_info()
await async_setup_media_player_cast(hass, info)
assert "Invalid cast platform <Mock id" in caplog.text
async def test_cast_platform_play_media(hass: HomeAssistant, quick_play_mock, caplog):
"""Test we can play media through a cast platform."""
entity_id = "media_player.speaker"
_can_play = True
def can_play(*args):
return _can_play
cast_platform_mock = Mock(
async_get_media_browser_root_object=AsyncMock(return_value=[]),
async_browse_media=AsyncMock(return_value=None),
async_play_media=AsyncMock(side_effect=can_play),
)
mock_platform(hass, "test.cast", cast_platform_mock)
await async_setup_component(hass, "test", {"test": {}})
await hass.async_block_till_done()
info = get_fake_chromecast_info()
chromecast, _ = await async_setup_media_player_cast(hass, info)
assert "Invalid cast platform <Mock id" not in caplog.text
_, conn_status_cb, _ = get_status_callbacks(chromecast)
connection_status = MagicMock()
connection_status.status = "CONNECTED"
conn_status_cb(connection_status)
await hass.async_block_till_done()
# This will play using the cast platform
await hass.services.async_call(
media_player.DOMAIN,
media_player.SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: entity_id,
media_player.ATTR_MEDIA_CONTENT_TYPE: "audio",
media_player.ATTR_MEDIA_CONTENT_ID: "best.mp3",
media_player.ATTR_MEDIA_EXTRA: {"metadata": {"metadatatype": 3}},
},
blocking=True,
)
# Assert the media player attempt to play media through the cast platform
cast_platform_mock.async_play_media.assert_called_once_with(
hass, entity_id, chromecast, "audio", "best.mp3"
)
# Assert pychromecast is not used to play media
chromecast.media_controller.play_media.assert_not_called()
quick_play_mock.assert_not_called()
# This will not play using the cast platform
_can_play = False
cast_platform_mock.async_play_media.reset_mock()
await hass.services.async_call(
media_player.DOMAIN,
media_player.SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: entity_id,
media_player.ATTR_MEDIA_CONTENT_TYPE: "audio",
media_player.ATTR_MEDIA_CONTENT_ID: "best.mp3",
media_player.ATTR_MEDIA_EXTRA: {"metadata": {"metadatatype": 3}},
},
blocking=True,
)
# Assert the media player attempt to play media through the cast platform
cast_platform_mock.async_play_media.assert_called_once_with(
hass, entity_id, chromecast, "audio", "best.mp3"
)
# Assert pychromecast is used to play media
chromecast.media_controller.play_media.assert_not_called()
quick_play_mock.assert_called()
async def test_cast_platform_browse_media(hass: HomeAssistant, hass_ws_client):
"""Test we can play media through a cast platform."""
cast_platform_mock = Mock(
async_get_media_browser_root_object=AsyncMock(
return_value=[
BrowseMedia(
title="Spotify",
media_class=MEDIA_CLASS_APP,
media_content_id="",
media_content_type="spotify",
thumbnail="https://brands.home-assistant.io/_/spotify/logo.png",
can_play=False,
can_expand=True,
)
]
),
async_browse_media=AsyncMock(
return_value=BrowseMedia(
title="Spotify Favourites",
media_class=MEDIA_CLASS_PLAYLIST,
media_content_id="",
media_content_type="spotify",
can_play=True,
can_expand=False,
)
),
async_play_media=AsyncMock(return_value=False),
)
mock_platform(hass, "test.cast", cast_platform_mock)
await async_setup_component(hass, "test", {"test": {}})
await async_setup_component(hass, "media_source", {"media_source": {}})
await hass.async_block_till_done()
info = get_fake_chromecast_info()
chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, _ = get_status_callbacks(chromecast)
connection_status = MagicMock()
connection_status.status = "CONNECTED"
conn_status_cb(connection_status)
await hass.async_block_till_done()
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "media_player/browse_media",
"entity_id": "media_player.speaker",
}
)
response = await client.receive_json()
assert response["success"]
expected_child = {
"title": "Spotify",
"media_class": "app",
"media_content_type": "spotify",
"media_content_id": "",
"can_play": False,
"can_expand": True,
"children_media_class": None,
"thumbnail": "https://brands.home-assistant.io/_/spotify/logo.png",
}
assert expected_child in response["result"]["children"]
client = await hass_ws_client()
await client.send_json(
{
"id": 2,
"type": "media_player/browse_media",
"entity_id": "media_player.speaker",
"media_content_id": "",
"media_content_type": "spotify",
}
)
response = await client.receive_json()
assert response["success"]
expected_response = {
"title": "Spotify Favourites",
"media_class": "playlist",
"media_content_type": "spotify",
"media_content_id": "",
"can_play": True,
"can_expand": False,
"children_media_class": None,
"thumbnail": None,
"children": [],
}
assert response["result"] == expected_response