Move Plex->Sonos playback to built-in service (#45066)

* Move Plex->Sonos playback service from integration to platform

* Test against 'native' Plex media_players

* Add Plex to Sonos after_dependencies

* Remove circular dependency

* Raise exceptions in failed service calls

* Add test to forward service call from Sonos

* Additional Sonos->Plex tests

* Fix docstring
This commit is contained in:
jjlawren 2021-01-13 08:24:44 -06:00 committed by GitHub
parent 3364e945aa
commit 411cc6542c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 312 additions and 316 deletions

View file

@ -14,24 +14,17 @@ from plexwebsocket import (
PlexWebsocket, PlexWebsocket,
) )
import requests.exceptions import requests.exceptions
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN 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.config_entries import ENTRY_STATE_SETUP_RETRY, SOURCE_REAUTH
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_SOURCE, CONF_SOURCE,
CONF_URL, CONF_URL,
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -48,12 +41,11 @@ from .const import (
PLEX_SERVER_CONFIG, PLEX_SERVER_CONFIG,
PLEX_UPDATE_PLATFORMS_SIGNAL, PLEX_UPDATE_PLATFORMS_SIGNAL,
SERVERS, SERVERS,
SERVICE_PLAY_ON_SONOS,
WEBSOCKETS, WEBSOCKETS,
) )
from .errors import ShouldUpdateConfigEntry from .errors import ShouldUpdateConfigEntry
from .server import PlexServer from .server import PlexServer
from .services import async_setup_services, lookup_plex_media from .services import async_setup_services
_LOGGER = logging.getLogger(__package__) _LOGGER = logging.getLogger(__package__)
@ -218,31 +210,13 @@ async def async_setup_entry(hass, entry):
) )
task.add_done_callback(partial(start_websocket_session, platform)) 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): def get_plex_account(plex_server):
try: try:
return plex_server.account return plex_server.account
except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized): except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized):
return None return None
plex_account = await hass.async_add_executor_job(get_plex_account, plex_server) 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,
)
return True return True
@ -276,30 +250,3 @@ async def async_options_updated(hass, entry):
# Guard incomplete setup during reauth flows # Guard incomplete setup during reauth flows
if server_id in hass.data[PLEX_DOMAIN][SERVERS]: if server_id in hass.data[PLEX_DOMAIN][SERVERS]:
hass.data[PLEX_DOMAIN][SERVERS][server_id].options = entry.options 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)

View file

@ -47,7 +47,6 @@ X_PLEX_VERSION = __version__
AUTOMATIC_SETUP_STRING = "Obtain a new token from plex.tv" AUTOMATIC_SETUP_STRING = "Obtain a new token from plex.tv"
MANUAL_SETUP_STRING = "Configure Plex server manually" MANUAL_SETUP_STRING = "Configure Plex server manually"
SERVICE_PLAY_ON_SONOS = "play_on_sonos"
SERVICE_REFRESH_LIBRARY = "refresh_library" SERVICE_REFRESH_LIBRARY = "refresh_library"
SERVICE_SCAN_CLIENTS = "scan_for_clients" SERVICE_SCAN_CLIENTS = "scan_for_clients"

View file

@ -9,6 +9,5 @@
"plexwebsocket==0.0.12" "plexwebsocket==0.0.12"
], ],
"dependencies": ["http"], "dependencies": ["http"],
"after_dependencies": ["sonos"],
"codeowners": ["@jjlawren"] "codeowners": ["@jjlawren"]
} }

View file

@ -5,6 +5,7 @@ import logging
from plexapi.exceptions import NotFound from plexapi.exceptions import NotFound
import voluptuous as vol import voluptuous as vol
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import ( from .const import (
@ -52,8 +53,6 @@ def refresh_library(hass, service_call):
library_name = service_call.data["library_name"] library_name = service_call.data["library_name"]
plex_server = get_plex_server(hass, plex_server_name) plex_server = get_plex_server(hass, plex_server_name)
if not plex_server:
return
try: try:
library = plex_server.library.section(title=library_name) 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.""" """Retrieve a configured Plex server by name."""
plex_servers = hass.data[DOMAIN][SERVERS].values() plex_servers = hass.data[DOMAIN][SERVERS].values()
if not plex_servers:
raise HomeAssistantError("No Plex servers available")
if plex_server_name: if plex_server_name:
plex_server = next( plex_server = next(
(x for x in plex_servers if x.friendly_name == plex_server_name), None (x for x in plex_servers if x.friendly_name == plex_server_name), None
) )
if plex_server is not None: if plex_server is not None:
return plex_server return plex_server
_LOGGER.error( friendly_names = [x.friendly_name for x in plex_servers]
"Requested Plex server '%s' not found in %s", raise HomeAssistantError(
plex_server_name, f"Requested Plex server '{plex_server_name}' not found in {friendly_names}"
[x.friendly_name for x in plex_servers],
) )
return None
if len(plex_servers) == 1: if len(plex_servers) == 1:
return next(iter(plex_servers)) return next(iter(plex_servers))
_LOGGER.error( friendly_names = [x.friendly_name for x in plex_servers]
"Multiple Plex servers configured, choose with 'plex_server' key: %s", raise HomeAssistantError(
[x.friendly_name for x in plex_servers], f"Multiple Plex servers configured, choose with 'plex_server' key: {friendly_names}"
) )
return None
def lookup_plex_media(hass, content_type, content_id): 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) content = json.loads(content_id)
if isinstance(content, int): if isinstance(content, int):
@ -108,13 +107,24 @@ def lookup_plex_media(hass, content_type, content_id):
shuffle = content.pop("shuffle", 0) shuffle = content.pop("shuffle", 0)
plex_server = get_plex_server(hass, plex_server_name=plex_server_name) 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) media = plex_server.lookup_media(content_type, **content)
if media is None: if media is None:
_LOGGER.error("Media could not be found: %s", content) raise HomeAssistantError(f"Plex media not found using payload: '{content_id}'")
return (None, None)
playqueue = plex_server.create_playqueue(media, shuffle=shuffle) playqueue = plex_server.create_playqueue(media, shuffle=shuffle)
return (playqueue, plex_server) 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)

View file

@ -4,11 +4,9 @@ import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.const import CONF_HOSTS from homeassistant.const import CONF_HOSTS
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv 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_ADVERTISE_ADDR = "advertise_addr"
CONF_INTERFACE_ADDR = "interface_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) hass.config_entries.async_forward_entry_setup(entry, MP_DOMAIN)
) )
return True 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

View file

@ -4,6 +4,7 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sonos", "documentation": "https://www.home-assistant.io/integrations/sonos",
"requirements": ["pysonos==0.0.37"], "requirements": ["pysonos==0.0.37"],
"after_dependencies": ["plex"],
"ssdp": [ "ssdp": [
{ {
"st": "urn:schemas-upnp-org:device:ZonePlayer:1" "st": "urn:schemas-upnp-org:device:ZonePlayer:1"

View file

@ -59,6 +59,8 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_SET, SUPPORT_VOLUME_SET,
) )
from homeassistant.components.media_player.errors import BrowseError 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 ( from homeassistant.const import (
ATTR_TIME, ATTR_TIME,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
@ -1186,12 +1188,17 @@ class SonosEntity(MediaPlayerEntity):
""" """
Send the play_media command to the media player. 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 If media_type is "playlist", media_id should be a Sonos
Playlist name. Otherwise, media_id should be a URI. Playlist name. Otherwise, media_id should be a URI.
If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. 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): if kwargs.get(ATTR_MEDIA_ENQUEUE):
try: try:
if self.soco.is_spotify_uri(media_id): if self.soco.is_spotify_uri(media_id):

View file

@ -254,12 +254,6 @@ def show_seasons_fixture():
return load_fixture("plex/show_seasons.xml") 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") @pytest.fixture(name="entry")
def mock_config_entry(): def mock_config_entry():
"""Return the default mocked config entry.""" """Return the default mocked config entry."""

View file

@ -1,101 +1,122 @@
"""Tests for Plex player playback methods/services.""" """Tests for Plex player playback methods/services."""
from unittest.mock import patch from unittest.mock import patch
from plexapi.exceptions import NotFound
from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.const import (
ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_TYPE,
DOMAIN as MP_DOMAIN,
MEDIA_TYPE_EPISODE,
MEDIA_TYPE_MOVIE,
MEDIA_TYPE_MUSIC, MEDIA_TYPE_MUSIC,
SERVICE_PLAY_MEDIA,
) )
from homeassistant.components.plex.const import ( from homeassistant.const import ATTR_ENTITY_ID
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
async def test_sonos_playback( async def test_media_player_playback(
hass, mock_plex_server, requests_mock, playqueue_created, sonos_resources hass, setup_plex_server, requests_mock, playqueue_created, player_plexweb_resources
): ):
"""Test playing media on a Sonos speaker.""" """Test playing media on a Plex media_player."""
server_id = mock_plex_server.machine_identifier requests_mock.get("http://1.2.3.5:32400/resources", text=player_plexweb_resources)
loaded_server = hass.data[DOMAIN][SERVERS][server_id]
# Test Sonos integration lookup failure await setup_plex_server()
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 media_player = "media_player.plex_plex_web_chrome"
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) requests_mock.post("/playqueues", text=playqueue_created)
with patch.object( requests_mock.get("/player/playback/playMedia", status_code=200)
hass.components.sonos,
"get_coordinator_name", # Test movie success
return_value="Speaker 2",
):
assert await hass.services.async_call( assert await hass.services.async_call(
DOMAIN, MP_DOMAIN,
SERVICE_PLAY_ON_SONOS, SERVICE_PLAY_MEDIA,
{ {
ATTR_ENTITY_ID: "media_player.sonos_kitchen", ATTR_ENTITY_ID: media_player,
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MOVIE,
ATTR_MEDIA_CONTENT_ID: "100", ATTR_MEDIA_CONTENT_ID: '{"library_name": "Movies", "title": "Movie 1" }',
}, },
True, True,
) )
# Test success with dict # Test movie incomplete dict
with patch.object(
hass.components.sonos,
"get_coordinator_name",
return_value="Speaker 2",
):
assert await hass.services.async_call( assert await hass.services.async_call(
DOMAIN, MP_DOMAIN,
SERVICE_PLAY_ON_SONOS, SERVICE_PLAY_MEDIA,
{ {
ATTR_ENTITY_ID: "media_player.sonos_kitchen", 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(
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 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_TYPE: MEDIA_TYPE_MUSIC,
ATTR_MEDIA_CONTENT_ID: '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}', ATTR_MEDIA_CONTENT_ID: '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}',
}, },
True, True,
) )
# Test media lookup failure # Test TV show episoe 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( assert await hass.services.async_call(
DOMAIN, MP_DOMAIN,
SERVICE_PLAY_ON_SONOS, SERVICE_PLAY_MEDIA,
{ {
ATTR_ENTITY_ID: "media_player.sonos_kitchen", 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_TYPE: MEDIA_TYPE_MUSIC,
ATTR_MEDIA_CONTENT_ID: "999", ATTR_MEDIA_CONTENT_ID: "999",
}, },
@ -103,117 +124,13 @@ async def test_sonos_playback(
) )
# Test invalid Plex server requested # Test invalid Plex server requested
with patch.object(
hass.components.sonos,
"get_coordinator_name",
return_value="Speaker 2",
):
assert await hass.services.async_call( assert await hass.services.async_call(
DOMAIN, MP_DOMAIN,
SERVICE_PLAY_ON_SONOS, SERVICE_PLAY_MEDIA,
{ {
ATTR_ENTITY_ID: "media_player.sonos_kitchen", ATTR_ENTITY_ID: media_player,
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, 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"}', ATTR_MEDIA_CONTENT_ID: '{"plex_server": "unknown_plex_server", "library_name": "Music", "artist_name": "Artist", "album_name": "Album"}',
}, },
True, 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)
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
)
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: '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}',
},
True,
)
assert (
"Multiple Plex servers configured, choose with 'plex_server' key" in caplog.text
)
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: f'{{"plex_server": "{MOCK_SERVERS[0][CONF_SERVER]}", "library_name": "Music", "artist_name": "Artist", "album_name": "Album"}}',
},
True,
)

View file

@ -1,4 +1,6 @@
"""Tests for various Plex services.""" """Tests for various Plex services."""
import pytest
from homeassistant.components.plex.const import ( from homeassistant.components.plex.const import (
CONF_SERVER, CONF_SERVER,
CONF_SERVER_IDENTIFIER, CONF_SERVER_IDENTIFIER,
@ -8,6 +10,7 @@ from homeassistant.components.plex.const import (
SERVICE_SCAN_CLIENTS, SERVICE_SCAN_CLIENTS,
) )
from homeassistant.const import CONF_URL from homeassistant.const import CONF_URL
from homeassistant.exceptions import HomeAssistantError
from .const import DEFAULT_OPTIONS, SECONDARY_DATA from .const import DEFAULT_OPTIONS, SECONDARY_DATA
@ -28,6 +31,7 @@ async def test_refresh_library(
refresh = requests_mock.get(f"{url}/library/sections/1/refresh", status_code=200) refresh = requests_mock.get(f"{url}/library/sections/1/refresh", status_code=200)
# Test with non-existent server # Test with non-existent server
with pytest.raises(HomeAssistantError):
assert await hass.services.async_call( assert await hass.services.async_call(
DOMAIN, DOMAIN,
SERVICE_REFRESH_LIBRARY, SERVICE_REFRESH_LIBRARY,
@ -78,12 +82,14 @@ async def test_refresh_library(
await setup_plex_server(config_entry=entry_2) await setup_plex_server(config_entry=entry_2)
# Test multiple servers available but none specified # Test multiple servers available but none specified
with pytest.raises(HomeAssistantError) as excinfo:
assert await hass.services.async_call( assert await hass.services.async_call(
DOMAIN, DOMAIN,
SERVICE_REFRESH_LIBRARY, SERVICE_REFRESH_LIBRARY,
{"library_name": "Movies"}, {"library_name": "Movies"},
True, True,
) )
assert "Multiple Plex servers configured" in str(excinfo.value)
assert refresh.call_count == 1 assert refresh.call_count == 1

View file

@ -7,7 +7,7 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.sonos import DOMAIN from homeassistant.components.sonos import DOMAIN
from homeassistant.const import CONF_HOSTS from homeassistant.const import CONF_HOSTS
from tests.common import MockConfigEntry from tests.common import MockConfigEntry, load_fixture
@pytest.fixture(name="config_entry") @pytest.fixture(name="config_entry")
@ -77,3 +77,21 @@ def speaker_info_fixture():
"software_version": "49.2-64250", "software_version": "49.2-64250",
"mac_address": "00-11-22-33-44-55", "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")

View file

@ -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,
)

View file

@ -1,5 +1,5 @@
<MediaContainer size="3"> <MediaContainer size="3">
<Player title="Speaker 1" machineIdentifier="RINCON_12345678901234561:1234567891" deviceClass="speaker" product="Sonos" platform="Sonos" platformVersion="56.0-76060" protocol="plex" protocolVersion="1" protocolCapabilities="timeline,playback,playqueues,provider-playback" lanIP="192.168.1.11"/> <Player title="Zone A" machineIdentifier="RINCON_12345678901234561:1234567891" deviceClass="speaker" product="Sonos" platform="Sonos" platformVersion="56.0-76060" protocol="plex" protocolVersion="1" protocolCapabilities="timeline,playback,playqueues,provider-playback" lanIP="192.168.1.11"/>
<Player title="Speaker 2 + 1" machineIdentifier="RINCON_12345678901234562:1234567892" deviceClass="speaker" product="Sonos" platform="Sonos" platformVersion="56.0-76060" protocol="plex" protocolVersion="1" protocolCapabilities="timeline,playback,playqueues,provider-playback" lanIP="192.168.1.12"/> <Player title="Zone B + 2" machineIdentifier="RINCON_12345678901234562:1234567892" deviceClass="speaker" product="Sonos" platform="Sonos" platformVersion="56.0-76060" protocol="plex" protocolVersion="1" protocolCapabilities="timeline,playback,playqueues,provider-playback" lanIP="192.168.1.12"/>
<Player title="Speaker 3" machineIdentifier="RINCON_12345678901234563:1234567893" deviceClass="speaker" product="Sonos" platform="Sonos" platformVersion="56.0-76060" protocol="plex" protocolVersion="1" protocolCapabilities="timeline,playback,playqueues,provider-playback" lanIP="192.168.1.13"/> <Player title="Zone C" machineIdentifier="RINCON_12345678901234563:1234567893" deviceClass="speaker" product="Sonos" platform="Sonos" platformVersion="56.0-76060" protocol="plex" protocolVersion="1" protocolCapabilities="timeline,playback,playqueues,provider-playback" lanIP="192.168.1.13"/>
</MediaContainer> </MediaContainer>