mirror of
https://github.com/pkkid/python-plexapi
synced 2024-11-21 19:23:05 +00:00
Merge pull request #471 from jjlawren/sonos_controls
Allow control of Sonos speakers using Plex API
This commit is contained in:
commit
6daaa85f18
8 changed files with 227 additions and 2 deletions
42
README.rst
42
README.rst
|
@ -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
|
||||
--------------------------
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
116
plexapi/sonos.py
Normal 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,
|
||||
),
|
||||
)
|
|
@ -11,6 +11,7 @@ pytest-cov
|
|||
pytest-mock<=1.11.1
|
||||
recommonmark
|
||||
requests
|
||||
requests-mock
|
||||
sphinx
|
||||
sphinxcontrib-napoleon
|
||||
tqdm
|
||||
|
|
|
@ -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
24
tests/payloads.py
Normal 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
15
tests/test_sonos.py
Normal 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"
|
Loading…
Reference in a new issue