Skip to content

Commit 6daaa85

Browse files
authored
Merge pull request #471 from jjlawren/sonos_controls
Allow control of Sonos speakers using Plex API
2 parents 2267378 + 771d051 commit 6daaa85

File tree

8 files changed

+227
-2
lines changed

8 files changed

+227
-2
lines changed

README.rst

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Plex Web Client. A few of the many features we currently support are:
2121

2222
* Navigate local or remote shared libraries.
2323
* Perform library actions such as scan, analyze, empty trash.
24-
* Remote control and play media on connected clients.
24+
* Remote control and play media on connected clients, including `Controlling Sonos speakers`_
2525
* Listen in on all Plex Server notifications.
2626

2727

@@ -135,6 +135,46 @@ Usage Examples
135135
plex.library.section('TV Shows').get('The 100').rate(8.0)
136136
137137
138+
Controlling Sonos speakers
139+
--------------------------
140+
141+
To control Sonos speakers directly using Plex APIs, the following requirements must be met:
142+
143+
1. Active Plex Pass subscription
144+
2. Sonos account linked to Plex account
145+
3. Plex remote access enabled
146+
147+
Due to the design of Sonos music services, the API calls to control Sonos speakers route through https://sonos.plex.tv
148+
and back via the Plex server's remote access. Actual media playback is local unless networking restrictions prevent the
149+
Sonos speakers from connecting to the Plex server directly.
150+
151+
.. code-block:: python
152+
153+
from plexapi.myplex import MyPlexAccount
154+
from plexapi.server import PlexServer
155+
156+
baseurl = 'http://plexserver:32400'
157+
token = '2ffLuB84dqLswk9skLos'
158+
159+
account = MyPlexAccount(token)
160+
server = PlexServer(baseurl, token)
161+
162+
# List available speakers/groups
163+
for speaker in account.sonos_speakers():
164+
print(speaker.title)
165+
166+
# Obtain PlexSonosPlayer instance
167+
speaker = account.sonos_speaker("Kitchen")
168+
169+
album = server.library.section('Music').get('Stevie Wonder').album('Innervisions')
170+
171+
# Speaker control examples
172+
speaker.playMedia(album)
173+
speaker.pause()
174+
speaker.setVolume(10)
175+
speaker.skipNext()
176+
177+
138178
Running tests over PlexAPI
139179
--------------------------
140180

plexapi/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ def query(self, path, method=None, headers=None, timeout=None, **kwargs):
157157
log.debug('%s %s', method.__name__.upper(), url)
158158
headers = self._headers(**headers or {})
159159
response = method(url, headers=headers, timeout=timeout, **kwargs)
160-
if response.status_code not in (200, 201):
160+
if response.status_code not in (200, 201, 204):
161161
codename = codes.get(response.status_code)[0]
162162
errtext = response.text.replace('\n', ' ')
163163
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)

plexapi/myplex.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from plexapi.compat import ElementTree
1313
from plexapi.library import LibrarySection
1414
from plexapi.server import PlexServer
15+
from plexapi.sonos import PlexSonosClient
1516
from plexapi.sync import SyncItem, SyncList
1617
from plexapi.utils import joinArgs
1718
from requests.status_codes import _codes as codes
@@ -88,6 +89,8 @@ class MyPlexAccount(PlexObject):
8889
def __init__(self, username=None, password=None, token=None, session=None, timeout=None):
8990
self._token = token
9091
self._session = session or requests.Session()
92+
self._sonos_cache = []
93+
self._sonos_cache_timestamp = 0
9194
data, initpath = self._signin(username, password, timeout)
9295
super(MyPlexAccount, self).__init__(self, data, initpath)
9396

@@ -209,6 +212,24 @@ def resources(self):
209212
data = self.query(MyPlexResource.key)
210213
return [MyPlexResource(self, elem) for elem in data]
211214

215+
def sonos_speakers(self):
216+
if 'companions_sonos' not in self.subscriptionFeatures:
217+
return []
218+
219+
t = time.time()
220+
if t - self._sonos_cache_timestamp > 60:
221+
self._sonos_cache_timestamp = t
222+
data = self.query('https://sonos.plex.tv/resources')
223+
self._sonos_cache = [PlexSonosClient(self, elem) for elem in data]
224+
225+
return self._sonos_cache
226+
227+
def sonos_speaker(self, name):
228+
return [x for x in self.sonos_speakers() if x.title == name][0]
229+
230+
def sonos_speaker_by_id(self, identifier):
231+
return [x for x in self.sonos_speakers() if x.machineIdentifier == identifier][0]
232+
212233
def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False,
213234
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
214235
""" Share library content with the specified user.

plexapi/sonos.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# -*- coding: utf-8 -*-
2+
import requests
3+
from plexapi import CONFIG, X_PLEX_IDENTIFIER
4+
from plexapi.client import PlexClient
5+
from plexapi.exceptions import BadRequest
6+
from plexapi.playqueue import PlayQueue
7+
8+
9+
class PlexSonosClient(PlexClient):
10+
""" Class for interacting with a Sonos speaker via the Plex API. This class
11+
makes requests to an external Plex API which then forwards the
12+
Sonos-specific commands back to your Plex server & Sonos speakers. Use
13+
of this feature requires an active Plex Pass subscription and Sonos
14+
speakers linked to your Plex account. It also requires remote access to
15+
be working properly.
16+
17+
More details on the Sonos integration are avaialble here:
18+
https://support.plex.tv/articles/218237558-requirements-for-using-plex-for-sonos/
19+
20+
The Sonos API emulates the Plex player control API closely:
21+
https://github.com/plexinc/plex-media-player/wiki/Remote-control-API
22+
23+
Parameters:
24+
account (:class:`~plexapi.myplex.PlexAccount`): PlexAccount instance this
25+
Sonos speaker is associated with.
26+
data (ElementTree): Response from Plex Sonos API used to build this client.
27+
28+
Attributes:
29+
deviceClass (str): "speaker"
30+
lanIP (str): Local IP address of speaker.
31+
machineIdentifier (str): Unique ID for this device.
32+
platform (str): "Sonos"
33+
platformVersion (str): Build version of Sonos speaker firmware.
34+
product (str): "Sonos"
35+
protocol (str): "plex"
36+
protocolCapabilities (list<str>): List of client capabilities (timeline, playback,
37+
playqueues, provider-playback)
38+
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
39+
session (:class:`~requests.Session`): Session object used for connection.
40+
title (str): Name of this Sonos speaker.
41+
token (str): X-Plex-Token used for authenication
42+
_baseurl (str): Address of public Plex Sonos API endpoint.
43+
_commandId (int): Counter for commands sent to Plex API.
44+
_token (str): Token associated with linked Plex account.
45+
_session (obj): Requests session object used to access this client.
46+
"""
47+
48+
def __init__(self, account, data):
49+
self._data = data
50+
self.deviceClass = data.attrib.get("deviceClass")
51+
self.machineIdentifier = data.attrib.get("machineIdentifier")
52+
self.product = data.attrib.get("product")
53+
self.platform = data.attrib.get("platform")
54+
self.platformVersion = data.attrib.get("platformVersion")
55+
self.protocol = data.attrib.get("protocol")
56+
self.protocolCapabilities = data.attrib.get("protocolCapabilities")
57+
self.lanIP = data.attrib.get("lanIP")
58+
self.title = data.attrib.get("title")
59+
self._baseurl = "https://sonos.plex.tv"
60+
self._commandId = 0
61+
self._token = account._token
62+
self._session = account._session or requests.Session()
63+
64+
# Dummy values for PlexClient inheritance
65+
self._last_call = 0
66+
self._proxyThroughServer = False
67+
self._showSecrets = CONFIG.get("log.show_secrets", "").lower() == "true"
68+
69+
def playMedia(self, media, offset=0, **params):
70+
71+
if hasattr(media, "playlistType"):
72+
mediatype = media.playlistType
73+
else:
74+
if isinstance(media, PlayQueue):
75+
mediatype = media.items[0].listType
76+
else:
77+
mediatype = media.listType
78+
79+
if mediatype == "audio":
80+
mediatype = "music"
81+
else:
82+
raise BadRequest("Sonos currently only supports music for playback")
83+
84+
server_protocol, server_address, server_port = media._server._baseurl.split(":")
85+
server_address = server_address.strip("/")
86+
server_port = server_port.strip("/")
87+
88+
playqueue = (
89+
media
90+
if isinstance(media, PlayQueue)
91+
else media._server.createPlayQueue(media)
92+
)
93+
self.sendCommand(
94+
"playback/playMedia",
95+
**dict(
96+
{
97+
"type": "music",
98+
"providerIdentifier": "com.plexapp.plugins.library",
99+
"containerKey": "/playQueues/{}?own=1".format(
100+
playqueue.playQueueID
101+
),
102+
"key": media.key,
103+
"offset": offset,
104+
"machineIdentifier": media._server.machineIdentifier,
105+
"protocol": server_protocol,
106+
"address": server_address,
107+
"port": server_port,
108+
"token": media._server.createToken(),
109+
"commandID": self._nextCommandId(),
110+
"X-Plex-Client-Identifier": X_PLEX_IDENTIFIER,
111+
"X-Plex-Token": media._server._token,
112+
"X-Plex-Target-Client-Identifier": self.machineIdentifier,
113+
},
114+
**params,
115+
),
116+
)

requirements_dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pytest-cov
1111
pytest-mock<=1.11.1
1212
recommonmark
1313
requests
14+
requests-mock
1415
sphinx
1516
sphinxcontrib-napoleon
1617
tqdm

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from plexapi.myplex import MyPlexAccount
1313
from plexapi.server import PlexServer
1414

15+
from .payloads import ACCOUNT_XML
16+
1517
try:
1618
from unittest.mock import patch, MagicMock, mock_open
1719
except ImportError:
@@ -137,6 +139,12 @@ def account_synctarget(account_plexpass):
137139
return account_plexpass
138140

139141

142+
@pytest.fixture()
143+
def mocked_account(requests_mock):
144+
requests_mock.get("https://plex.tv/users/account", text=ACCOUNT_XML)
145+
return MyPlexAccount(token="faketoken")
146+
147+
140148
@pytest.fixture(scope="session")
141149
def plex(request):
142150
assert SERVER_BASEURL, "Required SERVER_BASEURL not specified."

tests/payloads.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
ACCOUNT_XML = """<?xml version="1.0" encoding="UTF-8"?>
2+
<user email="[email protected]" 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="[email protected]" queueUid="" hasPassword="true" homeSize="2" maxHomeSize="15" secure="1" certificateVersion="2">
3+
<subscription active="1" status="Active" plan="lifetime">
4+
<feature id="companions_sonos"/>
5+
</subscription>
6+
<roles>
7+
<role id="plexpass"/>
8+
</roles>
9+
<entitlements all="1"/>
10+
<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"/>
11+
<services/>
12+
<username>testuser</username>
13+
<email>[email protected]</email>
14+
<joined-at type="datetime">2000-01-01 12:348:56 UTC</joined-at>
15+
<authentication-token>faketoken</authentication-token>
16+
</user>
17+
"""
18+
19+
SONOS_RESOURCES = """<MediaContainer size="3">
20+
<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"/>
21+
<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"/>
22+
<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"/>
23+
</MediaContainer>
24+
"""

tests/test_sonos.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# -*- coding: utf-8 -*-
2+
from .payloads import SONOS_RESOURCES
3+
4+
5+
def test_sonos_resources(mocked_account, requests_mock):
6+
requests_mock.get("https://sonos.plex.tv/resources", text=SONOS_RESOURCES)
7+
8+
speakers = mocked_account.sonos_speakers()
9+
assert len(speakers) == 3
10+
11+
speaker1 = mocked_account.sonos_speaker("Speaker 1")
12+
assert speaker1.machineIdentifier == "RINCON_12345678901234567:1234567891"
13+
14+
speaker3 = mocked_account.sonos_speaker_by_id("RINCON_12345678901234567:1234567893")
15+
assert speaker3.title == "Speaker 3"

0 commit comments

Comments
 (0)