PlexKodiConnect/resources/lib/plexnet/plexserver.py

624 lines
22 KiB
Python
Raw Permalink Normal View History

2018-09-30 21:16:17 +10:00
# -*- coding: utf-8 -*-
import http
import time
import util
import exceptions
import compat
import verlib
import re
import json
from xml.etree import ElementTree
import signalsmixin
import plexobjects
import plexresource
import plexlibrary
import plexapp
# from plexapi.client import Client
# from plexapi.playqueue import PlayQueue
TOTAL_QUERIES = 0
DEFAULT_BASEURI = 'http://localhost:32400'
class PlexServer(plexresource.PlexResource, signalsmixin.SignalsMixin):
TYPE = 'PLEXSERVER'
def __init__(self, data=None):
signalsmixin.SignalsMixin.__init__(self)
plexresource.PlexResource.__init__(self, data)
self.accessToken = None
self.multiuser = False
self.isSupported = None
self.hasFallback = False
self.supportsAudioTranscoding = False
self.supportsVideoTranscoding = False
self.supportsPhotoTranscoding = False
self.supportsVideoRemuxOnly = False
self.supportsScrobble = True
self.allowsMediaDeletion = False
self.allowChannelAccess = False
self.activeConnection = None
self.serverClass = None
self.pendingReachabilityRequests = 0
self.pendingSecureRequests = 0
self.features = {}
self.librariesByUuid = {}
self.server = self
self.session = http.Session()
self.owner = None
self.owned = False
self.synced = False
self.sameNetwork = False
self.uuid = None
self.name = None
self.platform = None
self.versionNorm = None
self.rawVersion = None
self.transcodeSupport = False
if data is None:
return
self.owner = data.attrib.get('sourceTitle')
self.owned = data.attrib.get('owned') == '1'
self.synced = data.attrib.get('synced') == '1'
self.sameNetwork = data.attrib.get('publicAddressMatches') == '1'
self.uuid = data.attrib.get('clientIdentifier')
self.name = data.attrib.get('name')
self.platform = data.attrib.get('platform')
self.rawVersion = data.attrib.get('productVersion')
self.versionNorm = util.normalizedVersion(self.rawVersion)
self.transcodeSupport = data.attrib.get('transcodeSupport') == '1'
def __eq__(self, other):
if not other:
return False
if self.__class__ != other.__class__:
return False
return self.uuid == other.uuid and self.owner == other.owner
def __ne__(self, other):
return not self.__eq__(other)
def __str__(self):
return "<PlexServer {0} owned: {1} uuid: {2} version: {3}>".format(repr(self.name), self.owned, self.uuid, self.versionNorm)
def __repr__(self):
return self.__str__()
def close(self):
self.session.cancel()
def get(self, attr, default=None):
return default
@property
def isSecure(self):
if self.activeConnection:
return self.activeConnection.isSecure
def getObject(self, key):
data = self.query(key)
return plexobjects.buildItem(self, data[0], key, container=self)
def hubs(self, section=None, count=None, search_query=None):
hubs = []
params = {}
if search_query:
q = '/hubs/search'
params['query'] = search_query.lower()
if section:
params['sectionId'] = section
if count is not None:
params['limit'] = count
else:
q = '/hubs'
if section:
if section == 'playlists':
audio = plexlibrary.AudioPlaylistHub(False, server=self.server)
video = plexlibrary.VideoPlaylistHub(False, server=self.server)
if audio.items:
hubs.append(audio)
if video.items:
hubs.append(video)
return hubs
else:
q = '/hubs/sections/%s' % section
if count is not None:
params['count'] = count
data = self.query(q, params=params)
container = plexobjects.PlexContainer(data, initpath=q, server=self, address=q)
for elem in data:
hubs.append(plexlibrary.Hub(elem, server=self, container=container))
return hubs
def playlists(self, start=0, size=10, hub=None):
try:
return plexobjects.listItems(self, '/playlists/all')
except exceptions.BadRequest:
return None
@property
def library(self):
if self.platform == 'cloudsync':
return plexlibrary.Library(None, server=self)
else:
return plexlibrary.Library(self.query('/library/'), server=self)
def buildUrl(self, path, includeToken=False):
if self.activeConnection:
return self.activeConnection.buildUrl(self, path, includeToken)
else:
util.WARN_LOG("Server connection is None, returning an empty url")
return ""
def query(self, path, method=None, **kwargs):
method = method or self.session.get
url = self.buildUrl(path, includeToken=True)
util.LOG('{0} {1}'.format(method.__name__.upper(), re.sub('X-Plex-Token=[^&]+', 'X-Plex-Token=****', url)))
try:
response = method(url, **kwargs)
if response.status_code not in (200, 201):
codename = http.status_codes.get(response.status_code, ['Unknown'])[0]
raise exceptions.BadRequest('({0}) {1}'.format(response.status_code, codename))
data = response.text.encode('utf8')
except http.requests.ConnectionError:
util.ERROR()
return None
return ElementTree.fromstring(data) if data else None
def getImageTranscodeURL(self, path, width, height, **extraOpts):
if not path:
return ''
params = ("&width=%s&height=%s" % (width, height)) + ''.join(["&%s=%s" % (key, extraOpts[key]) for key in extraOpts])
if "://" in path:
imageUrl = self.convertUrlToLoopBack(path)
else:
imageUrl = "http://127.0.0.1:" + self.getLocalServerPort() + path
path = "/photo/:/transcode?url=" + compat.quote_plus(imageUrl) + params
# Try to use a better server to transcode for synced servers
if self.synced:
import plexservermanager
selectedServer = plexservermanager.MANAGER.getTranscodeServer("photo")
if selectedServer:
return selectedServer.buildUrl(path, True)
if self.activeConnection:
return self.activeConnection.simpleBuildUrl(self, path)
else:
util.WARN_LOG("Server connection is None, returning an empty url")
return ""
def isReachable(self, onlySupported=True):
if onlySupported and not self.isSupported:
return False
return self.activeConnection and self.activeConnection.state == plexresource.ResourceConnection.STATE_REACHABLE
def isLocalConnection(self):
return self.activeConnection and (self.sameNetwork or self.activeConnection.isLocal)
def isRequestToServer(self, url):
if not self.activeConnection:
return False
if ':' in self.activeConnection.address[8:]:
schemeAndHost = self.activeConnection.address.rsplit(':', 1)[0]
else:
schemeAndHost = self.activeConnection.address
return url.startswith(schemeAndHost)
def getToken(self):
# It's dangerous to use for each here, because it may reset the index
# on self.connections when something else was in the middle of an iteration.
for i in range(len(self.connections)):
conn = self.connections[i]
if conn.token:
return conn.token
return None
def getLocalServerPort(self):
# TODO(schuyler): The correct thing to do here is to iterate over local
# connections and pull out the port. For now, we're always returning 32400.
return '32400'
def collectDataFromRoot(self, data):
# Make sure we're processing data for our server, and not some other
# server that happened to be at the same IP.
if self.uuid != data.attrib.get('machineIdentifier'):
util.LOG("Got a reachability response, but from a different server")
return False
self.serverClass = data.attrib.get('serverClass')
self.supportsAudioTranscoding = data.attrib.get('transcoderAudio') == '1'
self.supportsVideoTranscoding = data.attrib.get('transcoderVideo') == '1' or data.attrib.get('transcoderVideoQualities')
self.supportsVideoRemuxOnly = data.attrib.get('transcoderVideoRemuxOnly') == '1'
self.supportsPhotoTranscoding = data.attrib.get('transcoderPhoto') == '1' or (
not data.attrib.get('transcoderPhoto') and not self.synced and not self.isSecondary()
)
self.allowChannelAccess = data.attrib.get('allowChannelAccess') == '1' or (
not data.attrib.get('allowChannelAccess') and self.owned and not self.synced and not self.isSecondary()
)
self.supportsScrobble = not self.isSecondary() or self.synced
self.allowsMediaDeletion = not self.synced and self.owned and data.attrib.get('allowMediaDeletion') == '1'
self.multiuser = data.attrib.get('multiuser') == '1'
self.name = data.attrib.get('friendlyName') or self.name
self.platform = data.attrib.get('platform')
# TODO(schuyler): Process transcoder qualities
self.rawVersion = data.attrib.get('version')
if self.rawVersion:
self.versionNorm = util.normalizedVersion(self.rawVersion)
features = {
'mkvTranscode': '0.9.11.11',
'themeTranscode': '0.9.14.0',
'allPartsStreamSelection': '0.9.12.5',
'claimServer': '0.9.14.2',
'streamingBrain': '1.2.0'
}
for f, v in features.items():
if util.normalizedVersion(v) <= self.versionNorm:
self.features[f] = True
appMinVer = plexapp.INTERFACE.getGlobal('minServerVersionArr', '0.0.0.0')
self.isSupported = self.isSecondary() or util.normalizedVersion(appMinVer) <= self.versionNorm
util.DEBUG_LOG("Server information updated from reachability check: {0}".format(self))
return True
def updateReachability(self, force=True, allowFallback=False):
if not force and self.activeConnection and self.activeConnection.state != plexresource.ResourceConnection.STATE_UNKNOWN:
return
util.LOG('Updating reachability for {0}: conns={1}, allowFallback={2}'.format(repr(self.name), len(self.connections), allowFallback))
epoch = time.time()
retrySeconds = 60
minSeconds = 10
for i in range(len(self.connections)):
conn = self.connections[i]
diff = epoch - (conn.lastTestedAt or 0)
if conn.hasPendingRequest:
util.DEBUG_LOG("Skip reachability test for {0} (has pending request)".format(conn))
elif diff < minSeconds or (not self.isSecondary() and self.isReachable() and diff < retrySeconds):
util.DEBUG_LOG("Skip reachability test for {0} (checked {1} secs ago)".format(conn, diff))
elif conn.testReachability(self, allowFallback):
self.pendingReachabilityRequests += 1
if conn.isSecure:
self.pendingSecureRequests += 1
if self.pendingReachabilityRequests == 1:
self.trigger("started:reachability")
if self.pendingReachabilityRequests <= 0:
self.trigger("completed:reachability")
def cancelReachability(self):
for i in range(len(self.connections)):
conn = self.connections[i]
conn.cancelReachability()
def onReachabilityResult(self, connection):
connection.lastTestedAt = time.time()
connection.hasPendingRequest = None
self.pendingReachabilityRequests -= 1
if connection.isSecure:
self.pendingSecureRequests -= 1
util.DEBUG_LOG("Reachability result for {0}: {1} is {2}".format(repr(self.name), connection.address, connection.state))
# Noneate active connection if the state is unreachable
if self.activeConnection and self.activeConnection.state != plexresource.ResourceConnection.STATE_REACHABLE:
self.activeConnection = None
# Pick a best connection. If we already had an active connection and
# it's still reachable, stick with it. (replace with local if
# available)
best = self.activeConnection
for i in range(len(self.connections) - 1, -1, -1):
conn = self.connections[i]
if not best or conn.getScore() > best.getScore():
best = conn
if best and best.state == best.STATE_REACHABLE:
if best.isSecure or self.pendingSecureRequests <= 0:
self.activeConnection = best
else:
util.DEBUG_LOG("Found a good connection for {0}, but holding out for better".format(repr(self.name)))
if self.pendingReachabilityRequests <= 0:
# Retest the server with fallback enabled. hasFallback will only
# be True if there are available insecure connections and fallback
# is allowed.
if self.hasFallback:
self.updateReachability(False, True)
else:
self.trigger("completed:reachability")
util.LOG("Active connection for {0} is {1}".format(repr(self.name), self.activeConnection))
import plexservermanager
plexservermanager.MANAGER.updateReachabilityResult(self, bool(self.activeConnection))
def markAsRefreshing(self):
for i in range(len(self.connections)):
conn = self.connections[i]
conn.refreshed = False
def markUpdateFinished(self, source):
# Any connections for the given source which haven't been refreshed should
# be removed. Since removing from a list is hard, we'll make a new list.
toKeep = []
hasSecureConn = False
for i in range(len(self.connections)):
conn = self.connections[i]
if not conn.refreshed:
conn.sources = conn.sources & (~source)
# If we lost our plex.tv connection, don't remember the token.
if source == conn.SOURCE_MYPLEX:
conn.token = None
if conn.sources:
if conn.address[:5] == "https":
hasSecureConn = True
toKeep.append(conn)
else:
util.DEBUG_LOG("Removed connection for {0} after updating connections for {1}".format(repr(self.name), source))
if conn == self.activeConnection:
util.DEBUG_LOG("Active connection lost")
self.activeConnection = None
# Update fallback flag if our connections have changed
if len(toKeep) != len(self.connections):
for conn in toKeep:
conn.isFallback = hasSecureConn and conn.address[:5] != "https"
self.connections = toKeep
return len(self.connections) > 0
def merge(self, other):
# Wherever this other server came from, assume its information is better
# except for manual connections.
if other.sourceType != plexresource.ResourceConnection.SOURCE_MANUAL:
self.name = other.name
self.versionNorm = other.versionNorm
self.sameNetwork = other.sameNetwork
# Merge connections
for otherConn in other.connections:
merged = False
for i in range(len(self.connections)):
myConn = self.connections[i]
if myConn == otherConn:
myConn.merge(otherConn)
merged = True
break
if not merged:
self.connections.append(otherConn)
# If the other server has a token, then it came from plex.tv, which
# means that its ownership information is better than ours. But if
# it was discovered, then it may incorrectly claim to be owned, so
# we stick with whatever we already had.
if other.getToken():
self.owned = other.owned
self.owner = other.owner
def supportsFeature(self, feature):
return feature in self.features
def getVersion(self):
if not self.versionNorm:
return ''
return str(self.versionNorm)
def convertUrlToLoopBack(self, url):
# If the URL starts with our server URL, replace it with 127.0.0.1:32400.
if self.isRequestToServer(url):
url = 'http://127.0.0.1:32400/' + url.split('://', 1)[-1].split('/', 1)[-1]
return url
def resetLastTest(self):
for i in range(len(self.connections)):
conn = self.connections[i]
conn.lastTestedAt = None
def isSecondary(self):
return self.serverClass == "secondary"
def getLibrarySectionByUuid(self, uuid=None):
if not uuid:
return None
return self.librariesByUuid[uuid]
def setLibrarySectionByUuid(self, uuid, library):
self.librariesByUuid[uuid] = library
def hasInsecureConnections(self):
if plexapp.INTERFACE.getPreference('allow_insecure') == 'always':
return False
# True if we have any insecure connections we have disallowed
for i in range(len(self.connections)):
conn = self.connections[i]
if not conn.isSecure and conn.state == conn.STATE_INSECURE:
return True
return False
def hasSecureConnections(self):
for i in range(len(self.connections)):
conn = self.connections[i]
if conn.isSecure:
return True
return False
def getLibrarySectionPrefs(self, uuid):
# TODO: Make sure I did this right - ruuk
librarySection = self.getLibrarySectionByUuid(uuid)
if librarySection and librarySection.key:
# Query and store the prefs only when asked for. We could just return the
# items, but it'll be more useful to store the pref ids in an associative
# array for ease of selecting the pref we need.
if not librarySection.sectionPrefs:
path = "/library/sections/{0}/prefs".format(librarySection.key)
data = self.query(path)
if data:
librarySection.sectionPrefs = {}
for elem in data:
item = plexobjects.buildItem(self, elem, path)
if item.id:
librarySection.sectionPrefs[item.id] = item
return librarySection.sectionPrefs
return None
def swizzleUrl(self, url, includeToken=False):
m = re.Search("^\w+:\/\/.+?(\/.+)", url)
newUrl = m and m.group(1) or None
return self.buildUrl(newUrl or url, includeToken)
def hasHubs(self):
return self.platform != 'cloudsync'
@property
def address(self):
return self.activeConnection.address
@classmethod
def deSerialize(cls, jstring):
try:
serverObj = json.loads(jstring)
except:
util.ERROR()
util.ERROR_LOG("Failed to deserialize PlexServer JSON")
return
import plexconnection
server = createPlexServerForName(serverObj['uuid'], serverObj['name'])
server.owned = bool(serverObj.get('owned'))
server.sameNetwork = serverObj.get('sameNetwork')
hasSecureConn = False
for i in range(len(serverObj.get('connections', []))):
conn = serverObj['connections'][i]
if conn['address'][:5] == "https":
hasSecureConn = True
break
for i in range(len(serverObj.get('connections', []))):
conn = serverObj['connections'][i]
isFallback = hasSecureConn and conn['address'][:5] != "https"
sources = plexconnection.PlexConnection.SOURCE_BY_VAL[conn['sources']]
connection = plexconnection.PlexConnection(sources, conn['address'], conn['isLocal'], conn['token'], isFallback)
# Keep the secure connection on top
if connection.isSecure:
server.connections.insert(0, connection)
else:
server.connections.append(connection)
if conn.get('active'):
server.activeConnection = connection
return server
def serialize(self, full=False):
serverObj = {
'name': self.name,
'uuid': self.uuid,
'owned': self.owned,
'connections': []
}
if full:
for conn in self.connections:
serverObj['connections'].append({
'sources': conn.sources,
'address': conn.address,
'isLocal': conn.isLocal,
'isSecure': conn.isSecure,
'token': conn.token
})
if conn == self.activeConnection:
serverObj['connections'][-1]['active'] = True
else:
serverObj['connections'] = [{
'sources': self.activeConnection.sources,
'address': self.activeConnection.address,
'isLocal': self.activeConnection.isLocal,
'isSecure': self.activeConnection.isSecure,
'token': self.activeConnection.token or self.getToken(),
'active': True
}]
return json.dumps(serverObj)
def dummyPlexServer():
return createPlexServer()
def createPlexServer():
return PlexServer()
def createPlexServerForConnection(conn):
obj = createPlexServer()
obj.connections.append(conn)
obj.activeConnection = conn
return obj
def createPlexServerForName(uuid, name):
obj = createPlexServer()
obj.uuid = uuid
obj.name = name
return obj
def createPlexServerForResource(resource):
# resource.__class__ = PlexServer
# resource.server = resource
# resource.session = http.Session()
return resource