Playback on Sonos speakers from Plex integration (#36177)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
pull/36210/head
jjlawren 2020-05-27 17:36:08 -05:00 committed by GitHub
parent 1e9ec917f6
commit 4e74fae615
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 265 additions and 4 deletions

View File

@ -1,6 +1,7 @@
"""Support to embed Plex."""
import asyncio
import functools
import json
import logging
import plexapi.exceptions
@ -10,7 +11,12 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.media_player.const import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_HOST,
CONF_PORT,
CONF_SSL,
@ -19,7 +25,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
@ -44,6 +50,7 @@ from .const import (
PLEX_SERVER_CONFIG,
PLEX_UPDATE_PLATFORMS_SIGNAL,
SERVERS,
SERVICE_PLAY_ON_SONOS,
WEBSOCKETS,
)
from .errors import ShouldUpdateConfigEntry
@ -215,6 +222,24 @@ async def async_setup_entry(hass, entry):
)
task.add_done_callback(functools.partial(start_websocket_session, platform))
async def async_play_on_sonos_service(service_call):
await hass.async_add_executor_job(play_on_sonos, hass, service_call)
play_on_sonos_schema = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_MEDIA_CONTENT_ID): str,
vol.Optional(ATTR_MEDIA_CONTENT_TYPE): vol.In("music"),
}
)
hass.services.async_register(
PLEX_DOMAIN,
SERVICE_PLAY_ON_SONOS,
async_play_on_sonos_service,
schema=play_on_sonos_schema,
)
return True
@ -244,3 +269,52 @@ async def async_options_updated(hass, entry):
"""Triggered by config entry options updates."""
server_id = entry.data[CONF_SERVER_IDENTIFIER]
hass.data[PLEX_DOMAIN][SERVERS][server_id].options = entry.options
def play_on_sonos(hass, service_call):
"""Play Plex media on a linked Sonos device."""
entity_id = service_call.data[ATTR_ENTITY_ID]
content_id = service_call.data[ATTR_MEDIA_CONTENT_ID]
content = json.loads(content_id)
sonos = hass.components.sonos
try:
sonos_id = sonos.get_coordinator_id(entity_id)
except HomeAssistantError as err:
_LOGGER.error("Cannot get Sonos device: %s", err)
return
if isinstance(content, int):
content = {"plex_key": content}
plex_server_name = content.get("plex_server")
shuffle = content.pop("shuffle", 0)
plex_servers = hass.data[PLEX_DOMAIN][SERVERS].values()
if plex_server_name:
plex_server = [x for x in plex_servers if x.friendly_name == plex_server_name]
if not plex_server:
_LOGGER.error(
"Requested Plex server '%s' not found in %s",
plex_server_name,
list(map(lambda x: x.friendly_name, plex_servers)),
)
return
else:
plex_server = next(iter(plex_servers))
sonos_speaker = plex_server.account.sonos_speaker_by_id(sonos_id)
if sonos_speaker is None:
_LOGGER.error(
"Sonos speaker '%s' could not be found on this Plex account", sonos_id
)
return
media = plex_server.lookup_media("music", **content)
if media is None:
_LOGGER.error("Media could not be found: %s", content)
return
_LOGGER.debug("Attempting to play '%s' on %s", media, sonos_speaker)
playqueue = plex_server.create_playqueue(media, shuffle=shuffle)
sonos_speaker.playMedia(playqueue)

View File

@ -43,3 +43,5 @@ X_PLEX_VERSION = __version__
AUTOMATIC_SETUP_STRING = "Obtain a new token from plex.tv"
MANUAL_SETUP_STRING = "Configure Plex server manually"
SERVICE_PLAY_ON_SONOS = "play_on_sonos"

View File

@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/plex",
"requirements": ["plexapi==3.6.0", "plexauth==0.0.5", "plexwebsocket==0.0.8"],
"dependencies": ["http"],
"after_dependencies": ["sonos"],
"codeowners": ["@jjlawren"]
}

View File

@ -57,6 +57,7 @@ class PlexServer:
def __init__(self, hass, server_config, known_server_id=None, options=None):
"""Initialize a Plex server instance."""
self.hass = hass
self._plex_account = None
self._plex_server = None
self._created_clients = set()
self._known_clients = set()
@ -85,6 +86,13 @@ class PlexServer:
plexapi.myplex.BASE_HEADERS = plexapi.reset_base_headers()
plexapi.server.BASE_HEADERS = plexapi.reset_base_headers()
@property
def account(self):
"""Return a MyPlexAccount instance."""
if not self._plex_account:
self._plex_account = plexapi.myplex.MyPlexAccount(token=self._token)
return self._plex_account
def connect(self):
"""Connect to a Plex server directly, obtaining direct URL if necessary."""
config_entry_update_needed = False

View File

@ -0,0 +1,13 @@
play_on_sonos:
description: Play music hosted on a Plex server on a linked Sonos speaker.
fields:
entity_id:
description: Entity ID of a media_player from the Sonos integration.
example: "media_player.sonos_living_room"
media_content_id:
description: The ID of the content to play. See https://www.home-assistant.io/integrations/plex/#music for details.
example: >-
'{ "library_name": "Music", "artist_name": "Stevie Wonder" }'
media_content_type:
description: The type of content to play. Must be "music".
example: "music"

View File

@ -4,9 +4,11 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.const import CONF_HOSTS
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.loader import bind_hass
from .const import DOMAIN
from .const import DATA_SONOS, DOMAIN
CONF_ADVERTISE_ADDR = "advertise_addr"
CONF_INTERFACE_ADDR = "interface_addr"
@ -53,3 +55,21 @@ async def async_setup_entry(hass, entry):
hass.config_entries.async_forward_entry_setup(entry, MP_DOMAIN)
)
return True
@bind_hass
def get_coordinator_id(hass, entity_id):
"""Obtain the unique_id of a device's coordinator.
This function is safe to run inside the event loop.
"""
if DATA_SONOS not in hass.data:
raise HomeAssistantError("Sonos integration not set up")
device = next(
(x for x in hass.data[DATA_SONOS].entities if x.entity_id == entity_id), None
)
if device.is_coordinator:
return device.unique_id
return device.coordinator.unique_id

View File

@ -1,3 +1,4 @@
"""Const for Sonos."""
DOMAIN = "sonos"
DATA_SONOS = "sonos_media_player"

View File

@ -47,6 +47,7 @@ from . import (
CONF_ADVERTISE_ADDR,
CONF_HOSTS,
CONF_INTERFACE_ADDR,
DATA_SONOS,
DOMAIN as SONOS_DOMAIN,
)
@ -70,8 +71,6 @@ SUPPORT_SONOS = (
| SUPPORT_CLEAR_PLAYLIST
)
DATA_SONOS = "sonos_media_player"
SOURCE_LINEIN = "Line-in"
SOURCE_TV = "TV"

View File

@ -69,6 +69,10 @@ class MockPlexAccount:
"""Mock the PlexAccount resources listing method."""
return self._resources
def sonos_speaker_by_id(self, machine_identifier):
"""Mock the PlexAccount Sonos lookup method."""
return MockPlexSonosClient(machine_identifier)
class MockPlexSystemAccount:
"""Mock a PlexSystemAccount instance."""
@ -351,3 +355,15 @@ class MockPlexMediaTrack(MockPlexMediaItem):
"""Initialize the object."""
super().__init__(f"Track {index}", "track")
self.index = index
class MockPlexSonosClient:
"""Mock a PlexSonosClient instance."""
def __init__(self, machine_identifier):
"""Initialize the object."""
self.machineIdentifier = machine_identifier
def playMedia(self, item):
"""Mock the playMedia method."""
pass

View File

@ -0,0 +1,127 @@
"""Tests for Plex player playback methods/services."""
from homeassistant.components.media_player.const import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
MEDIA_TYPE_MUSIC,
)
from homeassistant.components.plex.const import DOMAIN, SERVERS, SERVICE_PLAY_ON_SONOS
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.exceptions import HomeAssistantError
from .const import DEFAULT_DATA, DEFAULT_OPTIONS
from .mock_classes import MockPlexAccount, MockPlexServer
from tests.async_mock import patch
from tests.common import MockConfigEntry
async def test_sonos_playback(hass):
"""Test playing media on a Sonos speaker."""
entry = MockConfigEntry(
domain=DOMAIN,
data=DEFAULT_DATA,
options=DEFAULT_OPTIONS,
unique_id=DEFAULT_DATA["server_id"],
)
mock_plex_server = MockPlexServer(config_entry=entry)
with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
"homeassistant.components.plex.PlexWebsocket.listen"
):
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
server_id = mock_plex_server.machineIdentifier
loaded_server = hass.data[DOMAIN][SERVERS][server_id]
with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()):
# Access and cache PlexAccount
assert loaded_server.account
# Test Sonos integration lookup failure
with patch.object(
hass.components.sonos, "get_coordinator_id", side_effect=HomeAssistantError
):
assert await hass.services.async_call(
DOMAIN,
SERVICE_PLAY_ON_SONOS,
{
ATTR_ENTITY_ID: "media_player.sonos_kitchen",
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC,
ATTR_MEDIA_CONTENT_ID: '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}',
},
True,
)
# Test success with dict
with patch.object(
hass.components.sonos,
"get_coordinator_id",
return_value="media_player.sonos_kitchen",
), patch("plexapi.playqueue.PlayQueue.create"):
assert await hass.services.async_call(
DOMAIN,
SERVICE_PLAY_ON_SONOS,
{
ATTR_ENTITY_ID: "media_player.sonos_kitchen",
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC,
ATTR_MEDIA_CONTENT_ID: "2",
},
True,
)
# Test success with plex_key
with patch.object(
hass.components.sonos,
"get_coordinator_id",
return_value="media_player.sonos_kitchen",
), patch("plexapi.playqueue.PlayQueue.create"):
assert await hass.services.async_call(
DOMAIN,
SERVICE_PLAY_ON_SONOS,
{
ATTR_ENTITY_ID: "media_player.sonos_kitchen",
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC,
ATTR_MEDIA_CONTENT_ID: '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}',
},
True,
)
# Test invalid Plex server requested
with patch.object(
hass.components.sonos,
"get_coordinator_id",
return_value="media_player.sonos_kitchen",
):
assert await hass.services.async_call(
DOMAIN,
SERVICE_PLAY_ON_SONOS,
{
ATTR_ENTITY_ID: "media_player.sonos_kitchen",
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC,
ATTR_MEDIA_CONTENT_ID: '{"plex_server": "unknown_plex_server", "library_name": "Music", "artist_name": "Artist", "album_name": "Album"}',
},
True,
)
# Test no speakers available
with patch.object(
loaded_server.account, "sonos_speaker_by_id", return_value=None
), patch.object(
hass.components.sonos,
"get_coordinator_id",
return_value="media_player.sonos_kitchen",
):
assert await hass.services.async_call(
DOMAIN,
SERVICE_PLAY_ON_SONOS,
{
ATTR_ENTITY_ID: "media_player.sonos_kitchen",
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC,
ATTR_MEDIA_CONTENT_ID: '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}',
},
True,
)