1
mirror of https://github.com/home-assistant/core synced 2024-09-12 15:16:21 +02:00

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

View File

@ -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"

View File

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

View File

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

View File

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

View File

@ -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"

View File

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

View File

@ -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."""

View File

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

View File

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

View File

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

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">
<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="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="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 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="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="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>