Merge pull request #471 from jjlawren/sonos_controls

Allow control of Sonos speakers using Plex API
This commit is contained in:
Steffen Fredriksen 2020-05-10 14:31:16 +02:00 committed by GitHub
commit 6daaa85f18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 227 additions and 2 deletions

View file

@ -21,7 +21,7 @@ Plex Web Client. A few of the many features we currently support are:
* Navigate local or remote shared libraries.
* Perform library actions such as scan, analyze, empty trash.
* Remote control and play media on connected clients.
* Remote control and play media on connected clients, including `Controlling Sonos speakers`_
* Listen in on all Plex Server notifications.
@ -135,6 +135,46 @@ Usage Examples
plex.library.section('TV Shows').get('The 100').rate(8.0)
Controlling Sonos speakers
--------------------------
To control Sonos speakers directly using Plex APIs, the following requirements must be met:
1. Active Plex Pass subscription
2. Sonos account linked to Plex account
3. Plex remote access enabled
Due to the design of Sonos music services, the API calls to control Sonos speakers route through https://sonos.plex.tv
and back via the Plex server's remote access. Actual media playback is local unless networking restrictions prevent the
Sonos speakers from connecting to the Plex server directly.
.. code-block:: python
from plexapi.myplex import MyPlexAccount
from plexapi.server import PlexServer
baseurl = 'http://plexserver:32400'
token = '2ffLuB84dqLswk9skLos'
account = MyPlexAccount(token)
server = PlexServer(baseurl, token)
# List available speakers/groups
for speaker in account.sonos_speakers():
print(speaker.title)
# Obtain PlexSonosPlayer instance
speaker = account.sonos_speaker("Kitchen")
album = server.library.section('Music').get('Stevie Wonder').album('Innervisions')
# Speaker control examples
speaker.playMedia(album)
speaker.pause()
speaker.setVolume(10)
speaker.skipNext()
Running tests over PlexAPI
--------------------------

View file

@ -157,7 +157,7 @@ class PlexClient(PlexObject):
log.debug('%s %s', method.__name__.upper(), url)
headers = self._headers(**headers or {})
response = method(url, headers=headers, timeout=timeout, **kwargs)
if response.status_code not in (200, 201):
if response.status_code not in (200, 201, 204):
codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ')
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)

View file

@ -12,6 +12,7 @@ from plexapi.client import PlexClient
from plexapi.compat import ElementTree
from plexapi.library import LibrarySection
from plexapi.server import PlexServer
from plexapi.sonos import PlexSonosClient
from plexapi.sync import SyncItem, SyncList
from plexapi.utils import joinArgs
from requests.status_codes import _codes as codes
@ -88,6 +89,8 @@ class MyPlexAccount(PlexObject):
def __init__(self, username=None, password=None, token=None, session=None, timeout=None):
self._token = token
self._session = session or requests.Session()
self._sonos_cache = []
self._sonos_cache_timestamp = 0
data, initpath = self._signin(username, password, timeout)
super(MyPlexAccount, self).__init__(self, data, initpath)
@ -209,6 +212,24 @@ class MyPlexAccount(PlexObject):
data = self.query(MyPlexResource.key)
return [MyPlexResource(self, elem) for elem in data]
def sonos_speakers(self):
if 'companions_sonos' not in self.subscriptionFeatures:
return []
t = time.time()
if t - self._sonos_cache_timestamp > 60:
self._sonos_cache_timestamp = t
data = self.query('https://sonos.plex.tv/resources')
self._sonos_cache = [PlexSonosClient(self, elem) for elem in data]
return self._sonos_cache
def sonos_speaker(self, name):
return [x for x in self.sonos_speakers() if x.title == name][0]
def sonos_speaker_by_id(self, identifier):
return [x for x in self.sonos_speakers() if x.machineIdentifier == identifier][0]
def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False,
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
""" Share library content with the specified user.

116
plexapi/sonos.py Normal file
View file

@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
import requests
from plexapi import CONFIG, X_PLEX_IDENTIFIER
from plexapi.client import PlexClient
from plexapi.exceptions import BadRequest
from plexapi.playqueue import PlayQueue
class PlexSonosClient(PlexClient):
""" Class for interacting with a Sonos speaker via the Plex API. This class
makes requests to an external Plex API which then forwards the
Sonos-specific commands back to your Plex server & Sonos speakers. Use
of this feature requires an active Plex Pass subscription and Sonos
speakers linked to your Plex account. It also requires remote access to
be working properly.
More details on the Sonos integration are avaialble here:
https://support.plex.tv/articles/218237558-requirements-for-using-plex-for-sonos/
The Sonos API emulates the Plex player control API closely:
https://github.com/plexinc/plex-media-player/wiki/Remote-control-API
Parameters:
account (:class:`~plexapi.myplex.PlexAccount`): PlexAccount instance this
Sonos speaker is associated with.
data (ElementTree): Response from Plex Sonos API used to build this client.
Attributes:
deviceClass (str): "speaker"
lanIP (str): Local IP address of speaker.
machineIdentifier (str): Unique ID for this device.
platform (str): "Sonos"
platformVersion (str): Build version of Sonos speaker firmware.
product (str): "Sonos"
protocol (str): "plex"
protocolCapabilities (list<str>): List of client capabilities (timeline, playback,
playqueues, provider-playback)
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
session (:class:`~requests.Session`): Session object used for connection.
title (str): Name of this Sonos speaker.
token (str): X-Plex-Token used for authenication
_baseurl (str): Address of public Plex Sonos API endpoint.
_commandId (int): Counter for commands sent to Plex API.
_token (str): Token associated with linked Plex account.
_session (obj): Requests session object used to access this client.
"""
def __init__(self, account, data):
self._data = data
self.deviceClass = data.attrib.get("deviceClass")
self.machineIdentifier = data.attrib.get("machineIdentifier")
self.product = data.attrib.get("product")
self.platform = data.attrib.get("platform")
self.platformVersion = data.attrib.get("platformVersion")
self.protocol = data.attrib.get("protocol")
self.protocolCapabilities = data.attrib.get("protocolCapabilities")
self.lanIP = data.attrib.get("lanIP")
self.title = data.attrib.get("title")
self._baseurl = "https://sonos.plex.tv"
self._commandId = 0
self._token = account._token
self._session = account._session or requests.Session()
# Dummy values for PlexClient inheritance
self._last_call = 0
self._proxyThroughServer = False
self._showSecrets = CONFIG.get("log.show_secrets", "").lower() == "true"
def playMedia(self, media, offset=0, **params):
if hasattr(media, "playlistType"):
mediatype = media.playlistType
else:
if isinstance(media, PlayQueue):
mediatype = media.items[0].listType
else:
mediatype = media.listType
if mediatype == "audio":
mediatype = "music"
else:
raise BadRequest("Sonos currently only supports music for playback")
server_protocol, server_address, server_port = media._server._baseurl.split(":")
server_address = server_address.strip("/")
server_port = server_port.strip("/")
playqueue = (
media
if isinstance(media, PlayQueue)
else media._server.createPlayQueue(media)
)
self.sendCommand(
"playback/playMedia",
**dict(
{
"type": "music",
"providerIdentifier": "com.plexapp.plugins.library",
"containerKey": "/playQueues/{}?own=1".format(
playqueue.playQueueID
),
"key": media.key,
"offset": offset,
"machineIdentifier": media._server.machineIdentifier,
"protocol": server_protocol,
"address": server_address,
"port": server_port,
"token": media._server.createToken(),
"commandID": self._nextCommandId(),
"X-Plex-Client-Identifier": X_PLEX_IDENTIFIER,
"X-Plex-Token": media._server._token,
"X-Plex-Target-Client-Identifier": self.machineIdentifier,
},
**params,
),
)

View file

@ -11,6 +11,7 @@ pytest-cov
pytest-mock<=1.11.1
recommonmark
requests
requests-mock
sphinx
sphinxcontrib-napoleon
tqdm

View file

@ -12,6 +12,8 @@ from plexapi.client import PlexClient
from plexapi.myplex import MyPlexAccount
from plexapi.server import PlexServer
from .payloads import ACCOUNT_XML
try:
from unittest.mock import patch, MagicMock, mock_open
except ImportError:
@ -137,6 +139,12 @@ def account_synctarget(account_plexpass):
return account_plexpass
@pytest.fixture()
def mocked_account(requests_mock):
requests_mock.get("https://plex.tv/users/account", text=ACCOUNT_XML)
return MyPlexAccount(token="faketoken")
@pytest.fixture(scope="session")
def plex(request):
assert SERVER_BASEURL, "Required SERVER_BASEURL not specified."

24
tests/payloads.py Normal file
View file

@ -0,0 +1,24 @@
ACCOUNT_XML = """<?xml version="1.0" encoding="UTF-8"?>
<user email="testuser@email.com" id="12345" uuid="1234567890" mailing_list_status="active" thumb="https://plex.tv/users/1234567890abcdef/avatar?c=12345" username="testuser" title="testuser" cloudSyncDevice="" locale="" authenticationToken="faketoken" authToken="faketoken" scrobbleTypes="" restricted="0" home="1" guest="0" queueEmail="queue+1234567890@save.plex.tv" queueUid="" hasPassword="true" homeSize="2" maxHomeSize="15" secure="1" certificateVersion="2">
<subscription active="1" status="Active" plan="lifetime">
<feature id="companions_sonos"/>
</subscription>
<roles>
<role id="plexpass"/>
</roles>
<entitlements all="1"/>
<profile_settings default_audio_language="en" default_subtitle_language="en" auto_select_subtitle="1" auto_select_audio="1" default_subtitle_accessibility="0" default_subtitle_forced="0"/>
<services/>
<username>testuser</username>
<email>testuser@email.com</email>
<joined-at type="datetime">2000-01-01 12:348:56 UTC</joined-at>
<authentication-token>faketoken</authentication-token>
</user>
"""
SONOS_RESOURCES = """<MediaContainer size="3">
<Player title="Speaker 1" machineIdentifier="RINCON_12345678901234567: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" machineIdentifier="RINCON_12345678901234567: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_12345678901234567: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>
"""

15
tests/test_sonos.py Normal file
View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
from .payloads import SONOS_RESOURCES
def test_sonos_resources(mocked_account, requests_mock):
requests_mock.get("https://sonos.plex.tv/resources", text=SONOS_RESOURCES)
speakers = mocked_account.sonos_speakers()
assert len(speakers) == 3
speaker1 = mocked_account.sonos_speaker("Speaker 1")
assert speaker1.machineIdentifier == "RINCON_12345678901234567:1234567891"
speaker3 = mocked_account.sonos_speaker_by_id("RINCON_12345678901234567:1234567893")
assert speaker3.title == "Speaker 3"