From e6e4c9cf591e43383971a05a38d4dbf6805223a2 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 19 Oct 2020 16:34:22 -0500 Subject: [PATCH] Allow Cast to play Plex media (#41869) * Allow Cast to play Plex media * Add Plex to after_dependencies, add missing constant * Extract function from Sonos service to allow media lookups * Move to non-async method * Check if media_id exists * Add test to make codecov happy --- homeassistant/components/cast/manifest.json | 2 +- homeassistant/components/cast/media_player.py | 12 +++++ homeassistant/components/plex/__init__.py | 38 +++----------- homeassistant/components/plex/const.py | 2 + homeassistant/components/plex/services.py | 49 +++++++++++++++---- tests/components/plex/const.py | 10 ++++ tests/components/plex/test_playback.py | 41 +++++++++++++++- 7 files changed, 110 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index dc2f21b32c0..3a795b60420 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", "requirements": ["pychromecast==7.5.1"], - "after_dependencies": ["cloud", "http", "media_source", "tts", "zeroconf"], + "after_dependencies": ["cloud", "http", "media_source", "plex", "tts", "zeroconf"], "zeroconf": ["_googlecast._tcp.local."], "codeowners": ["@emontnemery"] } diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 6b01b21848e..97956965b66 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -9,6 +9,7 @@ from typing import Optional import pychromecast from pychromecast.controllers.homeassistant import HomeAssistantController from pychromecast.controllers.multizone import MultizoneManager +from pychromecast.controllers.plex import PlexController from pychromecast.quick_play import quick_play from pychromecast.socket_client import ( CONNECTION_STATUS_CONNECTED, @@ -38,6 +39,8 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) +from homeassistant.components.plex.const import PLEX_URI_SCHEME +from homeassistant.components.plex.services import lookup_plex_media from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, STATE_IDLE, @@ -536,6 +539,15 @@ class CastDevice(MediaPlayerEntity): quick_play(self._chromecast, app_name, app_data) except NotImplementedError: _LOGGER.error("App %s not supported", app_name) + # Handle plex + elif media_id and media_id.startswith(PLEX_URI_SCHEME): + media_id = media_id[len(PLEX_URI_SCHEME) :] + media, _ = lookup_plex_media(self.hass, media_type, media_id) + if media is None: + return + controller = PlexController() + self._chromecast.register_handler(controller) + controller.play_media(media) else: self._chromecast.media_controller.play_media( media_id, media_type, **kwargs.get(ATTR_MEDIA_EXTRA, {}) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 306e81be65a..32ee5c56f64 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -2,7 +2,6 @@ import asyncio import functools from functools import partial -import json import logging import plexapi.exceptions @@ -58,7 +57,7 @@ from .const import ( ) from .errors import ShouldUpdateConfigEntry from .server import PlexServer -from .services import async_setup_services +from .services import async_setup_services, lookup_plex_media _LOGGER = logging.getLogger(__package__) @@ -286,7 +285,7 @@ def play_on_sonos(hass, service_call): """Play Plex media on a linked Sonos device.""" entity_id = service_call.data[ATTR_ENTITY_ID] content_id = service_call.data[ATTR_MEDIA_CONTENT_ID] - content = json.loads(content_id) + content_type = service_call.data.get(ATTR_MEDIA_CONTENT_TYPE) sonos = hass.components.sonos try: @@ -295,27 +294,9 @@ def play_on_sonos(hass, service_call): _LOGGER.error("Cannot get Sonos device: %s", err) return - if isinstance(content, int): - content = {"plex_key": content} - content_type = PLEX_DOMAIN - else: - content_type = "music" - - plex_server_name = content.get("plex_server") - shuffle = content.pop("shuffle", 0) - - plex_servers = hass.data[PLEX_DOMAIN][SERVERS].values() - if plex_server_name: - plex_server = [x for x in plex_servers if x.friendly_name == plex_server_name] - if not plex_server: - _LOGGER.error( - "Requested Plex server '%s' not found in %s", - plex_server_name, - [x.friendly_name for x in plex_servers], - ) - return - else: - plex_server = next(iter(plex_servers)) + media, plex_server = lookup_plex_media(hass, content_type, content_id) + if media is None: + return sonos_speaker = plex_server.account.sonos_speaker(sonos_name) if sonos_speaker is None: @@ -324,11 +305,4 @@ def play_on_sonos(hass, service_call): ) return - media = plex_server.lookup_media(content_type, **content) - if media is None: - _LOGGER.error("Media could not be found: %s", content) - return - - _LOGGER.debug("Attempting to play '%s' on %s", media, sonos_speaker) - playqueue = plex_server.create_playqueue(media, shuffle=shuffle) - sonos_speaker.playMedia(playqueue) + sonos_speaker.playMedia(media) diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index 44505ddd5db..f54c376c667 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -49,3 +49,5 @@ MANUAL_SETUP_STRING = "Configure Plex server manually" SERVICE_PLAY_ON_SONOS = "play_on_sonos" SERVICE_REFRESH_LIBRARY = "refresh_library" SERVICE_SCAN_CLIENTS = "scan_for_clients" + +PLEX_URI_SCHEME = "plex://" diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index c16da485b57..19a04a61a9f 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -1,4 +1,5 @@ """Services for the Plex integration.""" +import json import logging from plexapi.exceptions import NotFound @@ -73,19 +74,47 @@ def get_plex_server(hass, plex_server_name=None): plex_servers = hass.data[DOMAIN][SERVERS].values() if plex_server_name: - plex_server = [x for x in plex_servers if x.friendly_name == plex_server_name] - if not plex_server: - _LOGGER.error( - "Requested Plex server '%s' not found in %s", - plex_server_name, - [x.friendly_name for x in plex_servers], - ) - return None - elif len(plex_servers) == 1: + plex_server = next( + (x for x in plex_servers if x.friendly_name == plex_server_name), None + ) + if plex_server is not None: + return plex_server + _LOGGER.error( + "Requested Plex server '%s' not found in %s", + plex_server_name, + [x.friendly_name for x in plex_servers], + ) + return None + + if len(plex_servers) == 1: return next(iter(plex_servers)) _LOGGER.error( - "Multiple Plex servers configured and no selection made: %s", + "Multiple Plex servers configured, choose with 'plex_server' key: %s", [x.friendly_name for x in plex_servers], ) return None + + +def lookup_plex_media(hass, content_type, content_id): + """Look up Plex media using media_player.play_media service payloads.""" + content = json.loads(content_id) + + if isinstance(content, int): + content = {"plex_key": content} + content_type = DOMAIN + + plex_server_name = content.pop("plex_server", None) + shuffle = content.pop("shuffle", 0) + + plex_server = get_plex_server(hass, plex_server_name=plex_server_name) + if not plex_server: + return (None, None) + + media = plex_server.lookup_media(content_type, **content) + if media is None: + _LOGGER.error("Media could not be found: %s", content) + return (None, None) + + playqueue = plex_server.create_playqueue(media, shuffle=shuffle) + return (playqueue, plex_server) diff --git a/tests/components/plex/const.py b/tests/components/plex/const.py index bfc4b8ef78e..548be2edeb8 100644 --- a/tests/components/plex/const.py +++ b/tests/components/plex/const.py @@ -43,6 +43,16 @@ DEFAULT_DATA = { }, const.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][const.CONF_SERVER_IDENTIFIER], } +SECONDARY_DATA = { + const.CONF_SERVER: MOCK_SERVERS[1][const.CONF_SERVER], + const.PLEX_SERVER_CONFIG: { + CONF_CLIENT_ID: "00000000-0000-0000-0000-000000000002", + CONF_TOKEN: MOCK_TOKEN, + CONF_URL: f"https://{MOCK_SERVERS[1][CONF_HOST]}:{MOCK_SERVERS[1][CONF_PORT]}", + CONF_VERIFY_SSL: True, + }, + const.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[1][const.CONF_SERVER_IDENTIFIER], +} DEFAULT_OPTIONS = { MP_DOMAIN: { diff --git a/tests/components/plex/test_playback.py b/tests/components/plex/test_playback.py index bd694419421..5119286d3b8 100644 --- a/tests/components/plex/test_playback.py +++ b/tests/components/plex/test_playback.py @@ -6,11 +6,19 @@ from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_TYPE, MEDIA_TYPE_MUSIC, ) -from homeassistant.components.plex.const import DOMAIN, SERVERS, SERVICE_PLAY_ON_SONOS +from homeassistant.components.plex.const import ( + CONF_SERVER, + DOMAIN, + SERVERS, + SERVICE_PLAY_ON_SONOS, +) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.exceptions import HomeAssistantError +from .const import DEFAULT_OPTIONS, SECONDARY_DATA + from tests.async_mock import patch +from tests.common import MockConfigEntry async def test_sonos_playback(hass, mock_plex_server): @@ -108,6 +116,8 @@ async def test_sonos_playback(hass, mock_plex_server): hass.components.sonos, "get_coordinator_name", return_value="media_player.sonos_kitchen", + ), patch( + "plexapi.playqueue.PlayQueue.create" ): assert await hass.services.async_call( DOMAIN, @@ -119,3 +129,32 @@ async def test_sonos_playback(hass, mock_plex_server): }, True, ) + + +async def test_playback_multiple_servers(hass, mock_websocket, setup_plex_server): + """Test playing media when multiple servers available.""" + secondary_entry = MockConfigEntry( + domain=DOMAIN, + data=SECONDARY_DATA, + options=DEFAULT_OPTIONS, + unique_id=SECONDARY_DATA["server_id"], + ) + + await setup_plex_server() + await setup_plex_server(config_entry=secondary_entry) + + with patch.object( + hass.components.sonos, + "get_coordinator_name", + return_value="media_player.sonos_kitchen", + ), patch("plexapi.playqueue.PlayQueue.create"): + assert await hass.services.async_call( + DOMAIN, + SERVICE_PLAY_ON_SONOS, + { + ATTR_ENTITY_ID: "media_player.sonos_kitchen", + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_CONTENT_ID: f'{{"plex_server": "{SECONDARY_DATA[CONF_SERVER]}", "library_name": "Music", "artist_name": "Artist", "album_name": "Album"}}', + }, + True, + )