# -*- coding: utf-8 -*- import time import weakref from xml.etree import ElementTree import requests from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils from plexapi.base import PlexObject from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported from plexapi.playqueue import PlayQueue from requests.status_codes import _codes as codes DEFAULT_MTYPE = 'video' @utils.registerPlexObject class PlexClient(PlexObject): """ 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: 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. baseurl (str): HTTP URL to connect directly to this client. identifier (str): The resource/machine identifier for the desired client. May be necessary when connecting to a specific proxied client (optional). token (str): X-Plex-Token used for authentication (optional). session (:class:`~requests.Session`): requests.Session object if you want more control (optional). timeout (int): timeout in seconds on initial connect to client (default config.TIMEOUT). Attributes: TAG (str): 'Player' key (str): '/resources' 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): 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 authentication vendor (str): Unknown version (str): Device version (4.6.1, etc). _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. _proxyThroughServer (bool): Set to True after calling :func:`~plexapi.client.PlexClient.proxyThroughServer` (default False). """ TAG = 'Player' key = '/resources' def __init__(self, server=None, data=None, initpath=None, baseurl=None, identifier=None, token=None, connect=True, session=None, timeout=None, parent=None): super(PlexClient, self).__init__(server, data, initpath) self._baseurl = baseurl.strip('/') if baseurl else None self._clientIdentifier = identifier self._token = logfilter.add_secret(token) self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' server_session = server._session if server else None self._session = session or server_session or requests.Session() self._timeout = timeout or TIMEOUT self._proxyThroughServer = False self._commandId = 0 self._last_call = 0 self._timeline_cache = [] self._timeline_cache_timestamp = 0 self._parent = weakref.ref(parent) if parent is not None else None if not any([data is not None, initpath, baseurl, token]): self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433') self._token = logfilter.add_secret(CONFIG.get('auth.client_token')) if connect and self._baseurl: self.connect(timeout=timeout) def _nextCommandId(self): self._commandId += 1 return self._commandId def connect(self, timeout=None): """ Alias of reload as any subsequent requests to this client will be made directly to the device even if the object attributes were initially populated from a PlexServer. """ if not self.key: raise Unsupported('Cannot reload an object not built from a URL.') self._initpath = self.key data = self.query(self.key, timeout=timeout) if data is None: raise NotFound(f"Client not found at {self._baseurl}") if self._clientIdentifier: client = next( ( x for x in data if x.attrib.get("machineIdentifier") == self._clientIdentifier ), None, ) if client is None: raise NotFound( f"Client with identifier {self._clientIdentifier} not found at {self._baseurl}" ) else: client = data[0] self._loadData(client) return self def reload(self): """ Alias to self.connect(). """ return self.connect() def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data self.deviceClass = data.attrib.get('deviceClass') self.machineIdentifier = data.attrib.get('machineIdentifier') self.product = data.attrib.get('product') self.protocol = data.attrib.get('protocol') self.protocolCapabilities = data.attrib.get('protocolCapabilities', '').split(',') 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') # Active session details # Since protocolCapabilities is missing from /sessions we can't really control this player without # creating a client manually. # Add this in next breaking release. # if self._initpath == 'status/sessions': self.device = data.attrib.get('device') # session self.profile = data.attrib.get('profile') # 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 self.local = utils.cast(bool, data.attrib.get('local', 0)) # session self.relayed = utils.cast(bool, data.attrib.get('relayed', 0)) # session self.secure = utils.cast(bool, data.attrib.get('secure', 0)) # session self.address = data.attrib.get('address') # session self.remotePublicAddress = data.attrib.get('remotePublicAddress') self.userID = data.attrib.get('userID') def _headers(self, **kwargs): """ Returns a dict of all default headers for Client requests. """ headers = BASE_HEADERS if self._token: headers['X-Plex-Token'] = self._token headers.update(kwargs) return headers def proxyThroughServer(self, value=True, server=None): """ Tells this PlexClient instance to proxy all future commands through the PlexServer. Useful if you do not wish to connect directly to the Client device itself. Parameters: value (bool): Enable or disable proxying (optional, default True). Raises: :exc:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server. """ if server: self._server = server if value is True and not self._server: raise Unsupported('Cannot use client proxy with unknown server.') self._proxyThroughServer = value def query(self, path, method=None, headers=None, timeout=None, **kwargs): """ 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 timeout = timeout or self._timeout 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, 204): codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') message = f'({response.status_code}) {codename}; {response.url} {errtext}' if response.status_code == 401: raise Unauthorized(message) elif response.status_code == 404: raise NotFound(message) else: raise BadRequest(message) data = utils.cleanXMLString(response.text).encode('utf8') return ElementTree.fromstring(data) if data.strip() else None def sendCommand(self, command, proxy=None, **params): """ Convenience wrapper around :func:`~plexapi.client.PlexClient.query` to more easily send simple commands to the client. Returns an ElementTree object containing the response. Parameters: command (str): Command to be sent in for format '/'. proxy (bool): Set True to proxy this command through the PlexServer. **params (dict): Additional GET parameters to include with the command. Raises: :exc:`~plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability. """ command = command.strip('/') controller = command.split('/')[0] headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier} if controller not in self.protocolCapabilities: log.debug("Client %s doesn't support %s controller. What your trying might not work", self.title, controller) proxy = self._proxyThroughServer if proxy is None else proxy query = self._server.query if proxy else self.query # Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244 t = time.time() if command == 'timeline/poll': self._last_call = t elif t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'): self._last_call = t self.sendCommand(ClientTimeline.key, wait=0) params['commandID'] = self._nextCommandId() key = f'/player/{command}{utils.joinArgs(params)}' try: return query(key, headers=headers) except ElementTree.ParseError: # Workaround for players which don't return valid XML on successful commands # - Plexamp, Plex for Android: `b'OK'` # - Plex for Samsung: `b''` if self.product in ( 'Plexamp', 'Plex for Android (TV)', 'Plex for Android (Mobile)', 'Plex for Samsung', ): return raise 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'. """ if not self._baseurl: raise BadRequest('PlexClient object missing baseurl.') if self._token and (includeToken or self._showSecrets): delim = '&' if '?' in key else '?' return f'{self._baseurl}{key}{delim}X-Plex-Token={self._token}' return f'{self._baseurl}{key}' # --------------------- # Navigation Commands # These commands navigate around the user-interface. def contextMenu(self): """ Open the context menu on the client. """ self.sendCommand('navigation/contextMenu') def goBack(self): """ Navigate back one position. """ self.sendCommand('navigation/back') def goToHome(self): """ Go directly to the home screen. """ self.sendCommand('navigation/home') def goToMusic(self): """ Go directly to the playing music panel. """ self.sendCommand('navigation/music') def moveDown(self): """ Move selection down a position. """ self.sendCommand('navigation/moveDown') def moveLeft(self): """ Move selection left a position. """ self.sendCommand('navigation/moveLeft') def moveRight(self): """ Move selection right a position. """ self.sendCommand('navigation/moveRight') def moveUp(self): """ Move selection up a position. """ self.sendCommand('navigation/moveUp') def nextLetter(self): """ Jump to next letter in the alphabet. """ self.sendCommand('navigation/nextLetter') def pageDown(self): """ Move selection down a full page. """ self.sendCommand('navigation/pageDown') def pageUp(self): """ Move selection up a full page. """ self.sendCommand('navigation/pageUp') def previousLetter(self): """ Jump to previous letter in the alphabet. """ self.sendCommand('navigation/previousLetter') def select(self): """ Select element at the current position. """ self.sendCommand('navigation/select') def toggleOSD(self): """ Toggle the on screen display during playback. """ self.sendCommand('navigation/toggleOSD') def goToMedia(self, media, **params): """ Navigate directly to the specified media page. Parameters: media (:class:`~plexapi.media.Media`): Media object to navigate to. **params (dict): Additional GET parameters to include with the command. """ server_url = media._server._baseurl.split(':') command = { 'machineIdentifier': media._server.machineIdentifier, 'address': server_url[1].strip('/'), 'port': server_url[-1], 'key': media.key, 'protocol': server_url[0], **params, } token = media._server.createToken() if token: command["token"] = token self.sendCommand("mirror/details", **command) # ------------------- # Playback Commands # Most of the playback commands take a mandatory mtype {'music','photo','video'} argument, # 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). def pause(self, mtype=DEFAULT_MTYPE): """ Pause the currently playing media type. Parameters: mtype (str): Media type to take action against (music, photo, video). """ self.sendCommand('playback/pause', type=mtype) def play(self, mtype=DEFAULT_MTYPE): """ Start playback for the specified media type. Parameters: mtype (str): Media type to take action against (music, photo, video). """ self.sendCommand('playback/play', type=mtype) def refreshPlayQueue(self, playQueueID, mtype=DEFAULT_MTYPE): """ Refresh the specified Playqueue. Parameters: playQueueID (str): Playqueue ID. mtype (str): Media type to take action against (music, photo, video). """ self.sendCommand( 'playback/refreshPlayQueue', playQueueID=playQueueID, type=mtype) def seekTo(self, offset, mtype=DEFAULT_MTYPE): """ Seek to the specified offset (ms) during playback. Parameters: offset (int): Position to seek to (milliseconds). mtype (str): Media type to take action against (music, photo, video). """ self.sendCommand('playback/seekTo', offset=offset, type=mtype) def skipNext(self, mtype=DEFAULT_MTYPE): """ Skip to the next playback item. Parameters: mtype (str): Media type to take action against (music, photo, video). """ self.sendCommand('playback/skipNext', type=mtype) def skipPrevious(self, mtype=DEFAULT_MTYPE): """ Skip to previous playback item. Parameters: mtype (str): Media type to take action against (music, photo, video). """ self.sendCommand('playback/skipPrevious', type=mtype) def skipTo(self, key, mtype=DEFAULT_MTYPE): """ Skip to the playback item with the specified key. Parameters: key (str): Key of the media item to skip to. mtype (str): Media type to take action against (music, photo, video). """ self.sendCommand('playback/skipTo', key=key, type=mtype) def stepBack(self, mtype=DEFAULT_MTYPE): """ Step backward a chunk of time in the current playback item. Parameters: mtype (str): Media type to take action against (music, photo, video). """ self.sendCommand('playback/stepBack', type=mtype) def stepForward(self, mtype=DEFAULT_MTYPE): """ Step forward a chunk of time in the current playback item. Parameters: mtype (str): Media type to take action against (music, photo, video). """ self.sendCommand('playback/stepForward', type=mtype) def stop(self, mtype=DEFAULT_MTYPE): """ Stop the currently playing item. Parameters: mtype (str): Media type to take action against (music, photo, video). """ self.sendCommand('playback/stop', type=mtype) def setRepeat(self, repeat, mtype=DEFAULT_MTYPE): """ Enable repeat for the specified playback items. Parameters: repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall). mtype (str): Media type to take action against (music, photo, video). """ self.setParameters(repeat=repeat, mtype=mtype) def setShuffle(self, shuffle, mtype=DEFAULT_MTYPE): """ Enable shuffle for the specified playback items. Parameters: shuffle (int): Shuffle mode (0=off, 1=on) mtype (str): Media type to take action against (music, photo, video). """ self.setParameters(shuffle=shuffle, mtype=mtype) def setVolume(self, volume, mtype=DEFAULT_MTYPE): """ Enable volume for the current playback item. Parameters: volume (int): Volume level (0-100). mtype (str): Media type to take action against (music, photo, video). """ self.setParameters(volume=volume, mtype=mtype) def setAudioStream(self, audioStreamID, mtype=DEFAULT_MTYPE): """ Select the audio stream for the current playback item (only video). Parameters: audioStreamID (str): ID of the audio stream from the media object. mtype (str): Media type to take action against (music, photo, video). """ self.setStreams(audioStreamID=audioStreamID, mtype=mtype) def setSubtitleStream(self, subtitleStreamID, mtype=DEFAULT_MTYPE): """ Select the subtitle stream for the current playback item (only video). Parameters: subtitleStreamID (str): ID of the subtitle stream from the media object. mtype (str): Media type to take action against (music, photo, video). """ self.setStreams(subtitleStreamID=subtitleStreamID, mtype=mtype) def setVideoStream(self, videoStreamID, mtype=DEFAULT_MTYPE): """ Select the video stream for the current playback item (only video). Parameters: videoStreamID (str): ID of the video stream from the media object. mtype (str): Media type to take action against (music, photo, video). """ self.setStreams(videoStreamID=videoStreamID, mtype=mtype) def playMedia(self, media, offset=0, **params): """ Start playback of the specified media item. See also: Parameters: 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). **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 """ server_url = media._server._baseurl.split(':') server_port = server_url[-1].strip('/') if hasattr(media, "playlistType"): mediatype = media.playlistType else: 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" playqueue = media if isinstance(media, PlayQueue) else media._server.createPlayQueue(media) command = { 'providerIdentifier': 'com.plexapp.plugins.library', 'machineIdentifier': media._server.machineIdentifier, 'protocol': server_url[0], 'address': server_url[1].strip('/'), 'port': server_port, 'offset': offset, 'key': media.key or playqueue.selectedItem.key, 'type': mediatype, 'containerKey': f'/playQueues/{playqueue.playQueueID}?window=100&own=1', **params, } token = media._server.createToken() if token: command["token"] = token self.sendCommand("playback/playMedia", **command) def setParameters(self, volume=None, shuffle=None, repeat=None, mtype=DEFAULT_MTYPE): """ Set multiple playback parameters at once. 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). """ params = {} if repeat is not None: params['repeat'] = repeat if shuffle is not None: params['shuffle'] = shuffle if volume is not None: params['volume'] = volume if mtype is not None: params['type'] = mtype self.sendCommand('playback/setParameters', **params) def setStreams(self, audioStreamID=None, subtitleStreamID=None, videoStreamID=None, mtype=DEFAULT_MTYPE): """ Select multiple playback streams at once. 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). """ params = {} 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: params['type'] = mtype self.sendCommand('playback/setStreams', **params) # ------------------- # Timeline Commands 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 @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. Parameters: includePaused (bool): Set True to treat currently paused items as playing (optional; default True). """ 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(int, 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'))