From 55b335ee7c00846b1a4f3ff5dbcae0bdd11e994f Mon Sep 17 00:00:00 2001 From: Jason Lawrence Date: Tue, 28 Apr 2020 11:52:09 -0500 Subject: [PATCH] Add lookup & control of linked Sonos speakers --- plexapi/client.py | 2 +- plexapi/myplex.py | 7 ++++ plexapi/sonos.py | 104 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 plexapi/sonos.py 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..54bc0b20 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 @@ -209,6 +210,12 @@ 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 [] + data = self.query('https://sonos.plex.tv/resources') + return [PlexSonosClient(self, elem) for elem in data] + 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..3f09c7f6 --- /dev/null +++ b/plexapi/sonos.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +import requests +from plexapi import CONFIG +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: + server (: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": f"/playQueues/{playqueue.playQueueID}?own=1", + "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": client_id, + "X-Plex-Token": media._server._token, + "X-Plex-Target-Client-Identifier": self.machineIdentifier, + }, **params))