#!/usr/bin/env python # -*- coding: utf-8 -*- from __future__ import absolute_import, division, unicode_literals from logging import getLogger from ast import literal_eval from copy import deepcopy from time import time from threading import Thread from .downloadutils import DownloadUtils as DU, exceptions from . import backgroundthread, utils, plex_tv, variables as v, app ############################################################################### LOG = getLogger('PLEX.plex_functions') CONTAINERSIZE = int(utils.settings('limitindex')) # For discovery of PMS in the local LAN PLEX_GDM_IP = '239.0.0.250' # multicast to PMS PLEX_GDM_PORT = 32414 PLEX_GDM_MSG = 'M-SEARCH * HTTP/1.0' ############################################################################### def ConvertPlexToKodiTime(plexTime): """ Converts Plextime to Koditime. Returns an int (in seconds). """ if plexTime is None: return None return int(float(plexTime) * v.PLEX_TO_KODI_TIMEFACTOR) def GetPlexKeyNumber(plexKey): """ Deconstructs e.g. '/library/metadata/xxxx' to the tuple (unicode, int) ('library/metadata', xxxx) Returns (None, None) if nothing is found """ try: result = utils.REGEX_END_DIGITS.findall(plexKey)[0] except IndexError: return (None, None) else: return (result[0], utils.cast(int, result[1])) def ParseContainerKey(containerKey): """ Parses e.g. /playQueues/3045?own=1&repeat=0&window=200 to: 'playQueues', 3045, {'window': '200', 'own': '1', 'repeat': '0'} Output hence: library, key, query (str, int, dict) """ result = utils.urlparse(containerKey) library, key = GetPlexKeyNumber(result.path.decode('utf-8')) query = dict(utils.parse_qsl(result.query)) return library, key, query def LiteralEval(string): """ Turns a string e.g. in a dict, safely :-) """ return literal_eval(string) def GetMethodFromPlexType(plexType): methods = { 'movie': 'add_update', 'episode': 'add_updateEpisode', 'show': 'add_update', 'season': 'add_updateSeason', 'track': 'add_updateSong', 'album': 'add_updateAlbum', 'artist': 'add_updateArtist' } return methods[plexType] def GetPlexLoginFromSettings(): """ Returns a dict: 'plexLogin': utils.settings('plexLogin'), 'plexToken': utils.settings('plexToken'), 'plexid': utils.settings('plexid'), 'myplexlogin': utils.settings('myplexlogin'), 'plexAvatar': utils.settings('plexAvatar'), Returns strings or unicode Returns empty strings '' for a setting if not found. myplexlogin is 'true' if user opted to log into plex.tv (the default) """ return { 'plexLogin': utils.settings('plexLogin'), 'plexToken': utils.settings('plexToken'), 'plexid': utils.settings('plexid'), 'myplexlogin': utils.settings('myplexlogin'), 'plexAvatar': utils.settings('plexAvatar'), } def check_connection(url, token=None, verifySSL=None): """ Checks connection to a Plex server, available at url. Can also be used to check for connection with plex.tv. Override SSL to skip the check by setting verifySSL=False if 'None', SSL will be checked (standard requests setting) if 'True', SSL settings from file settings are used (False/True) Input: url URL to Plex server (e.g. https://192.168.1.1:32400) token appropriate token to access server. If None is passed, the current token is used Output: False if server could not be reached or timeout occured 200 if connection was successfull int or other HTML status codes as received from the server """ # Add '/clients' to URL because then an authentication is necessary # If a plex.tv URL was passed, this does not work. header_options = None if token is not None: header_options = {'X-Plex-Token': token} if verifySSL is True: if v.KODIVERSION >= 18: # Always verify with Kodi >= 18 verifySSL = True else: verifySSL = True if utils.settings('sslverify') == 'true' else False if 'plex.tv' in url: url = 'https://plex.tv/api/home/users' LOG.debug("Checking connection to server %s with verifySSL=%s", url, verifySSL) answer = DU().downloadUrl(url, authenticate=False, headerOptions=header_options, verifySSL=verifySSL, timeout=10) if answer is None: LOG.debug("Could not connect to %s", url) return False try: # xml received? answer.attrib except AttributeError: if answer is True: # Maybe no xml but connection was successful nevertheless answer = 200 else: # Success - we downloaded an xml! answer = 200 # We could connect but maybe were not authenticated. No worries LOG.debug("Checking connection successfull. Answer: %s", answer) return answer def discover_pms(token=None): """ Optional parameter: token token for plex.tv - WARNING: for the main Plex user only! Returns a list of available PMS to connect to, one entry is the dict: { 'machineIdentifier' [str] unique identifier of the PMS 'name' [str] name of the PMS 'token' [str] token needed to access that PMS 'ownername' [str] name of the owner of this PMS or None if the owner itself supplied tries to connect 'product' e.g. 'Plex Media Server' or None 'version' e.g. '1.11.2.4772-3e...' or None 'device': e.g. 'PC' or 'Windows' or None 'platform': e.g. 'Windows', 'Android' or None 'local' [bool] True if plex.tv supplied 'publicAddressMatches'='1' or if found using Plex GDM in the local LAN 'owned' [bool] True if it's the owner's PMS 'relay' [bool] True if plex.tv supplied 'relay'='1' 'presence' [bool] True if plex.tv supplied 'presence'='1' 'httpsRequired' [bool] True if plex.tv supplied 'httpsRequired'='1' 'scheme' [str] either 'http' or 'https' 'ip': [str] IP of the PMS, e.g. '192.168.1.1' 'port': [str] Port of the PMS, e.g. '32400' 'baseURL': [str] ://: of the PMS } """ LOG.info('Start discovery of Plex Media Servers') # Look first for local PMS in the LAN local_pms_list = _plex_gdm() LOG.debug('PMS found in the local LAN using Plex GDM: %s', local_pms_list) # Get PMS from plex.tv if token: LOG.info('Checking with plex.tv for more PMS to connect to') plex_pms_list = _pms_list_from_plex_tv(token) _log_pms(plex_pms_list) else: LOG.info('No plex token supplied, only checked LAN for available PMS') plex_pms_list = [] # Add PMS found only in the LAN to the Plex.tv PMS list for pms in local_pms_list: for plex_pms in plex_pms_list: if pms['machineIdentifier'] == plex_pms['machineIdentifier']: break else: # Only found PMS using GDM. Check whether we can use baseURL # (which is in a different format) or need to connect directly if not _correct_baseurl(pms, '%s:%s' % (pms['baseURL'], pms['port'])): if not _correct_baseurl(pms, '%s:%s' % (pms['ip'], pms['port'])): continue plex_pms_list.append(pms) _log_pms(plex_pms_list) return plex_pms_list def _correct_baseurl(pms, url): https = _pms_https_enabled(url) if https is None: # Error contacting url return False elif https is True: pms['scheme'] = 'https' else: pms['scheme'] = 'http' pms['baseURL'] = '%s://%s' % (pms['scheme'], url) return True def _log_pms(pms_list): log_list = deepcopy(pms_list) for pms in log_list: if pms.get('token') is not None: pms['token'] = '%s...' % pms['token'][:5] LOG.debug('Found the following PMS: %s', log_list) def _plex_gdm(): """ PlexGDM - looks for PMS in the local LAN and returns a list of the PMS found """ # Import here because we might not need to do gdm because we already # connected to a PMS successfully in the past import struct import socket # setup socket for discovery -> multicast message gdm = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) gdm.settimeout(2.0) # Set the time-to-live for messages to 2 for local network ttl = struct.pack('b', 2) gdm.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl) return_data = [] try: # Send data to the multicast group gdm.sendto(PLEX_GDM_MSG, (PLEX_GDM_IP, PLEX_GDM_PORT)) # Look for responses from all recipients while True: try: data, server = gdm.recvfrom(1024) return_data.append({'from': server, 'data': data.decode('utf-8')}) except socket.timeout: break except Exception as e: # Probably error: (101, 'Network is unreachable') LOG.error(e) import traceback LOG.error("Traceback:\n%s", traceback.format_exc()) finally: gdm.close() LOG.debug('Plex GDM returned the data: %s', return_data) pms_list = [] for response in return_data: # Check if we had a positive HTTP response if '200 OK' not in response['data']: continue pms = { 'ip': response['from'][0], 'scheme': None, 'local': True, # Since we found it using GDM 'product': None, 'baseURL': None, 'name': None, 'version': None, 'token': None, 'ownername': None, 'device': None, 'platform': None, 'owned': None, 'relay': None, 'presence': True, # Since we're talking to the PMS 'httpsRequired': None, } for line in response['data'].split('\n'): if 'Content-Type:' in line: pms['product'] = utils.try_decode(line.split(':')[1].strip()) elif 'Host:' in line: pms['baseURL'] = line.split(':')[1].strip() elif 'Name:' in line: pms['name'] = utils.try_decode(line.split(':')[1].strip()) elif 'Port:' in line: pms['port'] = line.split(':')[1].strip() elif 'Resource-Identifier:' in line: pms['machineIdentifier'] = line.split(':')[1].strip() elif 'Version:' in line: pms['version'] = line.split(':')[1].strip() pms_list.append(pms) return pms_list def _pms_list_from_plex_tv(token): """ get Plex media Server List from plex.tv/pms/resources """ xml = DU().downloadUrl('https://plex.tv/api/resources', authenticate=False, parameters={'includeHttps': 1}, headerOptions={'X-Plex-Token': token}) try: xml.attrib except AttributeError: LOG.error('Could not get list of PMS from plex.tv') return [] from Queue import Queue queue = Queue() thread_queue = [] max_age_in_seconds = 2 * 60 * 60 * 24 for device in xml.findall('Device'): if 'server' not in device.get('provides'): # No PMS - skip continue if device.find('Connection') is None: # no valid connection - skip continue # check MyPlex data age - skip if >2 days info_age = time() - int(device.get('lastSeenAt')) if info_age > max_age_in_seconds: LOG.debug("Skip server %s not seen for 2 days", device.get('name')) continue pms = { 'machineIdentifier': device.get('clientIdentifier'), 'name': device.get('name'), 'token': device.get('accessToken'), 'ownername': device.get('sourceTitle'), 'product': device.get('product'), # e.g. 'Plex Media Server' 'version': device.get('productVersion'), # e.g. '1.11.2.4772-3e..' 'device': device.get('device'), # e.g. 'PC' or 'Windows' 'platform': device.get('platform'), # e.g. 'Windows', 'Android' 'local': device.get('publicAddressMatches') == '1', 'owned': device.get('owned') == '1', 'relay': device.get('relay') == '1', 'presence': device.get('presence') == '1', 'httpsRequired': device.get('httpsRequired') == '1', 'connections': [] } # Try a local connection first, no matter what plex.tv tells us for connection in device.findall('Connection'): if connection.get('local') == '1': pms['connections'].append(connection) # Then try non-local for connection in device.findall('Connection'): if connection.get('local') != '1': pms['connections'].append(connection) # Spawn threads to ping each PMS simultaneously thread = Thread(target=_poke_pms, args=(pms, queue)) thread_queue.append(thread) max_threads = 5 threads = [] # poke PMS, own thread for each PMS while True: # Remove finished threads for thread in threads: if not thread.isAlive(): threads.remove(thread) if len(threads) < max_threads: try: thread = thread_queue.pop() except IndexError: # We have done our work break else: thread.start() threads.append(thread) else: app.APP.monitor.waitForAbort(0.05) # wait for requests being answered for thread in threads: thread.join() # declare new PMSs pms_list = [] while not queue.empty(): pms = queue.get() del pms['connections'] pms_list.append(pms) queue.task_done() return pms_list def _poke_pms(pms, queue): data = pms['connections'][0].attrib url = data['uri'] if data['local'] == '1' and utils.REGEX_PLEX_DIRECT.findall(url): # In case DNS resolve of plex.direct does not work, append a new # connection that will directly access the local IP (e.g. internet down) conn = deepcopy(pms['connections'][0]) # Overwrite plex.direct conn.attrib['uri'] = '%s://%s:%s' % (data['protocol'], data['address'], data['port']) pms['connections'].insert(1, conn) try: protocol, address, port = url.split(':', 2) except ValueError: # e.g. .ork.plex.services uri, thanks Plex protocol, address = url.split(':', 1) port = data['port'] url = '%s:%s' % (url, port) address = address.replace('/', '') xml = DU().downloadUrl('%s/identity' % url, authenticate=False, headerOptions={'X-Plex-Token': pms['token']}, verifySSL=True if v.KODIVERSION >= 18 else False, timeout=(3.0, 5.0)) try: xml.attrib['machineIdentifier'] except (AttributeError, KeyError): # No connection, delete the one we just tested del pms['connections'][0] if pms['connections']: # Still got connections left, try them return _poke_pms(pms, queue) return else: # Connection successful - correct pms? if xml.get('machineIdentifier') == pms['machineIdentifier']: # process later pms['baseURL'] = url pms['scheme'] = protocol pms['ip'] = address pms['port'] = port queue.put(pms) return LOG.info('Found a pms at %s, but the expected machineIdentifier of ' '%s did not match the one we found: %s', url, pms['uuid'], xml.get('machineIdentifier')) def GetPlexMetadata(key, reraise=False): """ Returns raw API metadata for key as an etree XML. Can be called with either Plex key '/library/metadata/xxxx'metadata OR with the digits 'xxxx' only. Returns None or 401 if something went wrong """ key = str(key) if '/library/metadata/' in key: url = "{server}" + key else: url = "{server}/library/metadata/" + key arguments = { 'checkFiles': 0, 'includeExtras': 1, # Trailers and Extras => Extras 'includeReviews': 1, 'includeRelated': 0, # Similar movies => Video -> Related 'skipRefresh': 1, 'includeMarkers': 1, # e.g. start + stop of intros # 'includeRelatedCount': 0, # 'includeOnDeck': 1, # 'includeChapters': 1, # 'includePopularLeaves': 1, # 'includeConcerts': 1 } try: xml = DU().downloadUrl(utils.extend_url(url, arguments), reraise=reraise) except exceptions.RequestException: # "PMS offline" utils.dialog('notification', utils.lang(29999), utils.lang(39213).format(app.CONN.server_name), icon='{plex}') except Exception: # "Error" utils.dialog('notification', utils.lang(29999), utils.lang(30135), icon='{error}') else: if xml == 401: # Either unauthorized (taken care of by doUtils) or PMS under strain return 401 # Did we receive a valid XML? try: xml[0].attrib # Nope we did not receive a valid XML except (TypeError, IndexError, AttributeError): LOG.error("Error retrieving metadata for %s", url) xml = None return xml def get_playback_xml(url, server_name, authenticate=True, token=None): """ Returns None if something went wrong """ header_options = {'includeMarkers': 1} if not authenticate: header_options['X-Plex-Token'] = token try: xml = DU().downloadUrl(url, authenticate=authenticate, headerOptions=header_options, reraise=True) except exceptions.RequestException: # "{0} offline" utils.dialog('notification', utils.lang(29999), utils.lang(39213).format(server_name), icon='{plex}') except Exception as e: LOG.error(e) import traceback LOG.error("Traceback:\n%s", traceback.format_exc()) # "Play error" utils.dialog('notification', utils.lang(29999), utils.lang(30128), icon='{error}') else: try: xml[0].attrib except (TypeError, IndexError, AttributeError): LOG.error('Could not get a valid xml, unfortunately') # "Play error" utils.dialog('notification', utils.lang(29999), utils.lang(30128), icon='{error}') else: return xml def GetAllPlexChildren(key): """ Returns a list (raw xml API dump) of all Plex children for the key. (e.g. /library/metadata/194853/children pointing to a season) Input: key Key to a Plex item, e.g. 12345 """ return DownloadChunks("{server}/library/metadata/%s/children" % key) class ThreadedDownloadChunk(backgroundthread.Task): """ This task will also be executed while library sync is suspended! """ def __init__(self, url, args, callback): self.url = url self.args = args self.callback = callback super(ThreadedDownloadChunk, self).__init__() def run(self): xml = DU().downloadUrl(self.url, parameters=self.args) try: xml.attrib except AttributeError: LOG.error('Error while downloading chunks: %s, args: %s', self.url, self.args) xml = None self.callback(xml) class DownloadGen(object): """ Special iterator object that will yield all child xmls piece-wise. It also saves the original xml.attrib. Yields XML etree children or raises RuntimeError at the end """ def __init__(self, url, plex_type, last_viewed_at, updated_at, args, downloader): self._downloader = downloader self.successful = True self.xml = None self.args = args self.args.update({ 'X-Plex-Container-Start': 0, 'X-Plex-Container-Size': CONTAINERSIZE }) url += '?' if plex_type: url = '%stype=%s&' % (url, v.PLEX_TYPE_NUMBER_FROM_PLEX_TYPE[plex_type]) if last_viewed_at: url = '%slastViewedAt>=%s&' % (url, last_viewed_at) if updated_at: url = '%supdatedAt>=%s&' % (url, updated_at) self.url = url[:-1] _blocking_download_chunk(self.url, self.args, 0, self.set_xml) self.attrib = self.xml.attrib self.current = 0 self.total = int(self.attrib['totalSize']) self.cache_factor = 10 # Will keep track whether we still have results incoming self.pending_counter = [] end = min(self.cache_factor * CONTAINERSIZE, self.total + CONTAINERSIZE - self.total % CONTAINERSIZE) for pos in range(CONTAINERSIZE, end, CONTAINERSIZE): self.pending_counter.append(None) self._downloader(self.url, self.args, pos, self.on_chunk_downloaded) def set_xml(self, xml): self.xml = xml def on_chunk_downloaded(self, xml): if xml is not None: self.xml.extend(xml) else: self.successful = False self.pending_counter.pop() def get(self, key, default=None): """ Mimick etree xml's way to access xml.attrib via xml.get(key, default) """ return self.attrib.get(key, default) def __iter__(self): return self def __next__(self): while True: try: child = self.xml[0] self.current += 1 self.xml.remove(child) if (self.current % CONTAINERSIZE == 0 and self.current <= self.total - (self.cache_factor - 1) * CONTAINERSIZE): self.pending_counter.append(None) self._downloader( self.url, self.args, self.current + (self.cache_factor - 1) * CONTAINERSIZE, self.on_chunk_downloaded) return child except IndexError: if not self.pending_counter and not len(self.xml): if not self.successful: raise RuntimeError('Could not download everything') else: raise StopIteration() LOG.debug('Waiting for download to finish') if app.APP.monitor.waitForAbort(0.1): raise StopIteration('PKC needs to exit now') next = __next__ def _blocking_download_chunk(url, args, start, callback): """ callback will be called with the downloaded xml (fragment) """ args['X-Plex-Container-Start'] = start xml = DU().downloadUrl(url, parameters=args) try: xml.attrib except AttributeError: LOG.error('Error while downloading chunks: %s, args: %s', url, args) raise RuntimeError('Error while downloading chunks for %s' % url) callback(xml) def _async_download_chunk(url, args, start, callback): args['X-Plex-Container-Start'] = start task = ThreadedDownloadChunk(url, deepcopy(args), # Beware! callback) backgroundthread.BGThreader.addTask(task) def get_section_iterator(section_id, plex_type=None, last_viewed_at=None, updated_at=None, args=None): args = args or {} args.update({ 'checkFiles': 0, 'includeExtras': 0, # Trailers and Extras => Extras 'includeReviews': 0, 'includeRelated': 0, # Similar movies => Video -> Related 'skipRefresh': 1, # don't scan 'excludeAllLeaves': 1 # PMS wont attach a first summary child }) if plex_type == v.PLEX_TYPE_ALBUM: # Kodi sorts Newest Albums by their position within the Kodi music # database - great... downloader = _blocking_download_chunk args['sort'] = 'addedAt:asc' else: downloader = _async_download_chunk args['sort'] = 'id' # Entries are sorted by plex_id if plex_type in (v.PLEX_TYPE_EPISODE, v.PLEX_TYPE_SONG): # Annoying Plex bug. You won't get all episodes otherwise url = '{server}/library/sections/%s/allLeaves' % section_id plex_type = None else: url = '{server}/library/sections/%s/all' % section_id return DownloadGen(url, plex_type, last_viewed_at, updated_at, args, downloader) def DownloadChunks(url): """ Downloads PMS url in chunks of CONTAINERSIZE. Returns a stitched-together xml or None. """ xml = None pos = 0 error_counter = 0 while error_counter < 10: args = { 'X-Plex-Container-Size': CONTAINERSIZE, 'X-Plex-Container-Start': pos, 'sort': 'id' } xmlpart = DU().downloadUrl(utils.extend_url(url, args)) # If something went wrong - skip in the hope that it works next time try: xmlpart.attrib except AttributeError: LOG.error('Error while downloading chunks: %s, args: %s', url, args) pos += CONTAINERSIZE error_counter += 1 continue # Very first run: starting xml (to retain data in xml's root!) if xml is None: xml = deepcopy(xmlpart) if len(xmlpart) < CONTAINERSIZE: break else: pos += CONTAINERSIZE continue # Build answer xml - containing the entire library for child in xmlpart: xml.append(child) # Done as soon as we don't receive a full complement of items if len(xmlpart) < CONTAINERSIZE: break pos += CONTAINERSIZE if error_counter == 10: LOG.error('Fatal error while downloading chunks for %s', url) return None return xml def GetPlexOnDeck(viewId): """ """ return DownloadChunks("{server}/library/sections/%s/onDeck" % viewId) def get_plex_hub(): return DU().downloadUrl('{server}/hubs') def get_plex_sections(): """ Returns all Plex sections (libraries) of the PMS as an etree xml """ xml = DU().downloadUrl('{server}/library/sections') try: xml[0].attrib except (TypeError, IndexError, AttributeError): xml = None return xml def init_plex_playqueue(plex_id, plex_type, section_uuid, trailers=False): """ Returns raw API metadata XML dump for a playlist with e.g. trailers. """ url = "{server}/playQueues" args = { 'type': plex_type, 'uri': ('server://%s/com.plexapp.plugins.library/library/metadata/%s' % (app.CONN.machine_identifier, plex_id)), 'includeChapters': '1', 'shuffle': '0', 'repeat': '0', 'includeMarkers': 1, # e.g. start + stop of intros } if trailers is True: args['extrasPrefixCount'] = utils.settings('trailerNumber') xml = DU().downloadUrl(utils.extend_url(url, args), action_type="POST") try: xml[0].tag except (IndexError, TypeError, AttributeError): LOG.warn('Need to initialize Plex playqueue the old fashioned way') xml = init_plex_playqueue_old_fashioned(plex_id, section_uuid, url, args) return xml def init_plex_playqueue_old_fashioned(plex_id, section_uuid, url, args): """ In rare cases (old PMS version?), the PMS does not allow to add media using an uri server:///com.plexapp.plugins.library/library... We need to use library:///item/... This involves an extra step to grab the librarySectionUUID for plex_id """ args['uri'] = 'library://{0}/item/%2Flibrary%2Fmetadata%2F{1}'.format( section_uuid, plex_id) xml = DU().downloadUrl(utils.extend_url(url, args), action_type="POST") try: xml[0].tag except (IndexError, TypeError, AttributeError): LOG.error('Error initializing the playqueue the old fashioned way %s', utils.extend_url(url, args)) xml = None return xml def _pms_https_enabled(url): """ Returns True if the PMS can talk https, False otherwise. None if error occured, e.g. the connection timed out Call with e.g. url='192.168.0.1:32400' (NO http/https) This is done by GET /identity (returns an error if https is enabled and we are trying to use http) Prefers HTTPS over HTTP """ # Try HTTPS first try: DU().downloadUrl('https://%s/identity' % url, authenticate=False, reraise=True) except exceptions.SSLError: LOG.debug('SSLError trying to connect to https://%s/identity', url) except Exception as e: LOG.info('Couldnt check https connection to https://%s/identity: %s', url, e) else: return True # Try HTTP next try: DU().downloadUrl('http://%s/identity' % url, authenticate=False, reraise=True) except Exception as e: LOG.info('Couldnt check http connection to http://%s/identity: %s', url, e) return else: return False def GetMachineIdentifier(url): """ Returns the unique PMS machine identifier of url Returns None if something went wrong """ xml = DU().downloadUrl('%s/identity' % url, authenticate=False, verifySSL=True if v.KODIVERSION >= 18 else False, timeout=10, reraise=True) try: machineIdentifier = xml.attrib['machineIdentifier'] except (AttributeError, KeyError): LOG.error('Could not get the PMS machineIdentifier for %s', url) return None LOG.debug('Found machineIdentifier %s for the PMS %s', machineIdentifier, url) return machineIdentifier def GetPMSStatus(token): """ token: Needs to be authorized with a master Plex token (not a managed user token)! Calls /status/sessions on currently active PMS. Returns a dict with: 'sessionKey': { 'userId': Plex ID of the user (if applicable, otherwise '') 'username': Plex name (if applicable, otherwise '') 'ratingKey': Unique Plex id of item being played } or an empty dict. """ answer = {} xml = DU().downloadUrl('{server}/status/sessions', headerOptions={'X-Plex-Token': token}) try: xml.attrib except AttributeError: return answer for item in xml: ratingKey = item.attrib.get('ratingKey') sessionKey = item.attrib.get('sessionKey') userId = item.find('User') username = '' if userId is not None: username = userId.attrib.get('title', '') userId = userId.attrib.get('id', '') else: userId = '' answer[sessionKey] = { 'userId': userId, 'username': username, 'ratingKey': ratingKey } return answer def collections(section_id): """ Returns an etree with list of collections or None. """ url = '{server}/library/sections/%s/all' % section_id params = { 'type': 18, # Collections 'includeCollections': 1, } xml = DU().downloadUrl(url, parameters=params) try: xml.attrib except AttributeError: LOG.error("Error retrieving collections for %s", url) xml = None return xml def scrobble(ratingKey, state): """ Tells the PMS to set an item's watched state to state="watched" or state="unwatched" """ args = { 'key': ratingKey, 'identifier': 'com.plexapp.plugins.library' } if state == "watched": url = '{server}/:/scrobble' elif state == "unwatched": url = '{server}/:/unscrobble' else: return DU().downloadUrl(utils.extend_url(url, args)) LOG.info("Toggled watched state for Plex item %s", ratingKey) def delete_item_from_pms(plexid): """ Deletes the item plexid from the Plex Media Server (and the harddrive!). Do make sure that the currently logged in user has the credentials Returns True if successful, False otherwise """ if DU().downloadUrl('{server}/library/metadata/%s' % plexid, action_type="DELETE") is True: LOG.info('Successfully deleted Plex id %s from the PMS', plexid) return True LOG.error('Could not delete Plex id %s from the PMS', plexid) return False def pms_root(url, token): """ Retrieve the PMS' most basic settings by retrieving / """ return DU().downloadUrl( url, authenticate=False, verifySSL=True if v.KODIVERSION >= 18 else False, headerOptions={'X-Plex-Token': token} if token else None) def get_PMS_settings(url, token): """ Retrieve the PMS' settings via /:/prefs Call with url: scheme://ip:port """ return DU().downloadUrl( '%s/:/prefs' % url, authenticate=False, verifySSL=True if v.KODIVERSION >= 18 else False, headerOptions={'X-Plex-Token': token} if token else None) def GetUserArtworkURL(username): """ Returns the URL for the user's Avatar. Or False if something went wrong. """ users = plex_tv.plex_home_users(utils.settings('plexToken')) url = '' for user in users: if user.title == username: url = user.thumb LOG.debug("Avatar url for user %s is: %s", username, url) return url def show_episodes(plex_id): """ Returns all episodes for the tv show with plex_id """ url = "{server}/library/metadata/%s/allLeaves" % plex_id arguments = { 'checkFiles': 0, 'skipRefresh': 1, } return DownloadChunks(utils.extend_url(url, arguments)) def transcoding_arguments(path, media, part, playmethod, args=None): if playmethod == v.PLAYBACK_METHOD_DIRECT_PLAY: direct_play = 1 direct_stream = 1 elif playmethod == v.PLAYBACK_METHOD_DIRECT_STREAM: direct_play = 0 direct_stream = 1 elif playmethod == v.PLAYBACK_METHOD_TRANSCODE: direct_play = 0 direct_stream = 0 arguments = { # e.g. '/library/metadata/831399' 'path': path, # 1 if you want to directPlay, 0 if you want to transcode 'directPlay': direct_play, # 1 if you want to play a stream copy of data into a new container. This # is unlikely to come up but it’s possible if you are playing something # with a lot of tracks, a direct stream can result in lower bandwidth # when a direct play would be over the limit. # Assume Kodi can always handle any stream thrown at it! 'directStream': direct_stream, # Same for audio - assume Kodi can play any audio stream passed in! 'directStreamAudio': direct_stream, # This tells the server that you definitively know that the client can # direct play (when you have directPlay=1) the content in spite of what # client profiles may say about what the client can play. When this is # set and directPlay=1, the server just checks bandwidth restrictions # and gives you a reservation if bandwidth restrictions are met 'hasMDE': direct_play, # where # is an integer, 0 indexed. If you specify directPlay, this is # required. -1 indicates let the server choose. 'mediaIndex': media, # Similar to mediaIndex but indicates which part you want to direct # play. Really only comes into play with multi-part files which are # uncommon. -1 here means concatenate the parts together but that # requires the transcoder. 'partIndex': part, # all the rest 'audioBoost': utils.settings('audioBoost'), 'autoAdjustQuality': 1 if utils.settings('auto_adjust_transcode_quality') == 'true' else 0, 'protocol': 'hls', # seen in the wild: 'http', 'dash', 'http', 'hls' 'session': v.PKC_MACHINE_IDENTIFIER, # TODO: create new unique id 'fastSeek': 1, 'copyts': 1 } if playmethod != v.PLAYBACK_METHOD_TRANSCODE: # Essentially indicating what you want to do with subtitles and state # you aren’t want it to burn them into the video (requires transcoding) # none, embedded, sidecar args['subtitles'] = 'none' else: args['subtitleSize'] = utils.settings('subtitleSize') if args: arguments.update(args) return arguments def playback_decision(path, media, part, playmethod, video=True, args=None): """ Let's the PMS decide how we should playback this file """ arguments = transcoding_arguments(path, media, part, playmethod, args=args) if video: url = '{server}/video/:/transcode/universal/decision' else: url = '{server}/music/:/transcode/universal/decision' LOG.debug('Asking the PMS if we can play this video with settings: %s', arguments) return DU().downloadUrl(utils.extend_url(url, arguments), headerOptions=v.STREAMING_HEADERS, reraise=True) def change_subtitle(plex_stream_id, part_id): """ Tell the PMS to display/burn-in the subtitle stream with id plex_stream_id for the Part (PMS XML etree tag "Part") with unique id part_id. - plex_stream_id = 0 will deactivate the subtitle - We always do this for ALL parts of a video """ arguments = { 'subtitleStreamID': plex_stream_id, 'allParts': 1 } url = '{server}/library/parts/%s' % part_id return DU().downloadUrl(utils.extend_url(url, arguments), action_type='PUT') def change_audio_stream(plex_stream_id, part_id): """ Tell the PMS to display/burn-in the subtitle stream with id plex_stream_id for the Part (PMS XML etree tag "Part") with unique id part_id. - plex_stream_id = 0 will deactivate the subtitle - We always do this for ALL parts of a video """ arguments = { 'audioStreamID': plex_stream_id, 'allParts': 1 } url = '{server}/library/parts/%s' % part_id return DU().downloadUrl(utils.extend_url(url, arguments), action_type='PUT')