mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 05:07:41 +00:00
Playback on Sonos speakers from Plex integration (#36177)
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
parent
1e9ec917f6
commit
4e74fae615
@ -1,6 +1,7 @@
|
|||||||
"""Support to embed Plex."""
|
"""Support to embed Plex."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import functools
|
import functools
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import plexapi.exceptions
|
import plexapi.exceptions
|
||||||
@ -10,7 +11,12 @@ 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.components.media_player.const import (
|
||||||
|
ATTR_MEDIA_CONTENT_ID,
|
||||||
|
ATTR_MEDIA_CONTENT_TYPE,
|
||||||
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
|
ATTR_ENTITY_ID,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
CONF_SSL,
|
CONF_SSL,
|
||||||
@ -19,7 +25,7 @@ from homeassistant.const import (
|
|||||||
CONF_VERIFY_SSL,
|
CONF_VERIFY_SSL,
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
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 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.dispatcher import (
|
from homeassistant.helpers.dispatcher import (
|
||||||
@ -44,6 +50,7 @@ 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
|
||||||
@ -215,6 +222,24 @@ async def async_setup_entry(hass, entry):
|
|||||||
)
|
)
|
||||||
task.add_done_callback(functools.partial(start_websocket_session, platform))
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
@ -244,3 +269,52 @@ async def async_options_updated(hass, entry):
|
|||||||
"""Triggered by config entry options updates."""
|
"""Triggered by config entry options updates."""
|
||||||
server_id = entry.data[CONF_SERVER_IDENTIFIER]
|
server_id = entry.data[CONF_SERVER_IDENTIFIER]
|
||||||
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 = 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"
|
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"
|
||||||
|
@ -5,5 +5,6 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/plex",
|
"documentation": "https://www.home-assistant.io/integrations/plex",
|
||||||
"requirements": ["plexapi==3.6.0", "plexauth==0.0.5", "plexwebsocket==0.0.8"],
|
"requirements": ["plexapi==3.6.0", "plexauth==0.0.5", "plexwebsocket==0.0.8"],
|
||||||
"dependencies": ["http"],
|
"dependencies": ["http"],
|
||||||
|
"after_dependencies": ["sonos"],
|
||||||
"codeowners": ["@jjlawren"]
|
"codeowners": ["@jjlawren"]
|
||||||
}
|
}
|
||||||
|
@ -57,6 +57,7 @@ class PlexServer:
|
|||||||
def __init__(self, hass, server_config, known_server_id=None, options=None):
|
def __init__(self, hass, server_config, known_server_id=None, options=None):
|
||||||
"""Initialize a Plex server instance."""
|
"""Initialize a Plex server instance."""
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
|
self._plex_account = None
|
||||||
self._plex_server = None
|
self._plex_server = None
|
||||||
self._created_clients = set()
|
self._created_clients = set()
|
||||||
self._known_clients = set()
|
self._known_clients = set()
|
||||||
@ -85,6 +86,13 @@ class PlexServer:
|
|||||||
plexapi.myplex.BASE_HEADERS = plexapi.reset_base_headers()
|
plexapi.myplex.BASE_HEADERS = plexapi.reset_base_headers()
|
||||||
plexapi.server.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):
|
def connect(self):
|
||||||
"""Connect to a Plex server directly, obtaining direct URL if necessary."""
|
"""Connect to a Plex server directly, obtaining direct URL if necessary."""
|
||||||
config_entry_update_needed = False
|
config_entry_update_needed = False
|
||||||
|
13
homeassistant/components/plex/services.yaml
Normal file
13
homeassistant/components/plex/services.yaml
Normal 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"
|
@ -4,9 +4,11 @@ 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 DOMAIN
|
from .const import DATA_SONOS, DOMAIN
|
||||||
|
|
||||||
CONF_ADVERTISE_ADDR = "advertise_addr"
|
CONF_ADVERTISE_ADDR = "advertise_addr"
|
||||||
CONF_INTERFACE_ADDR = "interface_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)
|
hass.config_entries.async_forward_entry_setup(entry, MP_DOMAIN)
|
||||||
)
|
)
|
||||||
return True
|
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."""
|
"""Const for Sonos."""
|
||||||
|
|
||||||
DOMAIN = "sonos"
|
DOMAIN = "sonos"
|
||||||
|
DATA_SONOS = "sonos_media_player"
|
||||||
|
@ -47,6 +47,7 @@ from . import (
|
|||||||
CONF_ADVERTISE_ADDR,
|
CONF_ADVERTISE_ADDR,
|
||||||
CONF_HOSTS,
|
CONF_HOSTS,
|
||||||
CONF_INTERFACE_ADDR,
|
CONF_INTERFACE_ADDR,
|
||||||
|
DATA_SONOS,
|
||||||
DOMAIN as SONOS_DOMAIN,
|
DOMAIN as SONOS_DOMAIN,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -70,8 +71,6 @@ SUPPORT_SONOS = (
|
|||||||
| SUPPORT_CLEAR_PLAYLIST
|
| SUPPORT_CLEAR_PLAYLIST
|
||||||
)
|
)
|
||||||
|
|
||||||
DATA_SONOS = "sonos_media_player"
|
|
||||||
|
|
||||||
SOURCE_LINEIN = "Line-in"
|
SOURCE_LINEIN = "Line-in"
|
||||||
SOURCE_TV = "TV"
|
SOURCE_TV = "TV"
|
||||||
|
|
||||||
|
@ -69,6 +69,10 @@ class MockPlexAccount:
|
|||||||
"""Mock the PlexAccount resources listing method."""
|
"""Mock the PlexAccount resources listing method."""
|
||||||
return self._resources
|
return self._resources
|
||||||
|
|
||||||
|
def sonos_speaker_by_id(self, machine_identifier):
|
||||||
|
"""Mock the PlexAccount Sonos lookup method."""
|
||||||
|
return MockPlexSonosClient(machine_identifier)
|
||||||
|
|
||||||
|
|
||||||
class MockPlexSystemAccount:
|
class MockPlexSystemAccount:
|
||||||
"""Mock a PlexSystemAccount instance."""
|
"""Mock a PlexSystemAccount instance."""
|
||||||
@ -351,3 +355,15 @@ class MockPlexMediaTrack(MockPlexMediaItem):
|
|||||||
"""Initialize the object."""
|
"""Initialize the object."""
|
||||||
super().__init__(f"Track {index}", "track")
|
super().__init__(f"Track {index}", "track")
|
||||||
self.index = index
|
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
|
||||||
|
127
tests/components/plex/test_playback.py
Normal file
127
tests/components/plex/test_playback.py
Normal 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,
|
||||||
|
)
|
Loading…
x
Reference in New Issue
Block a user