Playback on Sonos speakers from Plex integration (#36177)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>pull/36210/head
parent
1e9ec917f6
commit
4e74fae615
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
|
@ -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
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
"""Const for Sonos."""
|
||||
|
||||
DOMAIN = "sonos"
|
||||
DATA_SONOS = "sonos_media_player"
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
Loading…
Reference in New Issue