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:
parent
3364e945aa
commit
411cc6542c
13 changed files with 312 additions and 316 deletions
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -9,6 +9,5 @@
|
|||
"plexwebsocket==0.0.12"
|
||||
],
|
||||
"dependencies": ["http"],
|
||||
"after_dependencies": ["sonos"],
|
||||
"codeowners": ["@jjlawren"]
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -1,101 +1,122 @@
|
|||
"""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 playing media on a Plex media_player."""
|
||||
requests_mock.get("http://1.2.3.5:32400/resources", text=player_plexweb_resources)
|
||||
|
||||
# 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,
|
||||
)
|
||||
await setup_plex_server()
|
||||
|
||||
# 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
|
||||
)
|
||||
media_player = "media_player.plex_plex_web_chrome"
|
||||
requests_mock.post("/playqueues", text=playqueue_created)
|
||||
with patch.object(
|
||||
hass.components.sonos,
|
||||
"get_coordinator_name",
|
||||
return_value="Speaker 2",
|
||||
):
|
||||
requests_mock.get("/player/playback/playMedia", status_code=200)
|
||||
|
||||
# Test movie success
|
||||
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: "100",
|
||||
ATTR_ENTITY_ID: media_player,
|
||||
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MOVIE,
|
||||
ATTR_MEDIA_CONTENT_ID: '{"library_name": "Movies", "title": "Movie 1" }',
|
||||
},
|
||||
True,
|
||||
)
|
||||
|
||||
# Test success with dict
|
||||
with patch.object(
|
||||
hass.components.sonos,
|
||||
"get_coordinator_name",
|
||||
return_value="Speaker 2",
|
||||
):
|
||||
# Test movie incomplete dict
|
||||
assert await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_PLAY_ON_SONOS,
|
||||
MP_DOMAIN,
|
||||
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_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):
|
||||
# Test TV show episoe lookup failure
|
||||
assert await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_PLAY_ON_SONOS,
|
||||
MP_DOMAIN,
|
||||
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_ID: "999",
|
||||
},
|
||||
|
@ -103,117 +124,13 @@ async def test_sonos_playback(
|
|||
)
|
||||
|
||||
# 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,
|
||||
MP_DOMAIN,
|
||||
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_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)
|
||||
|
||||
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,
|
||||
)
|
||||
|
|
|
@ -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,6 +31,7 @@ async def test_refresh_library(
|
|||
refresh = requests_mock.get(f"{url}/library/sections/1/refresh", status_code=200)
|
||||
|
||||
# Test with non-existent server
|
||||
with pytest.raises(HomeAssistantError):
|
||||
assert await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_REFRESH_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
|
||||
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
|
||||
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
120
tests/components/sonos/test_plex_playback.py
Normal file
120
tests/components/sonos/test_plex_playback.py
Normal 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,
|
||||
)
|
6
tests/fixtures/plex/sonos_resources.xml
vendored
6
tests/fixtures/plex/sonos_resources.xml
vendored
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue