diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 7f8caf8390b..cc281ed8147 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -1,6 +1,7 @@ """Support to embed Plex.""" import asyncio import functools +import json import logging import plexapi.exceptions @@ -10,7 +11,12 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, +) from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, CONF_SSL, @@ -19,7 +25,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -44,6 +50,7 @@ from .const import ( PLEX_SERVER_CONFIG, PLEX_UPDATE_PLATFORMS_SIGNAL, SERVERS, + SERVICE_PLAY_ON_SONOS, WEBSOCKETS, ) from .errors import ShouldUpdateConfigEntry @@ -215,6 +222,24 @@ async def async_setup_entry(hass, entry): ) task.add_done_callback(functools.partial(start_websocket_session, platform)) + async def async_play_on_sonos_service(service_call): + await hass.async_add_executor_job(play_on_sonos, hass, service_call) + + play_on_sonos_schema = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_MEDIA_CONTENT_ID): str, + vol.Optional(ATTR_MEDIA_CONTENT_TYPE): vol.In("music"), + } + ) + + hass.services.async_register( + PLEX_DOMAIN, + SERVICE_PLAY_ON_SONOS, + async_play_on_sonos_service, + schema=play_on_sonos_schema, + ) + return True @@ -244,3 +269,52 @@ async def async_options_updated(hass, entry): """Triggered by config entry options updates.""" server_id = entry.data[CONF_SERVER_IDENTIFIER] hass.data[PLEX_DOMAIN][SERVERS][server_id].options = entry.options + + +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) + + sonos = hass.components.sonos + try: + sonos_id = sonos.get_coordinator_id(entity_id) + except HomeAssistantError as err: + _LOGGER.error("Cannot get Sonos device: %s", err) + return + + if isinstance(content, int): + content = {"plex_key": content} + + 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, + list(map(lambda x: x.friendly_name, plex_servers)), + ) + return + else: + plex_server = next(iter(plex_servers)) + + sonos_speaker = plex_server.account.sonos_speaker_by_id(sonos_id) + if sonos_speaker is None: + _LOGGER.error( + "Sonos speaker '%s' could not be found on this Plex account", sonos_id + ) + return + + media = plex_server.lookup_media("music", **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) diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index e8bcfb42ca6..d17710c4436 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -43,3 +43,5 @@ X_PLEX_VERSION = __version__ AUTOMATIC_SETUP_STRING = "Obtain a new token from plex.tv" MANUAL_SETUP_STRING = "Configure Plex server manually" + +SERVICE_PLAY_ON_SONOS = "play_on_sonos" diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index e48a37a77d5..b864cae3557 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/plex", "requirements": ["plexapi==3.6.0", "plexauth==0.0.5", "plexwebsocket==0.0.8"], "dependencies": ["http"], + "after_dependencies": ["sonos"], "codeowners": ["@jjlawren"] } diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index 933a899e3b2..88b4b533f88 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -57,6 +57,7 @@ class PlexServer: def __init__(self, hass, server_config, known_server_id=None, options=None): """Initialize a Plex server instance.""" self.hass = hass + self._plex_account = None self._plex_server = None self._created_clients = set() self._known_clients = set() @@ -85,6 +86,13 @@ class PlexServer: plexapi.myplex.BASE_HEADERS = plexapi.reset_base_headers() plexapi.server.BASE_HEADERS = plexapi.reset_base_headers() + @property + def account(self): + """Return a MyPlexAccount instance.""" + if not self._plex_account: + self._plex_account = plexapi.myplex.MyPlexAccount(token=self._token) + return self._plex_account + def connect(self): """Connect to a Plex server directly, obtaining direct URL if necessary.""" config_entry_update_needed = False diff --git a/homeassistant/components/plex/services.yaml b/homeassistant/components/plex/services.yaml new file mode 100644 index 00000000000..0245edfb99e --- /dev/null +++ b/homeassistant/components/plex/services.yaml @@ -0,0 +1,13 @@ +play_on_sonos: + description: Play music hosted on a Plex server on a linked Sonos speaker. + fields: + entity_id: + description: Entity ID of a media_player from the Sonos integration. + example: "media_player.sonos_living_room" + media_content_id: + description: The ID of the content to play. See https://www.home-assistant.io/integrations/plex/#music for details. + example: >- + '{ "library_name": "Music", "artist_name": "Stevie Wonder" }' + media_content_type: + description: The type of content to play. Must be "music". + example: "music" diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index c3a977e32e1..f19816e865d 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -4,9 +4,11 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.const import CONF_HOSTS +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.loader import bind_hass -from .const import DOMAIN +from .const import DATA_SONOS, DOMAIN CONF_ADVERTISE_ADDR = "advertise_addr" CONF_INTERFACE_ADDR = "interface_addr" @@ -53,3 +55,21 @@ async def async_setup_entry(hass, entry): hass.config_entries.async_forward_entry_setup(entry, MP_DOMAIN) ) return True + + +@bind_hass +def get_coordinator_id(hass, entity_id): + """Obtain the unique_id of a device's coordinator. + + This function is safe to run inside the event loop. + """ + if DATA_SONOS not in hass.data: + raise HomeAssistantError("Sonos integration not set up") + + device = next( + (x for x in hass.data[DATA_SONOS].entities if x.entity_id == entity_id), None + ) + + if device.is_coordinator: + return device.unique_id + return device.coordinator.unique_id diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 5858f2bca9b..0d88249f740 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -1,3 +1,4 @@ """Const for Sonos.""" DOMAIN = "sonos" +DATA_SONOS = "sonos_media_player" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index b4a0fb7d208..15a168047e9 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -47,6 +47,7 @@ from . import ( CONF_ADVERTISE_ADDR, CONF_HOSTS, CONF_INTERFACE_ADDR, + DATA_SONOS, DOMAIN as SONOS_DOMAIN, ) @@ -70,8 +71,6 @@ SUPPORT_SONOS = ( | SUPPORT_CLEAR_PLAYLIST ) -DATA_SONOS = "sonos_media_player" - SOURCE_LINEIN = "Line-in" SOURCE_TV = "TV" diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py index 0c082474eb2..2d69801e797 100644 --- a/tests/components/plex/mock_classes.py +++ b/tests/components/plex/mock_classes.py @@ -69,6 +69,10 @@ class MockPlexAccount: """Mock the PlexAccount resources listing method.""" return self._resources + def sonos_speaker_by_id(self, machine_identifier): + """Mock the PlexAccount Sonos lookup method.""" + return MockPlexSonosClient(machine_identifier) + class MockPlexSystemAccount: """Mock a PlexSystemAccount instance.""" @@ -351,3 +355,15 @@ class MockPlexMediaTrack(MockPlexMediaItem): """Initialize the object.""" super().__init__(f"Track {index}", "track") self.index = index + + +class MockPlexSonosClient: + """Mock a PlexSonosClient instance.""" + + def __init__(self, machine_identifier): + """Initialize the object.""" + self.machineIdentifier = machine_identifier + + def playMedia(self, item): + """Mock the playMedia method.""" + pass diff --git a/tests/components/plex/test_playback.py b/tests/components/plex/test_playback.py new file mode 100644 index 00000000000..7a90d8dfad8 --- /dev/null +++ b/tests/components/plex/test_playback.py @@ -0,0 +1,127 @@ +"""Tests for Plex player playback methods/services.""" +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + MEDIA_TYPE_MUSIC, +) +from homeassistant.components.plex.const import DOMAIN, SERVERS, SERVICE_PLAY_ON_SONOS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.exceptions import HomeAssistantError + +from .const import DEFAULT_DATA, DEFAULT_OPTIONS +from .mock_classes import MockPlexAccount, MockPlexServer + +from tests.async_mock import patch +from tests.common import MockConfigEntry + + +async def test_sonos_playback(hass): + """Test playing media on a Sonos speaker.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data=DEFAULT_DATA, + options=DEFAULT_OPTIONS, + unique_id=DEFAULT_DATA["server_id"], + ) + + mock_plex_server = MockPlexServer(config_entry=entry) + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + server_id = mock_plex_server.machineIdentifier + loaded_server = hass.data[DOMAIN][SERVERS][server_id] + + with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()): + # Access and cache PlexAccount + assert loaded_server.account + + # Test Sonos integration lookup failure + with patch.object( + hass.components.sonos, "get_coordinator_id", side_effect=HomeAssistantError + ): + 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: '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}', + }, + True, + ) + + # Test success with dict + with patch.object( + hass.components.sonos, + "get_coordinator_id", + 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: "2", + }, + True, + ) + + # Test success with plex_key + with patch.object( + hass.components.sonos, + "get_coordinator_id", + 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: '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}', + }, + True, + ) + + # Test invalid Plex server requested + with patch.object( + hass.components.sonos, + "get_coordinator_id", + return_value="media_player.sonos_kitchen", + ): + 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: '{"plex_server": "unknown_plex_server", "library_name": "Music", "artist_name": "Artist", "album_name": "Album"}', + }, + True, + ) + + # Test no speakers available + with patch.object( + loaded_server.account, "sonos_speaker_by_id", return_value=None + ), patch.object( + hass.components.sonos, + "get_coordinator_id", + return_value="media_player.sonos_kitchen", + ): + 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: '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}', + }, + True, + )