diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index de8b278f3cf..6b403150e9c 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -14,24 +14,17 @@ from plexwebsocket import ( PlexWebsocket, ) import requests.exceptions -import voluptuous as vol 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.config_entries import ENTRY_STATE_SETUP_RETRY, SOURCE_REAUTH from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_SOURCE, CONF_URL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import callback -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers import config_validation as cv +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -48,12 +41,11 @@ from .const import ( PLEX_SERVER_CONFIG, PLEX_UPDATE_PLATFORMS_SIGNAL, SERVERS, - SERVICE_PLAY_ON_SONOS, WEBSOCKETS, ) from .errors import ShouldUpdateConfigEntry from .server import PlexServer -from .services import async_setup_services, lookup_plex_media +from .services import async_setup_services _LOGGER = logging.getLogger(__package__) @@ -218,31 +210,13 @@ async def async_setup_entry(hass, entry): ) task.add_done_callback(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): cv.string, - vol.Optional(ATTR_MEDIA_CONTENT_TYPE): vol.In("music"), - } - ) - def get_plex_account(plex_server): try: return plex_server.account except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized): return None - plex_account = await hass.async_add_executor_job(get_plex_account, plex_server) - if plex_account: - hass.services.async_register( - PLEX_DOMAIN, - SERVICE_PLAY_ON_SONOS, - async_play_on_sonos_service, - schema=play_on_sonos_schema, - ) + await hass.async_add_executor_job(get_plex_account, plex_server) return True @@ -276,30 +250,3 @@ async def async_options_updated(hass, entry): # Guard incomplete setup during reauth flows if server_id in hass.data[PLEX_DOMAIN][SERVERS]: 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_type = service_call.data.get(ATTR_MEDIA_CONTENT_TYPE) - - sonos = hass.components.sonos - try: - sonos_name = sonos.get_coordinator_name(entity_id) - except HomeAssistantError as err: - _LOGGER.error("Cannot get Sonos device: %s", err) - return - - 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: - _LOGGER.error( - "Sonos speaker '%s' could not be found on this Plex account", sonos_name - ) - return - - sonos_speaker.playMedia(media) diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index c13be439be7..eec433202e4 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -47,7 +47,6 @@ 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" SERVICE_REFRESH_LIBRARY = "refresh_library" SERVICE_SCAN_CLIENTS = "scan_for_clients" diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 5bfbc4932ab..34a02e8ae20 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -9,6 +9,5 @@ "plexwebsocket==0.0.12" ], "dependencies": ["http"], - "after_dependencies": ["sonos"], "codeowners": ["@jjlawren"] } diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index 19a04a61a9f..4d9b518aa62 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -5,6 +5,7 @@ import logging from plexapi.exceptions import NotFound import voluptuous as vol +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -52,8 +53,6 @@ def refresh_library(hass, service_call): library_name = service_call.data["library_name"] plex_server = get_plex_server(hass, plex_server_name) - if not plex_server: - return try: library = plex_server.library.section(title=library_name) @@ -73,31 +72,31 @@ def get_plex_server(hass, plex_server_name=None): """Retrieve a configured Plex server by name.""" plex_servers = hass.data[DOMAIN][SERVERS].values() + if not plex_servers: + raise HomeAssistantError("No Plex servers available") + if plex_server_name: 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], + friendly_names = [x.friendly_name for x in plex_servers] + raise HomeAssistantError( + f"Requested Plex server '{plex_server_name}' not found in {friendly_names}" ) - return None if len(plex_servers) == 1: return next(iter(plex_servers)) - _LOGGER.error( - "Multiple Plex servers configured, choose with 'plex_server' key: %s", - [x.friendly_name for x in plex_servers], + friendly_names = [x.friendly_name for x in plex_servers] + raise HomeAssistantError( + f"Multiple Plex servers configured, choose with 'plex_server' key: {friendly_names}" ) - return None def lookup_plex_media(hass, content_type, content_id): - """Look up Plex media using media_player.play_media service payloads.""" + """Look up Plex media for other integrations using media_player.play_media service payloads.""" content = json.loads(content_id) if isinstance(content, int): @@ -108,13 +107,24 @@ def lookup_plex_media(hass, content_type, content_id): 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) + raise HomeAssistantError(f"Plex media not found using payload: '{content_id}'") playqueue = plex_server.create_playqueue(media, shuffle=shuffle) return (playqueue, plex_server) + + +def play_on_sonos(hass, content_type, content_id, speaker_name): + """Play music on a connected Sonos speaker using Plex APIs. + + Called by Sonos 'media_player.play_media' service. + """ + media, plex_server = lookup_plex_media(hass, content_type, content_id) + sonos_speaker = plex_server.account.sonos_speaker(speaker_name) + if sonos_speaker is None: + message = f"Sonos speaker '{speaker_name}' is not associated with '{plex_server.friendly_name}'" + _LOGGER.error(message) + raise HomeAssistantError(message) + sonos_speaker.playMedia(media) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index cc33134c810..c3a977e32e1 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -4,11 +4,9 @@ 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 DATA_SONOS, DOMAIN +from .const import DOMAIN CONF_ADVERTISE_ADDR = "advertise_addr" CONF_INTERFACE_ADDR = "interface_addr" @@ -55,23 +53,3 @@ async def async_setup_entry(hass, entry): hass.config_entries.async_forward_entry_setup(entry, MP_DOMAIN) ) return True - - -@bind_hass -def get_coordinator_name(hass, entity_id): - """Obtain the room/name of a device's coordinator. - - Used by the Plex integration. - - 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.name - return device.coordinator.name diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 66e6587b9ff..1852f9c3849 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -4,6 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sonos", "requirements": ["pysonos==0.0.37"], + "after_dependencies": ["plex"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 48b22256030..9d89bdf68f8 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -59,6 +59,8 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_SET, ) from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.plex.const import PLEX_URI_SCHEME +from homeassistant.components.plex.services import play_on_sonos from homeassistant.const import ( ATTR_TIME, EVENT_HOMEASSISTANT_STOP, @@ -1186,12 +1188,17 @@ class SonosEntity(MediaPlayerEntity): """ Send the play_media command to the media player. + If media_id is a Plex payload, attempt Plex->Sonos playback. + If media_type is "playlist", media_id should be a Sonos Playlist name. Otherwise, media_id should be a URI. If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. """ - if media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_TRACK): + if media_id and media_id.startswith(PLEX_URI_SCHEME): + media_id = media_id[len(PLEX_URI_SCHEME) :] + play_on_sonos(self.hass, media_type, media_id, self.name) + elif media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_TRACK): if kwargs.get(ATTR_MEDIA_ENQUEUE): try: if self.soco.is_spotify_uri(media_id): diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index 8fc25a819e8..d74c8df1594 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -254,12 +254,6 @@ def show_seasons_fixture(): return load_fixture("plex/show_seasons.xml") -@pytest.fixture(name="sonos_resources", scope="session") -def sonos_resources_fixture(): - """Load Sonos resources payload and return it.""" - return load_fixture("plex/sonos_resources.xml") - - @pytest.fixture(name="entry") def mock_config_entry(): """Return the default mocked config entry.""" diff --git a/tests/components/plex/test_playback.py b/tests/components/plex/test_playback.py index e8d528eb073..86e55dab613 100644 --- a/tests/components/plex/test_playback.py +++ b/tests/components/plex/test_playback.py @@ -1,219 +1,136 @@ """Tests for Plex player playback methods/services.""" from unittest.mock import patch -from plexapi.exceptions import NotFound - from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as MP_DOMAIN, + MEDIA_TYPE_EPISODE, + MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, + SERVICE_PLAY_MEDIA, ) -from homeassistant.components.plex.const import ( - CONF_SERVER, - CONF_SERVER_IDENTIFIER, - DOMAIN, - PLEX_SERVER_CONFIG, - SERVERS, - SERVICE_PLAY_ON_SONOS, -) -from homeassistant.const import ATTR_ENTITY_ID, CONF_URL -from homeassistant.exceptions import HomeAssistantError - -from .const import DEFAULT_OPTIONS, MOCK_SERVERS, SECONDARY_DATA - -from tests.common import MockConfigEntry +from homeassistant.const import ATTR_ENTITY_ID -async def test_sonos_playback( - hass, mock_plex_server, requests_mock, playqueue_created, sonos_resources +async def test_media_player_playback( + hass, setup_plex_server, requests_mock, playqueue_created, player_plexweb_resources ): - """Test playing media on a Sonos speaker.""" - server_id = mock_plex_server.machine_identifier - loaded_server = hass.data[DOMAIN][SERVERS][server_id] - - # Test Sonos integration lookup failure - with patch.object( - hass.components.sonos, "get_coordinator_name", 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 plex_key - requests_mock.get("https://sonos.plex.tv/resources", text=sonos_resources) - requests_mock.get( - "https://sonos.plex.tv/player/playback/playMedia", status_code=200 - ) - requests_mock.post("/playqueues", text=playqueue_created) - with patch.object( - hass.components.sonos, - "get_coordinator_name", - return_value="Speaker 2", - ): - 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: "100", - }, - True, - ) - - # Test success with dict - with patch.object( - hass.components.sonos, - "get_coordinator_name", - return_value="Speaker 2", - ): - 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 media lookup failure - with patch.object( - hass.components.sonos, - "get_coordinator_name", - return_value="Speaker 2", - ), patch("plexapi.server.PlexServer.fetchItem", side_effect=NotFound): - 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: "999", - }, - True, - ) - - # Test invalid Plex server requested - with patch.object( - hass.components.sonos, - "get_coordinator_name", - return_value="Speaker 2", - ): - 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", return_value=None - ), patch.object( - hass.components.sonos, - "get_coordinator_name", - return_value="Speaker 2", - ), 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, - ) - - -async def test_playback_multiple_servers( - hass, - setup_plex_server, - requests_mock, - caplog, - empty_payload, - playqueue_created, - plex_server_accounts, - plex_server_base, - sonos_resources, -): - """Test playing media when multiple servers available.""" - secondary_entry = MockConfigEntry( - domain=DOMAIN, - data=SECONDARY_DATA, - options=DEFAULT_OPTIONS, - unique_id=SECONDARY_DATA["server_id"], - ) - - secondary_url = SECONDARY_DATA[PLEX_SERVER_CONFIG][CONF_URL] - secondary_name = SECONDARY_DATA[CONF_SERVER] - secondary_id = SECONDARY_DATA[CONF_SERVER_IDENTIFIER] - requests_mock.get( - secondary_url, - text=plex_server_base.format( - name=secondary_name, machine_identifier=secondary_id - ), - ) - requests_mock.get(f"{secondary_url}/accounts", text=plex_server_accounts) - requests_mock.get(f"{secondary_url}/clients", text=empty_payload) - requests_mock.get(f"{secondary_url}/status/sessions", text=empty_payload) + """Test playing media on a Plex media_player.""" + requests_mock.get("http://1.2.3.5:32400/resources", text=player_plexweb_resources) await setup_plex_server() - await setup_plex_server(config_entry=secondary_entry) - requests_mock.get("https://sonos.plex.tv/resources", text=sonos_resources) - requests_mock.get( - "https://sonos.plex.tv/player/playback/playMedia", status_code=200 - ) + media_player = "media_player.plex_plex_web_chrome" requests_mock.post("/playqueues", text=playqueue_created) + requests_mock.get("/player/playback/playMedia", status_code=200) - with patch.object( - hass.components.sonos, - "get_coordinator_name", - return_value="Speaker 2", - ): - 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, - ) - - assert ( - "Multiple Plex servers configured, choose with 'plex_server' key" in caplog.text + # Test movie success + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MOVIE, + ATTR_MEDIA_CONTENT_ID: '{"library_name": "Movies", "title": "Movie 1" }', + }, + True, ) - with patch.object( - hass.components.sonos, - "get_coordinator_name", - return_value="Speaker 2", - ): + # Test movie incomplete dict + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MOVIE, + ATTR_MEDIA_CONTENT_ID: '{"library_name": "Movies"}', + }, + True, + ) + + # Test movie failure with options + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MOVIE, + ATTR_MEDIA_CONTENT_ID: '{"library_name": "Movies", "title": "Does not exist" }', + }, + True, + ) + + # Test movie failure with nothing found + with patch("plexapi.library.LibrarySection.search", return_value=None): assert await hass.services.async_call( - DOMAIN, - SERVICE_PLAY_ON_SONOS, + MP_DOMAIN, + SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.sonos_kitchen", - ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, - ATTR_MEDIA_CONTENT_ID: f'{{"plex_server": "{MOCK_SERVERS[0][CONF_SERVER]}", "library_name": "Music", "artist_name": "Artist", "album_name": "Album"}}', + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MOVIE, + ATTR_MEDIA_CONTENT_ID: '{"library_name": "Movies", "title": "Does not exist" }', }, True, ) + + # Test movie success with dict + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_CONTENT_ID: '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}', + }, + True, + ) + + # Test TV show episoe lookup failure + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_EPISODE, + ATTR_MEDIA_CONTENT_ID: '{"library_name": "TV Shows", "show_name": "TV Show", "season_number": 1, "episode_number": 99}', + }, + True, + ) + + # Test track name lookup failure + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_CONTENT_ID: '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album", "track_name": "Not a track"}', + }, + True, + ) + + # Test media lookup failure by key + requests_mock.get("/library/metadata/999", status_code=404) + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_CONTENT_ID: "999", + }, + True, + ) + + # Test invalid Plex server requested + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + 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, + ) diff --git a/tests/components/plex/test_services.py b/tests/components/plex/test_services.py index 18375a9f80f..520458ebfe8 100644 --- a/tests/components/plex/test_services.py +++ b/tests/components/plex/test_services.py @@ -1,4 +1,6 @@ """Tests for various Plex services.""" +import pytest + from homeassistant.components.plex.const import ( CONF_SERVER, CONF_SERVER_IDENTIFIER, @@ -8,6 +10,7 @@ from homeassistant.components.plex.const import ( SERVICE_SCAN_CLIENTS, ) from homeassistant.const import CONF_URL +from homeassistant.exceptions import HomeAssistantError from .const import DEFAULT_OPTIONS, SECONDARY_DATA @@ -28,12 +31,13 @@ async def test_refresh_library( refresh = requests_mock.get(f"{url}/library/sections/1/refresh", status_code=200) # Test with non-existent server - assert await hass.services.async_call( - DOMAIN, - SERVICE_REFRESH_LIBRARY, - {"server_name": "Not a Server", "library_name": "Movies"}, - True, - ) + with pytest.raises(HomeAssistantError): + assert await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_LIBRARY, + {"server_name": "Not a Server", "library_name": "Movies"}, + True, + ) assert not refresh.called # Test with non-existent library @@ -78,12 +82,14 @@ async def test_refresh_library( await setup_plex_server(config_entry=entry_2) # Test multiple servers available but none specified - assert await hass.services.async_call( - DOMAIN, - SERVICE_REFRESH_LIBRARY, - {"library_name": "Movies"}, - True, - ) + with pytest.raises(HomeAssistantError) as excinfo: + assert await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_LIBRARY, + {"library_name": "Movies"}, + True, + ) + assert "Multiple Plex servers configured" in str(excinfo.value) assert refresh.call_count == 1 diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 1ce2205813b..688026ba06c 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -7,7 +7,7 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos import DOMAIN from homeassistant.const import CONF_HOSTS -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture @pytest.fixture(name="config_entry") @@ -77,3 +77,21 @@ def speaker_info_fixture(): "software_version": "49.2-64250", "mac_address": "00-11-22-33-44-55", } + + +@pytest.fixture(name="plex_empty_payload", scope="session") +def plex_empty_payload_fixture(): + """Load an empty payload and return it.""" + return load_fixture("plex/empty_payload.xml") + + +@pytest.fixture(name="plextv_account", scope="session") +def plextv_account_fixture(): + """Load account info from plex.tv and return it.""" + return load_fixture("plex/plextv_account.xml") + + +@pytest.fixture(name="plex_sonos_resources", scope="session") +def plex_sonos_resources_fixture(): + """Load Sonos resources payload and return it.""" + return load_fixture("plex/sonos_resources.xml") diff --git a/tests/components/sonos/test_plex_playback.py b/tests/components/sonos/test_plex_playback.py new file mode 100644 index 00000000000..ce84b69057a --- /dev/null +++ b/tests/components/sonos/test_plex_playback.py @@ -0,0 +1,120 @@ +"""Tests for the Sonos Media Player platform.""" +from unittest.mock import patch + +from plexapi.myplex import MyPlexAccount +import pytest + +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as MP_DOMAIN, + MEDIA_TYPE_MUSIC, + SERVICE_PLAY_MEDIA, +) +from homeassistant.components.plex.const import DOMAIN as PLEX_DOMAIN, SERVERS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.exceptions import HomeAssistantError + +from .test_media_player import setup_platform + + +async def test_plex_play_media( + hass, + config_entry, + config, + requests_mock, + plextv_account, + plex_empty_payload, + plex_sonos_resources, +): + """Test playing media via the Plex integration.""" + requests_mock.get("https://plex.tv/users/account", text=plextv_account) + requests_mock.get("https://sonos.plex.tv/resources", text=plex_empty_payload) + + class MockPlexServer: + """Mock a PlexServer instance.""" + + def __init__(self, has_media=False): + self.account = MyPlexAccount(token="token") + self.friendly_name = "plex" + if has_media: + self.media = "media" + else: + self.media = None + + def create_playqueue(self, media, **kwargs): + pass + + def lookup_media(self, content_type, **kwargs): + return self.media + + await setup_platform(hass, config_entry, config) + hass.data[PLEX_DOMAIN] = {SERVERS: {}} + media_player = "media_player.zone_a" + + # Test Plex service call with media key + with pytest.raises(HomeAssistantError) as excinfo: + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_CONTENT_ID: "plex://5", + }, + True, + ) + assert "No Plex servers available" in str(excinfo.value) + + # Add a mocked Plex server with no media + hass.data[PLEX_DOMAIN][SERVERS] = {"plex": MockPlexServer()} + + # Test Plex service call with dict + with pytest.raises(HomeAssistantError) as excinfo: + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_CONTENT_ID: 'plex://{"library_name": "Music", "artist_name": "Artist"}', + }, + True, + ) + assert "Plex media not found" in str(excinfo.value) + + # Add a mocked Plex server + hass.data[PLEX_DOMAIN][SERVERS] = {"plex": MockPlexServer(has_media=True)} + + # Test Plex service call with no Sonos speakers + requests_mock.get("https://sonos.plex.tv/resources", text=plex_empty_payload) + with pytest.raises(HomeAssistantError) as excinfo: + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_CONTENT_ID: 'plex://{"library_name": "Music", "artist_name": "Artist"}', + }, + True, + ) + assert "Sonos speaker 'Zone A' is not associated with" in str(excinfo.value) + + # Test successful Plex service call + account = hass.data[PLEX_DOMAIN][SERVERS]["plex"].account + requests_mock.get("https://sonos.plex.tv/resources", text=plex_sonos_resources) + + with patch.object(account, "_sonos_cache_timestamp", 0), patch( + "plexapi.sonos.PlexSonosClient.playMedia" + ): + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_CONTENT_ID: 'plex://{"plex_server": "plex", "library_name": "Music", "artist_name": "Artist", "album_name": "Album"}', + }, + True, + ) diff --git a/tests/fixtures/plex/sonos_resources.xml b/tests/fixtures/plex/sonos_resources.xml index 334fdd311ef..1cf8f276822 100644 --- a/tests/fixtures/plex/sonos_resources.xml +++ b/tests/fixtures/plex/sonos_resources.xml @@ -1,5 +1,5 @@ - - - + + +