PlexKodiConnect/resources/lib/plex_functions.py

1152 lines
40 KiB
Python
Raw Normal View History

#!/usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, unicode_literals
2017-04-02 02:28:02 +10:00
from logging import getLogger
2016-01-30 06:07:21 +11:00
from ast import literal_eval
from copy import deepcopy
2018-02-11 03:59:20 +11:00
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
2016-09-03 01:26:17 +10:00
###############################################################################
2018-06-22 03:24:37 +10:00
LOG = getLogger('PLEX.plex_functions')
2017-04-02 02:28:02 +10:00
2018-06-22 03:24:37 +10:00
CONTAINERSIZE = int(utils.settings('limitindex'))
2018-02-11 03:59:20 +11:00
# 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'
2016-09-03 01:26:17 +10:00
###############################################################################
def ConvertPlexToKodiTime(plexTime):
"""
Converts Plextime to Koditime. Returns an int (in seconds).
"""
2016-03-25 04:52:02 +11:00
if plexTime is None:
return None
2018-06-22 03:24:37 +10:00
return int(float(plexTime) * v.PLEX_TO_KODI_TIMEFACTOR)
2016-01-30 06:07:21 +11:00
def GetPlexKeyNumber(plexKey):
"""
2018-11-07 00:08:14 +11:00
Deconstructs e.g. '/library/metadata/xxxx' to the tuple (unicode, int)
2016-01-30 06:07:21 +11:00
2018-11-07 00:08:14 +11:00
('library/metadata', xxxx)
2016-01-30 06:07:21 +11:00
2018-11-07 00:08:14 +11:00
Returns (None, None) if nothing is found
2016-01-30 06:07:21 +11:00
"""
try:
result = utils.REGEX_END_DIGITS.findall(plexKey)[0]
2016-01-30 06:07:21 +11:00
except IndexError:
2018-11-07 00:11:47 +11:00
return (None, None)
2018-11-07 00:08:14 +11:00
else:
2018-11-07 00:11:47 +11:00
return (result[0], utils.cast(int, result[1]))
2016-01-30 06:07:21 +11:00
def ParseContainerKey(containerKey):
"""
Parses e.g. /playQueues/3045?own=1&repeat=0&window=200 to:
2018-11-07 00:08:14 +11:00
'playQueues', 3045, {'window': '200', 'own': '1', 'repeat': '0'}
2016-01-30 06:07:21 +11:00
2018-11-07 00:08:14 +11:00
Output hence: library, key, query (str, int, dict)
2016-01-30 06:07:21 +11:00
"""
2019-03-30 20:32:56 +11:00
result = utils.urlparse(containerKey)
library, key = GetPlexKeyNumber(result.path.decode('utf-8'))
query = dict(utils.parse_qsl(result.query))
2016-01-30 06:07:21 +11:00
return library, key, query
def LiteralEval(string):
"""
Turns a string e.g. in a dict, safely :-)
"""
return literal_eval(string)
2016-01-28 06:41:28 +11:00
def GetMethodFromPlexType(plexType):
methods = {
'movie': 'add_update',
2016-02-07 22:38:50 +11:00
'episode': 'add_updateEpisode',
'show': 'add_update',
'season': 'add_updateSeason',
'track': 'add_updateSong',
'album': 'add_updateAlbum',
'artist': 'add_updateArtist'
2016-01-28 06:41:28 +11:00
}
return methods[plexType]
2018-02-11 03:59:20 +11:00
def GetPlexLoginFromSettings():
"""
Returns a dict:
2018-06-22 03:24:37 +10:00
'plexLogin': utils.settings('plexLogin'),
'plexToken': utils.settings('plexToken'),
'plexid': utils.settings('plexid'),
'myplexlogin': utils.settings('myplexlogin'),
'plexAvatar': utils.settings('plexAvatar'),
2018-02-11 03:59:20 +11:00
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 {
2018-06-22 03:24:37 +10:00
'plexLogin': utils.settings('plexLogin'),
'plexToken': utils.settings('plexToken'),
'plexid': utils.settings('plexid'),
'myplexlogin': utils.settings('myplexlogin'),
'plexAvatar': utils.settings('plexAvatar'),
2018-02-11 03:59:20 +11:00
}
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
2018-02-11 03:59:20 +11:00
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:
2021-01-04 03:17:47 +11:00
token token for plex.tv - WARNING: for the main Plex user only!
2018-02-11 03:59:20 +11:00
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] <scheme>://<ip>:<port> 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)
2018-05-30 19:24:51 +10:00
_log_pms(plex_pms_list)
2018-02-11 03:59:20 +11:00
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
2018-02-11 03:59:20 +11:00
for pms in local_pms_list:
for plex_pms in plex_pms_list:
2018-02-11 03:59:20 +11:00
if pms['machineIdentifier'] == plex_pms['machineIdentifier']:
2018-05-16 03:39:34 +10:00
break
2018-02-11 03:59:20 +11:00
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)
2018-05-30 19:24:51 +10:00
_log_pms(plex_pms_list)
return plex_pms_list
2018-05-30 18:40:58 +10:00
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
2018-05-30 19:24:51 +10:00
def _log_pms(pms_list):
log_list = deepcopy(pms_list)
2018-05-30 18:40:58 +10:00
for pms in log_list:
if pms.get('token') is not None:
pms['token'] = '%s...' % pms['token'][:5]
2018-05-30 19:24:51 +10:00
LOG.debug('Found the following PMS: %s', log_list)
2018-02-11 03:59:20 +11:00
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')})
2018-02-11 03:59:20 +11:00
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:
2018-06-22 03:24:37 +10:00
pms['product'] = utils.try_decode(line.split(':')[1].strip())
2018-02-11 03:59:20 +11:00
elif 'Host:' in line:
pms['baseURL'] = line.split(':')[1].strip()
elif 'Name:' in line:
2018-06-22 03:24:37 +10:00
pms['name'] = utils.try_decode(line.split(':')[1].strip())
2018-02-11 03:59:20 +11:00
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 []
2018-02-11 03:59:20 +11:00
from Queue import Queue
queue = Queue()
thread_queue = []
2018-06-22 03:24:37 +10:00
max_age_in_seconds = 2 * 60 * 60 * 24
2018-02-11 03:59:20 +11:00
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'
2018-06-22 03:24:37 +10:00
'version': device.get('productVersion'), # e.g. '1.11.2.4772-3e..'
2018-02-11 03:59:20 +11:00
'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)
2018-02-11 03:59:20 +11:00
# 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('/', '')
2018-02-11 03:59:20 +11:00
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))
2018-02-11 03:59:20 +11:00
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
2018-02-11 03:59:20 +11:00
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.
2016-06-27 00:10:32 +10:00
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,
2021-02-08 07:42:47 +11:00
'includeMarkers': 1, # e.g. start + stop of intros
# 'includeRelatedCount': 0,
# 'includeOnDeck': 1,
# 'includeChapters': 1,
# 'includePopularLeaves': 1,
# 'includeConcerts': 1
}
try:
2019-03-30 20:32:56 +11:00
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
"""
2021-02-08 07:42:47 +11:00
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
2017-04-02 02:28:02 +10:00
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
"""
2019-03-30 20:32:56 +11:00
return DownloadChunks("{server}/library/metadata/%s/children" % key)
2019-11-12 03:21:07 +11:00
class ThreadedDownloadChunk(backgroundthread.Task):
2018-11-10 00:39:43 +11:00
"""
This task will also be executed while library sync is suspended!
"""
2018-12-22 01:18:06 +11:00
def __init__(self, url, args, callback):
2018-11-10 00:39:43 +11:00
self.url = url
self.args = args
self.callback = callback
2019-11-12 03:21:07 +11:00
super(ThreadedDownloadChunk, self).__init__()
2018-11-10 00:39:43 +11:00
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)
2018-10-20 23:49:04 +11:00
class DownloadGen(object):
2018-10-15 04:59:11 +11:00
"""
2018-10-20 23:49:04 +11:00
Special iterator object that will yield all child xmls piece-wise. It also
saves the original xml.attrib.
2018-10-15 04:59:11 +11:00
Yields XML etree children or raises RuntimeError at the end
2018-10-15 04:59:11 +11:00
"""
2019-11-12 03:21:07 +11:00
def __init__(self, url, plex_type, last_viewed_at, updated_at, args,
downloader):
self._downloader = downloader
self.successful = True
2019-11-12 03:21:07 +11:00
self.xml = None
self.args = args
2018-11-14 00:38:38 +11:00
self.args.update({
2019-11-12 03:21:07 +11:00
'X-Plex-Container-Start': 0,
'X-Plex-Container-Size': CONTAINERSIZE
2018-11-14 00:38:38 +11:00
})
2018-11-05 02:53:42 +11:00
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]
2019-11-12 03:21:07 +11:00
_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,
2018-11-14 00:38:38 +11:00
self.total + CONTAINERSIZE - self.total % CONTAINERSIZE)
for pos in range(CONTAINERSIZE, end, CONTAINERSIZE):
self.pending_counter.append(None)
2019-11-12 03:21:07 +11:00
self._downloader(self.url, self.args, pos, self.on_chunk_downloaded)
2019-11-12 03:21:07 +11:00
def set_xml(self, xml):
self.xml = xml
2018-11-10 00:39:43 +11:00
def on_chunk_downloaded(self, xml):
2018-12-01 19:13:23 +11:00
if xml is not None:
2019-11-12 03:21:07 +11:00
self.xml.extend(xml)
else:
self.successful = False
self.pending_counter.pop()
2018-10-20 23:49:04 +11:00
2019-11-12 03:21:07 +11:00
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)
2018-10-20 23:49:04 +11:00
def __iter__(self):
return self
def __next__(self):
2018-11-10 00:39:43 +11:00
while True:
2018-12-22 02:03:56 +11:00
try:
2018-11-10 00:39:43 +11:00
child = self.xml[0]
2018-12-22 02:03:56 +11:00
self.current += 1
self.xml.remove(child)
2018-11-12 03:48:11 +11:00
if (self.current % CONTAINERSIZE == 0 and
2018-11-14 00:38:38 +11:00
self.current <= self.total - (self.cache_factor - 1) * CONTAINERSIZE):
2018-11-12 03:48:11 +11:00
self.pending_counter.append(None)
2019-11-12 03:21:07 +11:00
self._downloader(
self.url,
self.args,
self.current + (self.cache_factor - 1) * CONTAINERSIZE,
self.on_chunk_downloaded)
2018-11-10 00:39:43 +11:00
return child
2018-12-22 02:03:56 +11:00
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()
2018-11-12 06:37:40 +11:00
LOG.debug('Waiting for download to finish')
2019-11-12 03:21:07 +11:00
if app.APP.monitor.waitForAbort(0.1):
raise StopIteration('PKC needs to exit now')
2018-10-20 23:49:04 +11:00
2018-12-22 01:18:19 +11:00
next = __next__
2018-10-20 23:49:04 +11:00
2019-11-12 03:21:07 +11:00
def _blocking_download_chunk(url, args, start, callback):
2018-10-22 01:56:13 +11:00
"""
2019-11-12 03:21:07 +11:00
callback will be called with the downloaded xml (fragment)
2018-10-22 01:56:13 +11:00
"""
2019-11-12 03:21:07 +11:00
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)
2018-10-22 01:56:13 +11:00
2019-11-12 03:21:07 +11:00
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)
2018-10-22 01:56:13 +11:00
2019-11-12 03:21:07 +11:00
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)
2018-10-15 04:59:11 +11:00
2017-04-02 02:28:02 +10:00
def DownloadChunks(url):
"""
2017-04-02 02:28:02 +10:00
Downloads PMS url in chunks of CONTAINERSIZE.
Returns a stitched-together xml or None.
"""
xml = None
pos = 0
2018-02-11 03:59:20 +11:00
error_counter = 0
while error_counter < 10:
args = {
2017-04-02 02:28:02 +10:00
'X-Plex-Container-Size': CONTAINERSIZE,
2018-10-21 21:03:21 +11:00
'X-Plex-Container-Start': pos,
'sort': 'id'
}
2019-03-30 20:32:56 +11:00
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:
2019-03-30 20:32:56 +11:00
LOG.error('Error while downloading chunks: %s, args: %s',
url, args)
2017-04-02 02:28:02 +10:00
pos += CONTAINERSIZE
2018-02-11 03:59:20 +11:00
error_counter += 1
continue
# Very first run: starting xml (to retain data in xml's root!)
if xml is None:
xml = deepcopy(xmlpart)
2017-04-02 02:28:02 +10:00
if len(xmlpart) < CONTAINERSIZE:
break
else:
2017-04-02 02:28:02 +10:00
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
2017-04-02 02:28:02 +10:00
if len(xmlpart) < CONTAINERSIZE:
break
2017-04-02 02:28:02 +10:00
pos += CONTAINERSIZE
2018-02-11 03:59:20 +11:00
if error_counter == 10:
LOG.error('Fatal error while downloading chunks for %s', url)
return None
return xml
2017-04-02 02:28:02 +10:00
def GetPlexOnDeck(viewId):
2016-03-15 03:47:05 +11:00
"""
"""
2019-03-30 20:32:56 +11:00
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
"""
2019-01-09 04:00:54 +11:00
xml = DU().downloadUrl('{server}/library/sections')
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
xml = None
return xml
2016-02-03 23:01:13 +11:00
def init_plex_playqueue(plex_id, plex_type, section_uuid, trailers=False):
2016-02-03 23:01:13 +11:00
"""
Returns raw API metadata XML dump for a playlist with e.g. trailers.
"""
2016-02-03 23:01:13 +11:00
url = "{server}/playQueues"
args = {
'type': plex_type,
'uri': ('server://%s/com.plexapp.plugins.library/library/metadata/%s' %
(app.CONN.machine_identifier, plex_id)),
2016-02-03 23:01:13 +11:00
'includeChapters': '1',
'shuffle': '0',
2021-02-08 07:42:47 +11:00
'repeat': '0',
'includeMarkers': 1, # e.g. start + stop of intros
2016-02-03 23:01:13 +11:00
}
2016-12-30 01:41:14 +11:00
if trailers is True:
2018-06-22 03:24:37 +10:00
args['extrasPrefixCount'] = utils.settings('trailerNumber')
2019-03-30 20:32:56 +11:00
xml = DU().downloadUrl(utils.extend_url(url, args), action_type="POST")
2016-02-03 23:01:13 +11:00
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://<machineIdentifier>/com.plexapp.plugins.library/library...
We need to use
library://<librarySectionUUID>/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
2016-02-03 23:01:13 +11:00
return xml
2016-02-07 22:38:50 +11:00
2018-02-11 03:59:20 +11:00
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)
2016-03-11 02:02:46 +11:00
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
2016-03-12 00:42:14 +11:00
# 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
2016-03-12 00:42:14 +11:00
def GetMachineIdentifier(url):
"""
Returns the unique PMS machine identifier of url
Returns None if something went wrong
"""
2018-02-11 03:59:20 +11:00
xml = DU().downloadUrl('%s/identity' % url,
authenticate=False,
verifySSL=True if v.KODIVERSION >= 18 else False,
timeout=10,
reraise=True)
try:
2016-05-25 03:00:39 +10:00
machineIdentifier = xml.attrib['machineIdentifier']
except (AttributeError, KeyError):
2018-02-11 03:59:20 +11:00
LOG.error('Could not get the PMS machineIdentifier for %s', url)
return None
2018-02-11 03:59:20 +11:00
LOG.debug('Found machineIdentifier %s for the PMS %s',
machineIdentifier, url)
return machineIdentifier
2016-03-28 01:57:20 +11:00
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 = {}
2018-02-11 03:59:20 +11:00
xml = DU().downloadUrl('{server}/status/sessions',
headerOptions={'X-Plex-Token': token})
2016-03-28 01:57:20 +11:00
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
2016-03-12 00:42:14 +11:00
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":
2019-03-30 20:32:56 +11:00
url = '{server}/:/scrobble'
2016-03-12 00:42:14 +11:00
elif state == "unwatched":
2019-03-30 20:32:56 +11:00
url = '{server}/:/unscrobble'
2016-03-12 00:42:14 +11:00
else:
return
2019-03-30 20:32:56 +11:00
DU().downloadUrl(utils.extend_url(url, args))
2018-02-11 03:59:20 +11:00
LOG.info("Toggled watched state for Plex item %s", ratingKey)
2016-10-23 02:15:10 +11:00
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
"""
2018-02-11 03:59:20 +11:00
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
2018-02-11 03:59:20 +11:00
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 <url>/
"""
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):
"""
2018-02-11 03:59:20 +11:00
Retrieve the PMS' settings via <url>/:/prefs
Call with url: scheme://ip:port
"""
2018-02-11 03:59:20 +11:00
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)
2018-02-11 03:59:20 +11:00
def GetUserArtworkURL(username):
"""
Returns the URL for the user's Avatar. Or False if something went
wrong.
"""
2018-09-11 04:53:46 +10:00
users = plex_tv.plex_home_users(utils.settings('plexToken'))
2018-02-11 03:59:20 +11:00
url = ''
for user in users:
2018-09-11 04:53:46 +10:00
if user.title == username:
url = user.thumb
2018-02-11 03:59:20 +11:00
LOG.debug("Avatar url for user %s is: %s", username, url)
return url
2019-07-14 20:00:07 +10:00
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 its 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 arent 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')