diff --git a/README.rst b/README.rst index 290526dc..9aa635e5 100644 --- a/README.rst +++ b/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 -------------------------- diff --git a/plexapi/client.py b/plexapi/client.py index a6dd72eb..e4381496 100644 --- a/plexapi/client.py +++ b/plexapi/client.py @@ -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) diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 3cc68fcc..e9bac9c0 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -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. diff --git a/plexapi/sonos.py b/plexapi/sonos.py new file mode 100644 index 00000000..f6a011f3 --- /dev/null +++ b/plexapi/sonos.py @@ -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): 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, + ), + ) diff --git a/requirements_dev.txt b/requirements_dev.txt index 711c160b..79d5d242 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -11,6 +11,7 @@ pytest-cov pytest-mock<=1.11.1 recommonmark requests +requests-mock sphinx sphinxcontrib-napoleon tqdm diff --git a/tests/conftest.py b/tests/conftest.py index 5a8f8ce1..06f5545e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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." diff --git a/tests/payloads.py b/tests/payloads.py new file mode 100644 index 00000000..2ae31941 --- /dev/null +++ b/tests/payloads.py @@ -0,0 +1,24 @@ +ACCOUNT_XML = """ + + + + + + + + + + + testuser + testuser@email.com + 2000-01-01 12:348:56 UTC + faketoken + +""" + +SONOS_RESOURCES = """ + + + + +""" diff --git a/tests/test_sonos.py b/tests/test_sonos.py new file mode 100644 index 00000000..adbedfc0 --- /dev/null +++ b/tests/test_sonos.py @@ -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"