mirror of
https://github.com/pkkid/python-plexapi
synced 2025-02-16 12:58:26 +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.
|
* Navigate local or remote shared libraries.
|
||||||
* Perform library actions such as scan, analyze, empty trash.
|
* 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.
|
* Listen in on all Plex Server notifications.
|
||||||
|
|
||||||
|
|
||||||
|
@ -135,6 +135,46 @@ Usage Examples
|
||||||
plex.library.section('TV Shows').get('The 100').rate(8.0)
|
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
|
Running tests over PlexAPI
|
||||||
--------------------------
|
--------------------------
|
||||||
|
|
||||||
|
|
|
@ -157,7 +157,7 @@ class PlexClient(PlexObject):
|
||||||
log.debug('%s %s', method.__name__.upper(), url)
|
log.debug('%s %s', method.__name__.upper(), url)
|
||||||
headers = self._headers(**headers or {})
|
headers = self._headers(**headers or {})
|
||||||
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
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]
|
codename = codes.get(response.status_code)[0]
|
||||||
errtext = response.text.replace('\n', ' ')
|
errtext = response.text.replace('\n', ' ')
|
||||||
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
|
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.compat import ElementTree
|
||||||
from plexapi.library import LibrarySection
|
from plexapi.library import LibrarySection
|
||||||
from plexapi.server import PlexServer
|
from plexapi.server import PlexServer
|
||||||
|
from plexapi.sonos import PlexSonosClient
|
||||||
from plexapi.sync import SyncItem, SyncList
|
from plexapi.sync import SyncItem, SyncList
|
||||||
from plexapi.utils import joinArgs
|
from plexapi.utils import joinArgs
|
||||||
from requests.status_codes import _codes as codes
|
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):
|
def __init__(self, username=None, password=None, token=None, session=None, timeout=None):
|
||||||
self._token = token
|
self._token = token
|
||||||
self._session = session or requests.Session()
|
self._session = session or requests.Session()
|
||||||
|
self._sonos_cache = []
|
||||||
|
self._sonos_cache_timestamp = 0
|
||||||
data, initpath = self._signin(username, password, timeout)
|
data, initpath = self._signin(username, password, timeout)
|
||||||
super(MyPlexAccount, self).__init__(self, data, initpath)
|
super(MyPlexAccount, self).__init__(self, data, initpath)
|
||||||
|
|
||||||
|
@ -209,6 +212,24 @@ class MyPlexAccount(PlexObject):
|
||||||
data = self.query(MyPlexResource.key)
|
data = self.query(MyPlexResource.key)
|
||||||
return [MyPlexResource(self, elem) for elem in data]
|
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,
|
def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False,
|
||||||
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
|
allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None):
|
||||||
""" Share library content with the specified user.
|
""" 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
|
pytest-mock<=1.11.1
|
||||||
recommonmark
|
recommonmark
|
||||||
requests
|
requests
|
||||||
|
requests-mock
|
||||||
sphinx
|
sphinx
|
||||||
sphinxcontrib-napoleon
|
sphinxcontrib-napoleon
|
||||||
tqdm
|
tqdm
|
||||||
|
|
|
@ -12,6 +12,8 @@ from plexapi.client import PlexClient
|
||||||
from plexapi.myplex import MyPlexAccount
|
from plexapi.myplex import MyPlexAccount
|
||||||
from plexapi.server import PlexServer
|
from plexapi.server import PlexServer
|
||||||
|
|
||||||
|
from .payloads import ACCOUNT_XML
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from unittest.mock import patch, MagicMock, mock_open
|
from unittest.mock import patch, MagicMock, mock_open
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
@ -137,6 +139,12 @@ def account_synctarget(account_plexpass):
|
||||||
return 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")
|
@pytest.fixture(scope="session")
|
||||||
def plex(request):
|
def plex(request):
|
||||||
assert SERVER_BASEURL, "Required SERVER_BASEURL not specified."
|
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…
Add table
Reference in a new issue