2016-03-21 04:26:02 +00:00
|
|
|
# -*- coding: utf-8 -*-
|
2018-11-17 00:46:42 +00:00
|
|
|
import time
|
2020-05-12 21:15:16 +00:00
|
|
|
from xml.etree import ElementTree
|
2017-09-12 06:58:43 +00:00
|
|
|
|
2020-04-15 20:53:17 +00:00
|
|
|
import requests
|
|
|
|
from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils
|
2017-02-06 04:52:10 +00:00
|
|
|
from plexapi.base import PlexObject
|
2020-04-15 22:09:27 +00:00
|
|
|
from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported
|
2017-02-15 05:37:02 +00:00
|
|
|
from plexapi.playqueue import PlayQueue
|
2020-04-15 20:53:17 +00:00
|
|
|
from requests.status_codes import _codes as codes
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
DEFAULT_MTYPE = 'video'
|
|
|
|
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2017-02-13 06:37:23 +00:00
|
|
|
@utils.registerPlexObject
|
2017-02-06 04:52:10 +00:00
|
|
|
class PlexClient(PlexObject):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Main class for interacting with a Plex client. This class can connect
|
|
|
|
directly to the client and control it or proxy commands through your
|
|
|
|
Plex Server. To better understand the Plex client API's read this page:
|
|
|
|
https://github.com/plexinc/plex-media-player/wiki/Remote-control-API
|
|
|
|
|
|
|
|
Parameters:
|
2017-02-14 04:32:27 +00:00
|
|
|
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional).
|
|
|
|
data (ElementTree): Response from PlexServer used to build this object (optional).
|
|
|
|
initpath (str): Path used to generate data.
|
2017-01-23 05:15:51 +00:00
|
|
|
baseurl (str): HTTP URL to connect dirrectly to this client.
|
|
|
|
token (str): X-Plex-Token used for authenication (optional).
|
|
|
|
session (:class:`~requests.Session`): requests.Session object if you want more control (optional).
|
2017-04-24 02:59:22 +00:00
|
|
|
timeout (int): timeout in seconds on initial connect to client (default config.TIMEOUT).
|
2017-02-20 05:37:00 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Attributes:
|
2017-02-14 04:32:27 +00:00
|
|
|
TAG (str): 'Player'
|
|
|
|
key (str): '/resources'
|
2017-01-23 05:15:51 +00:00
|
|
|
device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc).
|
|
|
|
deviceClass (str): Device class (pc, phone, etc).
|
|
|
|
machineIdentifier (str): Unique ID for this device.
|
|
|
|
model (str): Unknown
|
|
|
|
platform (str): Unknown
|
|
|
|
platformVersion (str): Description
|
|
|
|
product (str): Client Product (Plex for iOS, etc).
|
|
|
|
protocol (str): Always seems ot be 'plex'.
|
|
|
|
protocolCapabilities (list<str>): List of client capabilities (navigation, playback,
|
|
|
|
timeline, mirror, playqueues).
|
|
|
|
protocolVersion (str): Protocol version (1, future proofing?)
|
|
|
|
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
|
|
|
|
session (:class:`~requests.Session`): Session object used for connection.
|
|
|
|
state (str): Unknown
|
|
|
|
title (str): Name of this client (Johns iPhone, etc).
|
|
|
|
token (str): X-Plex-Token used for authenication
|
|
|
|
vendor (str): Unknown
|
|
|
|
version (str): Device version (4.6.1, etc).
|
2017-06-05 02:01:07 +00:00
|
|
|
_baseurl (str): HTTP address of the client.
|
|
|
|
_token (str): Token used to access this client.
|
|
|
|
_session (obj): Requests session object used to access this client.
|
2017-01-23 05:15:51 +00:00
|
|
|
_proxyThroughServer (bool): Set to True after calling
|
2020-11-23 03:06:30 +00:00
|
|
|
:func:`~plexapi.client.PlexClient.proxyThroughServer` (default False).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
2017-02-13 02:55:55 +00:00
|
|
|
TAG = 'Player'
|
2017-02-06 04:52:10 +00:00
|
|
|
key = '/resources'
|
|
|
|
|
2017-05-19 03:04:57 +00:00
|
|
|
def __init__(self, server=None, data=None, initpath=None, baseurl=None,
|
|
|
|
token=None, connect=True, session=None, timeout=None):
|
2017-02-13 06:37:23 +00:00
|
|
|
super(PlexClient, self).__init__(server, data, initpath)
|
|
|
|
self._baseurl = baseurl.strip('/') if baseurl else None
|
|
|
|
self._token = logfilter.add_secret(token)
|
2018-01-05 02:44:35 +00:00
|
|
|
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
|
2017-02-13 06:37:23 +00:00
|
|
|
server_session = server._session if server else None
|
|
|
|
self._session = session or server_session or requests.Session()
|
2016-03-24 06:20:08 +00:00
|
|
|
self._proxyThroughServer = False
|
|
|
|
self._commandId = 0
|
2018-11-17 00:46:42 +00:00
|
|
|
self._last_call = 0
|
2020-10-02 16:33:53 +00:00
|
|
|
self._timeline_cache = []
|
|
|
|
self._timeline_cache_timestamp = 0
|
2020-06-29 22:31:05 +00:00
|
|
|
if not any([data is not None, initpath, baseurl, token]):
|
2017-02-22 06:22:10 +00:00
|
|
|
self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433')
|
|
|
|
self._token = logfilter.add_secret(CONFIG.get('auth.client_token'))
|
2017-04-28 01:21:40 +00:00
|
|
|
if connect and self._baseurl:
|
2017-04-24 02:59:22 +00:00
|
|
|
self.connect(timeout=timeout)
|
2017-02-06 04:52:10 +00:00
|
|
|
|
2017-05-13 03:25:57 +00:00
|
|
|
def _nextCommandId(self):
|
2017-05-13 20:13:38 +00:00
|
|
|
self._commandId += 1
|
2017-05-13 03:25:57 +00:00
|
|
|
return self._commandId
|
|
|
|
|
2017-04-24 02:59:22 +00:00
|
|
|
def connect(self, timeout=None):
|
2017-02-06 04:52:10 +00:00
|
|
|
""" Alias of reload as any subsequent requests to this client will be
|
2017-02-20 05:37:00 +00:00
|
|
|
made directly to the device even if the object attributes were initially
|
2017-02-06 04:52:10 +00:00
|
|
|
populated from a PlexServer.
|
|
|
|
"""
|
2017-02-13 06:37:23 +00:00
|
|
|
if not self.key:
|
|
|
|
raise Unsupported('Cannot reload an object not built from a URL.')
|
|
|
|
self._initpath = self.key
|
2017-04-24 02:59:22 +00:00
|
|
|
data = self.query(self.key, timeout=timeout)
|
2017-02-13 06:37:23 +00:00
|
|
|
self._loadData(data[0])
|
|
|
|
return self
|
|
|
|
|
|
|
|
def reload(self):
|
|
|
|
""" Alias to self.connect(). """
|
|
|
|
return self.connect()
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2016-04-08 02:48:45 +00:00
|
|
|
def _loadData(self, data):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Load attribute values from Plex XML response. """
|
2017-02-04 08:08:47 +00:00
|
|
|
self._data = data
|
2016-04-08 02:48:45 +00:00
|
|
|
self.deviceClass = data.attrib.get('deviceClass')
|
|
|
|
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
|
|
|
self.product = data.attrib.get('product')
|
|
|
|
self.protocol = data.attrib.get('protocol')
|
2017-01-23 05:15:51 +00:00
|
|
|
self.protocolCapabilities = data.attrib.get('protocolCapabilities', '').split(',')
|
2016-04-08 02:48:45 +00:00
|
|
|
self.protocolVersion = data.attrib.get('protocolVersion')
|
|
|
|
self.platform = data.attrib.get('platform')
|
|
|
|
self.platformVersion = data.attrib.get('platformVersion')
|
|
|
|
self.title = data.attrib.get('title') or data.attrib.get('name')
|
2017-01-23 05:15:51 +00:00
|
|
|
# Active session details
|
2017-09-12 06:58:43 +00:00
|
|
|
# Since protocolCapabilities is missing from /sessions we cant really control this player without
|
|
|
|
# creating a client manually.
|
2017-09-12 07:30:12 +00:00
|
|
|
# Add this in next breaking release.
|
|
|
|
# if self._initpath == 'status/sessions':
|
2017-02-13 06:37:23 +00:00
|
|
|
self.device = data.attrib.get('device') # session
|
|
|
|
self.model = data.attrib.get('model') # session
|
|
|
|
self.state = data.attrib.get('state') # session
|
|
|
|
self.vendor = data.attrib.get('vendor') # session
|
|
|
|
self.version = data.attrib.get('version') # session
|
2017-09-12 07:01:40 +00:00
|
|
|
self.local = utils.cast(bool, data.attrib.get('local', 0))
|
2017-09-12 07:30:12 +00:00
|
|
|
self.address = data.attrib.get('address') # session
|
2017-09-12 06:58:43 +00:00
|
|
|
self.remotePublicAddress = data.attrib.get('remotePublicAddress')
|
|
|
|
self.userID = data.attrib.get('userID')
|
2016-04-08 02:48:45 +00:00
|
|
|
|
2017-02-06 04:52:10 +00:00
|
|
|
def _headers(self, **kwargs):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Returns a dict of all default headers for Client requests. """
|
2016-04-02 06:19:32 +00:00
|
|
|
headers = BASE_HEADERS
|
2017-02-06 04:52:10 +00:00
|
|
|
if self._token:
|
|
|
|
headers['X-Plex-Token'] = self._token
|
|
|
|
headers.update(kwargs)
|
2016-04-02 06:19:32 +00:00
|
|
|
return headers
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2017-05-13 03:25:57 +00:00
|
|
|
def proxyThroughServer(self, value=True, server=None):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Tells this PlexClient instance to proxy all future commands through the PlexServer.
|
2017-02-20 05:37:00 +00:00
|
|
|
Useful if you do not wish to connect directly to the Client device itself.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
value (bool): Enable or disable proxying (optional, default True).
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Raises:
|
2020-11-23 20:20:56 +00:00
|
|
|
:exc:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server.
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
2017-05-13 03:25:57 +00:00
|
|
|
if server:
|
|
|
|
self._server = server
|
2017-02-07 06:20:49 +00:00
|
|
|
if value is True and not self._server:
|
2016-04-02 06:19:32 +00:00
|
|
|
raise Unsupported('Cannot use client proxy with unknown server.')
|
2016-03-24 06:20:08 +00:00
|
|
|
self._proxyThroughServer = value
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2017-04-24 02:59:22 +00:00
|
|
|
def query(self, path, method=None, headers=None, timeout=None, **kwargs):
|
2017-02-09 04:29:17 +00:00
|
|
|
""" Main method used to handle HTTPS requests to the Plex client. This method helps
|
|
|
|
by encoding the response to utf-8 and parsing the returned XML into and
|
|
|
|
ElementTree object. Returns None if no data exists in the response.
|
|
|
|
"""
|
|
|
|
url = self.url(path)
|
|
|
|
method = method or self._session.get
|
2017-04-24 02:59:22 +00:00
|
|
|
timeout = timeout or TIMEOUT
|
2017-02-09 04:29:17 +00:00
|
|
|
log.debug('%s %s', method.__name__.upper(), url)
|
|
|
|
headers = self._headers(**headers or {})
|
2017-04-24 02:59:22 +00:00
|
|
|
response = method(url, headers=headers, timeout=timeout, **kwargs)
|
2020-04-28 16:52:09 +00:00
|
|
|
if response.status_code not in (200, 201, 204):
|
2017-02-09 04:29:17 +00:00
|
|
|
codename = codes.get(response.status_code)[0]
|
2017-05-27 02:35:33 +00:00
|
|
|
errtext = response.text.replace('\n', ' ')
|
2020-04-09 20:56:26 +00:00
|
|
|
message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)
|
|
|
|
if response.status_code == 401:
|
|
|
|
raise Unauthorized(message)
|
2020-04-15 22:09:27 +00:00
|
|
|
elif response.status_code == 404:
|
|
|
|
raise NotFound(message)
|
2020-04-09 20:56:26 +00:00
|
|
|
else:
|
|
|
|
raise BadRequest(message)
|
2017-02-09 04:29:17 +00:00
|
|
|
data = response.text.encode('utf8')
|
2017-02-25 07:37:30 +00:00
|
|
|
return ElementTree.fromstring(data) if data.strip() else None
|
2017-02-09 04:29:17 +00:00
|
|
|
|
2016-04-02 06:19:32 +00:00
|
|
|
def sendCommand(self, command, proxy=None, **params):
|
2020-11-23 03:06:30 +00:00
|
|
|
""" Convenience wrapper around :func:`~plexapi.client.PlexClient.query` to more easily
|
2017-01-31 04:44:03 +00:00
|
|
|
send simple commands to the client. Returns an ElementTree object containing
|
2017-01-23 05:15:51 +00:00
|
|
|
the response.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
command (str): Command to be sent in for format '<controller>/<command>'.
|
|
|
|
proxy (bool): Set True to proxy this command through the PlexServer.
|
|
|
|
**params (dict): Additional GET parameters to include with the command.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Raises:
|
2020-11-23 20:20:56 +00:00
|
|
|
:exc:`~plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability.
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
2016-04-06 03:32:49 +00:00
|
|
|
command = command.strip('/')
|
|
|
|
controller = command.split('/')[0]
|
2018-11-17 00:46:42 +00:00
|
|
|
headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier}
|
2016-04-06 03:32:49 +00:00
|
|
|
if controller not in self.protocolCapabilities:
|
2018-01-01 23:01:05 +00:00
|
|
|
log.debug('Client %s doesnt support %s controller.'
|
|
|
|
'What your trying might not work' % (self.title, controller))
|
|
|
|
|
2019-10-08 21:06:45 +00:00
|
|
|
proxy = self._proxyThroughServer if proxy is None else proxy
|
|
|
|
query = self._server.query if proxy else self.query
|
|
|
|
|
2018-11-17 00:46:42 +00:00
|
|
|
# Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244
|
|
|
|
t = time.time()
|
2020-05-12 18:33:20 +00:00
|
|
|
if command == 'timeline/poll':
|
2018-11-17 00:46:42 +00:00
|
|
|
self._last_call = t
|
2020-05-12 18:33:20 +00:00
|
|
|
elif t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'):
|
|
|
|
self._last_call = t
|
2020-10-02 16:33:53 +00:00
|
|
|
self.sendCommand(ClientTimeline.key, wait=0)
|
2018-11-17 00:46:42 +00:00
|
|
|
|
2017-05-13 03:42:27 +00:00
|
|
|
params['commandID'] = self._nextCommandId()
|
2017-02-07 06:20:49 +00:00
|
|
|
key = '/player/%s%s' % (command, utils.joinArgs(params))
|
2018-11-17 00:46:42 +00:00
|
|
|
|
2019-10-08 20:31:37 +00:00
|
|
|
try:
|
2019-10-08 21:06:45 +00:00
|
|
|
return query(key, headers=headers)
|
2019-10-08 20:31:37 +00:00
|
|
|
except ElementTree.ParseError:
|
2019-10-08 20:20:48 +00:00
|
|
|
# Workaround for players which don't return valid XML on successful commands
|
2020-03-29 19:14:48 +00:00
|
|
|
# - Plexamp, Plex for Android: `b'OK'`
|
|
|
|
# - Plex for Samsung: `b'<?xml version="1.0"?><Response code="200" status="OK">'`
|
2019-10-25 03:23:48 +00:00
|
|
|
if self.product in (
|
|
|
|
'Plexamp',
|
|
|
|
'Plex for Android (TV)',
|
2020-03-29 19:14:48 +00:00
|
|
|
'Plex for Android (Mobile)',
|
|
|
|
'Plex for Samsung',
|
2019-10-25 03:23:48 +00:00
|
|
|
):
|
2019-10-11 20:30:32 +00:00
|
|
|
return
|
2019-10-08 21:06:45 +00:00
|
|
|
raise
|
2017-02-09 04:29:17 +00:00
|
|
|
|
2018-01-05 02:44:35 +00:00
|
|
|
def url(self, key, includeToken=False):
|
|
|
|
""" Build a URL string with proper token argument. Token will be appended to the URL
|
|
|
|
if either includeToken is True or CONFIG.log.show_secrets is 'true'.
|
|
|
|
"""
|
2017-04-25 02:49:15 +00:00
|
|
|
if not self._baseurl:
|
|
|
|
raise BadRequest('PlexClient object missing baseurl.')
|
2018-01-05 02:44:35 +00:00
|
|
|
if self._token and (includeToken or self._showSecrets):
|
2017-02-09 04:29:17 +00:00
|
|
|
delim = '&' if '?' in key else '?'
|
|
|
|
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
|
|
|
|
return '%s%s' % (self._baseurl, key)
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2017-02-20 05:37:00 +00:00
|
|
|
# ---------------------
|
2014-12-29 03:21:58 +00:00
|
|
|
# Navigation Commands
|
2016-04-07 05:39:04 +00:00
|
|
|
# These commands navigate around the user-interface.
|
2016-12-17 01:09:01 +00:00
|
|
|
def contextMenu(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Open the context menu on the client. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/contextMenu')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def goBack(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Navigate back one position. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/back')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def goToHome(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Go directly to the home screen. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/home')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def goToMusic(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Go directly to the playing music panel. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/music')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def moveDown(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Move selection down a position. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/moveDown')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def moveLeft(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Move selection left a position. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/moveLeft')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def moveRight(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Move selection right a position. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/moveRight')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def moveUp(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Move selection up a position. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/moveUp')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def nextLetter(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Jump to next letter in the alphabet. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/nextLetter')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def pageDown(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Move selection down a full page. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/pageDown')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def pageUp(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Move selection up a full page. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/pageUp')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def previousLetter(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Jump to previous letter in the alphabet. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/previousLetter')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def select(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Select element at the current position. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/select')
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2016-12-17 01:09:01 +00:00
|
|
|
def toggleOSD(self):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Toggle the on screen display during playback. """
|
2016-12-17 01:09:01 +00:00
|
|
|
self.sendCommand('navigation/toggleOSD')
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2016-03-24 06:20:08 +00:00
|
|
|
def goToMedia(self, media, **params):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Navigate directly to the specified media page.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
media (:class:`~plexapi.media.Media`): Media object to navigate to.
|
|
|
|
**params (dict): Additional GET parameters to include with the command.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Raises:
|
2020-11-23 20:20:56 +00:00
|
|
|
:exc:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
2017-02-07 06:20:49 +00:00
|
|
|
if not self._server:
|
2017-01-23 05:15:51 +00:00
|
|
|
raise Unsupported('A server must be specified before using this command.')
|
2017-02-07 06:20:49 +00:00
|
|
|
server_url = media._server._baseurl.split(':')
|
2016-03-25 02:36:25 +00:00
|
|
|
self.sendCommand('mirror/details', **dict({
|
2017-02-07 06:20:49 +00:00
|
|
|
'machineIdentifier': self._server.machineIdentifier,
|
2016-04-02 06:19:32 +00:00
|
|
|
'address': server_url[1].strip('/'),
|
|
|
|
'port': server_url[-1],
|
2016-03-24 06:20:08 +00:00
|
|
|
'key': media.key,
|
2019-11-28 22:02:11 +00:00
|
|
|
'protocol': server_url[0],
|
|
|
|
'token': media._server.createToken()
|
2016-03-24 06:20:08 +00:00
|
|
|
}, **params))
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2017-02-20 05:37:00 +00:00
|
|
|
# -------------------
|
2016-03-24 06:20:08 +00:00
|
|
|
# Playback Commands
|
2016-04-01 03:39:09 +00:00
|
|
|
# Most of the playback commands take a mandatory mtype {'music','photo','video'} argument,
|
2016-03-24 06:20:08 +00:00
|
|
|
# to specify which media type to apply the command to, (except for playMedia). This
|
|
|
|
# is in case there are multiple things happening (e.g. music in the background, photo
|
|
|
|
# slideshow in the foreground).
|
2017-02-15 05:37:02 +00:00
|
|
|
def pause(self, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Pause the currently playing media type.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.sendCommand('playback/pause', type=mtype)
|
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def play(self, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Start playback for the specified media type.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.sendCommand('playback/play', type=mtype)
|
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def refreshPlayQueue(self, playQueueID, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Refresh the specified Playqueue.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
playQueueID (str): Playqueue ID.
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.sendCommand(
|
|
|
|
'playback/refreshPlayQueue', playQueueID=playQueueID, type=mtype)
|
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def seekTo(self, offset, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Seek to the specified offset (ms) during playback.
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
offset (int): Position to seek to (milliseconds).
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.sendCommand('playback/seekTo', offset=offset, type=mtype)
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def skipNext(self, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Skip to the next playback item.
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.sendCommand('playback/skipNext', type=mtype)
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def skipPrevious(self, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Skip to previous playback item.
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.sendCommand('playback/skipPrevious', type=mtype)
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def skipTo(self, key, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Skip to the playback item with the specified key.
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
key (str): Key of the media item to skip to.
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.sendCommand('playback/skipTo', key=key, type=mtype)
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def stepBack(self, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Step backward a chunk of time in the current playback item.
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.sendCommand('playback/stepBack', type=mtype)
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def stepForward(self, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Step forward a chunk of time in the current playback item.
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.sendCommand('playback/stepForward', type=mtype)
|
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def stop(self, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Stop the currently playing item.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.sendCommand('playback/stop', type=mtype)
|
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def setRepeat(self, repeat, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Enable repeat for the specified playback items.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall).
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.setParameters(repeat=repeat, mtype=mtype)
|
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def setShuffle(self, shuffle, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Enable shuffle for the specified playback items.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
shuffle (int): Shuffle mode (0=off, 1=on)
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.setParameters(shuffle=shuffle, mtype=mtype)
|
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def setVolume(self, volume, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Enable volume for the current playback item.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
volume (int): Volume level (0-100).
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.setParameters(volume=volume, mtype=mtype)
|
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def setAudioStream(self, audioStreamID, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Select the audio stream for the current playback item (only video).
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
audioStreamID (str): ID of the audio stream from the media object.
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.setStreams(audioStreamID=audioStreamID, mtype=mtype)
|
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def setSubtitleStream(self, subtitleStreamID, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Select the subtitle stream for the current playback item (only video).
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
subtitleStreamID (str): ID of the subtitle stream from the media object.
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.setStreams(subtitleStreamID=subtitleStreamID, mtype=mtype)
|
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def setVideoStream(self, videoStreamID, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Select the video stream for the current playback item (only video).
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
videoStreamID (str): ID of the video stream from the media object.
|
|
|
|
mtype (str): Media type to take action against (music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
|
|
|
self.setStreams(videoStreamID=videoStreamID, mtype=mtype)
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-02-02 06:32:38 +00:00
|
|
|
def playMedia(self, media, offset=0, **params):
|
|
|
|
""" Start playback of the specified media item. See also:
|
2017-02-20 05:37:00 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
2017-02-15 05:37:02 +00:00
|
|
|
media (:class:`~plexapi.media.Media`): Media item to be played back
|
|
|
|
(movie, music, photo, playlist, playqueue).
|
|
|
|
offset (int): Number of milliseconds at which to start playing with zero
|
|
|
|
representing the beginning (default 0).
|
2017-02-02 06:32:38 +00:00
|
|
|
**params (dict): Optional additional parameters to include in the playback request. See
|
|
|
|
also: https://github.com/plexinc/plex-media-player/wiki/Remote-control-API#modified-commands
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Raises:
|
2020-11-23 20:20:56 +00:00
|
|
|
:exc:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object.
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
2017-02-07 06:20:49 +00:00
|
|
|
if not self._server:
|
2017-02-02 03:53:05 +00:00
|
|
|
raise Unsupported('A server must be specified before using this command.')
|
2017-02-07 06:20:49 +00:00
|
|
|
server_url = media._server._baseurl.split(':')
|
2018-07-20 20:17:18 +00:00
|
|
|
server_port = server_url[-1].strip('/')
|
2017-08-18 19:01:01 +00:00
|
|
|
|
2019-11-28 20:58:31 +00:00
|
|
|
if hasattr(media, "playlistType"):
|
|
|
|
mediatype = media.playlistType
|
|
|
|
else:
|
2020-04-10 03:34:14 +00:00
|
|
|
if isinstance(media, PlayQueue):
|
|
|
|
mediatype = media.items[0].listType
|
|
|
|
else:
|
|
|
|
mediatype = media.listType
|
|
|
|
|
|
|
|
# mediatype must be in ["video", "music", "photo"]
|
|
|
|
if mediatype == "audio":
|
|
|
|
mediatype = "music"
|
2019-11-28 20:58:31 +00:00
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
playqueue = media if isinstance(media, PlayQueue) else self._server.createPlayQueue(media)
|
2016-03-25 02:36:25 +00:00
|
|
|
self.sendCommand('playback/playMedia', **dict({
|
2017-02-07 06:20:49 +00:00
|
|
|
'machineIdentifier': self._server.machineIdentifier,
|
2016-04-02 06:19:32 +00:00
|
|
|
'address': server_url[1].strip('/'),
|
2018-07-20 20:17:18 +00:00
|
|
|
'port': server_port,
|
2017-02-02 06:32:38 +00:00
|
|
|
'offset': offset,
|
2016-03-24 06:20:08 +00:00
|
|
|
'key': media.key,
|
2019-11-28 22:07:15 +00:00
|
|
|
'token': media._server.createToken(),
|
2019-11-28 20:58:31 +00:00
|
|
|
'type': mediatype,
|
2014-12-29 03:21:58 +00:00
|
|
|
'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID,
|
2016-03-24 06:20:08 +00:00
|
|
|
}, **params))
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def setParameters(self, volume=None, shuffle=None, repeat=None, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Set multiple playback parameters at once.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
volume (int): Volume level (0-100; optional).
|
|
|
|
shuffle (int): Shuffle mode (0=off, 1=on; optional).
|
|
|
|
repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall; optional).
|
|
|
|
mtype (str): Media type to take action against (optional music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
2016-03-24 06:20:08 +00:00
|
|
|
params = {}
|
2016-12-16 23:51:16 +00:00
|
|
|
if repeat is not None:
|
2016-12-17 01:09:01 +00:00
|
|
|
params['repeat'] = repeat
|
2016-12-16 23:51:16 +00:00
|
|
|
if shuffle is not None:
|
2016-12-17 01:09:01 +00:00
|
|
|
params['shuffle'] = shuffle
|
2016-12-16 23:51:16 +00:00
|
|
|
if volume is not None:
|
2016-12-17 01:09:01 +00:00
|
|
|
params['volume'] = volume
|
2016-12-16 23:51:16 +00:00
|
|
|
if mtype is not None:
|
2016-12-17 01:09:01 +00:00
|
|
|
params['type'] = mtype
|
2016-03-25 02:36:25 +00:00
|
|
|
self.sendCommand('playback/setParameters', **params)
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-02-15 05:37:02 +00:00
|
|
|
def setStreams(self, audioStreamID=None, subtitleStreamID=None, videoStreamID=None, mtype=DEFAULT_MTYPE):
|
2017-01-23 05:15:51 +00:00
|
|
|
""" Select multiple playback streams at once.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
audioStreamID (str): ID of the audio stream from the media object.
|
|
|
|
subtitleStreamID (str): ID of the subtitle stream from the media object.
|
|
|
|
videoStreamID (str): ID of the video stream from the media object.
|
|
|
|
mtype (str): Media type to take action against (optional music, photo, video).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
2016-03-24 06:20:08 +00:00
|
|
|
params = {}
|
2016-12-16 23:51:16 +00:00
|
|
|
if audioStreamID is not None:
|
|
|
|
params['audioStreamID'] = audioStreamID
|
|
|
|
if subtitleStreamID is not None:
|
|
|
|
params['subtitleStreamID'] = subtitleStreamID
|
|
|
|
if videoStreamID is not None:
|
|
|
|
params['videoStreamID'] = videoStreamID
|
|
|
|
if mtype is not None:
|
2016-12-17 01:09:01 +00:00
|
|
|
params['type'] = mtype
|
2016-03-25 02:36:25 +00:00
|
|
|
self.sendCommand('playback/setStreams', **params)
|
2016-12-16 23:51:16 +00:00
|
|
|
|
2017-02-20 05:37:00 +00:00
|
|
|
# -------------------
|
2016-03-24 06:20:08 +00:00
|
|
|
# Timeline Commands
|
2020-10-02 16:33:53 +00:00
|
|
|
def timelines(self, wait=0):
|
|
|
|
"""Poll the client's timelines, create, and return timeline objects.
|
|
|
|
Some clients may not always respond to timeline requests, believe this
|
|
|
|
to be a Plex bug.
|
|
|
|
"""
|
|
|
|
t = time.time()
|
|
|
|
if t - self._timeline_cache_timestamp > 1:
|
|
|
|
self._timeline_cache_timestamp = t
|
|
|
|
timelines = self.sendCommand(ClientTimeline.key, wait=wait) or []
|
|
|
|
self._timeline_cache = [ClientTimeline(self, data) for data in timelines]
|
|
|
|
|
|
|
|
return self._timeline_cache
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2020-10-02 16:33:53 +00:00
|
|
|
@property
|
|
|
|
def timeline(self):
|
|
|
|
"""Returns the active timeline object."""
|
|
|
|
return next((x for x in self.timelines() if x.state != 'stopped'), None)
|
|
|
|
|
|
|
|
def isPlayingMedia(self, includePaused=True):
|
|
|
|
"""Returns True if any media is currently playing.
|
2016-12-17 01:09:01 +00:00
|
|
|
|
2017-01-23 05:15:51 +00:00
|
|
|
Parameters:
|
|
|
|
includePaused (bool): Set True to treat currently paused items
|
2020-10-02 16:33:53 +00:00
|
|
|
as playing (optional; default True).
|
2016-12-17 01:09:01 +00:00
|
|
|
"""
|
2020-10-02 16:33:53 +00:00
|
|
|
state = getattr(self.timeline, "state", None)
|
|
|
|
return bool(state == 'playing' or (includePaused and state == 'paused'))
|
|
|
|
|
|
|
|
|
|
|
|
class ClientTimeline(PlexObject):
|
|
|
|
"""Get the timeline's attributes."""
|
|
|
|
|
|
|
|
key = 'timeline/poll'
|
|
|
|
|
|
|
|
def _loadData(self, data):
|
|
|
|
self._data = data
|
|
|
|
self.address = data.attrib.get('address')
|
|
|
|
self.audioStreamId = utils.cast(int, data.attrib.get('audioStreamId'))
|
|
|
|
self.autoPlay = utils.cast(bool, data.attrib.get('autoPlay'))
|
|
|
|
self.containerKey = data.attrib.get('containerKey')
|
|
|
|
self.controllable = data.attrib.get('controllable')
|
|
|
|
self.duration = utils.cast(int, data.attrib.get('duration'))
|
|
|
|
self.itemType = data.attrib.get('itemType')
|
|
|
|
self.key = data.attrib.get('key')
|
|
|
|
self.location = data.attrib.get('location')
|
|
|
|
self.machineIdentifier = data.attrib.get('machineIdentifier')
|
|
|
|
self.partCount = utils.cast(int, data.attrib.get('partCount'))
|
|
|
|
self.partIndex = utils.cast(int, data.attrib.get('partIndex'))
|
|
|
|
self.playQueueID = utils.cast(int, data.attrib.get('playQueueID'))
|
|
|
|
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID'))
|
|
|
|
self.playQueueVersion = utils.cast(int, data.attrib.get('playQueueVersion'))
|
|
|
|
self.port = utils.cast(int, data.attrib.get('port'))
|
|
|
|
self.protocol = data.attrib.get('protocol')
|
|
|
|
self.providerIdentifier = data.attrib.get('providerIdentifier')
|
|
|
|
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
|
|
|
|
self.repeat = utils.cast(bool, data.attrib.get('repeat'))
|
|
|
|
self.seekRange = data.attrib.get('seekRange')
|
|
|
|
self.shuffle = utils.cast(bool, data.attrib.get('shuffle'))
|
|
|
|
self.state = data.attrib.get('state')
|
|
|
|
self.subtitleColor = data.attrib.get('subtitleColor')
|
|
|
|
self.subtitlePosition = data.attrib.get('subtitlePosition')
|
|
|
|
self.subtitleSize = utils.cast(int, data.attrib.get('subtitleSize'))
|
|
|
|
self.time = utils.cast(int, data.attrib.get('time'))
|
|
|
|
self.type = data.attrib.get('type')
|
|
|
|
self.volume = utils.cast(int, data.attrib.get('volume'))
|