Compare commits
18 commits
master
...
plex_for_k
Author | SHA1 | Date | |
---|---|---|---|
|
b988344c9b | ||
|
517f41b534 | ||
|
ae13a6f3cc | ||
|
b27a846292 | ||
|
c26e1283db | ||
|
b6dc458b54 | ||
|
9eba24485e | ||
|
a547dd790c | ||
|
3971e24b95 | ||
|
a58d284108 | ||
|
3388765b63 | ||
|
1a55301f24 | ||
|
504044b283 | ||
|
3045ecbccd | ||
|
dbe0339b71 | ||
|
5cdda0e334 | ||
|
59040f3b3e | ||
|
32927931c4 |
65 changed files with 12271 additions and 63 deletions
12
resources/lib/image.py
Normal file
12
resources/lib/image.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
from . import util, path_ops
|
||||||
|
|
||||||
|
CACHE_PATH = path_ops.path.join(util.PROFILE, 'avatars', '')
|
||||||
|
if not path_ops.exists(CACHE_PATH):
|
||||||
|
path_ops.makedirs(CACHE_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def getImage(url, ID):
|
||||||
|
return url, ''
|
105
resources/lib/kodijsonrpc.py
Normal file
105
resources/lib/kodijsonrpc.py
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
import xbmc
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class JSONRPCMethod:
|
||||||
|
|
||||||
|
class Exception(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.family = None
|
||||||
|
|
||||||
|
def __getattr__(self, method):
|
||||||
|
def handler(**kwargs):
|
||||||
|
command = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'id': 1,
|
||||||
|
'method': '{0}.{1}'.format(self.family, method)
|
||||||
|
}
|
||||||
|
|
||||||
|
if kwargs:
|
||||||
|
command['params'] = kwargs
|
||||||
|
|
||||||
|
# xbmc.log(json.dumps(command))
|
||||||
|
ret = json.loads(xbmc.executeJSONRPC(json.dumps(command)))
|
||||||
|
|
||||||
|
if ret:
|
||||||
|
if 'error' in ret:
|
||||||
|
raise self.Exception(ret['error'])
|
||||||
|
else:
|
||||||
|
return ret['result']
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
def __call__(self, family):
|
||||||
|
self.family = family
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class KodiJSONRPC:
|
||||||
|
def __init__(self):
|
||||||
|
self.methodHandler = JSONRPCMethod()
|
||||||
|
|
||||||
|
def __getattr__(self, family):
|
||||||
|
return self.methodHandler(family)
|
||||||
|
|
||||||
|
|
||||||
|
rpc = KodiJSONRPC()
|
||||||
|
|
||||||
|
|
||||||
|
class BuiltInMethod:
|
||||||
|
|
||||||
|
class Exception(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.module = None
|
||||||
|
|
||||||
|
def __getattr__(self, method):
|
||||||
|
def handler(*args, **kwargs):
|
||||||
|
args = [str(a).replace(',', '\,') for a in args]
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
args.append('{0}={v}'.format(k, str(v).replace(',', '\,')))
|
||||||
|
|
||||||
|
if args:
|
||||||
|
command = '{0}.{1}({2})'.format(self.module, method, ','.join(args))
|
||||||
|
else:
|
||||||
|
command = '{0}.{1}'.format(self.module, method)
|
||||||
|
|
||||||
|
xbmc.log(command, xbmc.LOGNOTICE)
|
||||||
|
|
||||||
|
xbmc.executebuiltin(command)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
args = [str(a).replace(',', '\,') for a in args]
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
args.append('{0}={v}'.format(k, str(v).replace(',', '\,')))
|
||||||
|
|
||||||
|
if args:
|
||||||
|
command = '{0}({1})'.format(self.module, ','.join(args))
|
||||||
|
else:
|
||||||
|
command = '{0}'.format(self.module)
|
||||||
|
|
||||||
|
xbmc.log(command, xbmc.LOGNOTICE)
|
||||||
|
|
||||||
|
xbmc.executebuiltin(command)
|
||||||
|
|
||||||
|
def initModule(self, module):
|
||||||
|
self.module = module
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class KodiBuiltin:
|
||||||
|
def __init__(self):
|
||||||
|
self.methodHandler = BuiltInMethod()
|
||||||
|
|
||||||
|
def __getattr__(self, module):
|
||||||
|
return self.methodHandler.initModule(module)
|
||||||
|
|
||||||
|
|
||||||
|
builtin = KodiBuiltin()
|
|
@ -3,8 +3,15 @@
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
import gc
|
||||||
|
|
||||||
import xbmc
|
import xbmc
|
||||||
|
|
||||||
|
from . import plex, util, backgroundthread
|
||||||
|
from .plexnet import plexapp, threadutils
|
||||||
|
from .windows import userselect
|
||||||
|
|
||||||
from . import utils
|
from . import utils
|
||||||
from . import userclient
|
from . import userclient
|
||||||
from . import initialsetup
|
from . import initialsetup
|
||||||
|
@ -23,7 +30,7 @@ from . import loghandler
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
loghandler.config()
|
loghandler.config()
|
||||||
LOG = logging.getLogger("PLEX.service_entry")
|
LOG = logging.getLogger("PLEX.main")
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
|
||||||
|
@ -264,22 +271,125 @@ class Service():
|
||||||
LOG.info("======== STOP %s ========", v.ADDON_NAME)
|
LOG.info("======== STOP %s ========", v.ADDON_NAME)
|
||||||
|
|
||||||
|
|
||||||
def start():
|
def waitForThreads():
|
||||||
# Safety net - Kody starts PKC twice upon first installation!
|
LOG.debug('Checking for any remaining threads')
|
||||||
if utils.window('plex_service_started') == 'true':
|
while len(threading.enumerate()) > 1:
|
||||||
EXIT = True
|
for t in threading.enumerate():
|
||||||
else:
|
if t != threading.currentThread():
|
||||||
utils.window('plex_service_started', value='true')
|
if t.isAlive():
|
||||||
EXIT = False
|
LOG.debug('Waiting on thread: %s', t.name)
|
||||||
|
if isinstance(t, threading._Timer):
|
||||||
|
t.cancel()
|
||||||
|
t.join()
|
||||||
|
elif isinstance(t, threadutils.KillableThread):
|
||||||
|
t.kill(force_and_wait=True)
|
||||||
|
else:
|
||||||
|
t.join()
|
||||||
|
LOG.debug('All threads done')
|
||||||
|
|
||||||
# Delay option
|
|
||||||
DELAY = int(utils.settings('startupDelay'))
|
|
||||||
|
|
||||||
LOG.info("Delaying Plex startup by: %s sec...", DELAY)
|
def signout():
|
||||||
if EXIT:
|
util.setSetting('auth.token', '')
|
||||||
LOG.error('PKC service.py already started - exiting this instance')
|
LOG.info('Signing out...')
|
||||||
elif DELAY and xbmc.Monitor().waitForAbort(DELAY):
|
plexapp.ACCOUNT.signOut()
|
||||||
# Start the service
|
|
||||||
LOG.info("Abort requested while waiting. PKC not started.")
|
|
||||||
else:
|
def main():
|
||||||
Service().ServiceEntryPoint()
|
LOG.info('Starting PKC %s', util.ADDON.getAddonInfo('version'))
|
||||||
|
LOG.info('User-agent: %s', plex.defaultUserAgent())
|
||||||
|
|
||||||
|
server_select_shown = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
plex.init()
|
||||||
|
# Check plex.tv sign-in status
|
||||||
|
if not plexapp.ACCOUNT.authToken:
|
||||||
|
LOG.info('Not signed in to plex.tv yet')
|
||||||
|
if utils.settings('myplexlogin') == 'true':
|
||||||
|
plex.authorize_user()
|
||||||
|
# Main Loop
|
||||||
|
while not xbmc.abortRequested:
|
||||||
|
# Check whether a PMS server has been set
|
||||||
|
selectedServer = plexapp.SERVERMANAGER.selectedServer
|
||||||
|
if not selectedServer:
|
||||||
|
if not server_select_shown:
|
||||||
|
server_select_shown = True
|
||||||
|
LOG.debug('No PMS set yet, presenting dialog')
|
||||||
|
plex.select_server()
|
||||||
|
else:
|
||||||
|
util.MONITOR.waitForAbort(1)
|
||||||
|
continue
|
||||||
|
# Ping the PMS until we're sure its online
|
||||||
|
if not selectedServer.isReachable():
|
||||||
|
selectedServer.updateReachability(force=True,
|
||||||
|
allowFallback=True)
|
||||||
|
continue
|
||||||
|
while not xbmc.abortRequested:
|
||||||
|
|
||||||
|
if (
|
||||||
|
not plexapp.ACCOUNT.isOffline and not
|
||||||
|
plexapp.ACCOUNT.isAuthenticated and
|
||||||
|
(len(plexapp.ACCOUNT.homeUsers) > 1 or plexapp.ACCOUNT.isProtected)
|
||||||
|
|
||||||
|
):
|
||||||
|
result = userselect.start()
|
||||||
|
if not result:
|
||||||
|
return
|
||||||
|
elif result == 'signout':
|
||||||
|
signout()
|
||||||
|
break
|
||||||
|
elif result == 'signin':
|
||||||
|
break
|
||||||
|
LOG.info('User selected')
|
||||||
|
|
||||||
|
try:
|
||||||
|
selectedServer = plexapp.SERVERMANAGER.selectedServer
|
||||||
|
|
||||||
|
if not selectedServer:
|
||||||
|
LOG.debug('Waiting for selected server...')
|
||||||
|
for timeout, skip_preferred, skip_owned in ((10, True, False), (10, True, True)):
|
||||||
|
plex.CallbackEvent(plexapp.APP, 'change:selectedServer', timeout=timeout).wait()
|
||||||
|
|
||||||
|
selectedServer = plexapp.SERVERMANAGER.checkSelectedServerSearch(skip_preferred=skip_preferred, skip_owned=skip_owned)
|
||||||
|
if selectedServer:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
LOG.debug('Finished waiting for selected server...')
|
||||||
|
|
||||||
|
LOG.info('Starting with server: %s', selectedServer)
|
||||||
|
|
||||||
|
windowutils.HOME = home.HomeWindow.open()
|
||||||
|
util.CRON.cancelReceiver(windowutils.HOME)
|
||||||
|
|
||||||
|
if not windowutils.HOME.closeOption:
|
||||||
|
return
|
||||||
|
|
||||||
|
closeOption = windowutils.HOME.closeOption
|
||||||
|
|
||||||
|
windowutils.shutdownHome()
|
||||||
|
|
||||||
|
if closeOption == 'signout':
|
||||||
|
signout()
|
||||||
|
break
|
||||||
|
elif closeOption == 'switch':
|
||||||
|
plexapp.ACCOUNT.isAuthenticated = False
|
||||||
|
finally:
|
||||||
|
windowutils.shutdownHome()
|
||||||
|
gc.collect(2)
|
||||||
|
except:
|
||||||
|
util.ERROR()
|
||||||
|
finally:
|
||||||
|
LOG.info('SHUTTING DOWN...')
|
||||||
|
# player.shutdown()
|
||||||
|
plexapp.APP.preShutdown()
|
||||||
|
if util.CRON:
|
||||||
|
util.CRON.stop()
|
||||||
|
backgroundthread.BGThreader.shutdown()
|
||||||
|
plexapp.APP.shutdown()
|
||||||
|
waitForThreads()
|
||||||
|
LOG.info('SHUTDOWN FINISHED')
|
||||||
|
|
||||||
|
from .windows import kodigui
|
||||||
|
kodigui.MONITOR = None
|
||||||
|
util.shutdown()
|
||||||
|
gc.collect(2)
|
365
resources/lib/plex.py
Normal file
365
resources/lib/plex.py
Normal file
|
@ -0,0 +1,365 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import platform
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
|
||||||
|
import xbmc
|
||||||
|
|
||||||
|
from .plexnet import plexapp, myplex
|
||||||
|
from . import util, utils
|
||||||
|
|
||||||
|
LOG = logging.getLogger('PLEX.plex')
|
||||||
|
|
||||||
|
|
||||||
|
class PlexTimer(plexapp.Timer):
|
||||||
|
def shouldAbort(self):
|
||||||
|
return xbmc.abortRequested
|
||||||
|
|
||||||
|
|
||||||
|
def abortFlag():
|
||||||
|
return util.MONITOR.abortRequested()
|
||||||
|
|
||||||
|
|
||||||
|
plexapp.setTimer(PlexTimer)
|
||||||
|
plexapp.setAbortFlagFunction(abortFlag)
|
||||||
|
|
||||||
|
maxVideoRes = plexapp.Res((3840, 2160)) # INTERFACE.globals["supports4k"] and plexapp.Res((3840, 2160)) or plexapp.Res((1920, 1080))
|
||||||
|
|
||||||
|
CLIENT_ID = util.getSetting('client.ID')
|
||||||
|
if not CLIENT_ID:
|
||||||
|
CLIENT_ID = str(uuid.uuid4())
|
||||||
|
util.setSetting('client.ID', CLIENT_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def defaultUserAgent():
|
||||||
|
"""Return a string representing the default user agent."""
|
||||||
|
_implementation = platform.python_implementation()
|
||||||
|
|
||||||
|
if _implementation == 'CPython':
|
||||||
|
_implementation_version = platform.python_version()
|
||||||
|
elif _implementation == 'PyPy':
|
||||||
|
_implementation_version = '%s.%s.%s' % (sys.pypy_version_info.major,
|
||||||
|
sys.pypy_version_info.minor,
|
||||||
|
sys.pypy_version_info.micro)
|
||||||
|
if sys.pypy_version_info.releaselevel != 'final':
|
||||||
|
_implementation_version = ''.join([_implementation_version, sys.pypy_version_info.releaselevel])
|
||||||
|
elif _implementation == 'Jython':
|
||||||
|
_implementation_version = platform.python_version() # Complete Guess
|
||||||
|
elif _implementation == 'IronPython':
|
||||||
|
_implementation_version = platform.python_version() # Complete Guess
|
||||||
|
else:
|
||||||
|
_implementation_version = 'Unknown'
|
||||||
|
|
||||||
|
try:
|
||||||
|
p_system = platform.system()
|
||||||
|
p_release = platform.release()
|
||||||
|
except IOError:
|
||||||
|
p_system = 'Unknown'
|
||||||
|
p_release = 'Unknown'
|
||||||
|
|
||||||
|
return " ".join(['%s/%s' % ('PlexKodiConnect', util.ADDON.getAddonInfo('version')),
|
||||||
|
'%s/%s' % ('Kodi', xbmc.getInfoLabel('System.BuildVersion').replace(' ', '-')),
|
||||||
|
'%s/%s' % (_implementation, _implementation_version),
|
||||||
|
'%s/%s' % (p_system, p_release)])
|
||||||
|
|
||||||
|
class PlexInterface(plexapp.AppInterface):
|
||||||
|
_regs = {
|
||||||
|
None: {},
|
||||||
|
}
|
||||||
|
_globals = {
|
||||||
|
'platform': 'Kodi',
|
||||||
|
'appVersionStr': util.ADDON.getAddonInfo('version'),
|
||||||
|
'clientIdentifier': CLIENT_ID,
|
||||||
|
'platformVersion': xbmc.getInfoLabel('System.BuildVersion'),
|
||||||
|
'product': 'PlexKodiConnect',
|
||||||
|
'provides': 'client,controller,player,pubsub-player',
|
||||||
|
'device': util.getPlatform() or plexapp.PLATFORM,
|
||||||
|
'model': 'Unknown',
|
||||||
|
'friendlyName': 'PlexKodiConnect {0}'.format(platform.node()),
|
||||||
|
'supports1080p60': True,
|
||||||
|
'vp9Support': True,
|
||||||
|
'transcodeVideoQualities': [
|
||||||
|
"10", "20", "30", "30", "40", "60", "60", "75", "100", "60", "75", "90", "100", "100"
|
||||||
|
],
|
||||||
|
'transcodeVideoResolutions': [
|
||||||
|
plexapp.Res((220, 180)),
|
||||||
|
plexapp.Res((220, 128)),
|
||||||
|
plexapp.Res((284, 160)),
|
||||||
|
plexapp.Res((420, 240)),
|
||||||
|
plexapp.Res((576, 320)),
|
||||||
|
plexapp.Res((720, 480)),
|
||||||
|
plexapp.Res((1024, 768)),
|
||||||
|
plexapp.Res((1280, 720)),
|
||||||
|
plexapp.Res((1280, 720)),
|
||||||
|
maxVideoRes, maxVideoRes, maxVideoRes, maxVideoRes, maxVideoRes
|
||||||
|
],
|
||||||
|
'transcodeVideoBitrates': [
|
||||||
|
"64", "96", "208", "320", "720", "1500", "2000", "3000", "4000", "8000", "10000", "12000", "20000", "200000"
|
||||||
|
],
|
||||||
|
'deviceInfo': plexapp.DeviceInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
def getPreference(self, pref, default=None):
|
||||||
|
if pref == 'manual_connections':
|
||||||
|
return self.getManualConnections()
|
||||||
|
else:
|
||||||
|
return util.getSetting(pref, default)
|
||||||
|
|
||||||
|
def getManualConnections(self):
|
||||||
|
conns = []
|
||||||
|
for i in range(2):
|
||||||
|
ip = util.getSetting('manual_ip_{0}'.format(i))
|
||||||
|
if not ip:
|
||||||
|
continue
|
||||||
|
port = util.getSetting('manual_port_{0}'.format(i), 32400)
|
||||||
|
conns.append({'connection': ip, 'port': port})
|
||||||
|
return json.dumps(conns)
|
||||||
|
|
||||||
|
def setPreference(self, pref, value):
|
||||||
|
util.setSetting(pref, value)
|
||||||
|
|
||||||
|
def getRegistry(self, reg, default=None, sec=None):
|
||||||
|
if sec == 'myplex' and reg == 'MyPlexAccount':
|
||||||
|
ret = util.getSetting('{0}.{1}'.format(sec, reg), default)
|
||||||
|
if ret:
|
||||||
|
return ret
|
||||||
|
return json.dumps({'authToken': util.getSetting('auth.token')})
|
||||||
|
else:
|
||||||
|
return util.getSetting('{0}.{1}'.format(sec, reg), default)
|
||||||
|
|
||||||
|
def setRegistry(self, reg, value, sec=None):
|
||||||
|
util.setSetting('{0}.{1}'.format(sec, reg), value)
|
||||||
|
|
||||||
|
def clearRegistry(self, reg, sec=None):
|
||||||
|
util.setSetting('{0}.{1}'.format(sec, reg), '')
|
||||||
|
|
||||||
|
def addInitializer(self, sec):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def clearInitializer(self, sec):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def getGlobal(self, glbl, default=None):
|
||||||
|
if glbl == 'transcodeVideoResolutions':
|
||||||
|
maxres = self.getPreference('allow_4k', True) and plexapp.Res((3840, 2160)) or plexapp.Res((1920, 1080))
|
||||||
|
self._globals['transcodeVideoResolutions'][-5:] = [maxres] * 5
|
||||||
|
return self._globals.get(glbl, default)
|
||||||
|
|
||||||
|
def getCapabilities(self):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def LOG(self, msg):
|
||||||
|
LOG.debug('API: %s', msg)
|
||||||
|
|
||||||
|
def DEBUG_LOG(self, msg):
|
||||||
|
LOG.debug('API: %s', msg)
|
||||||
|
|
||||||
|
def WARN_LOG(self, msg):
|
||||||
|
LOG.warn('API: %s', msg)
|
||||||
|
|
||||||
|
def ERROR_LOG(self, msg):
|
||||||
|
LOG.error('API: %s', msg)
|
||||||
|
|
||||||
|
def ERROR(self, msg=None, err=None):
|
||||||
|
if err:
|
||||||
|
LOG.error('%s - %s', msg, err.message)
|
||||||
|
else:
|
||||||
|
util.ERROR()
|
||||||
|
|
||||||
|
def supportsAudioStream(self, codec, channels):
|
||||||
|
return True
|
||||||
|
# if codec = invalid then return true
|
||||||
|
|
||||||
|
# canDownmix = (m.globals["audioDownmix"][codec] <> invalid)
|
||||||
|
# supportsSurroundSound = m.SupportsSurroundSound()
|
||||||
|
|
||||||
|
# if not supportsSurroundSound and canDownmix then
|
||||||
|
# maxChannels = m.globals["audioDownmix"][codec]
|
||||||
|
# else
|
||||||
|
# maxChannels = firstOf(m.globals["audioDecoders"][codec], 0)
|
||||||
|
# end if
|
||||||
|
|
||||||
|
# if maxChannels > 2 and not canDownmix and not supportsSurroundSound then
|
||||||
|
# ' It's a surround sound codec and we can't do surround sound
|
||||||
|
# supported = false
|
||||||
|
# else if maxChannels = 0 or maxChannels < channels then
|
||||||
|
# ' The codec is either unsupported or can't handle the requested channels
|
||||||
|
# supported = false
|
||||||
|
# else
|
||||||
|
# supported = true
|
||||||
|
|
||||||
|
# return supported
|
||||||
|
|
||||||
|
def supportsSurroundSound(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def getQualityIndex(self, qualityType):
|
||||||
|
if qualityType == self.QUALITY_LOCAL:
|
||||||
|
return self.getPreference("local_quality", 13)
|
||||||
|
elif qualityType == self.QUALITY_ONLINE:
|
||||||
|
return self.getPreference("online_quality", 8)
|
||||||
|
else:
|
||||||
|
return self.getPreference("remote_quality", 13)
|
||||||
|
|
||||||
|
def getMaxResolution(self, quality_type, allow4k=False):
|
||||||
|
qualityIndex = self.getQualityIndex(quality_type)
|
||||||
|
|
||||||
|
if qualityIndex >= 9:
|
||||||
|
if self.getPreference('allow_4k', True):
|
||||||
|
return allow4k and 2160 or 1088
|
||||||
|
else:
|
||||||
|
return 1088
|
||||||
|
elif qualityIndex >= 6:
|
||||||
|
return 720
|
||||||
|
elif qualityIndex >= 5:
|
||||||
|
return 480
|
||||||
|
else:
|
||||||
|
return 360
|
||||||
|
|
||||||
|
|
||||||
|
plexapp.setInterface(PlexInterface())
|
||||||
|
plexapp.setUserAgent(defaultUserAgent())
|
||||||
|
|
||||||
|
|
||||||
|
class CallbackEvent(plexapp.CompatEvent):
|
||||||
|
def __init__(self, context, signal, timeout=20, *args, **kwargs):
|
||||||
|
threading._Event.__init__(self, *args, **kwargs)
|
||||||
|
self.start = time.time()
|
||||||
|
self.context = context
|
||||||
|
self.signal = signal
|
||||||
|
self.timeout = timeout
|
||||||
|
self.context.on(self.signal, self.set)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
self.wait()
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return '<{0}:{1}>'.format(self.__class__.__name__, self.signal)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.__unicode__().encode('utf-8')
|
||||||
|
|
||||||
|
def set(self, **kwargs):
|
||||||
|
threading._Event.set(self)
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
if not threading._Event.wait(self, self.timeout):
|
||||||
|
LOG.debug('%s: TIMED-OUT', self)
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def triggeredOrTimedOut(self, timeout=None):
|
||||||
|
try:
|
||||||
|
if time.time() - self.start() > self.timeout:
|
||||||
|
LOG.debug('%s: TIMED-OUT', self)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if timeout:
|
||||||
|
threading._Event.wait(self, timeout)
|
||||||
|
finally:
|
||||||
|
return self.isSet()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.set()
|
||||||
|
self.context.off(self.signal, self.set)
|
||||||
|
|
||||||
|
|
||||||
|
def init():
|
||||||
|
LOG.info('Initializing')
|
||||||
|
with CallbackEvent(plexapp.APP, 'init'):
|
||||||
|
plexapp.init()
|
||||||
|
LOG.info('Waiting for account initialization...')
|
||||||
|
LOG.info('Account initialization done...')
|
||||||
|
|
||||||
|
|
||||||
|
def authorize_user():
|
||||||
|
"""
|
||||||
|
Display userselect dialog. Returns True if user has been selected
|
||||||
|
and a valid token has been retrieved, False otherwise
|
||||||
|
"""
|
||||||
|
LOG.info('Displaying userselect dialog')
|
||||||
|
from .windows import background
|
||||||
|
with background.BackgroundContext(function=authorize) as d:
|
||||||
|
token = d.result
|
||||||
|
if not token:
|
||||||
|
LOG.info('Did not get a Plex token')
|
||||||
|
return False
|
||||||
|
with CallbackEvent(plexapp.APP, 'account:response'):
|
||||||
|
plexapp.ACCOUNT.validateToken(token)
|
||||||
|
LOG.info('Waiting for account initialization')
|
||||||
|
return plexapp.ACCOUNT.isSignedIn
|
||||||
|
|
||||||
|
|
||||||
|
def select_user():
|
||||||
|
"""
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def select_server():
|
||||||
|
"""
|
||||||
|
Displays a window for the user to select a pms.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def authorize():
|
||||||
|
"""
|
||||||
|
Shows dialogs to sign in to plex.tv with pin. Returns token or None
|
||||||
|
"""
|
||||||
|
from .windows import signin, background
|
||||||
|
background.setSplash(False)
|
||||||
|
back = signin.Background.create()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
pinLoginWindow = signin.PinLoginWindow.create()
|
||||||
|
try:
|
||||||
|
pl = myplex.PinLogin()
|
||||||
|
except requests.ConnectionError:
|
||||||
|
util.ERROR()
|
||||||
|
# Could not sign in to plex.tv Try again later
|
||||||
|
utils.messageDialog(utils.lang(29999), utils.lang(39305))
|
||||||
|
return
|
||||||
|
|
||||||
|
pinLoginWindow.setPin(pl.pin)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pl.startTokenPolling()
|
||||||
|
while not pl.finished():
|
||||||
|
if pinLoginWindow.abort:
|
||||||
|
LOG.info('Pin login aborted')
|
||||||
|
pl.abort()
|
||||||
|
return
|
||||||
|
xbmc.sleep(100)
|
||||||
|
else:
|
||||||
|
if not pl.expired():
|
||||||
|
if pl.authenticationToken:
|
||||||
|
pinLoginWindow.setLinking()
|
||||||
|
return pl.authenticationToken
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
pinLoginWindow.doClose()
|
||||||
|
del pinLoginWindow
|
||||||
|
|
||||||
|
if pl.expired():
|
||||||
|
LOG.info('Pin expired')
|
||||||
|
expiredWindow = signin.ExpiredWindow.open()
|
||||||
|
try:
|
||||||
|
if not expiredWindow.refresh:
|
||||||
|
LOG.info('Pin refresh aborted')
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
del expiredWindow
|
||||||
|
finally:
|
||||||
|
back.doClose()
|
||||||
|
del back
|
0
resources/lib/plexnet/__init__.py
Normal file
0
resources/lib/plexnet/__init__.py
Normal file
321
resources/lib/plexnet/asyncadapter.py
Normal file
321
resources/lib/plexnet/asyncadapter.py
Normal file
|
@ -0,0 +1,321 @@
|
||||||
|
import time
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from requests.packages.urllib3 import HTTPConnectionPool, HTTPSConnectionPool
|
||||||
|
from requests.packages.urllib3.poolmanager import PoolManager, proxy_from_url
|
||||||
|
from requests.packages.urllib3.connectionpool import VerifiedHTTPSConnection
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from requests.compat import urlparse
|
||||||
|
|
||||||
|
from httplib import HTTPConnection
|
||||||
|
import errno
|
||||||
|
|
||||||
|
DEFAULT_POOLBLOCK = False
|
||||||
|
SSL_KEYWORDS = ('key_file', 'cert_file', 'cert_reqs', 'ca_certs',
|
||||||
|
'ssl_version')
|
||||||
|
|
||||||
|
WIN_WSAEINVAL = 10022
|
||||||
|
WIN_EWOULDBLOCK = 10035
|
||||||
|
WIN_ECONNRESET = 10054
|
||||||
|
WIN_EISCONN = 10056
|
||||||
|
WIN_ENOTCONN = 10057
|
||||||
|
WIN_EHOSTUNREACH = 10065
|
||||||
|
|
||||||
|
|
||||||
|
def ABORT_FLAG_FUNCTION():
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class TimeoutException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CanceledException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncTimeout(float):
|
||||||
|
def __repr__(self):
|
||||||
|
return '{0}({1})'.format(float(self), self.getConnectTimeout())
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return repr(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def fromTimeout(cls, t):
|
||||||
|
if isinstance(t, AsyncTimeout):
|
||||||
|
return t
|
||||||
|
|
||||||
|
try:
|
||||||
|
return AsyncTimeout(float(t)) or DEFAULT_TIMEOUT
|
||||||
|
except TypeError:
|
||||||
|
return DEFAULT_TIMEOUT
|
||||||
|
|
||||||
|
def setConnectTimeout(self, val):
|
||||||
|
self._connectTimout = val
|
||||||
|
return self
|
||||||
|
|
||||||
|
def getConnectTimeout(self):
|
||||||
|
if hasattr(self, '_connectTimout'):
|
||||||
|
return self._connectTimout
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = AsyncTimeout(10).setConnectTimeout(10)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncVerifiedHTTPSConnection(VerifiedHTTPSConnection):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
VerifiedHTTPSConnection.__init__(self, *args, **kwargs)
|
||||||
|
self._canceled = False
|
||||||
|
self.deadline = 0
|
||||||
|
self._timeout = AsyncTimeout(DEFAULT_TIMEOUT)
|
||||||
|
|
||||||
|
def _check_timeout(self):
|
||||||
|
if time.time() > self.deadline:
|
||||||
|
raise TimeoutException('connection timed out')
|
||||||
|
|
||||||
|
def create_connection(self, address, timeout=None, source_address=None):
|
||||||
|
"""Connect to *address* and return the socket object.
|
||||||
|
|
||||||
|
Convenience function. Connect to *address* (a 2-tuple ``(host,
|
||||||
|
port)``) and return the socket object. Passing the optional
|
||||||
|
*timeout* parameter will set the timeout on the socket instance
|
||||||
|
before attempting to connect. If no *timeout* is supplied, the
|
||||||
|
global default timeout setting returned by :func:`getdefaulttimeout`
|
||||||
|
is used. If *source_address* is set it must be a tuple of (host, port)
|
||||||
|
for the socket to bind as a source address before making the connection.
|
||||||
|
An host of '' or port 0 tells the OS to use the default.
|
||||||
|
"""
|
||||||
|
timeout = AsyncTimeout.fromTimeout(timeout)
|
||||||
|
self._timeout = timeout
|
||||||
|
|
||||||
|
host, port = address
|
||||||
|
err = None
|
||||||
|
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
|
||||||
|
af, socktype, proto, canonname, sa = res
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = socket.socket(af, socktype, proto)
|
||||||
|
sock.setblocking(False) # this is obviously critical
|
||||||
|
self.deadline = time.time() + timeout.getConnectTimeout()
|
||||||
|
# sock.settimeout(timeout)
|
||||||
|
|
||||||
|
if source_address:
|
||||||
|
sock.bind(source_address)
|
||||||
|
for msg in self._connect(sock, sa):
|
||||||
|
if self._canceled or ABORT_FLAG_FUNCTION():
|
||||||
|
raise CanceledException('Request canceled')
|
||||||
|
sock.setblocking(True)
|
||||||
|
return sock
|
||||||
|
|
||||||
|
except socket.error as _:
|
||||||
|
err = _
|
||||||
|
if sock is not None:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
if err is not None:
|
||||||
|
raise err
|
||||||
|
else:
|
||||||
|
raise socket.error("getaddrinfo returns an empty list")
|
||||||
|
|
||||||
|
def _connect(self, sock, sa):
|
||||||
|
while not self._canceled and not ABORT_FLAG_FUNCTION():
|
||||||
|
time.sleep(0.01)
|
||||||
|
self._check_timeout() # this should be done at the beginning of each loop
|
||||||
|
status = sock.connect_ex(sa)
|
||||||
|
if not status or status in (errno.EISCONN, WIN_EISCONN):
|
||||||
|
break
|
||||||
|
elif status in (errno.EINPROGRESS, WIN_EWOULDBLOCK):
|
||||||
|
self.deadline = time.time() + self._timeout.getConnectTimeout()
|
||||||
|
# elif status in (errno.EWOULDBLOCK, errno.EALREADY) or (os.name == 'nt' and status == errno.WSAEINVAL):
|
||||||
|
# pass
|
||||||
|
yield
|
||||||
|
|
||||||
|
if self._canceled or ABORT_FLAG_FUNCTION():
|
||||||
|
raise CanceledException('Request canceled')
|
||||||
|
|
||||||
|
error = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
|
||||||
|
if error:
|
||||||
|
# TODO: determine when this case can actually happen
|
||||||
|
raise socket.error((error,))
|
||||||
|
|
||||||
|
def _new_conn(self):
|
||||||
|
sock = self.create_connection(
|
||||||
|
address=(self.host, self.port),
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
return sock
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self._canceled = True
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncHTTPConnection(HTTPConnection):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
HTTPConnection.__init__(self, *args, **kwargs)
|
||||||
|
self._canceled = False
|
||||||
|
self.deadline = 0
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self._canceled = True
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncHTTPConnectionPool(HTTPConnectionPool):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
HTTPConnectionPool.__init__(self, *args, **kwargs)
|
||||||
|
self.connections = []
|
||||||
|
|
||||||
|
def _new_conn(self):
|
||||||
|
"""
|
||||||
|
Return a fresh :class:`httplib.HTTPConnection`.
|
||||||
|
"""
|
||||||
|
self.num_connections += 1
|
||||||
|
|
||||||
|
extra_params = {}
|
||||||
|
extra_params['strict'] = self.strict
|
||||||
|
|
||||||
|
conn = AsyncHTTPConnection(host=self.host, port=self.port, timeout=self.timeout.connect_timeout, **extra_params)
|
||||||
|
|
||||||
|
# Backport fix LP #1412545
|
||||||
|
if getattr(conn, '_tunnel_host', None):
|
||||||
|
# TODO: Fix tunnel so it doesn't depend on self.sock state.
|
||||||
|
conn._tunnel()
|
||||||
|
# Mark this connection as not reusable
|
||||||
|
conn.auto_open = 0
|
||||||
|
|
||||||
|
self.connections.append(conn)
|
||||||
|
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
for c in self.connections:
|
||||||
|
c.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncHTTPSConnectionPool(HTTPSConnectionPool):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
HTTPSConnectionPool.__init__(self, *args, **kwargs)
|
||||||
|
self.connections = []
|
||||||
|
|
||||||
|
def _new_conn(self):
|
||||||
|
"""
|
||||||
|
Return a fresh :class:`httplib.HTTPSConnection`.
|
||||||
|
"""
|
||||||
|
self.num_connections += 1
|
||||||
|
|
||||||
|
actual_host = self.host
|
||||||
|
actual_port = self.port
|
||||||
|
if self.proxy is not None:
|
||||||
|
actual_host = self.proxy.host
|
||||||
|
actual_port = self.proxy.port
|
||||||
|
|
||||||
|
connection_class = AsyncVerifiedHTTPSConnection
|
||||||
|
|
||||||
|
extra_params = {}
|
||||||
|
extra_params['strict'] = self.strict
|
||||||
|
connection = connection_class(host=actual_host, port=actual_port, timeout=self.timeout.connect_timeout, **extra_params)
|
||||||
|
|
||||||
|
self.connections.append(connection)
|
||||||
|
|
||||||
|
return self._prepare_conn(connection)
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
for c in self.connections:
|
||||||
|
c.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
pool_classes_by_scheme = {
|
||||||
|
'http': AsyncHTTPConnectionPool,
|
||||||
|
'https': AsyncHTTPSConnectionPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncPoolManager(PoolManager):
|
||||||
|
def _new_pool(self, scheme, host, port, request_context=None):
|
||||||
|
"""
|
||||||
|
Create a new :class:`ConnectionPool` based on host, port and scheme.
|
||||||
|
|
||||||
|
This method is used to actually create the connection pools handed out
|
||||||
|
by :meth:`connection_from_url` and companion methods. It is intended
|
||||||
|
to be overridden for customization.
|
||||||
|
"""
|
||||||
|
pool_cls = pool_classes_by_scheme[scheme]
|
||||||
|
kwargs = self.connection_pool_kw
|
||||||
|
if scheme == 'http':
|
||||||
|
kwargs = self.connection_pool_kw.copy()
|
||||||
|
for kw in SSL_KEYWORDS:
|
||||||
|
kwargs.pop(kw, None)
|
||||||
|
|
||||||
|
return pool_cls(host, port, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncHTTPAdapter(HTTPAdapter):
|
||||||
|
def cancel(self):
|
||||||
|
for c in self.connections:
|
||||||
|
c.cancel()
|
||||||
|
|
||||||
|
def init_poolmanager(self, connections, maxsize, block=DEFAULT_POOLBLOCK):
|
||||||
|
"""Initializes a urllib3 PoolManager. This method should not be called
|
||||||
|
from user code, and is only exposed for use when subclassing the
|
||||||
|
:class:`HTTPAdapter <requests.adapters.HTTPAdapter>`.
|
||||||
|
|
||||||
|
:param connections: The number of urllib3 connection pools to cache.
|
||||||
|
:param maxsize: The maximum number of connections to save in the pool.
|
||||||
|
:param block: Block when no free connections are available.
|
||||||
|
"""
|
||||||
|
# save these values for pickling
|
||||||
|
self._pool_connections = connections
|
||||||
|
self._pool_maxsize = maxsize
|
||||||
|
self._pool_block = block
|
||||||
|
|
||||||
|
self.poolmanager = AsyncPoolManager(num_pools=connections, maxsize=maxsize, block=block)
|
||||||
|
self.connections = []
|
||||||
|
|
||||||
|
def get_connection(self, url, proxies=None):
|
||||||
|
"""Returns a urllib3 connection for the given URL. This should not be
|
||||||
|
called from user code, and is only exposed for use when subclassing the
|
||||||
|
:class:`HTTPAdapter <requests.adapters.HTTPAdapter>`.
|
||||||
|
|
||||||
|
:param url: The URL to connect to.
|
||||||
|
:param proxies: (optional) A Requests-style dictionary of proxies used on this request.
|
||||||
|
"""
|
||||||
|
proxies = proxies or {}
|
||||||
|
proxy = proxies.get(urlparse(url.lower()).scheme)
|
||||||
|
|
||||||
|
if proxy:
|
||||||
|
proxy_headers = self.proxy_headers(proxy)
|
||||||
|
|
||||||
|
if proxy not in self.proxy_manager:
|
||||||
|
self.proxy_manager[proxy] = proxy_from_url(
|
||||||
|
proxy,
|
||||||
|
proxy_headers=proxy_headers,
|
||||||
|
num_pools=self._pool_connections,
|
||||||
|
maxsize=self._pool_maxsize,
|
||||||
|
block=self._pool_block
|
||||||
|
)
|
||||||
|
|
||||||
|
conn = self.proxy_manager[proxy].connection_from_url(url)
|
||||||
|
else:
|
||||||
|
# Only scheme should be lower case
|
||||||
|
parsed = urlparse(url)
|
||||||
|
url = parsed.geturl()
|
||||||
|
conn = self.poolmanager.connection_from_url(url)
|
||||||
|
|
||||||
|
self.connections.append(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
class Session(requests.Session):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
requests.Session.__init__(self, *args, **kwargs)
|
||||||
|
self.mount('https://', AsyncHTTPAdapter())
|
||||||
|
self.mount('http://', AsyncHTTPAdapter())
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
for v in self.adapters.values():
|
||||||
|
v.close()
|
||||||
|
v.cancel()
|
152
resources/lib/plexnet/audio.py
Normal file
152
resources/lib/plexnet/audio.py
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import plexobjects
|
||||||
|
import plexmedia
|
||||||
|
import media
|
||||||
|
|
||||||
|
|
||||||
|
class Audio(media.MediaItem):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self._settings = None
|
||||||
|
media.MediaItem.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return self.ratingKey == other.ratingKey
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
def _setData(self, data):
|
||||||
|
for k, v in data.attrib.items():
|
||||||
|
setattr(self, k, plexobjects.PlexValue(v, self))
|
||||||
|
|
||||||
|
self.key = plexobjects.PlexValue(self.key.replace('/children', ''), self)
|
||||||
|
|
||||||
|
def isMusicItem(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@plexobjects.registerLibType
|
||||||
|
class Artist(Audio):
|
||||||
|
TYPE = 'artist'
|
||||||
|
|
||||||
|
def _setData(self, data):
|
||||||
|
Audio._setData(self, data)
|
||||||
|
if self.isFullObject():
|
||||||
|
self.countries = plexobjects.PlexItemList(data, media.Country, media.Country.TYPE, server=self.server)
|
||||||
|
self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server)
|
||||||
|
self.similar = plexobjects.PlexItemList(data, media.Similar, media.Similar.TYPE, server=self.server)
|
||||||
|
|
||||||
|
def albums(self):
|
||||||
|
path = '%s/children' % self.key
|
||||||
|
return plexobjects.listItems(self.server, path, Album.TYPE)
|
||||||
|
|
||||||
|
def album(self, title):
|
||||||
|
path = '%s/children' % self.key
|
||||||
|
return plexobjects.findItem(self.server, path, title)
|
||||||
|
|
||||||
|
def tracks(self, watched=None):
|
||||||
|
leavesKey = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||||
|
return plexobjects.listItems(self.server, leavesKey, watched=watched)
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self.tracks()
|
||||||
|
|
||||||
|
def track(self, title):
|
||||||
|
path = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||||
|
return plexobjects.findItem(self.server, path, title)
|
||||||
|
|
||||||
|
def isFullObject(self):
|
||||||
|
# plex bug? http://bit.ly/1Sc2J3V
|
||||||
|
fixed_key = self.key.replace('/children', '')
|
||||||
|
return self.initpath == fixed_key
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
self.server.query('/library/metadata/%s/refresh' % self.ratingKey)
|
||||||
|
|
||||||
|
|
||||||
|
@plexobjects.registerLibType
|
||||||
|
class Album(Audio):
|
||||||
|
TYPE = 'album'
|
||||||
|
|
||||||
|
def _setData(self, data):
|
||||||
|
Audio._setData(self, data)
|
||||||
|
if self.isFullObject():
|
||||||
|
self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultTitle(self):
|
||||||
|
return self.parentTitle or self.title
|
||||||
|
|
||||||
|
def tracks(self, watched=None):
|
||||||
|
path = '%s/children' % self.key
|
||||||
|
return plexobjects.listItems(self.server, path, watched=watched)
|
||||||
|
|
||||||
|
def track(self, title):
|
||||||
|
path = '%s/children' % self.key
|
||||||
|
return plexobjects.findItem(self.server, path, title)
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self.tracks()
|
||||||
|
|
||||||
|
def isFullObject(self):
|
||||||
|
# plex bug? http://bit.ly/1Sc2J3V
|
||||||
|
fixed_key = self.key.replace('/children', '')
|
||||||
|
return self.initpath == fixed_key
|
||||||
|
|
||||||
|
def artist(self):
|
||||||
|
return plexobjects.listItems(self.server, self.parentKey)[0]
|
||||||
|
|
||||||
|
def watched(self):
|
||||||
|
return self.tracks(watched=True)
|
||||||
|
|
||||||
|
def unwatched(self):
|
||||||
|
return self.tracks(watched=False)
|
||||||
|
|
||||||
|
|
||||||
|
@plexobjects.registerLibType
|
||||||
|
class Track(Audio):
|
||||||
|
TYPE = 'track'
|
||||||
|
|
||||||
|
def _setData(self, data):
|
||||||
|
Audio._setData(self, data)
|
||||||
|
if self.isFullObject():
|
||||||
|
self.moods = plexobjects.PlexItemList(data, media.Mood, media.Mood.TYPE, server=self.server)
|
||||||
|
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||||
|
|
||||||
|
# data for active sessions
|
||||||
|
self.user = self._findUser(data)
|
||||||
|
self.player = self._findPlayer(data)
|
||||||
|
self.transcodeSession = self._findTranscodeSession(data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultTitle(self):
|
||||||
|
return self.parentTitle or self.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def settings(self):
|
||||||
|
if not self._settings:
|
||||||
|
import plexapp
|
||||||
|
self._settings = plexapp.PlayerSettingsInterface()
|
||||||
|
|
||||||
|
return self._settings
|
||||||
|
|
||||||
|
@property
|
||||||
|
def thumbUrl(self):
|
||||||
|
return self.server.url(self.parentThumb)
|
||||||
|
|
||||||
|
def album(self):
|
||||||
|
return plexobjects.listItems(self.server, self.parentKey)[0]
|
||||||
|
|
||||||
|
def artist(self):
|
||||||
|
return plexobjects.listItems(self.server, self.grandparentKey)[0]
|
||||||
|
|
||||||
|
def getStreamURL(self, **params):
|
||||||
|
return self._getStreamURL(**params)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultThumb(self):
|
||||||
|
return self.__dict__.get('thumb') or self.__dict__.get('parentThumb') or self.get('grandparentThumb')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultArt(self):
|
||||||
|
return self.__dict__.get('art') or self.get('grandparentArt')
|
82
resources/lib/plexnet/audioobject.py
Normal file
82
resources/lib/plexnet/audioobject.py
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import http
|
||||||
|
import mediadecisionengine
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class AudioObjectClass(object):
|
||||||
|
def __init__(self, item):
|
||||||
|
self.containerFormats = {
|
||||||
|
'aac': "es.aac-adts"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.item = item
|
||||||
|
self.choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item)
|
||||||
|
if self.choice:
|
||||||
|
self.media = self.choice.media
|
||||||
|
self.lyrics = None # createLyrics(item, self.media)
|
||||||
|
|
||||||
|
def build(self, directPlay=None):
|
||||||
|
directPlay = directPlay or self.choice.isDirectPlayable
|
||||||
|
|
||||||
|
obj = util.AttributeDict()
|
||||||
|
|
||||||
|
# TODO(schuyler): Do we want/need to add anything generic here? Title? Duration?
|
||||||
|
|
||||||
|
if directPlay:
|
||||||
|
obj = self.buildDirectPlay(obj)
|
||||||
|
else:
|
||||||
|
obj = self.buildTranscode(obj)
|
||||||
|
|
||||||
|
self.metadata = obj
|
||||||
|
|
||||||
|
util.LOG("Constructed audio item for playback: {0}".format(obj))
|
||||||
|
|
||||||
|
return self.metadata
|
||||||
|
|
||||||
|
def buildTranscode(self, obj):
|
||||||
|
transcodeServer = self.item.getTranscodeServer(True, "audio")
|
||||||
|
if not transcodeServer:
|
||||||
|
return None
|
||||||
|
|
||||||
|
obj.streamFormat = "mp3"
|
||||||
|
obj.isTranscoded = True
|
||||||
|
obj.transcodeServer = transcodeServer
|
||||||
|
|
||||||
|
builder = http.HttpRequest(transcodeServer.buildUrl("/music/:/transcode/universal/start.m3u8", True))
|
||||||
|
builder.addParam("protocol", "http")
|
||||||
|
builder.addParam("path", self.item.getAbsolutePath("key"))
|
||||||
|
builder.addParam("session", self.item.getGlobal("clientIdentifier"))
|
||||||
|
builder.addParam("directPlay", "0")
|
||||||
|
builder.addParam("directStream", "0")
|
||||||
|
|
||||||
|
obj.url = builder.getUrl()
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def buildDirectPlay(self, obj):
|
||||||
|
if self.choice.part:
|
||||||
|
obj.url = self.item.getServer().buildUrl(self.choice.part.getAbsolutePath("key"), True)
|
||||||
|
|
||||||
|
# Set and override the stream format if applicable
|
||||||
|
obj.streamFormat = self.choice.media.container or 'mp3'
|
||||||
|
if self.containerFormats.get(obj.streamFormat):
|
||||||
|
obj.streamFormat = self.containerFormats[obj.streamFormat]
|
||||||
|
|
||||||
|
# If we're direct playing a FLAC, bitrate can be required, and supposedly
|
||||||
|
# this is the only way to do it. plexinc/roku-client#48
|
||||||
|
#
|
||||||
|
bitrate = self.choice.media.getInt("bitrate")
|
||||||
|
if bitrate > 0:
|
||||||
|
obj.streams = [{'url': obj.url, 'bitrate': bitrate}]
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
# We may as well fallback to transcoding if we could not direct play
|
||||||
|
return self.buildTranscode(obj)
|
||||||
|
|
||||||
|
def getLyrics(self):
|
||||||
|
return self.lyrics
|
||||||
|
|
||||||
|
def hasLyrics(self):
|
||||||
|
return False
|
||||||
|
# return self.lyrics.isAvailable()
|
53
resources/lib/plexnet/callback.py
Normal file
53
resources/lib/plexnet/callback.py
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
class Callable(object):
|
||||||
|
_currID = 0
|
||||||
|
|
||||||
|
def __init__(self, func, forcedArgs=None, ID=None):
|
||||||
|
self.func = func
|
||||||
|
self.forcedArgs = forcedArgs
|
||||||
|
|
||||||
|
self.ID = ID or id(func)
|
||||||
|
|
||||||
|
if not self.ID:
|
||||||
|
self.ID = Callable.nextID()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<Callable:({0})>'.format(repr(self.func).strip('<>'))
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if not other:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.__class__ != other.__class__:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.ID and self.ID == other.ID
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
args = args or []
|
||||||
|
if self.forcedArgs:
|
||||||
|
args = self.forcedArgs
|
||||||
|
|
||||||
|
self.func(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def context(self):
|
||||||
|
return self.func.im_self
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def nextID(cls):
|
||||||
|
cls._currID += 1
|
||||||
|
return cls._currID
|
||||||
|
|
||||||
|
def deferCall(self, timeout=0.1):
|
||||||
|
timer = threading.Timer(timeout, self.onDeferCallTimer)
|
||||||
|
timer.name = 'ONDEFERCALLBACK-TIMER:{0}'.format(self.func)
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
def onDeferCallTimer(self):
|
||||||
|
self()
|
79
resources/lib/plexnet/captions.py
Normal file
79
resources/lib/plexnet/captions.py
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import plexapp
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class Captions(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.deviceInfo = plexapp.INTERFACE.getGlobal("deviceInfo")
|
||||||
|
|
||||||
|
self.textSize = util.AttributeDict({
|
||||||
|
'extrasmall': 15,
|
||||||
|
'small': 20,
|
||||||
|
'medium': 30,
|
||||||
|
'large': 45,
|
||||||
|
'extralarge': 65,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.burnedSize = util.AttributeDict({
|
||||||
|
'extrasmall': "60",
|
||||||
|
'small': "80",
|
||||||
|
'medium': "100",
|
||||||
|
'large': "135",
|
||||||
|
'extralarge': "200"
|
||||||
|
})
|
||||||
|
|
||||||
|
self.colors = util.AttributeDict({
|
||||||
|
'white': 0xffffffff,
|
||||||
|
'black': 0x000000ff,
|
||||||
|
'red': 0xff0000ff,
|
||||||
|
'green': 0x008000ff,
|
||||||
|
'blue': 0x0000ffff,
|
||||||
|
'yellow': 0xffff00ff,
|
||||||
|
'magenta': 0xff00ffff,
|
||||||
|
'cyan': 0x00ffffff,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.defaults = util.AttributeDict({
|
||||||
|
'textSize': self.textSize.medium,
|
||||||
|
'textColor': self.colors.white,
|
||||||
|
'textOpacity': 80,
|
||||||
|
'backgroundColor': self.colors.black,
|
||||||
|
'backgroundOpacity': 70,
|
||||||
|
'burnedSize': None
|
||||||
|
})
|
||||||
|
|
||||||
|
def getTextSize(self):
|
||||||
|
value = self.getOption("Text/Size")
|
||||||
|
return self.textSize.get(value) or self.defaults.textSize
|
||||||
|
|
||||||
|
def getTextColor(self):
|
||||||
|
value = self.getOption("Text/Color")
|
||||||
|
return self.colors.get(value) or self.defaults.textColor
|
||||||
|
|
||||||
|
def getTextOpacity(self):
|
||||||
|
value = self.getOption("Text/Opacity")
|
||||||
|
if value is None or value == "default":
|
||||||
|
return self.defaults.textOpacity
|
||||||
|
else:
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
def getBackgroundColor(self):
|
||||||
|
value = self.getOption("Background/Color")
|
||||||
|
return self.colors.get(value) or self.defaults.backgroundColor
|
||||||
|
|
||||||
|
def getBackgroundOpacity(self):
|
||||||
|
value = self.getOption("Background/Opacity")
|
||||||
|
if value is None or value == "default":
|
||||||
|
return self.defaults.backgroundOpacity
|
||||||
|
else:
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
def getBurnedSize(self):
|
||||||
|
value = self.getOption("Text/Size")
|
||||||
|
return self.burnedSize.get(value) or self.defaults.burnedSize
|
||||||
|
|
||||||
|
def getOption(self, key):
|
||||||
|
opt = self.deviceInfo.getCaptionsOption(key)
|
||||||
|
return opt is not None and opt.lower().replace(' ', '') or None
|
||||||
|
|
||||||
|
CAPTIONS = Captions()
|
25
resources/lib/plexnet/compat.py
Normal file
25
resources/lib/plexnet/compat.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Python 2/3 compatability
|
||||||
|
Always try Py3 first
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
except ImportError:
|
||||||
|
from urllib import urlencode
|
||||||
|
|
||||||
|
try:
|
||||||
|
from urllib.parse import quote
|
||||||
|
except ImportError:
|
||||||
|
from urllib import quote
|
||||||
|
|
||||||
|
try:
|
||||||
|
from urllib.parse import quote_plus
|
||||||
|
except ImportError:
|
||||||
|
from urllib import quote_plus
|
||||||
|
|
||||||
|
try:
|
||||||
|
from configparser import ConfigParser
|
||||||
|
except ImportError:
|
||||||
|
from ConfigParser import ConfigParser
|
18
resources/lib/plexnet/exceptions.py
Normal file
18
resources/lib/plexnet/exceptions.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
class BadRequest(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownType(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Unsupported(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Unauthorized(Exception):
|
||||||
|
pass
|
346
resources/lib/plexnet/gdm.py
Normal file
346
resources/lib/plexnet/gdm.py
Normal file
|
@ -0,0 +1,346 @@
|
||||||
|
import threading
|
||||||
|
import socket
|
||||||
|
import traceback
|
||||||
|
import time
|
||||||
|
import util
|
||||||
|
import netif
|
||||||
|
|
||||||
|
import plexconnection
|
||||||
|
|
||||||
|
DISCOVERY_PORT = 32414
|
||||||
|
WIN_NL = chr(13) + chr(10)
|
||||||
|
|
||||||
|
|
||||||
|
class GDMDiscovery(object):
|
||||||
|
def __init__(self):
|
||||||
|
self._close = False
|
||||||
|
self.thread = None
|
||||||
|
|
||||||
|
# def isActive(self):
|
||||||
|
# util.LOG('GDMDiscovery().isActive() - NOT IMPLEMENTED')
|
||||||
|
# return False
|
||||||
|
|
||||||
|
# def discover(self):
|
||||||
|
# util.LOG('GDMDiscovery().discover() - NOT IMPLEMENTED')
|
||||||
|
|
||||||
|
def isActive(self):
|
||||||
|
import plexapp
|
||||||
|
return plexapp.INTERFACE.getPreference("gdm_discovery", True) and self.thread and self.thread.isAlive()
|
||||||
|
|
||||||
|
'''
|
||||||
|
def discover(self):
|
||||||
|
# Only allow discovery if enabled and not currently running
|
||||||
|
self._close = False
|
||||||
|
import plexapp
|
||||||
|
if not plexapp.INTERFACE.getPreference("gdm_discovery", True) or self.isActive():
|
||||||
|
return
|
||||||
|
|
||||||
|
ifaces = netif.getInterfaces()
|
||||||
|
|
||||||
|
message = "M-SEARCH * HTTP/1.1" + WIN_NL + WIN_NL
|
||||||
|
|
||||||
|
# Broadcasting to 255.255.255.255 only works on some Rokus, but we
|
||||||
|
# can't reliably determine the broadcast address for our current
|
||||||
|
# interface. Try assuming a /24 network, and then fall back to the
|
||||||
|
# multicast address if that doesn't work.
|
||||||
|
|
||||||
|
multicast = "239.0.0.250"
|
||||||
|
ip = multicast
|
||||||
|
subnetRegex = re.compile("((\d+)\.(\d+)\.(\d+)\.)(\d+)")
|
||||||
|
addr = getFirstIPAddress() # TODO:: -------------------------------------------------------------------------------------------------------- HANDLE
|
||||||
|
if addr:
|
||||||
|
match = subnetRegex.search(addr)
|
||||||
|
if match:
|
||||||
|
ip = match.group(1) + "255"
|
||||||
|
util.DEBUG_LOG("Using broadcast address {0}".format())
|
||||||
|
|
||||||
|
# Socket things sometimes fail for no good reason, so try a few times.
|
||||||
|
attempt = 0
|
||||||
|
success = False
|
||||||
|
|
||||||
|
while attempt < 5 and not success:
|
||||||
|
udp = CreateObject("roDatagramSocket")
|
||||||
|
udp.setMessagePort(Application().port)
|
||||||
|
udp.setBroadcast(true)
|
||||||
|
|
||||||
|
# More things that have been observed to be flaky.
|
||||||
|
for i in range(5):
|
||||||
|
addr = CreateObject("roSocketAddress")
|
||||||
|
addr.setHostName(ip)
|
||||||
|
addr.setPort(32414)
|
||||||
|
udp.setSendToAddress(addr)
|
||||||
|
|
||||||
|
sendTo = udp.getSendToAddress()
|
||||||
|
if sendTo:
|
||||||
|
sendToStr = str(sendTo.getAddress())
|
||||||
|
addrStr = str(addr.getAddress())
|
||||||
|
util.DEBUG_LOG("GDM sendto address: " + sendToStr + " / " + addrStr)
|
||||||
|
if sendToStr == addrStr:
|
||||||
|
break
|
||||||
|
|
||||||
|
util.ERROR_LOG("Failed to set GDM sendto address")
|
||||||
|
|
||||||
|
udp.notifyReadable(true)
|
||||||
|
bytesSent = udp.sendStr(message)
|
||||||
|
util.DEBUG_LOG("Sent " + str(bytesSent) + " bytes")
|
||||||
|
if bytesSent > 0:
|
||||||
|
success = udp.eOK()
|
||||||
|
else:
|
||||||
|
success = False
|
||||||
|
if bytesSent == 0 and ip != multicast:
|
||||||
|
util.LOG("Falling back to multicast address")
|
||||||
|
ip = multicast
|
||||||
|
attempt = 0
|
||||||
|
|
||||||
|
if success:
|
||||||
|
break
|
||||||
|
elif attempt == 4 and ip != multicast:
|
||||||
|
util.LOG("Falling back to multicast address")
|
||||||
|
ip = multicast
|
||||||
|
attempt = 0
|
||||||
|
else:
|
||||||
|
time.sleep(500)
|
||||||
|
util.WARN_LOG("Retrying GDM, errno=" + str(udp.status()))
|
||||||
|
attempt += 1
|
||||||
|
|
||||||
|
if success:
|
||||||
|
util.DEBUG_LOG("Successfully sent GDM discovery message, waiting for servers")
|
||||||
|
self.servers = []
|
||||||
|
self.timer = plexapp.createTimer(5000, self.onTimer)
|
||||||
|
self.socket = udp
|
||||||
|
Application().AddSocketCallback(udp, createCallable("OnSocketEvent", m))
|
||||||
|
plexapp.APP.addTimer(self.timer)
|
||||||
|
else:
|
||||||
|
util.ERROR_LOG("Failed to send GDM discovery message")
|
||||||
|
import plexapp
|
||||||
|
import plexresource
|
||||||
|
plexapp.SERVERMANAGER.UpdateFromConnectionType([], plexresource.ResourceConnection.SOURCE_DISCOVERED)
|
||||||
|
self.socket = None
|
||||||
|
self.timer = None
|
||||||
|
'''
|
||||||
|
|
||||||
|
def discover(self):
|
||||||
|
import plexapp
|
||||||
|
if not plexapp.INTERFACE.getPreference("gdm_discovery", True) or self.isActive():
|
||||||
|
return
|
||||||
|
|
||||||
|
self.thread = threading.Thread(target=self._discover)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def _discover(self):
|
||||||
|
ifaces = netif.getInterfaces()
|
||||||
|
sockets = []
|
||||||
|
self.servers = []
|
||||||
|
|
||||||
|
packet = "M-SEARCH * HTTP/1.1" + WIN_NL + WIN_NL
|
||||||
|
|
||||||
|
for i in ifaces:
|
||||||
|
if not i.broadcast:
|
||||||
|
continue
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.settimeout(0.01) # 10ms
|
||||||
|
s.bind((i.ip, 0))
|
||||||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||||
|
sockets.append((s, i))
|
||||||
|
|
||||||
|
success = False
|
||||||
|
|
||||||
|
for attempt in (0, 1):
|
||||||
|
for s, i in sockets:
|
||||||
|
if self._close:
|
||||||
|
return
|
||||||
|
util.DEBUG_LOG(' o-> Broadcasting to {0}: {1}'.format(i.name, i.broadcast))
|
||||||
|
try:
|
||||||
|
s.sendto(packet, (i.broadcast, DISCOVERY_PORT))
|
||||||
|
success = True
|
||||||
|
except:
|
||||||
|
util.ERROR()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
break
|
||||||
|
|
||||||
|
end = time.time() + 5
|
||||||
|
|
||||||
|
while time.time() < end:
|
||||||
|
for s, i in sockets:
|
||||||
|
if self._close:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
message, address = s.recvfrom(4096)
|
||||||
|
self.onSocketEvent(message, address)
|
||||||
|
except socket.timeout:
|
||||||
|
pass
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
self.discoveryFinished()
|
||||||
|
|
||||||
|
def onSocketEvent(self, message, addr):
|
||||||
|
util.DEBUG_LOG('Received GDM message:\n' + str(message))
|
||||||
|
|
||||||
|
hostname = addr[0] # socket.gethostbyaddr(addr[0])[0]
|
||||||
|
|
||||||
|
name = parseFieldValue(message, "Name: ")
|
||||||
|
port = parseFieldValue(message, "Port: ") or "32400"
|
||||||
|
machineID = parseFieldValue(message, "Resource-Identifier: ")
|
||||||
|
secureHost = parseFieldValue(message, "Host: ")
|
||||||
|
|
||||||
|
util.DEBUG_LOG("Received GDM response for " + repr(name) + " at http://" + hostname + ":" + port)
|
||||||
|
|
||||||
|
if not name or not machineID:
|
||||||
|
return
|
||||||
|
|
||||||
|
import plexserver
|
||||||
|
conn = plexconnection.PlexConnection(plexconnection.PlexConnection.SOURCE_DISCOVERED, "http://" + hostname + ":" + port, True, None, bool(secureHost))
|
||||||
|
server = plexserver.createPlexServerForConnection(conn)
|
||||||
|
server.uuid = machineID
|
||||||
|
server.name = name
|
||||||
|
server.sameNetwork = True
|
||||||
|
|
||||||
|
# If the server advertised a secure hostname, add a secure connection as well, and
|
||||||
|
# set the http connection as a fallback.
|
||||||
|
#
|
||||||
|
if secureHost:
|
||||||
|
server.connections.insert(
|
||||||
|
0,
|
||||||
|
plexconnection.PlexConnection(
|
||||||
|
plexconnection.PlexConnection.SOURCE_DISCOVERED, "https://" + hostname.replace(".", "-") + "." + secureHost + ":" + port, True, None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.servers.append(server)
|
||||||
|
|
||||||
|
def discoveryFinished(self, *args, **kwargs):
|
||||||
|
# Time's up, report whatever we found
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
if self.servers:
|
||||||
|
util.LOG("Finished GDM discovery, found {0} server(s)".format(len(self.servers)))
|
||||||
|
import plexapp
|
||||||
|
plexapp.SERVERMANAGER.updateFromConnectionType(self.servers, plexconnection.PlexConnection.SOURCE_DISCOVERED)
|
||||||
|
self.servers = None
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._close = True
|
||||||
|
|
||||||
|
|
||||||
|
def parseFieldValue(message, label):
|
||||||
|
if label not in message:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return message.split(label, 1)[-1].split(chr(13))[0]
|
||||||
|
|
||||||
|
|
||||||
|
DISCOVERY = GDMDiscovery()
|
||||||
|
|
||||||
|
'''
|
||||||
|
# GDM Advertising
|
||||||
|
|
||||||
|
class GDMAdvertiser(object):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.responseString = None
|
||||||
|
|
||||||
|
def createSocket()
|
||||||
|
listenAddr = CreateObject("roSocketAddress")
|
||||||
|
listenAddr.setPort(32412)
|
||||||
|
listenAddr.setAddress("0.0.0.0")
|
||||||
|
|
||||||
|
udp = CreateObject("roDatagramSocket")
|
||||||
|
|
||||||
|
if not udp.setAddress(listenAddr) then
|
||||||
|
Error("Failed to set address on GDM advertiser socket")
|
||||||
|
return
|
||||||
|
end if
|
||||||
|
|
||||||
|
if not udp.setBroadcast(true) then
|
||||||
|
Error("Failed to set broadcast on GDM advertiser socket")
|
||||||
|
return
|
||||||
|
end if
|
||||||
|
|
||||||
|
udp.notifyReadable(true)
|
||||||
|
udp.setMessagePort(Application().port)
|
||||||
|
|
||||||
|
m.socket = udp
|
||||||
|
|
||||||
|
Application().AddSocketCallback(udp, createCallable("OnSocketEvent", m))
|
||||||
|
|
||||||
|
Debug("Created GDM player advertiser")
|
||||||
|
|
||||||
|
|
||||||
|
def refresh()
|
||||||
|
# Always regenerate our response, even if it might not have changed, it's
|
||||||
|
# just not that expensive.
|
||||||
|
m.responseString = invalid
|
||||||
|
|
||||||
|
enabled = AppSettings().GetBoolPreference("remotecontrol")
|
||||||
|
if enabled AND m.socket = invalid then
|
||||||
|
m.CreateSocket()
|
||||||
|
else if not enabled AND m.socket <> invalid then
|
||||||
|
m.Close()
|
||||||
|
end if
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup()
|
||||||
|
m.Close()
|
||||||
|
fn = function() :m.GDMAdvertiser = invalid :
|
||||||
|
fn()
|
||||||
|
|
||||||
|
|
||||||
|
def onSocketEvent(msg as object)
|
||||||
|
# PMS polls every five seconds, so this is chatty when not debugging.
|
||||||
|
# Debug("Got a GDM advertiser socket event, is readable: " + tostr(m.socket.isReadable()))
|
||||||
|
|
||||||
|
if m.socket.isReadable() then
|
||||||
|
message = m.socket.receiveStr(4096)
|
||||||
|
endIndex = instr(1, message, chr(13)) - 1
|
||||||
|
if endIndex <= 0 then endIndex = message.Len()
|
||||||
|
line = Mid(message, 1, endIndex)
|
||||||
|
|
||||||
|
if line = "M-SEARCH * HTTP/1.1" then
|
||||||
|
response = m.GetResponseString()
|
||||||
|
|
||||||
|
# Respond directly to whoever sent the search message.
|
||||||
|
sock = CreateObject("roDatagramSocket")
|
||||||
|
sock.setSendToAddress(m.socket.getReceivedFromAddress())
|
||||||
|
bytesSent = sock.sendStr(response)
|
||||||
|
sock.Close()
|
||||||
|
if bytesSent <> Len(response) then
|
||||||
|
Error("GDM player response only sent " + tostr(bytesSent) + " bytes out of " + tostr(Len(response)))
|
||||||
|
end if
|
||||||
|
else
|
||||||
|
Error("Received unexpected message on GDM advertiser socket: " + tostr(line) + ";")
|
||||||
|
end if
|
||||||
|
end if
|
||||||
|
|
||||||
|
|
||||||
|
def getResponseString() as string
|
||||||
|
if m.responseString = invalid then
|
||||||
|
buf = box("HTTP/1.0 200 OK" + WinNL())
|
||||||
|
|
||||||
|
settings = AppSettings()
|
||||||
|
|
||||||
|
appendNameValue(buf, "Name", settings.GetGlobal("friendlyName"))
|
||||||
|
appendNameValue(buf, "Port", WebServer().port.tostr())
|
||||||
|
appendNameValue(buf, "Product", "Plex for Roku")
|
||||||
|
appendNameValue(buf, "Content-Type", "plex/media-player")
|
||||||
|
appendNameValue(buf, "Protocol", "plex")
|
||||||
|
appendNameValue(buf, "Protocol-Version", "1")
|
||||||
|
appendNameValue(buf, "Protocol-Capabilities", "timeline,playback,navigation,playqueues")
|
||||||
|
appendNameValue(buf, "Version", settings.GetGlobal("appVersionStr"))
|
||||||
|
appendNameValue(buf, "Resource-Identifier", settings.GetGlobal("clientIdentifier"))
|
||||||
|
appendNameValue(buf, "Device-Class", "stb")
|
||||||
|
|
||||||
|
m.responseString = buf
|
||||||
|
|
||||||
|
Debug("Built GDM player response:" + m.responseString)
|
||||||
|
end if
|
||||||
|
|
||||||
|
return m.responseString
|
||||||
|
|
||||||
|
|
||||||
|
sub appendNameValue(buf, name, value)
|
||||||
|
line = name + ": " + value + WinNL()
|
||||||
|
buf.AppendString(line, Len(line))
|
||||||
|
|
||||||
|
'''
|
321
resources/lib/plexnet/http.py
Normal file
321
resources/lib/plexnet/http.py
Normal file
|
@ -0,0 +1,321 @@
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import traceback
|
||||||
|
import requests
|
||||||
|
import socket
|
||||||
|
import threadutils
|
||||||
|
import urllib
|
||||||
|
import mimetypes
|
||||||
|
import plexobjects
|
||||||
|
from defusedxml import ElementTree
|
||||||
|
|
||||||
|
import asyncadapter
|
||||||
|
|
||||||
|
import callback
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
codes = requests.codes
|
||||||
|
status_codes = requests.status_codes._codes
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = asyncadapter.AsyncTimeout(10).setConnectTimeout(10)
|
||||||
|
|
||||||
|
|
||||||
|
def GET(*args, **kwargs):
|
||||||
|
return requests.get(*args, headers=util.BASE_HEADERS.copy(), timeout=util.TIMEOUT, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def POST(*args, **kwargs):
|
||||||
|
return requests.post(*args, headers=util.BASE_HEADERS.copy(), timeout=util.TIMEOUT, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def Session():
|
||||||
|
s = asyncadapter.Session()
|
||||||
|
s.headers = util.BASE_HEADERS.copy()
|
||||||
|
s.timeout = util.TIMEOUT
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContext(dict):
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return self.get(attr)
|
||||||
|
|
||||||
|
def __setattr__(self, attr, value):
|
||||||
|
self[attr] = value
|
||||||
|
|
||||||
|
|
||||||
|
class HttpRequest(object):
|
||||||
|
_cancel = False
|
||||||
|
|
||||||
|
def __init__(self, url, method=None, forceCertificate=False):
|
||||||
|
self.server = None
|
||||||
|
self.path = None
|
||||||
|
self.hasParams = '?' in url
|
||||||
|
self.ignoreResponse = False
|
||||||
|
self.session = asyncadapter.Session()
|
||||||
|
self.session.headers = util.BASE_HEADERS.copy()
|
||||||
|
self.currentResponse = None
|
||||||
|
self.method = method
|
||||||
|
self.url = url
|
||||||
|
self.thread = None
|
||||||
|
|
||||||
|
# Use our specific plex.direct CA cert if applicable to improve performance
|
||||||
|
# if forceCertificate or url[:5] == "https": # TODO: ---------------------------------------------------------------------------------IMPLEMENT
|
||||||
|
# certsPath = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'certs')
|
||||||
|
# if "plex.direct" in url:
|
||||||
|
# self.session.cert = os.path.join(certsPath, 'plex-bundle.crt')
|
||||||
|
# else:
|
||||||
|
# self.session.cert = os.path.join(certsPath, 'ca-bundle.crt')
|
||||||
|
|
||||||
|
def removeAsPending(self):
|
||||||
|
import plexapp
|
||||||
|
plexapp.APP.delRequest(self)
|
||||||
|
|
||||||
|
def startAsync(self, *args, **kwargs):
|
||||||
|
self.thread = threadutils.KillableThread(target=self._startAsync, args=args, kwargs=kwargs, name='HTTP-ASYNC:{0}'.format(self.url))
|
||||||
|
self.thread.start()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _startAsync(self, body=None, contentType=None, context=None):
|
||||||
|
timeout = context and context.timeout or DEFAULT_TIMEOUT
|
||||||
|
self.logRequest(body, timeout)
|
||||||
|
if self._cancel:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if self.method == 'PUT':
|
||||||
|
res = self.session.put(self.url, timeout=timeout, stream=True)
|
||||||
|
elif self.method == 'DELETE':
|
||||||
|
res = self.session.delete(self.url, timeout=timeout, stream=True)
|
||||||
|
elif self.method == 'HEAD':
|
||||||
|
res = self.session.head(self.url, timeout=timeout, stream=True)
|
||||||
|
elif self.method == 'POST' or body is not None:
|
||||||
|
if not contentType:
|
||||||
|
self.session.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||||
|
else:
|
||||||
|
self.session.headers["Content-Type"] = mimetypes.guess_type(contentType)
|
||||||
|
|
||||||
|
res = self.session.post(self.url, data=body or None, timeout=timeout, stream=True)
|
||||||
|
else:
|
||||||
|
res = self.session.get(self.url, timeout=timeout, stream=True)
|
||||||
|
self.currentResponse = res
|
||||||
|
|
||||||
|
if self._cancel:
|
||||||
|
return
|
||||||
|
except asyncadapter.TimeoutException:
|
||||||
|
import plexapp
|
||||||
|
plexapp.APP.onRequestTimeout(context)
|
||||||
|
self.removeAsPending()
|
||||||
|
return
|
||||||
|
except Exception, e:
|
||||||
|
util.ERROR('Request failed {0}'.format(util.cleanToken(self.url)), e)
|
||||||
|
if not hasattr(e, 'response'):
|
||||||
|
return
|
||||||
|
res = e.response
|
||||||
|
|
||||||
|
self.onResponse(res, context)
|
||||||
|
|
||||||
|
self.removeAsPending()
|
||||||
|
|
||||||
|
def getWithTimeout(self, seconds=DEFAULT_TIMEOUT):
|
||||||
|
return HttpObjectResponse(self.getPostWithTimeout(seconds), self.path, self.server)
|
||||||
|
|
||||||
|
def postWithTimeout(self, seconds=DEFAULT_TIMEOUT, body=None):
|
||||||
|
self.method = 'POST'
|
||||||
|
return HttpObjectResponse(self.getPostWithTimeout(seconds, body), self.path, self.server)
|
||||||
|
|
||||||
|
def getToStringWithTimeout(self, seconds=DEFAULT_TIMEOUT):
|
||||||
|
res = self.getPostWithTimeout(seconds)
|
||||||
|
if not res:
|
||||||
|
return ''
|
||||||
|
return res.text.encode('utf8')
|
||||||
|
|
||||||
|
def postToStringWithTimeout(self, body=None, seconds=DEFAULT_TIMEOUT):
|
||||||
|
self.method = 'POST'
|
||||||
|
res = self.getPostWithTimeout(seconds, body)
|
||||||
|
if not res:
|
||||||
|
return ''
|
||||||
|
return res.text.encode('utf8')
|
||||||
|
|
||||||
|
def getPostWithTimeout(self, seconds=DEFAULT_TIMEOUT, body=None):
|
||||||
|
if self._cancel:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.logRequest(body, seconds, False)
|
||||||
|
try:
|
||||||
|
if self.method == 'PUT':
|
||||||
|
res = self.session.put(self.url, timeout=seconds, stream=True)
|
||||||
|
elif self.method == 'DELETE':
|
||||||
|
res = self.session.delete(self.url, timeout=seconds, stream=True)
|
||||||
|
elif self.method == 'HEAD':
|
||||||
|
res = self.session.head(self.url, timeout=seconds, stream=True)
|
||||||
|
elif self.method == 'POST' or body is not None:
|
||||||
|
res = self.session.post(self.url, data=body, timeout=seconds, stream=True)
|
||||||
|
else:
|
||||||
|
res = self.session.get(self.url, timeout=seconds, stream=True)
|
||||||
|
|
||||||
|
self.currentResponse = res
|
||||||
|
|
||||||
|
if self._cancel:
|
||||||
|
return None
|
||||||
|
|
||||||
|
util.LOG("Got a {0} from {1}".format(res.status_code, util.cleanToken(self.url)))
|
||||||
|
# self.event = msg
|
||||||
|
return res
|
||||||
|
except Exception, e:
|
||||||
|
info = traceback.extract_tb(sys.exc_info()[2])[-1]
|
||||||
|
util.WARN_LOG(
|
||||||
|
"Request errored out - URL: {0} File: {1} Line: {2} Msg: {3}".format(util.cleanToken(self.url), os.path.basename(info[0]), info[1], e.message)
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def wasOK(self):
|
||||||
|
return self.currentResponse and self.currentResponse.ok
|
||||||
|
|
||||||
|
def wasNotFound(self):
|
||||||
|
return self.currentResponse is not None and self.currentResponse.status_code == requests.codes.not_found
|
||||||
|
|
||||||
|
def getIdentity(self):
|
||||||
|
return str(id(self))
|
||||||
|
|
||||||
|
def getUrl(self):
|
||||||
|
return self.url
|
||||||
|
|
||||||
|
def getRelativeUrl(self):
|
||||||
|
url = self.getUrl()
|
||||||
|
m = re.match('^\w+:\/\/.+?(\/.+)', url)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return url
|
||||||
|
|
||||||
|
def killSocket(self):
|
||||||
|
if not self.currentResponse:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
socket.fromfd(self.currentResponse.raw.fileno(), socket.AF_INET, socket.SOCK_STREAM).shutdown(socket.SHUT_RDWR)
|
||||||
|
return
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except Exception, e:
|
||||||
|
util.ERROR(err=e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.currentResponse.raw._fp.fp._sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
except Exception, e:
|
||||||
|
util.ERROR(err=e)
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self._cancel = True
|
||||||
|
self.session.cancel()
|
||||||
|
self.removeAsPending()
|
||||||
|
self.killSocket()
|
||||||
|
|
||||||
|
def addParam(self, encodedName, value):
|
||||||
|
if self.hasParams:
|
||||||
|
self.url += "&" + encodedName + "=" + urllib.quote_plus(value)
|
||||||
|
else:
|
||||||
|
self.hasParams = True
|
||||||
|
self.url += "?" + encodedName + "=" + urllib.quote_plus(value)
|
||||||
|
|
||||||
|
def addHeader(self, name, value):
|
||||||
|
self.session.headers[name] = value
|
||||||
|
|
||||||
|
def createRequestContext(self, requestType, callback_=None):
|
||||||
|
context = RequestContext()
|
||||||
|
context.requestType = requestType
|
||||||
|
context.timeout = DEFAULT_TIMEOUT
|
||||||
|
|
||||||
|
if callback_:
|
||||||
|
context.callback = callback.Callable(self.onResponse)
|
||||||
|
context.completionCallback = callback_
|
||||||
|
context.callbackCtx = callback_.context
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def onResponse(self, event, context):
|
||||||
|
if context.completionCallback:
|
||||||
|
response = HttpResponse(event)
|
||||||
|
context.completionCallback(self, response, context)
|
||||||
|
|
||||||
|
def logRequest(self, body, timeout=None, async=True):
|
||||||
|
# Log the real request method
|
||||||
|
method = self.method
|
||||||
|
if not method:
|
||||||
|
method = body is not None and "POST" or "GET"
|
||||||
|
util.LOG(
|
||||||
|
"Starting request: {0} {1} (async={2} timeout={3})".format(method, util.cleanToken(self.url), async, timeout)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HttpResponse(object):
|
||||||
|
def __init__(self, event):
|
||||||
|
self.event = event
|
||||||
|
if not self.event is None:
|
||||||
|
self.event.content # force data to be read
|
||||||
|
self.event.close()
|
||||||
|
|
||||||
|
def isSuccess(self):
|
||||||
|
if not self.event:
|
||||||
|
return False
|
||||||
|
return self.event.status_code >= 200 and self.event.status_code < 300
|
||||||
|
|
||||||
|
def isError(self):
|
||||||
|
return not self.isSuccess()
|
||||||
|
|
||||||
|
def getStatus(self):
|
||||||
|
if self.event is None:
|
||||||
|
return 0
|
||||||
|
return self.event.status_code
|
||||||
|
|
||||||
|
def getBodyString(self):
|
||||||
|
if self.event is None:
|
||||||
|
return ''
|
||||||
|
return self.event.text.encode('utf-8')
|
||||||
|
|
||||||
|
def getErrorString(self):
|
||||||
|
if self.event is None:
|
||||||
|
return ''
|
||||||
|
return self.event.reason
|
||||||
|
|
||||||
|
def getBodyXml(self):
|
||||||
|
if not self.event is None:
|
||||||
|
return ElementTree.fromstring(self.getBodyString())
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getResponseHeader(self, name):
|
||||||
|
if self.event is None:
|
||||||
|
return None
|
||||||
|
return self.event.headers.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
class HttpObjectResponse(HttpResponse, plexobjects.PlexContainer):
|
||||||
|
def __init__(self, response, path, server=None):
|
||||||
|
self.event = response
|
||||||
|
if self.event:
|
||||||
|
self.event.content # force data to be read
|
||||||
|
self.event.close()
|
||||||
|
|
||||||
|
data = self.getBodyXml()
|
||||||
|
|
||||||
|
plexobjects.PlexContainer.__init__(self, data, initpath=path, server=server, address=path)
|
||||||
|
self.container = self
|
||||||
|
|
||||||
|
self.items = plexobjects.listItems(server, path, data=data, container=self)
|
||||||
|
|
||||||
|
|
||||||
|
def addRequestHeaders(transferObj, headers=None):
|
||||||
|
if isinstance(headers, dict):
|
||||||
|
for header in headers:
|
||||||
|
transferObj.addHeader(header, headers[header])
|
||||||
|
util.DEBUG_LOG("Adding header to {0}: {1}: {2}".format(transferObj, header, headers[header]))
|
||||||
|
|
||||||
|
|
||||||
|
def addUrlParam(url, param):
|
||||||
|
return url + ('?' in url and '&' or '?') + param
|
66
resources/lib/plexnet/locks.py
Normal file
66
resources/lib/plexnet/locks.py
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
# Generic Locks. These are only virtual. You will need to check for the lock to
|
||||||
|
# ignore processing depending on the lockName.
|
||||||
|
# * Locks().Lock("lockName") : creates virtual lock
|
||||||
|
# * Locks().IsLocked("lockName") : returns true if locked
|
||||||
|
# * Locks().Unlock("lockName") : return true if existed & removed
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class Locks(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.locks = {}
|
||||||
|
self.oneTimeLocks = {}
|
||||||
|
|
||||||
|
def lock(self, name):
|
||||||
|
self.locks[name] = (self.locks.get(name) or 0) + 1
|
||||||
|
util.DEBUG_LOG("Lock {0}, total={0}".format(name, self.locks[name]))
|
||||||
|
|
||||||
|
def lockOnce(self, name):
|
||||||
|
util.DEBUG_LOG("Locking once {0}".format(name))
|
||||||
|
self.oneTimeLocks[name] = True
|
||||||
|
|
||||||
|
def unlock(self, name, forceUnlock=False):
|
||||||
|
oneTime = False
|
||||||
|
if name in self.oneTimeLocks:
|
||||||
|
del self.oneTimeLocks[name]
|
||||||
|
oneTime = True
|
||||||
|
normal = (self.locks.get(name) or 0) > 0
|
||||||
|
|
||||||
|
if normal:
|
||||||
|
if forceUnlock:
|
||||||
|
self.locks[name] = 0
|
||||||
|
else:
|
||||||
|
self.locks[name] -= 1
|
||||||
|
|
||||||
|
if self.locks[name] <= 0:
|
||||||
|
del self.locks[name]
|
||||||
|
else:
|
||||||
|
normal = False
|
||||||
|
|
||||||
|
unlocked = (normal or oneTime)
|
||||||
|
util.DEBUG_LOG("Unlock {0}, total={1}, unlocked={2}".format(name, self.locks.get(name) or 0, unlocked))
|
||||||
|
|
||||||
|
return unlocked
|
||||||
|
|
||||||
|
def isLocked(self, name):
|
||||||
|
return name in self.oneTimeLocks or name in self.locks
|
||||||
|
# return (self.oneTimeLocks.Delete(name) or self.locks.DoesExist(name))
|
||||||
|
|
||||||
|
|
||||||
|
# lock helpers
|
||||||
|
def disableBackButton():
|
||||||
|
LOCKS.lock("BackButton")
|
||||||
|
|
||||||
|
|
||||||
|
def enableBackButton():
|
||||||
|
LOCKS.unlock("BackButton", True)
|
||||||
|
|
||||||
|
|
||||||
|
def disableRemoteControl():
|
||||||
|
LOCKS.lock("roUniversalControlEvent")
|
||||||
|
|
||||||
|
|
||||||
|
def enableRemoteControl():
|
||||||
|
LOCKS.unlock("roUniversalControlEvent", True)
|
||||||
|
|
||||||
|
LOCKS = Locks()
|
228
resources/lib/plexnet/media.py
Normal file
228
resources/lib/plexnet/media.py
Normal file
|
@ -0,0 +1,228 @@
|
||||||
|
import plexobjects
|
||||||
|
import plexstream
|
||||||
|
import plexrequest
|
||||||
|
import util
|
||||||
|
|
||||||
|
METADATA_RELATED_TRAILER = 1
|
||||||
|
METADATA_RELATED_DELETED_SCENE = 2
|
||||||
|
METADATA_RELATED_INTERVIEW = 3
|
||||||
|
METADATA_RELATED_MUSIC_VIDEO = 4
|
||||||
|
METADATA_RELATED_BEHIND_THE_SCENES = 5
|
||||||
|
METADATA_RELATED_SCENE_OR_SAMPLE = 6
|
||||||
|
METADATA_RELATED_LIVE_MUSIC_VIDEO = 7
|
||||||
|
METADATA_RELATED_LYRIC_MUSIC_VIDEO = 8
|
||||||
|
METADATA_RELATED_CONCERT = 9
|
||||||
|
METADATA_RELATED_FEATURETTE = 10
|
||||||
|
METADATA_RELATED_SHORT = 11
|
||||||
|
METADATA_RELATED_OTHER = 12
|
||||||
|
|
||||||
|
|
||||||
|
class MediaItem(plexobjects.PlexObject):
|
||||||
|
def getIdentifier(self):
|
||||||
|
identifier = self.get('identifier') or None
|
||||||
|
|
||||||
|
if identifier is None:
|
||||||
|
identifier = self.container.identifier
|
||||||
|
|
||||||
|
# HACK
|
||||||
|
# PMS doesn't return an identifier for playlist items. If we haven't found
|
||||||
|
# an identifier and the key looks like a library item, then we pretend like
|
||||||
|
# the identifier was set.
|
||||||
|
#
|
||||||
|
if identifier is None: # Modified from Roku code which had no check for None with iPhoto - is that right?
|
||||||
|
if self.key.startswith('/library/metadata'):
|
||||||
|
identifier = "com.plexapp.plugins.library"
|
||||||
|
elif self.isIPhoto():
|
||||||
|
identifier = "com.plexapp.plugins.iphoto"
|
||||||
|
|
||||||
|
return identifier
|
||||||
|
|
||||||
|
def getQualityType(self, server=None):
|
||||||
|
if self.isOnlineItem():
|
||||||
|
return util.QUALITY_ONLINE
|
||||||
|
|
||||||
|
if not server:
|
||||||
|
server = self.getServer()
|
||||||
|
|
||||||
|
return util.QUALITY_LOCAL if server.isLocalConnection() else util.QUALITY_REMOTE
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
if not self.ratingKey:
|
||||||
|
return
|
||||||
|
|
||||||
|
req = plexrequest.PlexRequest(self.server, '/library/metadata/{0}'.format(self.ratingKey), method='DELETE')
|
||||||
|
req.getToStringWithTimeout(10)
|
||||||
|
self.deleted = req.wasOK()
|
||||||
|
return self.deleted
|
||||||
|
|
||||||
|
def exists(self):
|
||||||
|
if self.deleted:
|
||||||
|
return False
|
||||||
|
|
||||||
|
data = self.server.query('/library/metadata/{0}'.format(self.ratingKey))
|
||||||
|
return data.attrib.get('size') != '0'
|
||||||
|
# req = plexrequest.PlexRequest(self.server, '/library/metadata/{0}'.format(self.ratingKey), method='HEAD')
|
||||||
|
# req.getToStringWithTimeout(10)
|
||||||
|
# return not req.wasNotFound()
|
||||||
|
|
||||||
|
def fixedDuration(self):
|
||||||
|
duration = self.duration.asInt()
|
||||||
|
if duration < 1000:
|
||||||
|
duration *= 60000
|
||||||
|
return duration
|
||||||
|
|
||||||
|
|
||||||
|
class Media(plexobjects.PlexObject):
|
||||||
|
TYPE = 'Media'
|
||||||
|
|
||||||
|
def __init__(self, data, initpath=None, server=None, video=None):
|
||||||
|
plexobjects.PlexObject.__init__(self, data, initpath=initpath, server=server)
|
||||||
|
self.video = video
|
||||||
|
self.parts = [MediaPart(elem, initpath=self.initpath, server=self.server, media=self) for elem in data]
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
title = self.video.title.replace(' ', '.')[0:20]
|
||||||
|
return '<%s:%s>' % (self.__class__.__name__, title.encode('utf8'))
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPart(plexobjects.PlexObject):
|
||||||
|
TYPE = 'Part'
|
||||||
|
|
||||||
|
def __init__(self, data, initpath=None, server=None, media=None):
|
||||||
|
plexobjects.PlexObject.__init__(self, data, initpath=initpath, server=server)
|
||||||
|
self.media = media
|
||||||
|
self.streams = [MediaPartStream.parse(e, initpath=self.initpath, server=server, part=self) for e in data if e.tag == 'Stream']
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<%s:%s>' % (self.__class__.__name__, self.id)
|
||||||
|
|
||||||
|
def selectedStream(self, stream_type):
|
||||||
|
streams = filter(lambda x: stream_type == x.type, self.streams)
|
||||||
|
selected = list(filter(lambda x: x.selected is True, streams))
|
||||||
|
if len(selected) == 0:
|
||||||
|
return None
|
||||||
|
return selected[0]
|
||||||
|
|
||||||
|
|
||||||
|
class MediaPartStream(plexstream.PlexStream):
|
||||||
|
TYPE = None
|
||||||
|
STREAMTYPE = None
|
||||||
|
|
||||||
|
def __init__(self, data, initpath=None, server=None, part=None):
|
||||||
|
plexobjects.PlexObject.__init__(self, data, initpath, server)
|
||||||
|
self.part = part
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(data, initpath=None, server=None, part=None):
|
||||||
|
STREAMCLS = {
|
||||||
|
1: VideoStream,
|
||||||
|
2: AudioStream,
|
||||||
|
3: SubtitleStream
|
||||||
|
}
|
||||||
|
stype = int(data.attrib.get('streamType'))
|
||||||
|
cls = STREAMCLS.get(stype, MediaPartStream)
|
||||||
|
return cls(data, initpath=initpath, server=server, part=part)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<%s:%s>' % (self.__class__.__name__, self.id)
|
||||||
|
|
||||||
|
|
||||||
|
class VideoStream(MediaPartStream):
|
||||||
|
TYPE = 'videostream'
|
||||||
|
STREAMTYPE = plexstream.PlexStream.TYPE_VIDEO
|
||||||
|
|
||||||
|
|
||||||
|
class AudioStream(MediaPartStream):
|
||||||
|
TYPE = 'audiostream'
|
||||||
|
STREAMTYPE = plexstream.PlexStream.TYPE_AUDIO
|
||||||
|
|
||||||
|
|
||||||
|
class SubtitleStream(MediaPartStream):
|
||||||
|
TYPE = 'subtitlestream'
|
||||||
|
STREAMTYPE = plexstream.PlexStream.TYPE_SUBTITLE
|
||||||
|
|
||||||
|
|
||||||
|
class TranscodeSession(plexobjects.PlexObject):
|
||||||
|
TYPE = 'TranscodeSession'
|
||||||
|
|
||||||
|
|
||||||
|
class MediaTag(plexobjects.PlexObject):
|
||||||
|
TYPE = None
|
||||||
|
ID = 'None'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
tag = self.tag.replace(' ', '.')[0:20]
|
||||||
|
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, tag)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if other.__class__ != self.__class__:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.id == other.id
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
|
||||||
|
class Collection(MediaTag):
|
||||||
|
TYPE = 'Collection'
|
||||||
|
FILTER = 'collection'
|
||||||
|
|
||||||
|
|
||||||
|
class Country(MediaTag):
|
||||||
|
TYPE = 'Country'
|
||||||
|
FILTER = 'country'
|
||||||
|
|
||||||
|
|
||||||
|
class Director(MediaTag):
|
||||||
|
TYPE = 'Director'
|
||||||
|
FILTER = 'director'
|
||||||
|
ID = '4'
|
||||||
|
|
||||||
|
|
||||||
|
class Genre(MediaTag):
|
||||||
|
TYPE = 'Genre'
|
||||||
|
FILTER = 'genre'
|
||||||
|
ID = '1'
|
||||||
|
|
||||||
|
|
||||||
|
class Mood(MediaTag):
|
||||||
|
TYPE = 'Mood'
|
||||||
|
FILTER = 'mood'
|
||||||
|
|
||||||
|
|
||||||
|
class Producer(MediaTag):
|
||||||
|
TYPE = 'Producer'
|
||||||
|
FILTER = 'producer'
|
||||||
|
|
||||||
|
|
||||||
|
class Role(MediaTag):
|
||||||
|
TYPE = 'Role'
|
||||||
|
FILTER = 'actor'
|
||||||
|
ID = '6'
|
||||||
|
|
||||||
|
def sectionRoles(self):
|
||||||
|
hubs = self.server.hubs(count=10, search_query=self.tag)
|
||||||
|
for hub in hubs:
|
||||||
|
if hub.type == 'actor':
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
roles = []
|
||||||
|
|
||||||
|
for actor in hub.items:
|
||||||
|
if actor.id == self.id:
|
||||||
|
roles.append(actor)
|
||||||
|
|
||||||
|
return roles or None
|
||||||
|
|
||||||
|
|
||||||
|
class Similar(MediaTag):
|
||||||
|
TYPE = 'Similar'
|
||||||
|
FILTER = 'similar'
|
||||||
|
|
||||||
|
|
||||||
|
class Writer(MediaTag):
|
||||||
|
TYPE = 'Writer'
|
||||||
|
FILTER = 'writer'
|
49
resources/lib/plexnet/mediachoice.py
Normal file
49
resources/lib/plexnet/mediachoice.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import plexstream
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class MediaChoice(object):
|
||||||
|
SUBTITLES_DEFAULT = 0
|
||||||
|
SUBTITLES_BURN = 1
|
||||||
|
SUBTITLES_SOFT_DP = 2
|
||||||
|
SUBTITLES_SOFT_ANY = 3
|
||||||
|
|
||||||
|
def __init__(self, media=None, partIndex=0):
|
||||||
|
self.media = media
|
||||||
|
self.part = None
|
||||||
|
self.forceTranscode = False
|
||||||
|
self.isDirectPlayable = False
|
||||||
|
self.videoStream = None
|
||||||
|
self.audioStream = None
|
||||||
|
self.subtitleStream = None
|
||||||
|
self.isSelected = False
|
||||||
|
self.subtitleDecision = self.SUBTITLES_DEFAULT
|
||||||
|
|
||||||
|
self.sorts = util.AttributeDict()
|
||||||
|
|
||||||
|
if media:
|
||||||
|
self.indirectHeaders = media.indirectHeaders
|
||||||
|
self.part = media.parts[partIndex]
|
||||||
|
if self.part:
|
||||||
|
# We generally just rely on PMS to have told us selected streams, so
|
||||||
|
# initialize our streams accordingly.
|
||||||
|
|
||||||
|
self.videoStream = self.part.getSelectedStreamOfType(plexstream.PlexStream.TYPE_VIDEO)
|
||||||
|
self.audioStream = self.part.getSelectedStreamOfType(plexstream.PlexStream.TYPE_AUDIO)
|
||||||
|
self.subtitleStream = self.part.getSelectedStreamOfType(plexstream.PlexStream.TYPE_SUBTITLE)
|
||||||
|
else:
|
||||||
|
util.WARN_LOG("Media does not contain a valid part")
|
||||||
|
|
||||||
|
util.LOG("Choice media: {0} part:{1}".format(media, partIndex))
|
||||||
|
for streamType in ("videoStream", "audioStream", "subtitleStream"):
|
||||||
|
attr = getattr(self, streamType)
|
||||||
|
if attr:
|
||||||
|
util.LOG("Choice {0}: {1}".format(streamType, repr(attr)))
|
||||||
|
else:
|
||||||
|
util.WARN_LOG("Could not create media choice for invalid media")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "direct playable={0} version={1}".format(self.isDirectPlayable, self.media)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.__str__()
|
474
resources/lib/plexnet/mediadecisionengine.py
Normal file
474
resources/lib/plexnet/mediadecisionengine.py
Normal file
|
@ -0,0 +1,474 @@
|
||||||
|
import mediachoice
|
||||||
|
import serverdecision
|
||||||
|
import plexapp
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class MediaDecisionEngine(object):
|
||||||
|
proxyTypes = util.AttributeDict({
|
||||||
|
'NORMAL': 0,
|
||||||
|
'LOCAL': 42,
|
||||||
|
'CLOUD': 43
|
||||||
|
})
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.softSubLanguages = None
|
||||||
|
|
||||||
|
# TODO(schuyler): Do we need to allow this to be async? We may have to request
|
||||||
|
# the media again to fetch details, and we may need to make multiple requests to
|
||||||
|
# resolve an indirect. We can do it all async, we can block, or we can allow
|
||||||
|
# both.
|
||||||
|
|
||||||
|
def chooseMedia(self, item, forceUpdate=False):
|
||||||
|
# If we've already evaluated this item, use our previous choice.
|
||||||
|
if not forceUpdate and item.mediaChoice is not None and item.mediaChoice.media is not None and not item.mediaChoice.media.isIndirect():
|
||||||
|
return item.mediaChoice
|
||||||
|
|
||||||
|
# See if we're missing media/stream details for this item.
|
||||||
|
if item.isLibraryItem() and item.isVideoItem() and len(item.media) > 0 and not item.media[0].hasStreams():
|
||||||
|
# TODO(schuyler): Fetch the details
|
||||||
|
util.WARN_LOG("Can't make media choice, missing details")
|
||||||
|
|
||||||
|
# Take a first pass through the media items to create an array of candidates
|
||||||
|
# that we'll evaluate more completely. If we find a forced item, we use it.
|
||||||
|
# If we find an indirect, we only keep a single candidate.
|
||||||
|
indirect = False
|
||||||
|
candidates = []
|
||||||
|
maxResolution = item.settings.getMaxResolution(item.getQualityType())
|
||||||
|
for mediaIndex in range(len(item.media)):
|
||||||
|
media = item.media[mediaIndex]
|
||||||
|
media.mediaIndex = mediaIndex
|
||||||
|
if media.isSelected():
|
||||||
|
candidates = []
|
||||||
|
candidates.append(media)
|
||||||
|
break
|
||||||
|
if media.isIndirect():
|
||||||
|
# Only add indirect media if the resolution fits. We cannot
|
||||||
|
# exit early as the user may have selected media.
|
||||||
|
|
||||||
|
indirect = True
|
||||||
|
if media.getVideoResolution() <= maxResolution:
|
||||||
|
candidates.append(media)
|
||||||
|
|
||||||
|
elif media.isAccessible():
|
||||||
|
# Only consider testing available media
|
||||||
|
candidates.append(media)
|
||||||
|
|
||||||
|
# Only use the first indirect media item
|
||||||
|
if indirect and candidates:
|
||||||
|
candidates = candidates[0]
|
||||||
|
|
||||||
|
# Make sure we have at least one valid item, regardless of availability
|
||||||
|
if len(candidates) == 0:
|
||||||
|
candidates.append(item.media[0])
|
||||||
|
|
||||||
|
# Now that we have an array of candidates, evaluate them completely.
|
||||||
|
choices = []
|
||||||
|
for media in candidates:
|
||||||
|
choice = None
|
||||||
|
if media is not None:
|
||||||
|
if item.isVideoItem():
|
||||||
|
choice = self.evaluateMediaVideo(item, media)
|
||||||
|
elif item.isMusicItem():
|
||||||
|
choice = self.evaluateMediaMusic(item, media)
|
||||||
|
else:
|
||||||
|
choice = mediachoice.MediaChoice(media)
|
||||||
|
choices.append(choice)
|
||||||
|
item.mediaChoice = self.sortChoices(choices)[-1]
|
||||||
|
util.LOG("MDE: MediaChoice: {0}".format(item.mediaChoice))
|
||||||
|
return item.mediaChoice
|
||||||
|
|
||||||
|
def sortChoices(self, choices):
|
||||||
|
if choices is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if len(choices) > 1:
|
||||||
|
self.sort(choices, "bitrate")
|
||||||
|
self.sort(choices, "audioChannels")
|
||||||
|
self.sort(choices, "audioDS")
|
||||||
|
self.sort(choices, "resolution")
|
||||||
|
self.sort(choices, "videoDS")
|
||||||
|
self.sort(choices, "directPlay")
|
||||||
|
self.sort(choices, self.higherResIfCapable)
|
||||||
|
self.sort(choices, self.cloudIfRemote)
|
||||||
|
|
||||||
|
return choices
|
||||||
|
|
||||||
|
def evaluateMediaVideo(self, item, media, partIndex=0):
|
||||||
|
# Resolve indirects before doing anything else.
|
||||||
|
if media.isIndirect():
|
||||||
|
util.LOG("Resolve indirect media for {0}".format(item))
|
||||||
|
media = media.resolveIndirect()
|
||||||
|
|
||||||
|
choice = mediachoice.MediaChoice(media, partIndex)
|
||||||
|
server = item.getServer()
|
||||||
|
|
||||||
|
if not media:
|
||||||
|
return choice
|
||||||
|
|
||||||
|
choice.isSelected = media.isSelected()
|
||||||
|
choice.protocol = media.protocol("http")
|
||||||
|
|
||||||
|
maxResolution = item.settings.getMaxResolution(item.getQualityType(), self.isSupported4k(media, choice.videoStream))
|
||||||
|
maxBitrate = item.settings.getMaxBitrate(item.getQualityType())
|
||||||
|
|
||||||
|
choice.resolution = media.getVideoResolution()
|
||||||
|
if choice.resolution > maxResolution or media.bitrate.asInt() > maxBitrate:
|
||||||
|
choice.forceTranscode = True
|
||||||
|
|
||||||
|
if choice.subtitleStream:
|
||||||
|
choice.subtitleDecision = self.evaluateSubtitles(choice.subtitleStream)
|
||||||
|
choice.hasBurnedInSubtitles = (choice.subtitleDecision != choice.SUBTITLES_SOFT_DP and choice.subtitleDecision != choice.SUBTITLES_SOFT_ANY)
|
||||||
|
else:
|
||||||
|
choice.hasBurnedInSubtitles = False
|
||||||
|
|
||||||
|
# For evaluation purposes, we only care about the first part
|
||||||
|
part = media.parts[partIndex]
|
||||||
|
if not part:
|
||||||
|
return choice
|
||||||
|
|
||||||
|
# Although PMS has already told us which streams are selected, we can't
|
||||||
|
# necessarily tell the video player which streams we want. So we need to
|
||||||
|
# iterate over the streams and see if there are any red flags that would
|
||||||
|
# prevent direct play. If there are multiple video streams, we're hosed.
|
||||||
|
# For audio streams, we have a fighting chance if the selected stream can
|
||||||
|
# be selected by language, but we need to be careful about guessing which
|
||||||
|
# audio stream the Roku will pick for a given language.
|
||||||
|
|
||||||
|
numVideoStreams = 0
|
||||||
|
problematicAudioStream = False
|
||||||
|
|
||||||
|
if part.get('hasChapterVideoStream').asBool():
|
||||||
|
numVideoStreams = 1
|
||||||
|
|
||||||
|
for stream in part.streams:
|
||||||
|
streamType = stream.streamType.asInt()
|
||||||
|
if streamType == stream.TYPE_VIDEO:
|
||||||
|
numVideoStreams = numVideoStreams + 1
|
||||||
|
|
||||||
|
if stream.codec == "h264" or (
|
||||||
|
stream.codec == "hevc" and item.settings.getPreference("allow_hevc", False)
|
||||||
|
) or (
|
||||||
|
stream.codec == "vp9" and item.settings.getGlobal("vp9Support")
|
||||||
|
):
|
||||||
|
choice.sorts.videoDS = 1
|
||||||
|
|
||||||
|
# Special cases to force direct play
|
||||||
|
forceDirectPlay = False
|
||||||
|
if choice.protocol == "hls":
|
||||||
|
util.LOG("MDE: Assuming HLS is direct playable")
|
||||||
|
forceDirectPlay = True
|
||||||
|
elif not server.supportsVideoTranscoding:
|
||||||
|
# See if we can use another server to transcode, otherwise force direct play
|
||||||
|
transcodeServer = item.getTranscodeServer(True, "video")
|
||||||
|
if not transcodeServer or not transcodeServer.supportsVideoTranscoding:
|
||||||
|
util.LOG("MDE: force direct play because the server does not support video transcoding")
|
||||||
|
forceDirectPlay = True
|
||||||
|
|
||||||
|
# See if we found any red flags based on the streams. Otherwise, go ahead
|
||||||
|
# with our codec checks.
|
||||||
|
|
||||||
|
if forceDirectPlay:
|
||||||
|
# Consider the choice DP, but continue to allow the
|
||||||
|
# choice to have the sorts set properly.
|
||||||
|
choice.isDirectPlayable = True
|
||||||
|
elif choice.hasBurnedInSubtitles:
|
||||||
|
util.LOG("MDE: Need to burn in subtitles")
|
||||||
|
elif choice.protocol != "http":
|
||||||
|
util.LOG("MDE: " + choice.protocol + " not supported")
|
||||||
|
elif numVideoStreams > 1:
|
||||||
|
util.LOG("MDE: Multiple video streams, won't try to direct play")
|
||||||
|
elif problematicAudioStream:
|
||||||
|
util.LOG("MDE: Problematic AAC stream with more than 2 channels prevents direct play")
|
||||||
|
elif self.canDirectPlay(item, choice):
|
||||||
|
choice.isDirectPlayable = True
|
||||||
|
elif item.isMediaSynthesized:
|
||||||
|
util.LOG("MDE: assuming synthesized media can direct play")
|
||||||
|
choice.isDirectPlayable = True
|
||||||
|
|
||||||
|
# Check for a server decision. This is authority as it's the only playback type
|
||||||
|
# the server will allow. This will also support forcing direct play, overriding
|
||||||
|
# only our local MDE checks based on the user pref, and only if the server
|
||||||
|
# agrees.
|
||||||
|
decision = part.get("decision")
|
||||||
|
if decision:
|
||||||
|
if decision != serverdecision.ServerDecision.DECISION_DIRECT_PLAY:
|
||||||
|
util.LOG("MDE: Server has decided this cannot direct play")
|
||||||
|
choice.isDirectPlayable = False
|
||||||
|
else:
|
||||||
|
util.LOG("MDE: Server has allowed direct play")
|
||||||
|
choice.isDirectPlayable = True
|
||||||
|
|
||||||
|
# Setup sorts
|
||||||
|
if choice.videoStream is not None:
|
||||||
|
choice.sorts.bitrate = choice.videoStream.bitrate.asInt()
|
||||||
|
elif choice.media is not None:
|
||||||
|
choice.sorts.bitrate = choice.media.bitrate.asInt()
|
||||||
|
else:
|
||||||
|
choice.sorts.bitrate = 0
|
||||||
|
|
||||||
|
if choice.audioStream is not None:
|
||||||
|
choice.sorts.audioChannels = choice.audioStream.channels.asInt()
|
||||||
|
elif choice.media is not None:
|
||||||
|
choice.sorts.audioChannels = choice.media.audioChannels.asInt()
|
||||||
|
else:
|
||||||
|
choice.sorts.audioChannels = 0
|
||||||
|
|
||||||
|
choice.sorts.videoDS = not (choice.sorts.videoDS is None or choice.forceTranscode is True) and choice.sorts.videoDS or 0
|
||||||
|
choice.sorts.resolution = choice.resolution
|
||||||
|
|
||||||
|
# Server properties probably don't need to be associated with each choice
|
||||||
|
choice.sorts.canTranscode = server.supportsVideoTranscoding and 1 or 0
|
||||||
|
choice.sorts.canRemuxOnly = server.supportsVideoRemuxOnly and 1 or 0
|
||||||
|
choice.sorts.directPlay = (choice.isDirectPlayable is True and choice.forceTranscode is not True) and 1 or 0
|
||||||
|
choice.sorts.proxyType = choice.media.proxyType and choice.media.proxyType or self.proxyTypes.NORMAL
|
||||||
|
|
||||||
|
return choice
|
||||||
|
|
||||||
|
def canDirectPlay(self, item, choice):
|
||||||
|
maxResolution = item.settings.getMaxResolution(item.getQualityType(), self.isSupported4k(choice.media, choice.videoStream))
|
||||||
|
height = choice.media.getVideoResolution()
|
||||||
|
if height > maxResolution:
|
||||||
|
util.LOG("MDE: (DP) Video height is greater than max allowed: {0} > {1}".format(height, maxResolution))
|
||||||
|
if height > 1088 and item.settings.getPreference("allow_4k", True):
|
||||||
|
util.LOG("MDE: (DP) Unsupported 4k media")
|
||||||
|
return False
|
||||||
|
|
||||||
|
maxBitrate = item.settings.getMaxBitrate(item.getQualityType())
|
||||||
|
bitrate = choice.media.bitrate.asInt()
|
||||||
|
if bitrate > maxBitrate:
|
||||||
|
util.LOG("MDE: (DP) Video bitrate is greater than the allowed max: {0} > {1}".format(bitrate, maxBitrate))
|
||||||
|
return False
|
||||||
|
|
||||||
|
if choice.videoStream is None:
|
||||||
|
util.ERROR_LOG("MDE: (DP) No video stream")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not item.settings.getGlobal("supports1080p60"):
|
||||||
|
videoFrameRate = choice.videoStream.asInt()
|
||||||
|
if videoFrameRate > 30 and height >= 1080:
|
||||||
|
util.LOG("MDE: (DP) Frame rate is not supported for resolution: {0}@{1}".format(height, videoFrameRate))
|
||||||
|
return False
|
||||||
|
|
||||||
|
if choice.videoStream.codec == "hevc" and not item.settings.getPreference("allow_hevc", False):
|
||||||
|
util.LOG("MDE: (DP) Codec is HEVC, which is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# container = choice.media.get('container')
|
||||||
|
# videoCodec = choice.videoStream.codec
|
||||||
|
# if choice.audioStream is None:
|
||||||
|
# audioCodec = None
|
||||||
|
# numChannels = 0
|
||||||
|
# else:
|
||||||
|
# audioCodec = choice.audioStream.codec
|
||||||
|
# numChannels = choice.audioStream.channels.asInt()
|
||||||
|
|
||||||
|
# Formats: https://support.roku.com/hc/en-us/articles/208754908-Roku-Media-Player-Playing-your-personal-videos-music-photos
|
||||||
|
# All Models: H.264/AVC (MKV, MP4, MOV),
|
||||||
|
# Roku 4 only: H.265/HEVC (MKV, MP4, MOV); VP9 (.MKV)
|
||||||
|
|
||||||
|
# if True: # container in ("mp4", "mov", "m4v", "mkv"):
|
||||||
|
# util.LOG("MDE: {0} container looks OK, checking streams".format(container))
|
||||||
|
|
||||||
|
# isHEVC = videoCodec == "hevc" and item.settings.getPreference("allow_hevc", False)
|
||||||
|
# isVP9 = videoCodec == "vp9" and container == "mkv" and item.settings.getGlobal("vp9Support")
|
||||||
|
|
||||||
|
# if videoCodec != "h264" and videoCodec != "mpeg4" and not isHEVC and not isVP9:
|
||||||
|
# util.LOG("MDE: Unsupported video codec: {0}".format(videoCodec))
|
||||||
|
# return False
|
||||||
|
|
||||||
|
# # TODO(schuyler): Fix ref frames check. It's more nuanced than this.
|
||||||
|
# if choice.videoStream.refFrames.asInt() > 8:
|
||||||
|
# util.LOG("MDE: Too many ref frames: {0}".format(choice.videoStream.refFrames))
|
||||||
|
# return False
|
||||||
|
|
||||||
|
# # HEVC supports a bitDepth of 10, otherwise 8 is the limit
|
||||||
|
# if choice.videoStream.bitDepth.asInt() > (isHEVC and 10 or 8):
|
||||||
|
# util.LOG("MDE: Bit depth too high: {0}".format(choice.videoStream.bitDepth))
|
||||||
|
# return False
|
||||||
|
|
||||||
|
# # We shouldn't have to whitelist particular audio codecs, we can just
|
||||||
|
# # check to see if the Roku can decode this codec with the number of channels.
|
||||||
|
# if not item.settings.supportsAudioStream(audioCodec, numChannels):
|
||||||
|
# util.LOG("MDE: Unsupported audio track: {0} ({1} channels)".format(audioCodec, numChannels))
|
||||||
|
# return False
|
||||||
|
|
||||||
|
# # # TODO(schuyler): We've reported this to Roku, they may fix it. If/when
|
||||||
|
# # # they do, we should move this behind a firmware version check.
|
||||||
|
# # if container == "mkv" and choice.videoStream.headerStripping.asBool() and audioCodec == "ac3":
|
||||||
|
# # util.ERROR_LOG("MDE: Header stripping with AC3 audio")
|
||||||
|
# # return False
|
||||||
|
|
||||||
|
# # Those were our problems, everything else should be OK.
|
||||||
|
# return True
|
||||||
|
# else:
|
||||||
|
# util.LOG("MDE: Unsupported container: {0}".format(container))
|
||||||
|
|
||||||
|
# return False
|
||||||
|
|
||||||
|
def evaluateSubtitles(self, stream):
|
||||||
|
if plexapp.INTERFACE.getPreference("burn_subtitles") == "always":
|
||||||
|
# If the user prefers them burned, always burn
|
||||||
|
return mediachoice.MediaChoice.SUBTITLES_BURN
|
||||||
|
# elif stream.codec != "srt":
|
||||||
|
# # We only support soft subtitles for SRT. Anything else has to use the
|
||||||
|
# # transcoder, and we defer to it on whether the subs will have to be
|
||||||
|
# # burned or can be converted to SRT and muxed.
|
||||||
|
|
||||||
|
# return mediachoice.MediaChoice.SUBTITLES_DEFAULT
|
||||||
|
elif stream.key is None:
|
||||||
|
# Embedded subs don't have keys and can only be direct played
|
||||||
|
result = mediachoice.MediaChoice.SUBTITLES_SOFT_DP
|
||||||
|
else:
|
||||||
|
# Sidecar subs can be direct played or used alongside a transcode
|
||||||
|
result = mediachoice.MediaChoice.SUBTITLES_SOFT_ANY
|
||||||
|
|
||||||
|
# # TODO(schuyler) If Roku adds support for non-Latin characters, remove
|
||||||
|
# # this hackery. To the extent that we continue using this hackery, it
|
||||||
|
# # seems that the Roku requires UTF-8 subtitles but only supports characters
|
||||||
|
# # from Windows-1252. This should be the full set of languages that are
|
||||||
|
# # completely representable in Windows-1252. PMS should specifically be
|
||||||
|
# # returning ISO 639-2/B language codes.
|
||||||
|
# # Update: Roku has added support for additional characters, but still only
|
||||||
|
# # Latin characters. We can now basically support anything from the various
|
||||||
|
# # ISO-8859 character sets, but nothing non-Latin.
|
||||||
|
|
||||||
|
# if not self.softSubLanguages:
|
||||||
|
# self.softSubLanguages = frozenset((
|
||||||
|
# 'afr',
|
||||||
|
# 'alb',
|
||||||
|
# 'baq',
|
||||||
|
# 'bre',
|
||||||
|
# 'cat',
|
||||||
|
# 'cze',
|
||||||
|
# 'dan',
|
||||||
|
# 'dut',
|
||||||
|
# 'eng',
|
||||||
|
# 'epo',
|
||||||
|
# 'est',
|
||||||
|
# 'fao',
|
||||||
|
# 'fin',
|
||||||
|
# 'fre',
|
||||||
|
# 'ger',
|
||||||
|
# 'gla',
|
||||||
|
# 'gle',
|
||||||
|
# 'glg',
|
||||||
|
# 'hrv',
|
||||||
|
# 'hun',
|
||||||
|
# 'ice',
|
||||||
|
# 'ita',
|
||||||
|
# 'lat',
|
||||||
|
# 'lav',
|
||||||
|
# 'lit',
|
||||||
|
# 'ltz',
|
||||||
|
# 'may',
|
||||||
|
# 'mlt',
|
||||||
|
# 'nno',
|
||||||
|
# 'nob',
|
||||||
|
# 'nor',
|
||||||
|
# 'oci',
|
||||||
|
# 'pol',
|
||||||
|
# 'por',
|
||||||
|
# 'roh',
|
||||||
|
# 'rum',
|
||||||
|
# 'slo',
|
||||||
|
# 'slv',
|
||||||
|
# 'spa',
|
||||||
|
# 'srd',
|
||||||
|
# 'swa',
|
||||||
|
# 'swe',
|
||||||
|
# 'tur',
|
||||||
|
# 'vie',
|
||||||
|
# 'wel',
|
||||||
|
# 'wln'
|
||||||
|
# ))
|
||||||
|
|
||||||
|
# if not (stream.languageCode or 'eng') in self.softSubLanguages:
|
||||||
|
# # If the language is unsupported,: we need to force burning
|
||||||
|
# result = mediachoice.MediaChoice.SUBTITLES_BURN
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def evaluateMediaMusic(self, item, media):
|
||||||
|
# Resolve indirects before doing anything else.
|
||||||
|
if media.isIndirect():
|
||||||
|
util.LOG("Resolve indirect media for {0}".format(item))
|
||||||
|
media = media.resolveIndirect()
|
||||||
|
|
||||||
|
choice = mediachoice.MediaChoice(media)
|
||||||
|
if media is None:
|
||||||
|
return choice
|
||||||
|
|
||||||
|
# Verify the server supports audio transcoding, otherwise force direct play
|
||||||
|
if not item.getServer().supportsAudioTranscoding:
|
||||||
|
util.LOG("MDE: force direct play because the server does not support audio transcoding")
|
||||||
|
choice.isDirectPlayable = True
|
||||||
|
return choice
|
||||||
|
|
||||||
|
# See if this part has a server decision to transcode and obey it
|
||||||
|
if choice.part and choice.part.get(
|
||||||
|
"decision", serverdecision.ServerDecision.DECISION_DIRECT_PLAY
|
||||||
|
) != serverdecision.ServerDecision.DECISION_DIRECT_PLAY:
|
||||||
|
util.WARN_LOG("MDE: Server has decided this cannot direct play")
|
||||||
|
return choice
|
||||||
|
|
||||||
|
# Verify the codec and container are compatible
|
||||||
|
codec = media.audioCodec
|
||||||
|
container = media.get('container')
|
||||||
|
canPlayCodec = item.settings.supportsAudioStream(codec, media.audioChannels.asInt())
|
||||||
|
canPlayContainer = (codec == container) or True # (container in ("mp4", "mka", "mkv"))
|
||||||
|
|
||||||
|
choice.isDirectPlayable = (canPlayCodec and canPlayContainer)
|
||||||
|
if choice.isDirectPlayable:
|
||||||
|
# Inspect the audio stream attributes if the codec/container can direct
|
||||||
|
# play. For now we only need to verify the sample rate.
|
||||||
|
|
||||||
|
if choice.audioStream is not None and choice.audioStream.samplingRate.asInt() >= 192000:
|
||||||
|
util.LOG("MDE: sampling rate is not compatible")
|
||||||
|
choice.isDirectPlayable = False
|
||||||
|
else:
|
||||||
|
util.LOG("MDE: container or codec is incompatible")
|
||||||
|
|
||||||
|
return choice
|
||||||
|
|
||||||
|
# Simple Quick sort function modeled after roku sdk function
|
||||||
|
def sort(self, choices, key=None):
|
||||||
|
if not isinstance(choices, list):
|
||||||
|
return
|
||||||
|
|
||||||
|
if key is None:
|
||||||
|
choices.sort()
|
||||||
|
elif isinstance(key, basestring):
|
||||||
|
choices.sort(key=lambda x: getattr(x.media, key))
|
||||||
|
elif hasattr(key, '__call__'):
|
||||||
|
choices.sort(key=key)
|
||||||
|
|
||||||
|
def higherResIfCapable(self, choice):
|
||||||
|
if choice.media is not None:
|
||||||
|
server = choice.media.getServer()
|
||||||
|
if server.supportsVideoTranscoding and not server.supportsVideoRemuxOnly and (choice.sorts.directPlay == 1 or choice.sorts.videoDS == 1):
|
||||||
|
return util.validInt(choice.sorts.resolution)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def cloudIfRemote(self, choice):
|
||||||
|
if choice.media is not None and choice.media.getServer().isLocalConnection() and choice.media.proxyType != self.proxyTypes.CLOUD:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def isSupported4k(self, media, videoStream):
|
||||||
|
if videoStream is None or not plexapp.INTERFACE.getPreference("allow_4k", True):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# # Roku 4 only: H.265/HEVC (MKV, MP4, MOV); VP9 (.MKV)
|
||||||
|
# if media.get('container') in ("mp4", "mov", "m4v", "mkv"):
|
||||||
|
# isHEVC = (videoStream.codec == "hevc" and plexapp.INTERFACE.getPreference("allow_hevc"))
|
||||||
|
# isVP9 = (videoStream.codec == "vp9" and media.get('container') == "mkv" and plexapp.INTERFACE.getGlobal("vp9Support"))
|
||||||
|
# return (isHEVC or isVP9)
|
||||||
|
|
||||||
|
# return False
|
||||||
|
|
||||||
|
return True
|
90
resources/lib/plexnet/myplex.py
Normal file
90
resources/lib/plexnet/myplex.py
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import util
|
||||||
|
import http
|
||||||
|
from threading import Thread
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
import time
|
||||||
|
|
||||||
|
import exceptions
|
||||||
|
|
||||||
|
import video
|
||||||
|
import audio
|
||||||
|
import photo
|
||||||
|
|
||||||
|
video, audio, photo # Hides warning message
|
||||||
|
|
||||||
|
|
||||||
|
class PinLogin(object):
|
||||||
|
INIT = 'https://plex.tv/pins.xml'
|
||||||
|
POLL = 'https://plex.tv/pins/{0}.xml'
|
||||||
|
POLL_INTERVAL = 1
|
||||||
|
|
||||||
|
def __init__(self, callback=None):
|
||||||
|
self._callback = callback
|
||||||
|
self.id = None
|
||||||
|
self.pin = None
|
||||||
|
self.authenticationToken = None
|
||||||
|
self._finished = False
|
||||||
|
self._abort = False
|
||||||
|
self._expired = False
|
||||||
|
self._init()
|
||||||
|
|
||||||
|
def _init(self):
|
||||||
|
response = http.POST(self.INIT)
|
||||||
|
if response.status_code != http.codes.created:
|
||||||
|
codename = http.status_codes.get(response.status_code)[0]
|
||||||
|
raise exceptions.BadRequest('({0}) {1}'.format(response.status_code, codename))
|
||||||
|
data = ElementTree.fromstring(response.text.encode('utf-8'))
|
||||||
|
self.pin = data.find('code').text
|
||||||
|
self.id = data.find('id').text
|
||||||
|
|
||||||
|
def _poll(self):
|
||||||
|
try:
|
||||||
|
start = time.time()
|
||||||
|
while not self._abort and time.time() - start < 300:
|
||||||
|
try:
|
||||||
|
response = http.GET(self.POLL.format(self.id))
|
||||||
|
except Exception, e:
|
||||||
|
util.ERROR('PinLogin connection error: {0}'.format(e.__class__), err=e)
|
||||||
|
time.sleep(self.POLL_INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.status_code != http.codes.ok:
|
||||||
|
self._expired = True
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
data = ElementTree.fromstring(response.text.encode('utf-8'))
|
||||||
|
except Exception, e:
|
||||||
|
util.ERROR('PinLogin data error: {0}'.format(e.__class__), err=e)
|
||||||
|
time.sleep(self.POLL_INTERVAL)
|
||||||
|
continue
|
||||||
|
|
||||||
|
token = data.find('auth_token').text
|
||||||
|
if token:
|
||||||
|
self.authenticationToken = token
|
||||||
|
break
|
||||||
|
time.sleep(self.POLL_INTERVAL)
|
||||||
|
|
||||||
|
if self._callback:
|
||||||
|
self._callback(self.authenticationToken)
|
||||||
|
finally:
|
||||||
|
self._finished = True
|
||||||
|
|
||||||
|
def finished(self):
|
||||||
|
return self._finished
|
||||||
|
|
||||||
|
def expired(self):
|
||||||
|
return self._expired
|
||||||
|
|
||||||
|
def startTokenPolling(self):
|
||||||
|
t = Thread(target=self._poll, name='PIN-LOGIN:Token-Poll')
|
||||||
|
t.start()
|
||||||
|
return t
|
||||||
|
|
||||||
|
def waitForToken(self):
|
||||||
|
t = self.startTokenPolling()
|
||||||
|
t.join()
|
||||||
|
return self.authenticationToken
|
||||||
|
|
||||||
|
def abort(self):
|
||||||
|
self._abort = True
|
310
resources/lib/plexnet/myplexaccount.py
Normal file
310
resources/lib/plexnet/myplexaccount.py
Normal file
|
@ -0,0 +1,310 @@
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
import plexapp
|
||||||
|
import myplexrequest
|
||||||
|
import locks
|
||||||
|
import callback
|
||||||
|
import asyncadapter
|
||||||
|
|
||||||
|
import util
|
||||||
|
|
||||||
|
LOG = logging.getLogger('PLEX.myplexaccount')
|
||||||
|
ACCOUNT = None
|
||||||
|
|
||||||
|
|
||||||
|
class HomeUser(util.AttributeDict):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MyPlexAccount(object):
|
||||||
|
def __init__(self):
|
||||||
|
# Strings
|
||||||
|
self.ID = None
|
||||||
|
self.title = None
|
||||||
|
self.username = None
|
||||||
|
self.thumb = None
|
||||||
|
self.email = None
|
||||||
|
self.authToken = None
|
||||||
|
self.pin = None
|
||||||
|
self.thumb = None
|
||||||
|
|
||||||
|
# Booleans
|
||||||
|
self.isAuthenticated = plexapp.INTERFACE.getPreference('auto_signin', False)
|
||||||
|
self.isSignedIn = False
|
||||||
|
self.isOffline = False
|
||||||
|
self.isExpired = False
|
||||||
|
self.isPlexPass = False
|
||||||
|
self.isManaged = False
|
||||||
|
self.isSecure = False
|
||||||
|
self.hasQueue = False
|
||||||
|
|
||||||
|
self.isAdmin = False
|
||||||
|
self.switchUser = False
|
||||||
|
|
||||||
|
self.adminHasPlexPass = False
|
||||||
|
|
||||||
|
self.lastHomeUserUpdate = None
|
||||||
|
self.homeUsers = []
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
self.loadState()
|
||||||
|
|
||||||
|
def saveState(self):
|
||||||
|
obj = {
|
||||||
|
'ID': self.ID,
|
||||||
|
'title': self.title,
|
||||||
|
'username': self.username,
|
||||||
|
'email': self.email,
|
||||||
|
'authToken': self.authToken,
|
||||||
|
'pin': self.pin,
|
||||||
|
'isPlexPass': self.isPlexPass,
|
||||||
|
'isManaged': self.isManaged,
|
||||||
|
'isAdmin': self.isAdmin,
|
||||||
|
'isSecure': self.isSecure,
|
||||||
|
'adminHasPlexPass': self.adminHasPlexPass
|
||||||
|
}
|
||||||
|
|
||||||
|
plexapp.INTERFACE.setRegistry("MyPlexAccount", json.dumps(obj), "myplex")
|
||||||
|
|
||||||
|
def loadState(self):
|
||||||
|
# Look for the new JSON serialization. If it's not there, look for the
|
||||||
|
# old token and Plex Pass values.
|
||||||
|
LOG.debug('Loading State')
|
||||||
|
plexapp.APP.addInitializer("myplex")
|
||||||
|
|
||||||
|
jstring = plexapp.INTERFACE.getRegistry("MyPlexAccount", None, "myplex")
|
||||||
|
|
||||||
|
if jstring:
|
||||||
|
try:
|
||||||
|
obj = json.loads(jstring)
|
||||||
|
except:
|
||||||
|
util.ERROR()
|
||||||
|
obj = None
|
||||||
|
|
||||||
|
if obj:
|
||||||
|
self.ID = obj.get('ID') or self.ID
|
||||||
|
self.title = obj.get('title') or self.title
|
||||||
|
self.username = obj.get('username') or self.username
|
||||||
|
self.email = obj.get('email') or self.email
|
||||||
|
self.authToken = obj.get('authToken') or self.authToken
|
||||||
|
self.pin = obj.get('pin') or self.pin
|
||||||
|
self.isPlexPass = obj.get('isPlexPass') or self.isPlexPass
|
||||||
|
self.isManaged = obj.get('isManaged') or self.isManaged
|
||||||
|
self.isAdmin = obj.get('isAdmin') or self.isAdmin
|
||||||
|
self.isSecure = obj.get('isSecure') or self.isSecure
|
||||||
|
self.isProtected = bool(obj.get('pin'))
|
||||||
|
self.adminHasPlexPass = obj.get('adminHasPlexPass') or self.adminHasPlexPass
|
||||||
|
|
||||||
|
if self.authToken:
|
||||||
|
request = myplexrequest.MyPlexRequest("/users/account")
|
||||||
|
context = request.createRequestContext("account", callback.Callable(self.onAccountResponse))
|
||||||
|
plexapp.APP.startRequest(request, context)
|
||||||
|
else:
|
||||||
|
plexapp.APP.clearInitializer("myplex")
|
||||||
|
|
||||||
|
def logState(self):
|
||||||
|
util.LOG("Authenticated as {0}:{1}".format(self.ID, repr(self.title)))
|
||||||
|
util.LOG("SignedIn: {0}".format(self.isSignedIn))
|
||||||
|
util.LOG("Offline: {0}".format(self.isOffline))
|
||||||
|
util.LOG("Authenticated: {0}".format(self.isAuthenticated))
|
||||||
|
util.LOG("PlexPass: {0}".format(self.isPlexPass))
|
||||||
|
util.LOG("Managed: {0}".format(self.isManaged))
|
||||||
|
util.LOG("Protected: {0}".format(self.isProtected))
|
||||||
|
util.LOG("Admin: {0}".format(self.isAdmin))
|
||||||
|
util.LOG("AdminPlexPass: {0}".format(self.adminHasPlexPass))
|
||||||
|
|
||||||
|
def onAccountResponse(self, request, response, context):
|
||||||
|
oldId = self.ID
|
||||||
|
|
||||||
|
if response.isSuccess():
|
||||||
|
data = response.getBodyXml()
|
||||||
|
|
||||||
|
# The user is signed in
|
||||||
|
self.isSignedIn = True
|
||||||
|
self.isOffline = False
|
||||||
|
self.ID = data.attrib.get('id')
|
||||||
|
self.title = data.attrib.get('title')
|
||||||
|
self.username = data.attrib.get('username')
|
||||||
|
self.email = data.attrib.get('email')
|
||||||
|
self.thumb = data.attrib.get('thumb')
|
||||||
|
self.authToken = data.attrib.get('authenticationToken')
|
||||||
|
self.isPlexPass = (data.find('subscription') is not None and data.find('subscription').attrib.get('active') == '1')
|
||||||
|
self.isManaged = data.attrib.get('restricted') == '1'
|
||||||
|
self.isSecure = data.attrib.get('secure') == '1'
|
||||||
|
self.hasQueue = bool(data.attrib.get('queueEmail'))
|
||||||
|
|
||||||
|
# PIN
|
||||||
|
self.pin = data.attrib.get('pin')
|
||||||
|
self.isProtected = bool(self.pin)
|
||||||
|
|
||||||
|
# update the list of users in the home
|
||||||
|
self.updateHomeUsers()
|
||||||
|
|
||||||
|
# set admin attribute for the user
|
||||||
|
self.isAdmin = False
|
||||||
|
if self.homeUsers:
|
||||||
|
for user in self.homeUsers:
|
||||||
|
if self.ID == user.id:
|
||||||
|
self.isAdmin = str(user.admin) == "1"
|
||||||
|
break
|
||||||
|
|
||||||
|
if self.isAdmin and self.isPlexPass:
|
||||||
|
self.adminHasPlexPass = True
|
||||||
|
|
||||||
|
# consider a single, unprotected user authenticated
|
||||||
|
if not self.isAuthenticated and not self.isProtected and len(self.homeUsers) <= 1:
|
||||||
|
self.isAuthenticated = True
|
||||||
|
|
||||||
|
self.logState()
|
||||||
|
|
||||||
|
self.saveState()
|
||||||
|
plexapp.MANAGER.publish()
|
||||||
|
plexapp.refreshResources()
|
||||||
|
elif response.getStatus() >= 400 and response.getStatus() < 500:
|
||||||
|
# The user is specifically unauthorized, clear everything
|
||||||
|
util.WARN_LOG("Sign Out: User is unauthorized")
|
||||||
|
self.signOut(True)
|
||||||
|
else:
|
||||||
|
# Unexpected error, keep using whatever we read from the registry
|
||||||
|
util.WARN_LOG("Unexpected response from plex.tv ({0}), switching to OFFLINE mode".format(response.getStatus()))
|
||||||
|
self.logState()
|
||||||
|
self.isOffline = True
|
||||||
|
# consider a single, unprotected user authenticated
|
||||||
|
if not self.isAuthenticated and not self.isProtected:
|
||||||
|
self.isAuthenticated = True
|
||||||
|
|
||||||
|
plexapp.APP.clearInitializer("myplex")
|
||||||
|
# Logger().UpdateSyslogHeader() # TODO: ------------------------------------------------------------------------------------------------------IMPLEMENT
|
||||||
|
|
||||||
|
if oldId != self.ID or self.switchUser:
|
||||||
|
self.switchUser = None
|
||||||
|
plexapp.APP.trigger("change:user", account=self, reallyChanged=oldId != self.ID)
|
||||||
|
|
||||||
|
plexapp.APP.trigger("account:response")
|
||||||
|
|
||||||
|
def signOut(self, expired=False):
|
||||||
|
# Strings
|
||||||
|
self.ID = None
|
||||||
|
self.title = None
|
||||||
|
self.username = None
|
||||||
|
self.email = None
|
||||||
|
self.authToken = None
|
||||||
|
self.pin = None
|
||||||
|
self.lastHomeUserUpdate = None
|
||||||
|
|
||||||
|
# Booleans
|
||||||
|
self.isSignedIn = False
|
||||||
|
self.isPlexPass = False
|
||||||
|
self.adminHasPlexPass = False
|
||||||
|
self.isManaged = False
|
||||||
|
self.isSecure = False
|
||||||
|
self.isExpired = expired
|
||||||
|
|
||||||
|
# Clear the saved resources
|
||||||
|
plexapp.INTERFACE.clearRegistry("mpaResources", "xml_cache")
|
||||||
|
|
||||||
|
# Remove all saved servers
|
||||||
|
plexapp.SERVERMANAGER.clearServers()
|
||||||
|
|
||||||
|
# Enable the welcome screen again
|
||||||
|
plexapp.INTERFACE.setPreference("show_welcome", True)
|
||||||
|
|
||||||
|
plexapp.APP.trigger("change:user", account=self, reallyChanged=True)
|
||||||
|
|
||||||
|
self.saveState()
|
||||||
|
|
||||||
|
def hasPlexPass(self):
|
||||||
|
if self.isPlexPass or self.isManaged:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return self.adminHasPlexPass
|
||||||
|
|
||||||
|
def validateToken(self, token, switchUser=False):
|
||||||
|
self.authToken = token
|
||||||
|
self.switchUser = switchUser
|
||||||
|
|
||||||
|
request = myplexrequest.MyPlexRequest("/users/sign_in.xml")
|
||||||
|
context = request.createRequestContext("sign_in", callback.Callable(self.onAccountResponse))
|
||||||
|
if self.isOffline:
|
||||||
|
context.timeout = self.isOffline and asyncadapter.AsyncTimeout(1).setConnectTimeout(1)
|
||||||
|
plexapp.APP.startRequest(request, context, {})
|
||||||
|
|
||||||
|
def refreshAccount(self):
|
||||||
|
if not self.authToken:
|
||||||
|
return
|
||||||
|
self.validateToken(self.authToken, False)
|
||||||
|
|
||||||
|
def updateHomeUsers(self):
|
||||||
|
# Ignore request and clear any home users we are not signed in
|
||||||
|
if not self.isSignedIn:
|
||||||
|
self.homeUsers = []
|
||||||
|
if self.isOffline:
|
||||||
|
self.homeUsers.append(MyPlexAccount())
|
||||||
|
|
||||||
|
self.lastHomeUserUpdate = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cache home users for 60 seconds, mainly to stop back to back tests
|
||||||
|
epoch = time.time()
|
||||||
|
if not self.lastHomeUserUpdate:
|
||||||
|
self.lastHomeUserUpdate = epoch
|
||||||
|
elif self.lastHomeUserUpdate + 60 > epoch:
|
||||||
|
util.DEBUG_LOG("Skipping home user update (updated {0} seconds ago)".format(epoch - self.lastHomeUserUpdate))
|
||||||
|
return
|
||||||
|
|
||||||
|
req = myplexrequest.MyPlexRequest("/api/home/users")
|
||||||
|
xml = req.getToStringWithTimeout()
|
||||||
|
data = ElementTree.fromstring(xml)
|
||||||
|
if data.attrib.get('size') and data.find('User') is not None:
|
||||||
|
self.homeUsers = []
|
||||||
|
for user in data.findall('User'):
|
||||||
|
homeUser = HomeUser(user.attrib)
|
||||||
|
homeUser.isAdmin = homeUser.admin == "1"
|
||||||
|
homeUser.isManaged = homeUser.restricted == "1"
|
||||||
|
homeUser.isProtected = homeUser.protected == "1"
|
||||||
|
self.homeUsers.append(homeUser)
|
||||||
|
|
||||||
|
self.lastHomeUserUpdate = epoch
|
||||||
|
|
||||||
|
util.LOG("home users: {0}".format(self.homeUsers))
|
||||||
|
|
||||||
|
def switchHomeUser(self, userId, pin=''):
|
||||||
|
if userId == self.ID and self.isAuthenticated:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Offline support
|
||||||
|
if self.isOffline:
|
||||||
|
hashed = 'NONE'
|
||||||
|
if pin and self.authToken:
|
||||||
|
hashed = hashlib.sha256(pin + self.authToken).digest()
|
||||||
|
|
||||||
|
if not self.isProtected or self.isAuthenticated or hashed == (self.pin or ""):
|
||||||
|
util.DEBUG_LOG("OFFLINE access granted")
|
||||||
|
self.isAuthenticated = True
|
||||||
|
self.validateToken(self.authToken, True)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# build path and post to myplex to swith the user
|
||||||
|
path = '/api/home/users/{0}/switch'.format(userId)
|
||||||
|
req = myplexrequest.MyPlexRequest(path)
|
||||||
|
xml = req.postToStringWithTimeout({'pin': pin})
|
||||||
|
data = ElementTree.fromstring(xml)
|
||||||
|
|
||||||
|
if data.attrib.get('authenticationToken'):
|
||||||
|
self.isAuthenticated = True
|
||||||
|
# validate the token (trigger change:user) on user change or channel startup
|
||||||
|
if userId != self.ID or not locks.LOCKS.isLocked("idleLock"):
|
||||||
|
self.validateToken(data.attrib.get('authenticationToken'), True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def isActive(self):
|
||||||
|
return self.isSignedIn or self.isOffline
|
||||||
|
|
||||||
|
|
||||||
|
ACCOUNT = MyPlexAccount()
|
76
resources/lib/plexnet/myplexmanager.py
Normal file
76
resources/lib/plexnet/myplexmanager.py
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
from defusedxml import ElementTree
|
||||||
|
import urllib
|
||||||
|
|
||||||
|
import plexapp
|
||||||
|
import plexconnection
|
||||||
|
import plexserver
|
||||||
|
import myplexrequest
|
||||||
|
import callback
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class MyPlexManager(object):
|
||||||
|
def publish(self):
|
||||||
|
util.LOG('MyPlexManager().publish() - NOT IMPLEMENTED')
|
||||||
|
return # TODO: ----------------------------------------------------------------------------------------------------------------------------- IMPLEMENT?
|
||||||
|
request = myplexrequest.MyPlexRequest("/devices/" + plexapp.INTERFACE.getGlobal("clientIdentifier"))
|
||||||
|
context = request.createRequestContext("publish")
|
||||||
|
|
||||||
|
addrs = plexapp.INTERFACE.getGlobal("roDeviceInfo").getIPAddrs()
|
||||||
|
|
||||||
|
for iface in addrs:
|
||||||
|
request.addParam(urllib.quote("Connection[][uri]"), "http://{0):8324".format(addrs[iface]))
|
||||||
|
|
||||||
|
plexapp.APP.startRequest(request, context, "_method=PUT")
|
||||||
|
|
||||||
|
def refreshResources(self, force=False):
|
||||||
|
if force:
|
||||||
|
plexapp.SERVERMANAGER.resetLastTest()
|
||||||
|
|
||||||
|
request = myplexrequest.MyPlexRequest("/pms/resources")
|
||||||
|
context = request.createRequestContext("resources", callback.Callable(self.onResourcesResponse))
|
||||||
|
|
||||||
|
if plexapp.ACCOUNT.isSecure:
|
||||||
|
request.addParam("includeHttps", "1")
|
||||||
|
|
||||||
|
plexapp.APP.startRequest(request, context)
|
||||||
|
|
||||||
|
def onResourcesResponse(self, request, response, context):
|
||||||
|
servers = []
|
||||||
|
|
||||||
|
response.parseResponse()
|
||||||
|
|
||||||
|
# Save the last successful response to cache
|
||||||
|
if response.isSuccess() and response.event:
|
||||||
|
plexapp.INTERFACE.setRegistry("mpaResources", response.event.text.encode('utf-8'), "xml_cache")
|
||||||
|
util.DEBUG_LOG("Saved resources response to registry")
|
||||||
|
# Load the last successful response from cache
|
||||||
|
elif plexapp.INTERFACE.getRegistry("mpaResources", None, "xml_cache"):
|
||||||
|
data = ElementTree.fromstring(plexapp.INTERFACE.getRegistry("mpaResources", None, "xml_cache"))
|
||||||
|
response.parseFakeXMLResponse(data)
|
||||||
|
util.DEBUG_LOG("Using cached resources")
|
||||||
|
|
||||||
|
if response.container:
|
||||||
|
for resource in response.container:
|
||||||
|
util.DEBUG_LOG(
|
||||||
|
"Parsed resource from plex.tv: type:{0} clientIdentifier:{1} name:{2} product:{3} provides:{4}".format(
|
||||||
|
resource.type,
|
||||||
|
resource.clientIdentifier,
|
||||||
|
resource.name.encode('utf-8'),
|
||||||
|
resource.product.encode('utf-8'),
|
||||||
|
resource.provides.encode('utf-8')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for conn in resource.connections:
|
||||||
|
util.DEBUG_LOG(' {0}'.format(conn))
|
||||||
|
|
||||||
|
if 'server' in resource.provides:
|
||||||
|
server = plexserver.createPlexServerForResource(resource)
|
||||||
|
util.DEBUG_LOG(' {0}'.format(server))
|
||||||
|
servers.append(server)
|
||||||
|
|
||||||
|
plexapp.SERVERMANAGER.updateFromConnectionType(servers, plexconnection.PlexConnection.SOURCE_MYPLEX)
|
||||||
|
|
||||||
|
|
||||||
|
MANAGER = MyPlexManager()
|
12
resources/lib/plexnet/myplexrequest.py
Normal file
12
resources/lib/plexnet/myplexrequest.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# We don't particularly need a class definition here (yet?), it's just a
|
||||||
|
# PlexRequest where the server is fixed.
|
||||||
|
import plexrequest
|
||||||
|
|
||||||
|
|
||||||
|
class MyPlexRequest(plexrequest.PlexServerRequest):
|
||||||
|
def __init__(self, path):
|
||||||
|
import myplexserver
|
||||||
|
plexrequest.PlexServerRequest.__init__(self, myplexserver.MyPlexServer(), path)
|
||||||
|
|
||||||
|
# Make sure we're always getting XML
|
||||||
|
self.addHeader("Accept", "application/xml")
|
35
resources/lib/plexnet/myplexserver.py
Normal file
35
resources/lib/plexnet/myplexserver.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import plexapp
|
||||||
|
import plexconnection
|
||||||
|
import plexserver
|
||||||
|
import plexresource
|
||||||
|
import plexservermanager
|
||||||
|
|
||||||
|
|
||||||
|
class MyPlexServer(plexserver.PlexServer):
|
||||||
|
TYPE = 'MYPLEXSERVER'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
plexserver.PlexServer.__init__(self)
|
||||||
|
self.uuid = 'myplex'
|
||||||
|
self.name = 'plex.tv'
|
||||||
|
conn = plexconnection.PlexConnection(plexresource.ResourceConnection.SOURCE_MYPLEX, "https://plex.tv", False, None)
|
||||||
|
self.connections.append(conn)
|
||||||
|
self.activeConnection = conn
|
||||||
|
|
||||||
|
def getToken(self):
|
||||||
|
return plexapp.ACCOUNT.authToken
|
||||||
|
|
||||||
|
def buildUrl(self, path, includeToken=False):
|
||||||
|
if "://node.plexapp.com" in path:
|
||||||
|
# Locate the best fit server that supports channels, otherwise we'll
|
||||||
|
# continue to use the node urls. Service code between the node and
|
||||||
|
# PMS differs sometimes, so it's a toss up which one is actually
|
||||||
|
# more accurate. Either way, we try to offload work from the node.
|
||||||
|
|
||||||
|
server = plexservermanager.MANAGER.getChannelServer()
|
||||||
|
if server:
|
||||||
|
url = server.swizzleUrl(path, includeToken)
|
||||||
|
if url:
|
||||||
|
return url
|
||||||
|
|
||||||
|
return plexserver.PlexServer.buildUrl(self, path, includeToken)
|
186
resources/lib/plexnet/netif/__init__.py
Normal file
186
resources/lib/plexnet/netif/__init__.py
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
|
||||||
|
class Interface:
|
||||||
|
def __init__(self):
|
||||||
|
self.name = ''
|
||||||
|
self.ip = ''
|
||||||
|
self.mask = ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def broadcast(self):
|
||||||
|
if self.name == 'FALLBACK': return '<broadcast>'
|
||||||
|
if not self.ip or not self.mask: return None
|
||||||
|
return calcBroadcast(self.ip,self.mask)
|
||||||
|
|
||||||
|
def getInterfaces():
|
||||||
|
try:
|
||||||
|
return _getInterfaces()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _getInterfacesBSD()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _getInterfacesWin()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
i = Interface()
|
||||||
|
i.name = 'FALLBACK'
|
||||||
|
return [i]
|
||||||
|
|
||||||
|
def _getInterfaces():
|
||||||
|
vals = all_interfaces()
|
||||||
|
interfaces = []
|
||||||
|
for name,ip in vals:
|
||||||
|
i = Interface()
|
||||||
|
i.name = name
|
||||||
|
i.ip = ip
|
||||||
|
try:
|
||||||
|
mask = getSubnetMask(i.name)
|
||||||
|
i.mask = mask
|
||||||
|
except:
|
||||||
|
i.mask = ''
|
||||||
|
interfaces.append(i)
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
def _getInterfacesBSD():
|
||||||
|
#name flags family address netmask
|
||||||
|
interfaces = []
|
||||||
|
import getifaddrs
|
||||||
|
for info in getifaddrs.getifaddrs():
|
||||||
|
if info.family == 2:
|
||||||
|
i = Interface()
|
||||||
|
i.name = info.name
|
||||||
|
i.ip = info.address
|
||||||
|
i.mask = info.netmask
|
||||||
|
interfaces.append(i)
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
def _getInterfacesWin():
|
||||||
|
import ipconfig
|
||||||
|
interfaces = []
|
||||||
|
adapters = ipconfig.parse()
|
||||||
|
for a in adapters:
|
||||||
|
if not 'IPv4 Address' in a: continue
|
||||||
|
if not 'Subnet Mask' in a: continue
|
||||||
|
i = Interface()
|
||||||
|
i.name = a.get('name','UNKNOWN')
|
||||||
|
i.ip = a['IPv4 Address']
|
||||||
|
i.mask = a['Subnet Mask']
|
||||||
|
interfaces.append(i)
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
def all_interfaces():
|
||||||
|
import sys
|
||||||
|
import array
|
||||||
|
import fcntl
|
||||||
|
|
||||||
|
is_64bits = sys.maxsize > 2**32
|
||||||
|
struct_size = 40 if is_64bits else 32
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
max_possible = 8 # initial value
|
||||||
|
while True:
|
||||||
|
bytes = max_possible * struct_size
|
||||||
|
names = array.array('B', '\0' * bytes)
|
||||||
|
outbytes = struct.unpack('iL', fcntl.ioctl(
|
||||||
|
s.fileno(),
|
||||||
|
0x8912, # SIOCGIFCONF
|
||||||
|
struct.pack('iL', bytes, names.buffer_info()[0])
|
||||||
|
))[0]
|
||||||
|
if outbytes == bytes:
|
||||||
|
max_possible *= 2
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
namestr = names.tostring()
|
||||||
|
return [(namestr[i:i+16].split('\0', 1)[0],
|
||||||
|
socket.inet_ntoa(namestr[i+20:i+24]))
|
||||||
|
for i in range(0, outbytes, struct_size)]
|
||||||
|
|
||||||
|
def getSubnetMask(name):
|
||||||
|
import fcntl
|
||||||
|
return socket.inet_ntoa(fcntl.ioctl(socket.socket(socket.AF_INET, socket.SOCK_DGRAM), 35099, struct.pack('256s', name))[20:24])
|
||||||
|
|
||||||
|
def calcIPValue(ipaddr):
|
||||||
|
"""
|
||||||
|
Calculates the binary
|
||||||
|
value of the ip addresse
|
||||||
|
"""
|
||||||
|
ipaddr = ipaddr.split('.')
|
||||||
|
value = 0
|
||||||
|
for i in range(len(ipaddr)):
|
||||||
|
value = value | (int(ipaddr[i]) << ( 8*(3-i) ))
|
||||||
|
return value
|
||||||
|
|
||||||
|
def calcIPNotation(value):
|
||||||
|
"""
|
||||||
|
Calculates the notation
|
||||||
|
of the ip addresse given its value
|
||||||
|
"""
|
||||||
|
notat = []
|
||||||
|
for i in range(4):
|
||||||
|
shift = 255 << ( 8*(3-i) )
|
||||||
|
part = value & shift
|
||||||
|
part = part >> ( 8*(3-i) )
|
||||||
|
notat.append(str(part))
|
||||||
|
notat = '.'.join(notat)
|
||||||
|
return notat
|
||||||
|
|
||||||
|
def calcSubnet(cidr):
|
||||||
|
"""
|
||||||
|
Calculates the Subnet
|
||||||
|
based on the CIDR
|
||||||
|
"""
|
||||||
|
subn = 4294967295 << (32-cidr) # 4294967295 = all bits set to 1
|
||||||
|
subn = subn % 4294967296 # round it back to be 4 bytes
|
||||||
|
subn = calcIPNotation(subn)
|
||||||
|
return subn
|
||||||
|
|
||||||
|
def calcCIDR(subnet):
|
||||||
|
"""
|
||||||
|
Calculates the CIDR
|
||||||
|
based on the SUbnet
|
||||||
|
"""
|
||||||
|
cidr = 0
|
||||||
|
subnet = calcIPValue(subnet)
|
||||||
|
while subnet != 0:
|
||||||
|
subnet = subnet << 1
|
||||||
|
subnet = subnet % 4294967296
|
||||||
|
cidr += 1
|
||||||
|
return cidr
|
||||||
|
|
||||||
|
def calcNetpart(ipaddr,subnet):
|
||||||
|
ipaddr = calcIPValue(ipaddr)
|
||||||
|
subnet = calcIPValue(subnet)
|
||||||
|
netpart = ipaddr & subnet
|
||||||
|
netpart = calcIPNotation(netpart)
|
||||||
|
return netpart
|
||||||
|
|
||||||
|
def calcMacpart(subnet):
|
||||||
|
macpart = ~calcIPValue(subnet)
|
||||||
|
macpart = calcIPNotation(macpart)
|
||||||
|
return macpart
|
||||||
|
|
||||||
|
def calcBroadcast(ipaddr,subnet):
|
||||||
|
netpart = calcNetpart(ipaddr,subnet)
|
||||||
|
macpart = calcMacpart(subnet)
|
||||||
|
netpart = calcIPValue(netpart)
|
||||||
|
macpart = calcIPValue(macpart)
|
||||||
|
broadcast = netpart | macpart
|
||||||
|
broadcast = calcIPNotation(broadcast)
|
||||||
|
return broadcast
|
||||||
|
|
||||||
|
def calcDefaultGate(ipaddr,subnet):
|
||||||
|
defaultgw = calcNetpart(ipaddr,subnet)
|
||||||
|
defaultgw = calcIPValue(defaultgw) + 1
|
||||||
|
defaultgw = calcIPNotation(defaultgw)
|
||||||
|
return defaultgw
|
||||||
|
|
||||||
|
def calcHostNum(subnet):
|
||||||
|
macpart = calcMacpart(subnet)
|
||||||
|
hostnum = calcIPValue(macpart) - 1
|
||||||
|
return hostnum
|
188
resources/lib/plexnet/netif/getifaddrs.py
Normal file
188
resources/lib/plexnet/netif/getifaddrs.py
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
"""
|
||||||
|
Wrapper for getifaddrs(3).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from collections import namedtuple
|
||||||
|
from ctypes import *
|
||||||
|
|
||||||
|
class sockaddr_in(Structure):
|
||||||
|
_fields_ = [
|
||||||
|
('sin_len', c_uint8),
|
||||||
|
('sin_family', c_uint8),
|
||||||
|
('sin_port', c_uint16),
|
||||||
|
('sin_addr', c_uint8 * 4),
|
||||||
|
('sin_zero', c_uint8 * 8)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
try:
|
||||||
|
assert self.sin_len >= sizeof(sockaddr_in)
|
||||||
|
data = ''.join(map(chr, self.sin_addr))
|
||||||
|
return socket.inet_ntop(socket.AF_INET, data)
|
||||||
|
except:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
class sockaddr_in6(Structure):
|
||||||
|
_fields_ = [
|
||||||
|
('sin6_len', c_uint8),
|
||||||
|
('sin6_family', c_uint8),
|
||||||
|
('sin6_port', c_uint16),
|
||||||
|
('sin6_flowinfo', c_uint32),
|
||||||
|
('sin6_addr', c_uint8 * 16),
|
||||||
|
('sin6_scope_id', c_uint32)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
try:
|
||||||
|
assert self.sin6_len >= sizeof(sockaddr_in6)
|
||||||
|
data = ''.join(map(chr, self.sin6_addr))
|
||||||
|
return socket.inet_ntop(socket.AF_INET6, data)
|
||||||
|
except:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
class sockaddr_dl(Structure):
|
||||||
|
_fields_ = [
|
||||||
|
('sdl_len', c_uint8),
|
||||||
|
('sdl_family', c_uint8),
|
||||||
|
('sdl_index', c_short),
|
||||||
|
('sdl_type', c_uint8),
|
||||||
|
('sdl_nlen', c_uint8),
|
||||||
|
('sdl_alen', c_uint8),
|
||||||
|
('sdl_slen', c_uint8),
|
||||||
|
('sdl_data', c_uint8 * 12)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
assert self.sdl_len >= sizeof(sockaddr_dl)
|
||||||
|
addrdata = self.sdl_data[self.sdl_nlen:self.sdl_nlen+self.sdl_alen]
|
||||||
|
return ':'.join('%02x' % x for x in addrdata)
|
||||||
|
|
||||||
|
class sockaddr_storage(Structure):
|
||||||
|
_fields_ = [
|
||||||
|
('sa_len', c_uint8),
|
||||||
|
('sa_family', c_uint8),
|
||||||
|
('sa_data', c_uint8 * 254)
|
||||||
|
]
|
||||||
|
|
||||||
|
class sockaddr(Union):
|
||||||
|
_anonymous_ = ('sa_storage', )
|
||||||
|
_fields_ = [
|
||||||
|
('sa_storage', sockaddr_storage),
|
||||||
|
('sa_sin', sockaddr_in),
|
||||||
|
('sa_sin6', sockaddr_in6),
|
||||||
|
('sa_sdl', sockaddr_dl),
|
||||||
|
]
|
||||||
|
|
||||||
|
def family(self):
|
||||||
|
return self.sa_storage.sa_family
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
family = self.family()
|
||||||
|
if family == socket.AF_INET:
|
||||||
|
return str(self.sa_sin)
|
||||||
|
elif family == socket.AF_INET6:
|
||||||
|
return str(self.sa_sin6)
|
||||||
|
elif family == 18: # AF_LINK
|
||||||
|
return str(self.sa_sdl)
|
||||||
|
else:
|
||||||
|
print family
|
||||||
|
raise NotImplementedError, "address family %d not supported" % family
|
||||||
|
|
||||||
|
|
||||||
|
class ifaddrs(Structure):
|
||||||
|
pass
|
||||||
|
ifaddrs._fields_ = [
|
||||||
|
('ifa_next', POINTER(ifaddrs)),
|
||||||
|
('ifa_name', c_char_p),
|
||||||
|
('ifa_flags', c_uint),
|
||||||
|
('ifa_addr', POINTER(sockaddr)),
|
||||||
|
('ifa_netmask', POINTER(sockaddr)),
|
||||||
|
('ifa_dstaddr', POINTER(sockaddr)),
|
||||||
|
('ifa_data', c_void_p)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Define constants for the most useful interface flags (from if.h).
|
||||||
|
IFF_UP = 0x0001
|
||||||
|
IFF_BROADCAST = 0x0002
|
||||||
|
IFF_LOOPBACK = 0x0008
|
||||||
|
IFF_POINTTOPOINT = 0x0010
|
||||||
|
IFF_RUNNING = 0x0040
|
||||||
|
if sys.platform == 'darwin' or 'bsd' in sys.platform:
|
||||||
|
IFF_MULTICAST = 0x8000
|
||||||
|
elif sys.platform == 'linux':
|
||||||
|
IFF_MULTICAST = 0x1000
|
||||||
|
|
||||||
|
# Load library implementing getifaddrs and freeifaddrs.
|
||||||
|
if sys.platform == 'darwin':
|
||||||
|
libc = cdll.LoadLibrary('libc.dylib')
|
||||||
|
else:
|
||||||
|
libc = cdll.LoadLibrary('libc.so')
|
||||||
|
|
||||||
|
# Tell ctypes the argument and return types for the getifaddrs and
|
||||||
|
# freeifaddrs functions so it can do marshalling for us.
|
||||||
|
libc.getifaddrs.argtypes = [POINTER(POINTER(ifaddrs))]
|
||||||
|
libc.getifaddrs.restype = c_int
|
||||||
|
libc.freeifaddrs.argtypes = [POINTER(ifaddrs)]
|
||||||
|
|
||||||
|
|
||||||
|
def getifaddrs():
|
||||||
|
"""
|
||||||
|
Get local interface addresses.
|
||||||
|
|
||||||
|
Returns generator of tuples consisting of interface name, interface flags,
|
||||||
|
address family (e.g. socket.AF_INET, socket.AF_INET6), address, and netmask.
|
||||||
|
The tuple members can also be accessed via the names 'name', 'flags',
|
||||||
|
'family', 'address', and 'netmask', respectively.
|
||||||
|
"""
|
||||||
|
# Get address information for each interface.
|
||||||
|
addrlist = POINTER(ifaddrs)()
|
||||||
|
if libc.getifaddrs(pointer(addrlist)) < 0:
|
||||||
|
raise OSError
|
||||||
|
|
||||||
|
X = namedtuple('ifaddrs', 'name flags family address netmask')
|
||||||
|
|
||||||
|
# Iterate through the address information.
|
||||||
|
ifaddr = addrlist
|
||||||
|
while ifaddr and ifaddr.contents:
|
||||||
|
# The following is a hack to workaround a bug in FreeBSD
|
||||||
|
# (PR kern/152036) and MacOSX wherein the netmask's sockaddr may be
|
||||||
|
# truncated. Specifically, AF_INET netmasks may have their sin_addr
|
||||||
|
# member truncated to the minimum number of bytes necessary to
|
||||||
|
# represent the netmask. For example, a sockaddr_in with the netmask
|
||||||
|
# 255.255.254.0 may be truncated to 7 bytes (rather than the normal
|
||||||
|
# 16) such that the sin_addr field only contains 0xff, 0xff, 0xfe.
|
||||||
|
# All bytes beyond sa_len bytes are assumed to be zero. Here we work
|
||||||
|
# around this truncation by copying the netmask's sockaddr into a
|
||||||
|
# zero-filled buffer.
|
||||||
|
if ifaddr.contents.ifa_netmask:
|
||||||
|
netmask = sockaddr()
|
||||||
|
memmove(byref(netmask), ifaddr.contents.ifa_netmask,
|
||||||
|
ifaddr.contents.ifa_netmask.contents.sa_len)
|
||||||
|
if netmask.sa_family == socket.AF_INET and netmask.sa_len < sizeof(sockaddr_in):
|
||||||
|
netmask.sa_len = sizeof(sockaddr_in)
|
||||||
|
else:
|
||||||
|
netmask = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield X(ifaddr.contents.ifa_name,
|
||||||
|
ifaddr.contents.ifa_flags,
|
||||||
|
ifaddr.contents.ifa_addr.contents.family(),
|
||||||
|
str(ifaddr.contents.ifa_addr.contents),
|
||||||
|
str(netmask) if netmask else None)
|
||||||
|
except NotImplementedError:
|
||||||
|
# Unsupported address family.
|
||||||
|
yield X(ifaddr.contents.ifa_name,
|
||||||
|
ifaddr.contents.ifa_flags,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None)
|
||||||
|
ifaddr = ifaddr.contents.ifa_next
|
||||||
|
|
||||||
|
# When we are done with the address list, ask libc to free whatever memory
|
||||||
|
# it allocated for the list.
|
||||||
|
libc.freeifaddrs(addrlist)
|
||||||
|
|
||||||
|
__all__ = ['getifaddrs'] + [n for n in dir() if n.startswith('IFF_')]
|
51
resources/lib/plexnet/netif/ipconfig.py
Normal file
51
resources/lib/plexnet/netif/ipconfig.py
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def parse(data=None):
|
||||||
|
data = data or subprocess.check_output('ipconfig /all',startupinfo=getStartupInfo())
|
||||||
|
dlist = [d.rstrip() for d in data.split('\n')]
|
||||||
|
mode = None
|
||||||
|
sections = []
|
||||||
|
while dlist:
|
||||||
|
d = dlist.pop(0)
|
||||||
|
try:
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
elif not d.startswith(' '):
|
||||||
|
sections.append({'name':d.strip('.: ')})
|
||||||
|
elif d.startswith(' '):
|
||||||
|
if d.endswith(':'):
|
||||||
|
k = d.strip(':. ')
|
||||||
|
mode = 'VALUE:' + k
|
||||||
|
sections[-1][k] = ''
|
||||||
|
elif ':' in d:
|
||||||
|
k,v = d.split(':',1)
|
||||||
|
k = k.strip(':. ')
|
||||||
|
mode = 'VALUE:' + k
|
||||||
|
v = v.replace('(Preferred)','')
|
||||||
|
sections[-1][k] = v.strip()
|
||||||
|
elif mode and mode.startswith('VALUE:'):
|
||||||
|
if not d.startswith(' '):
|
||||||
|
mode = None
|
||||||
|
dlist.insert(0,d)
|
||||||
|
continue
|
||||||
|
k = mode.split(':',1)[-1]
|
||||||
|
v = d.replace('(Preferred)','')
|
||||||
|
sections[-1][k] += ',' + v.strip()
|
||||||
|
except:
|
||||||
|
print d
|
||||||
|
raise
|
||||||
|
|
||||||
|
return sections[1:]
|
||||||
|
|
||||||
|
def getStartupInfo():
|
||||||
|
if hasattr(subprocess,'STARTUPINFO'): #Windows
|
||||||
|
startupinfo = subprocess.STARTUPINFO()
|
||||||
|
try:
|
||||||
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW #Suppress terminal window
|
||||||
|
except:
|
||||||
|
startupinfo.dwFlags |= 1
|
||||||
|
return startupinfo
|
||||||
|
|
||||||
|
return None
|
212
resources/lib/plexnet/nowplayingmanager.py
Normal file
212
resources/lib/plexnet/nowplayingmanager.py
Normal file
|
@ -0,0 +1,212 @@
|
||||||
|
# Most of this is ported from Roku code and much of it is currently unused
|
||||||
|
# TODO: Perhaps remove unnecessary code
|
||||||
|
import time
|
||||||
|
|
||||||
|
import util
|
||||||
|
import urllib
|
||||||
|
import urlparse
|
||||||
|
import plexapp
|
||||||
|
import plexrequest
|
||||||
|
import callback
|
||||||
|
import http
|
||||||
|
|
||||||
|
|
||||||
|
class ServerTimeline(util.AttributeDict):
|
||||||
|
def reset(self):
|
||||||
|
self.expires = time.time() + 15
|
||||||
|
|
||||||
|
def isExpired(self):
|
||||||
|
return time.time() > self.get('expires', 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineData(util.AttributeDict):
|
||||||
|
def __init__(self, timelineType, *args, **kwargs):
|
||||||
|
util.AttributeDict.__init__(self, *args, **kwargs)
|
||||||
|
self.type = timelineType
|
||||||
|
self.state = "stopped"
|
||||||
|
self.item = None
|
||||||
|
self.choice = None
|
||||||
|
self.playQueue = None
|
||||||
|
|
||||||
|
self.controllable = util.AttributeDict()
|
||||||
|
self.controllableStr = None
|
||||||
|
|
||||||
|
self.attrs = util.AttributeDict()
|
||||||
|
|
||||||
|
# Set default controllable for all content. Other controllable aspects
|
||||||
|
# will be set based on the players content.
|
||||||
|
#
|
||||||
|
self.setControllable("playPause", True)
|
||||||
|
self.setControllable("stop", True)
|
||||||
|
|
||||||
|
def setControllable(self, name, isControllable):
|
||||||
|
if isControllable:
|
||||||
|
self.controllable[name] = ""
|
||||||
|
else:
|
||||||
|
if name in self.controllable:
|
||||||
|
del self.controllable[name]
|
||||||
|
|
||||||
|
self.controllableStr = None
|
||||||
|
|
||||||
|
def updateControllableStr(self):
|
||||||
|
if not self.controllableStr:
|
||||||
|
self.controllableStr = ""
|
||||||
|
prependComma = False
|
||||||
|
|
||||||
|
for name in self.controllable:
|
||||||
|
if prependComma:
|
||||||
|
self.controllableStr += ','
|
||||||
|
else:
|
||||||
|
prependComma = True
|
||||||
|
self.controllableStr += name
|
||||||
|
|
||||||
|
def toXmlAttributes(self, elem):
|
||||||
|
self.updateControllableStr()
|
||||||
|
elem.attrib["type"] = self.type
|
||||||
|
elem.attrib["start"] = self.state
|
||||||
|
elem.attrib["controllable"] = self.controllableStr
|
||||||
|
|
||||||
|
if self.item:
|
||||||
|
if self.item.duration:
|
||||||
|
elem.attrib['duration'] = self.item.duration
|
||||||
|
if self.item.ratingKey:
|
||||||
|
elem.attrib['ratingKey'] = self.item.ratingKey
|
||||||
|
if self.item.key:
|
||||||
|
elem.attrib['key'] = self.item.key
|
||||||
|
if self.item.container.address:
|
||||||
|
elem.attrib['containerKey'] = self.item.container.address
|
||||||
|
|
||||||
|
# Send the audio, video and subtitle choice if it's available
|
||||||
|
if self.choice:
|
||||||
|
for stream in ("audioStream", "videoStream", "subtitleStream"):
|
||||||
|
if self.choice.get(stream) and self.choice[stream].id:
|
||||||
|
elem.attrib[stream + "ID"] = self.choice[stream].id
|
||||||
|
|
||||||
|
server = self.item.getServer()
|
||||||
|
if server:
|
||||||
|
elem.attrib["machineIdentifier"] = server.uuid
|
||||||
|
|
||||||
|
if server.activeConnection:
|
||||||
|
parts = urlparse.uslparse(server.activeConnection.address)
|
||||||
|
elem.attrib["protocol"] = parts.scheme
|
||||||
|
elem.attrib["address"] = parts.netloc.split(':', 1)[0]
|
||||||
|
if ':' in parts.netloc:
|
||||||
|
elem.attrib["port"] = parts.netloc.split(':', 1)[-1]
|
||||||
|
elif parts.scheme == 'https':
|
||||||
|
elem.attrib["port"] = '443'
|
||||||
|
else:
|
||||||
|
elem.attrib["port"] = '80'
|
||||||
|
|
||||||
|
if self.playQueue:
|
||||||
|
elem.attrib["playQueueID"] = str(self.playQueue.id)
|
||||||
|
elem.attrib["playQueueItemID"] = str(self.playQueue.selectedId)
|
||||||
|
elem.attrib["playQueueVersion"] = str(self.playQueue.version)
|
||||||
|
|
||||||
|
for key, val in self.attrs.items():
|
||||||
|
elem.attrib[key] = val
|
||||||
|
|
||||||
|
|
||||||
|
class NowPlayingManager(object):
|
||||||
|
def __init__(self):
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
self.NAVIGATION = "navigation"
|
||||||
|
self.FULLSCREEN_VIDEO = "fullScreenVideo"
|
||||||
|
self.FULLSCREEN_MUSIC = "fullScreenMusic"
|
||||||
|
self.FULLSCREEN_PHOTO = "fullScreenPhoto"
|
||||||
|
self.TIMELINE_TYPES = ["video", "music", "photo"]
|
||||||
|
|
||||||
|
# Members
|
||||||
|
self.serverTimelines = util.AttributeDict()
|
||||||
|
self.subscribers = util.AttributeDict()
|
||||||
|
self.pollReplies = util.AttributeDict()
|
||||||
|
self.timelines = util.AttributeDict()
|
||||||
|
self.location = self.NAVIGATION
|
||||||
|
|
||||||
|
self.textFieldName = None
|
||||||
|
self.textFieldContent = None
|
||||||
|
self.textFieldSecure = None
|
||||||
|
|
||||||
|
# Initialization
|
||||||
|
for timelineType in self.TIMELINE_TYPES:
|
||||||
|
self.timelines[timelineType] = TimelineData(timelineType)
|
||||||
|
|
||||||
|
def updatePlaybackState(self, timelineType, playerObject, state, time, playQueue=None, duration=0):
|
||||||
|
timeline = self.timelines[timelineType]
|
||||||
|
timeline.state = state
|
||||||
|
timeline.item = playerObject.item
|
||||||
|
timeline.choice = playerObject.choice
|
||||||
|
timeline.playQueue = playQueue
|
||||||
|
timeline.attrs["time"] = str(time)
|
||||||
|
timeline.duration = duration
|
||||||
|
|
||||||
|
# self.sendTimelineToAll()
|
||||||
|
|
||||||
|
self.sendTimelineToServer(timelineType, timeline, time)
|
||||||
|
|
||||||
|
def sendTimelineToServer(self, timelineType, timeline, time):
|
||||||
|
if not hasattr(timeline.item, 'getServer') or not timeline.item.getServer():
|
||||||
|
return
|
||||||
|
|
||||||
|
serverTimeline = self.getServerTimeline(timelineType)
|
||||||
|
|
||||||
|
# Only send timeline if it's the first, item changes, playstate changes or timer pops
|
||||||
|
itemsEqual = timeline.item and serverTimeline.item and timeline.item.ratingKey == serverTimeline.item.ratingKey
|
||||||
|
if itemsEqual and timeline.state == serverTimeline.state and not serverTimeline.isExpired():
|
||||||
|
return
|
||||||
|
|
||||||
|
serverTimeline.reset()
|
||||||
|
serverTimeline.item = timeline.item
|
||||||
|
serverTimeline.state = timeline.state
|
||||||
|
|
||||||
|
# Ignore sending timelines for multi part media with no duration
|
||||||
|
obj = timeline.choice
|
||||||
|
if obj and obj.part and obj.part.duration.asInt() == 0 and obj.media.parts and len(obj.media.parts) > 1:
|
||||||
|
util.WARN_LOG("Timeline not supported: the current part doesn't have a valid duration")
|
||||||
|
return
|
||||||
|
|
||||||
|
# It's possible with timers and in player seeking for the time to be greater than the
|
||||||
|
# duration, which causes a 400, so in that case we'll set the time to the duration.
|
||||||
|
duration = timeline.item.duration.asInt() or timeline.duration
|
||||||
|
if time > duration:
|
||||||
|
time = duration
|
||||||
|
|
||||||
|
params = util.AttributeDict()
|
||||||
|
params["time"] = time
|
||||||
|
params["duration"] = duration
|
||||||
|
params["state"] = timeline.state
|
||||||
|
params["guid"] = timeline.item.guid
|
||||||
|
params["ratingKey"] = timeline.item.ratingKey
|
||||||
|
params["url"] = timeline.item.url
|
||||||
|
params["key"] = timeline.item.key
|
||||||
|
params["containerKey"] = timeline.item.container.address
|
||||||
|
if timeline.playQueue:
|
||||||
|
params["playQueueItemID"] = timeline.playQueue.selectedId
|
||||||
|
|
||||||
|
path = "/:/timeline"
|
||||||
|
for paramKey in params:
|
||||||
|
if params[paramKey]:
|
||||||
|
path = http.addUrlParam(path, paramKey + "=" + urllib.quote(str(params[paramKey])))
|
||||||
|
|
||||||
|
request = plexrequest.PlexRequest(timeline.item.getServer(), path)
|
||||||
|
context = request.createRequestContext("timelineUpdate", callback.Callable(self.onTimelineResponse))
|
||||||
|
context.playQueue = timeline.playQueue
|
||||||
|
plexapp.APP.startRequest(request, context)
|
||||||
|
|
||||||
|
def getServerTimeline(self, timelineType):
|
||||||
|
if not self.serverTimelines.get(timelineType):
|
||||||
|
serverTL = ServerTimeline()
|
||||||
|
serverTL.reset()
|
||||||
|
|
||||||
|
self.serverTimelines[timelineType] = serverTL
|
||||||
|
|
||||||
|
return self.serverTimelines[timelineType]
|
||||||
|
|
||||||
|
def nowPlayingSetControllable(self, timelineType, name, isControllable):
|
||||||
|
self.timelines[timelineType].setControllable(name, isControllable)
|
||||||
|
|
||||||
|
def onTimelineResponse(self, request, response, context):
|
||||||
|
if not context.playQueue or not context.playQueue.refreshOnTimeline:
|
||||||
|
return
|
||||||
|
context.playQueue.refreshOnTimeline = False
|
||||||
|
context.playQueue.refresh(False)
|
59
resources/lib/plexnet/photo.py
Normal file
59
resources/lib/plexnet/photo.py
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import media
|
||||||
|
import plexobjects
|
||||||
|
import plexmedia
|
||||||
|
|
||||||
|
|
||||||
|
class Photo(media.MediaItem):
|
||||||
|
TYPE = 'photo'
|
||||||
|
|
||||||
|
def _setData(self, data):
|
||||||
|
media.MediaItem._setData(self, data)
|
||||||
|
|
||||||
|
if self.isFullObject():
|
||||||
|
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||||
|
|
||||||
|
def analyze(self):
|
||||||
|
""" The primary purpose of media analysis is to gather information about that media
|
||||||
|
item. All of the media you add to a Library has properties that are useful to
|
||||||
|
know–whether it's a video file, a music track, or one of your photos.
|
||||||
|
"""
|
||||||
|
self.server.query('/%s/analyze' % self.key)
|
||||||
|
|
||||||
|
def markWatched(self):
|
||||||
|
path = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||||
|
self.server.query(path)
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def markUnwatched(self):
|
||||||
|
path = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||||
|
self.server.query(path)
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def play(self, client):
|
||||||
|
client.playMedia(self)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
self.server.query('%s/refresh' % self.key, method=self.server.session.put)
|
||||||
|
|
||||||
|
def isPhotoOrDirectoryItem(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoDirectory(media.MediaItem):
|
||||||
|
TYPE = 'photodirectory'
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
path = self.key
|
||||||
|
return plexobjects.listItems(self.server, path)
|
||||||
|
|
||||||
|
def isPhotoOrDirectoryItem(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@plexobjects.registerLibFactory('photo')
|
||||||
|
def PhotoFactory(data, initpath=None, server=None, container=None):
|
||||||
|
if data.tag == 'Photo':
|
||||||
|
return Photo(data, initpath=initpath, server=server, container=container)
|
||||||
|
else:
|
||||||
|
return PhotoDirectory(data, initpath=initpath, server=server, container=container)
|
179
resources/lib/plexnet/playlist.py
Normal file
179
resources/lib/plexnet/playlist.py
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
import random
|
||||||
|
|
||||||
|
import plexobjects
|
||||||
|
import signalsmixin
|
||||||
|
|
||||||
|
|
||||||
|
class BasePlaylist(plexobjects.PlexObject, signalsmixin.SignalsMixin):
|
||||||
|
TYPE = 'baseplaylist'
|
||||||
|
|
||||||
|
isRemote = False
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
plexobjects.PlexObject.__init__(self, *args, **kwargs)
|
||||||
|
signalsmixin.SignalsMixin.__init__(self)
|
||||||
|
self._items = []
|
||||||
|
self._shuffle = None
|
||||||
|
self.pos = 0
|
||||||
|
self.startShuffled = False
|
||||||
|
self.isRepeat = False
|
||||||
|
self.isRepeatOne = False
|
||||||
|
|
||||||
|
def __getitem__(self, idx):
|
||||||
|
if self._shuffle:
|
||||||
|
return self._items[self._shuffle[idx]]
|
||||||
|
else:
|
||||||
|
return self._items[idx]
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
if self._shuffle:
|
||||||
|
for i in self._shuffle:
|
||||||
|
yield self._items[i]
|
||||||
|
else:
|
||||||
|
for i in self._items:
|
||||||
|
yield i
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self._items)
|
||||||
|
|
||||||
|
def items(self):
|
||||||
|
if self._shuffle:
|
||||||
|
return [i for i in self]
|
||||||
|
else:
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
def setRepeat(self, repeat, one=False):
|
||||||
|
if self.isRepeat == repeat and self.isRepeatOne == one:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.isRepeat = repeat
|
||||||
|
self.isRepeatOne = one
|
||||||
|
|
||||||
|
def hasNext(self):
|
||||||
|
if len(self._items) < 2:
|
||||||
|
return False
|
||||||
|
if self.isRepeatOne:
|
||||||
|
return False
|
||||||
|
if self.isRepeat:
|
||||||
|
return True
|
||||||
|
return self.pos < len(self._items) - 1
|
||||||
|
|
||||||
|
def hasPrev(self):
|
||||||
|
if len(self._items) < 2:
|
||||||
|
return False
|
||||||
|
if self.isRepeatOne:
|
||||||
|
return False
|
||||||
|
if self.isRepeat:
|
||||||
|
return True
|
||||||
|
return self.pos > 0
|
||||||
|
|
||||||
|
def next(self):
|
||||||
|
if not self.hasNext():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.isRepeatOne:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.pos += 1
|
||||||
|
if self.pos >= len(self._items):
|
||||||
|
self.pos = 0
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def prev(self):
|
||||||
|
if not self.hasPrev():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.isRepeatOne:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.pos -= 1
|
||||||
|
if self.pos < 0:
|
||||||
|
self.pos = len(self._items) - 1
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def getPosFromItem(self, item):
|
||||||
|
if item not in self._items:
|
||||||
|
return -1
|
||||||
|
return self._items.index(item)
|
||||||
|
|
||||||
|
def setCurrent(self, pos):
|
||||||
|
if not isinstance(pos, int):
|
||||||
|
item = pos
|
||||||
|
pos = self.getPosFromItem(item)
|
||||||
|
self._items[pos] = item
|
||||||
|
|
||||||
|
if pos < 0 or pos >= len(self._items):
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.pos = pos
|
||||||
|
return True
|
||||||
|
|
||||||
|
def current(self):
|
||||||
|
return self[self.pos]
|
||||||
|
|
||||||
|
def userCurrent(self):
|
||||||
|
for item in self._items:
|
||||||
|
if not item.isWatched or item.viewOffset.asInt():
|
||||||
|
return item
|
||||||
|
else:
|
||||||
|
return self.current()
|
||||||
|
|
||||||
|
def prevItem(self):
|
||||||
|
if self.pos < 1:
|
||||||
|
return None
|
||||||
|
return self[self.pos - 1]
|
||||||
|
|
||||||
|
def shuffle(self, on=True, first=False):
|
||||||
|
if on and self._items:
|
||||||
|
self._shuffle = range(len(self._items))
|
||||||
|
random.shuffle(self._shuffle)
|
||||||
|
if not first:
|
||||||
|
self.pos = self._shuffle.index(self.pos)
|
||||||
|
else:
|
||||||
|
if self._shuffle:
|
||||||
|
self.pos = self._shuffle[self.pos]
|
||||||
|
if not first:
|
||||||
|
self._shuffle = None
|
||||||
|
self.trigger('items.changed')
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def setShuffle(self, shuffle=None):
|
||||||
|
if shuffle is None:
|
||||||
|
shuffle = not self.isShuffled
|
||||||
|
|
||||||
|
self.shuffle(shuffle)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def isShuffled(self):
|
||||||
|
return bool(self._shuffle)
|
||||||
|
|
||||||
|
def refresh(self, *args, **kwargs):
|
||||||
|
self.trigger('change')
|
||||||
|
|
||||||
|
|
||||||
|
class LocalPlaylist(BasePlaylist):
|
||||||
|
TYPE = 'localplaylist'
|
||||||
|
|
||||||
|
def __init__(self, items, server, media_item=None):
|
||||||
|
BasePlaylist.__init__(self, None, server=server)
|
||||||
|
self._items = items
|
||||||
|
self._mediaItem = media_item
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
if not self._mediaItem:
|
||||||
|
return BasePlaylist.__getattr__(self, name)
|
||||||
|
return getattr(self._mediaItem, name)
|
||||||
|
|
||||||
|
def get(self, name, default=''):
|
||||||
|
if not self._mediaItem:
|
||||||
|
return BasePlaylist.get(self, name, default)
|
||||||
|
|
||||||
|
return self._mediaItem.get(name, default)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultArt(self):
|
||||||
|
if not self._mediaItem:
|
||||||
|
return super(LocalPlaylist, self).defaultArt
|
||||||
|
return self._mediaItem.defaultArt
|
767
resources/lib/plexnet/playqueue.py
Normal file
767
resources/lib/plexnet/playqueue.py
Normal file
|
@ -0,0 +1,767 @@
|
||||||
|
import re
|
||||||
|
import urllib
|
||||||
|
import time
|
||||||
|
|
||||||
|
import plexapp
|
||||||
|
import plexrequest
|
||||||
|
import callback
|
||||||
|
import plexobjects
|
||||||
|
import util
|
||||||
|
import signalsmixin
|
||||||
|
|
||||||
|
|
||||||
|
class AudioUsage(object):
|
||||||
|
def __init__(self, skipsPerHour, playQueueId):
|
||||||
|
self.HOUR = 3600
|
||||||
|
self.skipsPerHour = skipsPerHour
|
||||||
|
self.playQueueId = playQueueId
|
||||||
|
self.skips = []
|
||||||
|
|
||||||
|
def allowSkip(self):
|
||||||
|
if self.skipsPerHour < 0:
|
||||||
|
return True
|
||||||
|
self.updateSkips()
|
||||||
|
return len(self.skips) < self.skipsPerHour
|
||||||
|
|
||||||
|
def updateSkips(self, reset=False):
|
||||||
|
if reset or len(self.skips) == 0:
|
||||||
|
if reset:
|
||||||
|
self.skips = []
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove old skips if applicable
|
||||||
|
epoch = util.now()
|
||||||
|
if self.skips[0] + self.HOUR < epoch:
|
||||||
|
newSkips = []
|
||||||
|
for skip in self.skips:
|
||||||
|
if skip + self.HOUR > epoch:
|
||||||
|
newSkips.append(skip)
|
||||||
|
self.skips = newSkips
|
||||||
|
self.log("updated skips")
|
||||||
|
|
||||||
|
def registerSkip(self):
|
||||||
|
self.skips.append(util.now())
|
||||||
|
self.updateSkips()
|
||||||
|
self.log("registered skip")
|
||||||
|
|
||||||
|
def allowSkipMessage(self):
|
||||||
|
if self.skipsPerHour < 0 or self.allowSkip():
|
||||||
|
return None
|
||||||
|
return "You can skip {0} songs an hour per mix.".format(self.skipsPerHour)
|
||||||
|
|
||||||
|
def log(self, prefix):
|
||||||
|
util.DEBUG_LOG("AudioUsage {0}: total skips={1}, allowed skips={2}".format(prefix, len(self.skips), self.skipsPerHour))
|
||||||
|
|
||||||
|
|
||||||
|
class UsageFactory(object):
|
||||||
|
def __init__(self, play_queue):
|
||||||
|
self.playQueue = play_queue
|
||||||
|
self.type = play_queue.type
|
||||||
|
self.usage = play_queue.usage
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def createUsage(cls, playQueue):
|
||||||
|
obj = cls(playQueue)
|
||||||
|
|
||||||
|
if obj.type:
|
||||||
|
if obj.type == "audio":
|
||||||
|
return obj.createAudioUsage()
|
||||||
|
|
||||||
|
util.DEBUG_LOG("Don't know how to usage for " + str(obj.type))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def createAudioUsage(self):
|
||||||
|
skips = self.playQueue.container.stationSkipsPerHour.asInt(-1)
|
||||||
|
if skips == -1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Create new usage if invalid, or if we start a new PQ, otherwise
|
||||||
|
# we'll return the existing usage for the PQ.
|
||||||
|
if not self.usage or self.usage.playQueueId != self.playQueue.id:
|
||||||
|
self.usage = AudioUsage(skips, self.playQueue.id)
|
||||||
|
|
||||||
|
return self.usage
|
||||||
|
|
||||||
|
|
||||||
|
class PlayOptions(util.AttributeDict):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
util.AttributeDict.__init__(self, *args, **kwargs)
|
||||||
|
# At the moment, this is really just a glorified struct. But the
|
||||||
|
# expected fields include key, shuffle, extraPrefixCount,
|
||||||
|
# and unwatched. We may give this more definition over time.
|
||||||
|
|
||||||
|
# These aren't widely used yet, but half inspired by a PMS discussion...
|
||||||
|
self.CONTEXT_AUTO = 0
|
||||||
|
self.CONTEXT_SELF = 1
|
||||||
|
self.CONTEXT_PARENT = 2
|
||||||
|
self.CONTEXT_CONTAINER = 3
|
||||||
|
|
||||||
|
self.context = self.CONTEXT_AUTO
|
||||||
|
|
||||||
|
|
||||||
|
def createLocalPlayQueue(item, children, contentType, options):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PlayQueueFactory(object):
|
||||||
|
def getContentType(self, item):
|
||||||
|
if item.isMusicOrDirectoryItem():
|
||||||
|
return "audio"
|
||||||
|
elif item.isVideoOrDirectoryItem():
|
||||||
|
return "video"
|
||||||
|
elif item.isPhotoOrDirectoryItem():
|
||||||
|
return "photo"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def canCreateRemotePlayQueue(self):
|
||||||
|
if self.item.getServer().isSecondary():
|
||||||
|
reason = "Server is secondary"
|
||||||
|
elif not (self.item.isLibraryItem() or self.item.isGracenoteCollection() or self.item.isLibraryPQ):
|
||||||
|
reason = "Item is not a library item or gracenote collection"
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
util.DEBUG_LOG("Requires local play queue: " + reason)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def itemRequiresRemotePlayQueue(self):
|
||||||
|
# TODO(rob): handle entire section? (if we create PQ's of sections)
|
||||||
|
# return item instanceof PlexSection || item.type == PlexObject.Type.artist;
|
||||||
|
return self.item.type == "artist"
|
||||||
|
|
||||||
|
|
||||||
|
def createPlayQueueForItem(item, children=None, options=None, args=None):
|
||||||
|
obj = PlayQueueFactory()
|
||||||
|
|
||||||
|
contentType = obj.getContentType(item)
|
||||||
|
if not contentType:
|
||||||
|
# TODO(schuyler): We may need to try harder, but I'm not sure yet. For
|
||||||
|
# example, what if we're shuffling an entire library?
|
||||||
|
#
|
||||||
|
# No reason to crash here. We can safely return None and move on.
|
||||||
|
# We'll stop if we're in dev mode to catch and debug.
|
||||||
|
#
|
||||||
|
util.DEBUG_LOG("Don't know how to create play queue for item " + repr(item))
|
||||||
|
return None
|
||||||
|
|
||||||
|
obj.item = item
|
||||||
|
|
||||||
|
options = PlayOptions(options or {})
|
||||||
|
|
||||||
|
if obj.canCreateRemotePlayQueue():
|
||||||
|
return createRemotePlayQueue(item, contentType, options, args)
|
||||||
|
else:
|
||||||
|
if obj.itemRequiresRemotePlayQueue():
|
||||||
|
util.DEBUG_LOG("Can't create remote PQs and item does not support local PQs")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return createLocalPlayQueue(item, children, contentType, options)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayQueue(signalsmixin.SignalsMixin):
|
||||||
|
TYPE = 'playqueue'
|
||||||
|
|
||||||
|
isRemote = True
|
||||||
|
|
||||||
|
def __init__(self, server, contentType, options=None):
|
||||||
|
signalsmixin.SignalsMixin.__init__(self)
|
||||||
|
self.id = None
|
||||||
|
self.selectedId = None
|
||||||
|
self.version = -1
|
||||||
|
self.isShuffled = False
|
||||||
|
self.isRepeat = False
|
||||||
|
self.isRepeatOne = False
|
||||||
|
self.isLocalPlayQueue = False
|
||||||
|
self.isMixed = None
|
||||||
|
self.totalSize = 0
|
||||||
|
self.windowSize = 0
|
||||||
|
self.forcedWindow = False
|
||||||
|
self.container = None
|
||||||
|
|
||||||
|
# Forced limitations
|
||||||
|
self.allowShuffle = False
|
||||||
|
self.allowSeek = True
|
||||||
|
self.allowRepeat = False
|
||||||
|
self.allowSkipPrev = False
|
||||||
|
self.allowSkipNext = False
|
||||||
|
self.allowAddToQueue = False
|
||||||
|
|
||||||
|
self.refreshOnTimeline = False
|
||||||
|
|
||||||
|
self.server = server
|
||||||
|
self.type = contentType
|
||||||
|
self._items = []
|
||||||
|
self.options = options or util.AttributeDict()
|
||||||
|
|
||||||
|
self.usage = None
|
||||||
|
|
||||||
|
self.refreshTimer = None
|
||||||
|
|
||||||
|
self.canceled = False
|
||||||
|
self.responded = False
|
||||||
|
self.initialized = False
|
||||||
|
|
||||||
|
self.composite = plexobjects.PlexValue('', parent=self)
|
||||||
|
|
||||||
|
# Add a few default options for specific PQ types
|
||||||
|
if self.type == "audio":
|
||||||
|
self.options.includeRelated = True
|
||||||
|
elif self.type == "photo":
|
||||||
|
self.setRepeat(True)
|
||||||
|
|
||||||
|
def get(self, name):
|
||||||
|
return getattr(self, name, plexobjects.PlexValue('', parent=self))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultArt(self):
|
||||||
|
return self.current().defaultArt
|
||||||
|
|
||||||
|
def waitForInitialization(self):
|
||||||
|
start = time.time()
|
||||||
|
timeout = util.TIMEOUT
|
||||||
|
util.DEBUG_LOG('Waiting for playQueue to initialize...')
|
||||||
|
while not self.canceled and not self.initialized:
|
||||||
|
if not self.responded and time.time() - start > timeout:
|
||||||
|
util.DEBUG_LOG('PlayQueue timed out wating for initialization')
|
||||||
|
return self.initialized
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
if self.initialized:
|
||||||
|
util.DEBUG_LOG('PlayQueue initialized in {0:.2f} secs: {1}'.format(time.time() - start, self))
|
||||||
|
else:
|
||||||
|
util.DEBUG_LOG('PlayQueue failed to initialize')
|
||||||
|
|
||||||
|
return self.initialized
|
||||||
|
|
||||||
|
def onRefreshTimer(self):
|
||||||
|
self.refreshTimer = None
|
||||||
|
self.refresh(True, False)
|
||||||
|
|
||||||
|
def refresh(self, force=True, delay=False, wait=False):
|
||||||
|
# Ignore refreshing local PQs
|
||||||
|
if self.isLocal():
|
||||||
|
return
|
||||||
|
|
||||||
|
if wait:
|
||||||
|
self.responded = False
|
||||||
|
self.initialized = False
|
||||||
|
# We refresh our play queue if the caller insists or if we only have a
|
||||||
|
# portion of our play queue loaded. In particular, this means that we don't
|
||||||
|
# refresh the play queue if we're asked to refresh because a new track is
|
||||||
|
# being played but we have the entire album loaded already.
|
||||||
|
|
||||||
|
if force or self.isWindowed():
|
||||||
|
if delay:
|
||||||
|
# We occasionally want to refresh the PQ in response to moving to a
|
||||||
|
# new item and starting playback, but if we refresh immediately:
|
||||||
|
# we probably end up refreshing before PMS realizes we've moved on.
|
||||||
|
# There's no great solution, but delaying our refresh by just a few
|
||||||
|
# seconds makes us much more likely to get an accurate window (and
|
||||||
|
# accurate selected IDs) from PMS.
|
||||||
|
|
||||||
|
if not self.refreshTimer:
|
||||||
|
self.refreshTimer = plexapp.createTimer(5000, self.onRefreshTimer)
|
||||||
|
plexapp.APP.addTimer(self.refreshTimer)
|
||||||
|
else:
|
||||||
|
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id))
|
||||||
|
self.addRequestOptions(request)
|
||||||
|
context = request.createRequestContext("refresh", callback.Callable(self.onResponse))
|
||||||
|
plexapp.APP.startRequest(request, context)
|
||||||
|
|
||||||
|
if wait:
|
||||||
|
return self.waitForInitialization()
|
||||||
|
|
||||||
|
def shuffle(self, shuffle=True):
|
||||||
|
self.setShuffle(shuffle)
|
||||||
|
|
||||||
|
def setShuffle(self, shuffle=None):
|
||||||
|
if shuffle is None:
|
||||||
|
shuffle = not self.isShuffled
|
||||||
|
|
||||||
|
if self.isShuffled == shuffle:
|
||||||
|
return
|
||||||
|
|
||||||
|
if shuffle:
|
||||||
|
command = "/shuffle"
|
||||||
|
else:
|
||||||
|
command = "/unshuffle"
|
||||||
|
|
||||||
|
# Don't change self.isShuffled, it'll be set in OnResponse if all goes well
|
||||||
|
|
||||||
|
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id) + command, "PUT")
|
||||||
|
self.addRequestOptions(request)
|
||||||
|
context = request.createRequestContext("shuffle", callback.Callable(self.onResponse))
|
||||||
|
plexapp.APP.startRequest(request, context)
|
||||||
|
|
||||||
|
def setRepeat(self, repeat, one=False):
|
||||||
|
if self.isRepeat == repeat and self.isRepeatOne == one:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.options.repeat = repeat
|
||||||
|
self.isRepeat = repeat
|
||||||
|
self.isRepeatOne = one
|
||||||
|
|
||||||
|
def moveItemUp(self, item):
|
||||||
|
for index in range(1, len(self._items)):
|
||||||
|
if self._items[index].get("playQueueItemID") == item.get("playQueueItemID"):
|
||||||
|
if index > 1:
|
||||||
|
after = self._items[index - 2]
|
||||||
|
else:
|
||||||
|
after = None
|
||||||
|
|
||||||
|
self.swapItem(index, -1)
|
||||||
|
self.moveItem(item, after)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def moveItemDown(self, item):
|
||||||
|
for index in range(len(self._items) - 1):
|
||||||
|
if self._items[index].get("playQueueItemID") == item.get("playQueueItemID"):
|
||||||
|
after = self._items[index + 1]
|
||||||
|
self.swapItem(index)
|
||||||
|
self.moveItem(item, after)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def moveItem(self, item, after):
|
||||||
|
if after:
|
||||||
|
query = "?after=" + after.get("playQueueItemID", "-1")
|
||||||
|
else:
|
||||||
|
query = ""
|
||||||
|
|
||||||
|
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id) + "/items/" + item.get("playQueueItemID", "-1") + "/move" + query, "PUT")
|
||||||
|
self.addRequestOptions(request)
|
||||||
|
context = request.createRequestContext("move", callback.Callable(self.onResponse))
|
||||||
|
plexapp.APP.startRequest(request, context)
|
||||||
|
|
||||||
|
def swapItem(self, index, delta=1):
|
||||||
|
before = self._items[index]
|
||||||
|
after = self._items[index + delta]
|
||||||
|
|
||||||
|
self._items[index] = after
|
||||||
|
self._items[index + delta] = before
|
||||||
|
|
||||||
|
def removeItem(self, item):
|
||||||
|
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id) + "/items/" + item.get("playQueueItemID", "-1"), "DELETE")
|
||||||
|
self.addRequestOptions(request)
|
||||||
|
context = request.createRequestContext("delete", callback.Callable(self.onResponse))
|
||||||
|
plexapp.APP.startRequest(request, context)
|
||||||
|
|
||||||
|
def addItem(self, item, addNext=False, excludeSeedItem=False):
|
||||||
|
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id), "PUT")
|
||||||
|
request.addParam("uri", item.getItemUri())
|
||||||
|
request.addParam("next", addNext and "1" or "0")
|
||||||
|
request.addParam("excludeSeedItem", excludeSeedItem and "1" or "0")
|
||||||
|
self.addRequestOptions(request)
|
||||||
|
context = request.createRequestContext("add", callback.Callable(self.onResponse))
|
||||||
|
plexapp.APP.startRequest(request, context)
|
||||||
|
|
||||||
|
def onResponse(self, request, response, context):
|
||||||
|
# Close any loading modal regardless of response status
|
||||||
|
# Application().closeLoadingModal()
|
||||||
|
util.DEBUG_LOG('playQueue: Received response')
|
||||||
|
self.responded = True
|
||||||
|
if response.parseResponse():
|
||||||
|
util.DEBUG_LOG('playQueue: {0} items'.format(len(response.items)))
|
||||||
|
self.container = response.container
|
||||||
|
# Handle an empty PQ if we have specified an pqEmptyCallable
|
||||||
|
if self.options and self.options.pqEmptyCallable:
|
||||||
|
callable = self.options.pqEmptyCallable
|
||||||
|
del self.options["pqEmptyCallable"]
|
||||||
|
if len(response.items) == 0:
|
||||||
|
callable.call()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.id = response.container.playQueueID.asInt()
|
||||||
|
self.isShuffled = response.container.playQueueShuffled.asBool()
|
||||||
|
self.totalSize = response.container.playQueueTotalCount.asInt()
|
||||||
|
self.windowSize = len(response.items)
|
||||||
|
self.version = response.container.playQueueVersion.asInt()
|
||||||
|
|
||||||
|
itemsChanged = False
|
||||||
|
if len(response.items) == len(self._items):
|
||||||
|
for i in range(len(self._items)):
|
||||||
|
if self._items[i] != response.items[i]:
|
||||||
|
itemsChanged = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
itemsChanged = True
|
||||||
|
|
||||||
|
if itemsChanged:
|
||||||
|
self._items = response.items
|
||||||
|
|
||||||
|
# Process any forced limitations
|
||||||
|
self.allowSeek = response.container.allowSeek.asBool()
|
||||||
|
self.allowShuffle = (
|
||||||
|
self.totalSize > 1 and response.container.allowShuffle.asBool() and not response.container.playQueueLastAddedItemID
|
||||||
|
)
|
||||||
|
self.allowRepeat = response.container.allowRepeat.asBool()
|
||||||
|
self.allowSkipPrev = self.totalSize > 1 and response.container.allowSkipPrevious != "0"
|
||||||
|
self.allowSkipNext = self.totalSize > 1 and response.container.allowSkipNext != "0"
|
||||||
|
|
||||||
|
# Figure out the selected track index and offset. PMS tries to make some
|
||||||
|
# of this easy, but it might not realize that we've advanced to a new
|
||||||
|
# track, so we can't blindly trust it. On the other hand, it's possible
|
||||||
|
# that PMS completely changed the PQ item IDs (e.g. upon shuffling), so
|
||||||
|
# we might need to use its values. We iterate through the items and try
|
||||||
|
# to find the item that we believe is selected, only settling for what
|
||||||
|
# PMS says if we fail.
|
||||||
|
|
||||||
|
playQueueOffset = None
|
||||||
|
selectedId = None
|
||||||
|
pmsSelectedId = response.container.playQueueSelectedItemID.asInt()
|
||||||
|
self.deriveIsMixed()
|
||||||
|
|
||||||
|
# lastItem = None # Not used
|
||||||
|
for index in range(len(self._items)):
|
||||||
|
item = self._items[index]
|
||||||
|
|
||||||
|
if not playQueueOffset and item.playQueueItemID.asInt() == pmsSelectedId:
|
||||||
|
playQueueOffset = response.container.playQueueSelectedItemOffset.asInt() - index + 1
|
||||||
|
|
||||||
|
# Update the index of everything we've already past, and handle
|
||||||
|
# wrapping indexes (repeat).
|
||||||
|
for i in range(index):
|
||||||
|
pqIndex = playQueueOffset + i
|
||||||
|
if pqIndex < 1:
|
||||||
|
pqIndex = pqIndex + self.totalSize
|
||||||
|
|
||||||
|
self._items[i].playQueueIndex = plexobjects.PlexValue(str(pqIndex), parent=self._items[i])
|
||||||
|
|
||||||
|
if playQueueOffset:
|
||||||
|
pqIndex = playQueueOffset + index
|
||||||
|
if pqIndex > self.totalSize:
|
||||||
|
pqIndex = pqIndex - self.totalSize
|
||||||
|
|
||||||
|
item.playQueueIndex = plexobjects.PlexValue(str(pqIndex), parent=item)
|
||||||
|
|
||||||
|
# If we found the item that we believe is selected: we should
|
||||||
|
# continue to treat it as selected.
|
||||||
|
# TODO(schuyler): Should we be checking the metadata ID (rating key)
|
||||||
|
# instead? I don't think it matters in practice, but it may be
|
||||||
|
# more correct.
|
||||||
|
|
||||||
|
if not selectedId and item.playQueueItemID.asInt() == self.selectedId:
|
||||||
|
selectedId = self.selectedId
|
||||||
|
|
||||||
|
if not selectedId:
|
||||||
|
self.selectedId = pmsSelectedId
|
||||||
|
|
||||||
|
# TODO(schuyler): Set repeat as soon as PMS starts returning it
|
||||||
|
|
||||||
|
# Fix up the container for all our items
|
||||||
|
response.container.address = "/playQueues/" + str(self.id)
|
||||||
|
|
||||||
|
# Create usage limitations
|
||||||
|
self.usage = UsageFactory.createUsage(self)
|
||||||
|
|
||||||
|
self.initialized = True
|
||||||
|
self.trigger("change")
|
||||||
|
|
||||||
|
if itemsChanged:
|
||||||
|
self.trigger("items.changed")
|
||||||
|
|
||||||
|
def isWindowed(self):
|
||||||
|
return (not self.isLocal() and (self.totalSize > self.windowSize or self.forcedWindow))
|
||||||
|
|
||||||
|
def hasNext(self):
|
||||||
|
if self.isRepeatOne:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not self.allowSkipNext and -1 < self.items().index(self.current()) < (len(self.items()) - 1): # TODO: Was 'or' - did change cause issues?
|
||||||
|
return self.isRepeat and not self.isWindowed()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def hasPrev(self):
|
||||||
|
# return self.allowSkipPrev or self.items().index(self.current()) > 0
|
||||||
|
return self.items().index(self.current()) > 0
|
||||||
|
|
||||||
|
def next(self):
|
||||||
|
if not self.hasNext():
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.isRepeatOne:
|
||||||
|
return self.current()
|
||||||
|
|
||||||
|
pos = self.items().index(self.current()) + 1
|
||||||
|
if pos >= len(self.items()):
|
||||||
|
if not self.isRepeat or self.isWindowed():
|
||||||
|
return None
|
||||||
|
pos = 0
|
||||||
|
|
||||||
|
item = self.items()[pos]
|
||||||
|
self.selectedId = item.playQueueItemID.asInt()
|
||||||
|
return item
|
||||||
|
|
||||||
|
def prev(self):
|
||||||
|
if not self.hasPrev():
|
||||||
|
return None
|
||||||
|
if self.isRepeatOne:
|
||||||
|
return self.current()
|
||||||
|
pos = self.items().index(self.current()) - 1
|
||||||
|
item = self.items()[pos]
|
||||||
|
self.selectedId = item.playQueueItemID.asInt()
|
||||||
|
return item
|
||||||
|
|
||||||
|
def setCurrent(self, pos):
|
||||||
|
if pos < 0 or pos >= len(self.items()):
|
||||||
|
return False
|
||||||
|
|
||||||
|
item = self.items()[pos]
|
||||||
|
self.selectedId = item.playQueueItemID.asInt()
|
||||||
|
return item
|
||||||
|
|
||||||
|
def setCurrentItem(self, item):
|
||||||
|
self.selectedId = item.playQueueItemID.asInt()
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if not other:
|
||||||
|
return False
|
||||||
|
if self.__class__ != other.__class__:
|
||||||
|
return False
|
||||||
|
return self.id == other.id and self.type == other.type
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
def addRequestOptions(self, request):
|
||||||
|
boolOpts = ["repeat", "includeRelated"]
|
||||||
|
for opt in boolOpts:
|
||||||
|
if self.options.get(opt):
|
||||||
|
request.addParam(opt, "1")
|
||||||
|
|
||||||
|
intOpts = ["extrasPrefixCount"]
|
||||||
|
for opt in intOpts:
|
||||||
|
if self.options.get(opt):
|
||||||
|
request.addParam(opt, str(self.options.get(opt)))
|
||||||
|
|
||||||
|
includeChapters = self.options.get('includeChapters') is not None and self.options.includeChapters or 1
|
||||||
|
request.addParam("includeChapters", str(includeChapters))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (
|
||||||
|
str(self.__class__.__name__) + " " +
|
||||||
|
str(self.type) + " windowSize=" +
|
||||||
|
str(self.windowSize) + " totalSize=" +
|
||||||
|
str(self.totalSize) + " selectedId=" +
|
||||||
|
str(self.selectedId) + " shuffled=" +
|
||||||
|
str(self.isShuffled) + " repeat=" +
|
||||||
|
str(self.isRepeat) + " mixed=" +
|
||||||
|
str(self.isMixed) + " allowShuffle=" +
|
||||||
|
str(self.allowShuffle) + " version=" +
|
||||||
|
str(self.version) + " id=" + str(self.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def isLocal(self):
|
||||||
|
return self.isLocalPlayQueue
|
||||||
|
|
||||||
|
def deriveIsMixed(self):
|
||||||
|
if self.isMixed is None:
|
||||||
|
self.isMixed = False
|
||||||
|
|
||||||
|
lastItem = None
|
||||||
|
for item in self._items:
|
||||||
|
if not self.isMixed:
|
||||||
|
if not item.get("parentKey"):
|
||||||
|
self.isMixed = True
|
||||||
|
else:
|
||||||
|
self.isMixed = lastItem and item.get("parentKey") != lastItem.get("parentKey")
|
||||||
|
|
||||||
|
lastItem = item
|
||||||
|
|
||||||
|
def items(self):
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
def current(self):
|
||||||
|
for item in self.items():
|
||||||
|
if item.playQueueItemID.asInt() == self.selectedId:
|
||||||
|
return item
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def prevItem(self):
|
||||||
|
last = None
|
||||||
|
for item in self.items():
|
||||||
|
if item.playQueueItemID.asInt() == self.selectedId:
|
||||||
|
return last
|
||||||
|
last = item
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def createRemotePlayQueue(item, contentType, options, args):
|
||||||
|
util.DEBUG_LOG('Creating remote playQueue request...')
|
||||||
|
obj = PlayQueue(item.getServer(), contentType, options)
|
||||||
|
|
||||||
|
# The item's URI is made up of the library section UUID, a descriptor of
|
||||||
|
# the item type (item or directory), and the item's path, URL-encoded.
|
||||||
|
|
||||||
|
uri = "library://" + item.getLibrarySectionUuid() + "/"
|
||||||
|
itemType = item.isDirectory() and "directory" or "item"
|
||||||
|
path = None
|
||||||
|
|
||||||
|
if not options.key:
|
||||||
|
# if item.onDeck and len(item.onDeck) > 0:
|
||||||
|
# options.key = item.onDeck[0].getAbsolutePath("key")
|
||||||
|
# el
|
||||||
|
if not item.isDirectory():
|
||||||
|
options.key = item.get("key")
|
||||||
|
|
||||||
|
# If we're asked to play unwatched, ignore the option unless we are unwatched.
|
||||||
|
options.unwatched = options.unwatched and item.isUnwatched()
|
||||||
|
|
||||||
|
# TODO(schuyler): Until we build postplay, we're not allowed to queue containers for episodes.
|
||||||
|
if item.type == "episode":
|
||||||
|
options.context = options.CONTEXT_SELF
|
||||||
|
elif item.type == "movie":
|
||||||
|
if not options.extrasPrefixCount and not options.resume:
|
||||||
|
options.extrasPrefixCount = plexapp.INTERFACE.getPreference("cinema_trailers", 0)
|
||||||
|
|
||||||
|
# How exactly to construct the item URI depends on the metadata type, though
|
||||||
|
# whenever possible we simply use /library/metadata/:id.
|
||||||
|
|
||||||
|
if item.isLibraryItem() and not item.isLibraryPQ:
|
||||||
|
path = "/library/metadata/" + item.ratingKey
|
||||||
|
else:
|
||||||
|
path = item.getAbsolutePath("key")
|
||||||
|
|
||||||
|
if options.context == options.CONTEXT_SELF:
|
||||||
|
# If the context is specifically for just this item,: just use the
|
||||||
|
# item's key and get out.
|
||||||
|
pass
|
||||||
|
elif item.type == "playlist":
|
||||||
|
path = None
|
||||||
|
uri = item.get("ratingKey")
|
||||||
|
options.isPlaylist = True
|
||||||
|
elif item.type == "track":
|
||||||
|
# TODO(rob): Is there ever a time the container address is wrong? If we
|
||||||
|
# expect to play a single track,: use options.CONTEXT_SELF.
|
||||||
|
path = item.container.address or "/library/metadata/" + item.get("parentRatingKey", "")
|
||||||
|
itemType = "directory"
|
||||||
|
elif item.isPhotoOrDirectoryItem():
|
||||||
|
if item.type == "photoalbum" or item.parentKey:
|
||||||
|
path = item.getParentPath(item.type == "photoalbum" and "key" or "parentKey")
|
||||||
|
itemType = "item"
|
||||||
|
elif item.isDirectory():
|
||||||
|
path = item.getAbsolutePath("key")
|
||||||
|
else:
|
||||||
|
path = item.container.address
|
||||||
|
itemType = "directory"
|
||||||
|
options.key = item.getAbsolutePath("key")
|
||||||
|
|
||||||
|
elif item.type == "episode":
|
||||||
|
path = "/library/metadata/" + item.get("grandparentRatingKey", "")
|
||||||
|
itemType = "directory"
|
||||||
|
options.key = item.getAbsolutePath("key")
|
||||||
|
# elif item.type == "show":
|
||||||
|
# path = "/library/metadata/" + item.get("ratingKey", "")
|
||||||
|
|
||||||
|
if path:
|
||||||
|
if args:
|
||||||
|
path += util.joinArgs(args)
|
||||||
|
|
||||||
|
util.DEBUG_LOG("playQueue path: " + str(path))
|
||||||
|
|
||||||
|
if "/search" not in path:
|
||||||
|
# Convert a few params to the PQ spec
|
||||||
|
convert = {
|
||||||
|
'type': "sourceType",
|
||||||
|
'unwatchedLeaves': "unwatched"
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in convert:
|
||||||
|
regex = re.compile("(?i)([?&])" + key + "=")
|
||||||
|
path = regex.sub("\1" + convert[key] + "=", path)
|
||||||
|
|
||||||
|
util.DEBUG_LOG("playQueue path: " + str(path))
|
||||||
|
uri = uri + itemType + "/" + urllib.quote_plus(path)
|
||||||
|
|
||||||
|
util.DEBUG_LOG("playQueue uri: " + str(uri))
|
||||||
|
|
||||||
|
# Create the PQ request
|
||||||
|
request = plexrequest.PlexRequest(obj.server, "/playQueues")
|
||||||
|
|
||||||
|
request.addParam(not options.isPlaylist and "uri" or "playlistID", uri)
|
||||||
|
request.addParam("type", contentType)
|
||||||
|
# request.addParam('X-Plex-Client-Identifier', plexapp.INTERFACE.getGlobal('clientIdentifier'))
|
||||||
|
|
||||||
|
# Add options we pass once during PQ creation
|
||||||
|
if options.shuffle:
|
||||||
|
request.addParam("shuffle", "1")
|
||||||
|
options.key = None
|
||||||
|
else:
|
||||||
|
request.addParam("shuffle", "0")
|
||||||
|
|
||||||
|
if options.key:
|
||||||
|
request.addParam("key", options.key)
|
||||||
|
|
||||||
|
# Add options we pass every time querying PQs
|
||||||
|
obj.addRequestOptions(request)
|
||||||
|
|
||||||
|
util.DEBUG_LOG('Initial playQueue request started...')
|
||||||
|
context = request.createRequestContext("create", callback.Callable(obj.onResponse))
|
||||||
|
plexapp.APP.startRequest(request, context, body='')
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def createPlayQueueForId(id, server=None, contentType=None):
|
||||||
|
obj = PlayQueue(server, contentType)
|
||||||
|
obj.id = id
|
||||||
|
|
||||||
|
request = plexrequest.PlexRequest(server, "/playQueues/" + str(id))
|
||||||
|
request.addParam("own", "1")
|
||||||
|
obj.addRequestOptions(request)
|
||||||
|
context = request.createRequestContext("own", callback.Callable(obj.onResponse))
|
||||||
|
plexapp.APP.startRequest(request, context)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
class AudioPlayer():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VideoPlayer():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoPlayer():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def addItemToPlayQueue(item, addNext=False):
|
||||||
|
# See if we have an active play queue for this self.dia type or if we need to
|
||||||
|
# create one.
|
||||||
|
|
||||||
|
if item.isMusicOrDirectoryItem():
|
||||||
|
player = AudioPlayer()
|
||||||
|
elif item.isVideoOrDirectoryItem():
|
||||||
|
player = VideoPlayer()
|
||||||
|
elif item.isPhotoOrDirectoryItem():
|
||||||
|
player = PhotoPlayer()
|
||||||
|
else:
|
||||||
|
player = None
|
||||||
|
|
||||||
|
if not player:
|
||||||
|
util.ERROR_LOG("Don't know how to add item to play queue: " + str(item))
|
||||||
|
return None
|
||||||
|
elif not player.allowAddToQueue():
|
||||||
|
util.DEBUG_LOG("Not allowed to add to this player")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if player.playQueue:
|
||||||
|
playQueue = player.playQueue
|
||||||
|
playQueue.addItem(item, addNext)
|
||||||
|
else:
|
||||||
|
options = PlayOptions()
|
||||||
|
options.context = options.CONTEXT_SELF
|
||||||
|
playQueue = createPlayQueueForItem(item, None, options)
|
||||||
|
if playQueue:
|
||||||
|
player.setPlayQueue(playQueue, False)
|
||||||
|
|
||||||
|
return playQueue
|
473
resources/lib/plexnet/plexapp.py
Normal file
473
resources/lib/plexnet/plexapp.py
Normal file
|
@ -0,0 +1,473 @@
|
||||||
|
import threading
|
||||||
|
import platform
|
||||||
|
import uuid
|
||||||
|
import sys
|
||||||
|
import callback
|
||||||
|
|
||||||
|
import signalsmixin
|
||||||
|
import simpleobjects
|
||||||
|
import nowplayingmanager
|
||||||
|
import util
|
||||||
|
|
||||||
|
Res = simpleobjects.Res
|
||||||
|
|
||||||
|
APP = None
|
||||||
|
INTERFACE = None
|
||||||
|
|
||||||
|
MANAGER = None
|
||||||
|
SERVERMANAGER = None
|
||||||
|
ACCOUNT = None
|
||||||
|
|
||||||
|
PLATFORM = util.X_PLEX_DEVICE
|
||||||
|
|
||||||
|
def init():
|
||||||
|
global MANAGER, SERVERMANAGER, ACCOUNT
|
||||||
|
import myplexaccount
|
||||||
|
ACCOUNT = myplexaccount.ACCOUNT
|
||||||
|
import plexservermanager
|
||||||
|
SERVERMANAGER = plexservermanager.MANAGER
|
||||||
|
import myplexmanager
|
||||||
|
MANAGER = myplexmanager.MANAGER
|
||||||
|
ACCOUNT.init()
|
||||||
|
|
||||||
|
|
||||||
|
class App(signalsmixin.SignalsMixin):
|
||||||
|
def __init__(self):
|
||||||
|
signalsmixin.SignalsMixin.__init__(self)
|
||||||
|
self.pendingRequests = {}
|
||||||
|
self.initializers = {}
|
||||||
|
self.timers = []
|
||||||
|
self.nowplayingmanager = nowplayingmanager.NowPlayingManager()
|
||||||
|
|
||||||
|
def addTimer(self, timer):
|
||||||
|
self.timers.append(timer)
|
||||||
|
|
||||||
|
def startRequest(self, request, context, body=None, contentType=None):
|
||||||
|
context.request = request
|
||||||
|
|
||||||
|
started = request.startAsync(body=body, contentType=contentType, context=context)
|
||||||
|
|
||||||
|
if started:
|
||||||
|
requestID = context.request.getIdentity()
|
||||||
|
self.pendingRequests[requestID] = context
|
||||||
|
elif context.callback:
|
||||||
|
context.callback(None, context)
|
||||||
|
|
||||||
|
return started
|
||||||
|
|
||||||
|
def onRequestTimeout(self, context):
|
||||||
|
requestID = context.request.getIdentity()
|
||||||
|
|
||||||
|
if requestID not in self.pendingRequests:
|
||||||
|
return
|
||||||
|
|
||||||
|
context.request.cancel()
|
||||||
|
|
||||||
|
util.WARN_LOG("Request to {0} timed out after {1} sec".format(util.cleanToken(context.request.url), context.timeout))
|
||||||
|
|
||||||
|
if context.callback:
|
||||||
|
context.callback(None, context)
|
||||||
|
|
||||||
|
def delRequest(self, request):
|
||||||
|
requestID = request.getIdentity()
|
||||||
|
if requestID not in self.pendingRequests:
|
||||||
|
return
|
||||||
|
|
||||||
|
del self.pendingRequests[requestID]
|
||||||
|
|
||||||
|
def addInitializer(self, name):
|
||||||
|
self.initializers[name] = True
|
||||||
|
|
||||||
|
def clearInitializer(self, name):
|
||||||
|
if name in self.initializers:
|
||||||
|
del self.initializers[name]
|
||||||
|
if self.isInitialized():
|
||||||
|
self.onInitialized()
|
||||||
|
|
||||||
|
def isInitialized(self):
|
||||||
|
return not self.initializers
|
||||||
|
|
||||||
|
def onInitialized(self):
|
||||||
|
# Wire up a few of our own listeners
|
||||||
|
# PlexServerManager()
|
||||||
|
# self.on("change:user", callback.Callable(self.onAccountChange))
|
||||||
|
|
||||||
|
self.trigger('init')
|
||||||
|
|
||||||
|
def cancelAllTimers(self):
|
||||||
|
for timer in self.timers:
|
||||||
|
timer.cancel()
|
||||||
|
|
||||||
|
def preShutdown(self):
|
||||||
|
import http
|
||||||
|
http.HttpRequest._cancel = True
|
||||||
|
if self.pendingRequests:
|
||||||
|
util.DEBUG_LOG('Closing down {0} App() requests...'.format(len(self.pendingRequests)))
|
||||||
|
for p in self.pendingRequests.values():
|
||||||
|
if p:
|
||||||
|
p.request.cancel()
|
||||||
|
|
||||||
|
if self.timers:
|
||||||
|
util.DEBUG_LOG('Canceling App() timers...')
|
||||||
|
self.cancelAllTimers()
|
||||||
|
|
||||||
|
if SERVERMANAGER.selectedServer:
|
||||||
|
util.DEBUG_LOG('Closing server...')
|
||||||
|
SERVERMANAGER.selectedServer.close()
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
if self.timers:
|
||||||
|
util.DEBUG_LOG('Waiting for {0} App() timers: Started'.format(len(self.timers)))
|
||||||
|
|
||||||
|
self.cancelAllTimers()
|
||||||
|
|
||||||
|
for timer in self.timers:
|
||||||
|
timer.join()
|
||||||
|
|
||||||
|
util.DEBUG_LOG('Waiting for App() timers: Finished')
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceInfo(object):
|
||||||
|
def getCaptionsOption(self, key):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AppInterface(object):
|
||||||
|
QUALITY_LOCAL = 0
|
||||||
|
QUALITY_REMOTE = 1
|
||||||
|
QUALITY_ONLINE = 2
|
||||||
|
|
||||||
|
_globals = {}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.setQualities()
|
||||||
|
|
||||||
|
def setQualities(self):
|
||||||
|
# Calculate the max quality based on 4k support
|
||||||
|
if self._globals.get("supports4k"):
|
||||||
|
maxQuality = simpleobjects.AttributeDict({
|
||||||
|
'height': 2160,
|
||||||
|
'maxHeight': 2160,
|
||||||
|
'origHeight': 1080
|
||||||
|
})
|
||||||
|
maxResolution = self._globals.get("Is4k") and "4k" or "1080p"
|
||||||
|
else:
|
||||||
|
maxQuality = simpleobjects.AttributeDict({
|
||||||
|
'height': 1080,
|
||||||
|
'maxHeight': 1088
|
||||||
|
})
|
||||||
|
maxResolution = "1080p"
|
||||||
|
|
||||||
|
self._globals['qualities'] = [
|
||||||
|
simpleobjects.AttributeDict({'title': "Original", 'index': 13, 'maxBitrate': 200000}),
|
||||||
|
simpleobjects.AttributeDict({'title': "20 Mbps " + maxResolution, 'index': 12, 'maxBitrate': 20000}),
|
||||||
|
simpleobjects.AttributeDict({'title': "12 Mbps " + maxResolution, 'index': 11, 'maxBitrate': 12000}),
|
||||||
|
simpleobjects.AttributeDict({'title': "10 Mbps " + maxResolution, 'index': 10, 'maxBitrate': 10000}),
|
||||||
|
simpleobjects.AttributeDict({'title': "8 Mbps " + maxResolution, 'index': 9, 'maxBitrate': 8000}),
|
||||||
|
simpleobjects.AttributeDict({'title': "4 Mbps 720p", 'index': 8, 'maxBitrate': 4000, 'maxHeight': 720}),
|
||||||
|
simpleobjects.AttributeDict({'title': "3 Mbps 720p", 'index': 7, 'maxBitrate': 3000, 'maxHeight': 720}),
|
||||||
|
simpleobjects.AttributeDict({'title': "2 Mbps 720p", 'index': 6, 'maxBitrate': 2000, 'maxHeight': 720}),
|
||||||
|
simpleobjects.AttributeDict({'title': "1.5 Mbps 480p", 'index': 5, 'maxBitrate': 1500, 'maxHeight': 480}),
|
||||||
|
simpleobjects.AttributeDict({'title': "720 Kbps", 'index': 4, 'maxBitrate': 720, 'maxHeight': 360}),
|
||||||
|
simpleobjects.AttributeDict({'title': "320 Kbps", 'index': 3, 'maxBitrate': 320, 'maxHeight': 360}),
|
||||||
|
maxQuality
|
||||||
|
]
|
||||||
|
|
||||||
|
for quality in self._globals['qualities']:
|
||||||
|
if quality.index >= 9:
|
||||||
|
quality.update(maxQuality)
|
||||||
|
|
||||||
|
def getPreference(self, pref, default=None):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def setPreference(self, pref, value):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def clearRegistry(self, reg, sec=None):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def getRegistry(self, reg, default=None, sec=None):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def setRegistry(self, reg, value, sec=None):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def getGlobal(self, glbl, default=None):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def getCapabilities(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def LOG(self, msg):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def DEBUG_LOG(self, msg):
|
||||||
|
self.LOG(msg)
|
||||||
|
|
||||||
|
def WARN_LOG(self, msg):
|
||||||
|
self.LOG(msg)
|
||||||
|
|
||||||
|
def ERROR_LOG(self, msg):
|
||||||
|
self.LOG(msg)
|
||||||
|
|
||||||
|
def ERROR(self, msg=None, err=None):
|
||||||
|
self.LOG(msg)
|
||||||
|
|
||||||
|
def FATAL(self, msg=None):
|
||||||
|
self.ERROR_LOG('FATAL: {0}'.format(msg))
|
||||||
|
|
||||||
|
def supportsAudioStream(self, codec, channels):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def supportsSurroundSound(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def getMaxResolution(self, quality_type, allow4k=False):
|
||||||
|
return 480
|
||||||
|
|
||||||
|
def getQualityIndex(self, qualityType):
|
||||||
|
if qualityType == self.QUALITY_LOCAL:
|
||||||
|
return self.getPreference("local_quality", 13)
|
||||||
|
elif qualityType == self.QUALITY_ONLINE:
|
||||||
|
return self.getPreference("online_quality", 8)
|
||||||
|
else:
|
||||||
|
return self.getPreference("remote_quality", 13)
|
||||||
|
|
||||||
|
def settingsGetMaxResolution(self, qualityType, allow4k):
|
||||||
|
qualityIndex = self.getQualityIndex(qualityType)
|
||||||
|
|
||||||
|
if qualityIndex >= 9:
|
||||||
|
return allow4k and 2160 or 1088
|
||||||
|
elif qualityIndex >= 6:
|
||||||
|
return 720
|
||||||
|
elif qualityIndex >= 5:
|
||||||
|
return 480
|
||||||
|
else:
|
||||||
|
return 360
|
||||||
|
|
||||||
|
def getMaxBitrate(self, qualityType):
|
||||||
|
qualityIndex = self.getQualityIndex(qualityType)
|
||||||
|
|
||||||
|
qualities = self.getGlobal("qualities", [])
|
||||||
|
for quality in qualities:
|
||||||
|
if quality.index == qualityIndex:
|
||||||
|
return util.validInt(quality.maxBitrate)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerSettingsInterface(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.prefOverrides = {}
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(INTERFACE, name)
|
||||||
|
|
||||||
|
def setPrefOverride(self, key, val):
|
||||||
|
self.prefOverrides[key] = val
|
||||||
|
|
||||||
|
def getPrefOverride(self, key, default=None):
|
||||||
|
return self.prefOverrides.get(key, default)
|
||||||
|
|
||||||
|
def getQualityIndex(self, qualityType):
|
||||||
|
if qualityType == INTERFACE.QUALITY_LOCAL:
|
||||||
|
return self.getPreference("local_quality", 13)
|
||||||
|
elif qualityType == INTERFACE.QUALITY_ONLINE:
|
||||||
|
return self.getPreference("online_quality", 8)
|
||||||
|
else:
|
||||||
|
return self.getPreference("remote_quality", 13)
|
||||||
|
|
||||||
|
def getPreference(self, key, default=None):
|
||||||
|
if key in self.prefOverrides:
|
||||||
|
return self.prefOverrides[key]
|
||||||
|
else:
|
||||||
|
return INTERFACE.getPreference(key, default)
|
||||||
|
|
||||||
|
def getMaxResolution(self, quality_type, allow4k=False):
|
||||||
|
qualityIndex = self.getQualityIndex(quality_type)
|
||||||
|
|
||||||
|
if qualityIndex >= 9:
|
||||||
|
return allow4k and 2160 or 1088
|
||||||
|
elif qualityIndex >= 6:
|
||||||
|
return 720
|
||||||
|
elif qualityIndex >= 5:
|
||||||
|
return 480
|
||||||
|
else:
|
||||||
|
return 360
|
||||||
|
|
||||||
|
|
||||||
|
class DumbInterface(AppInterface):
|
||||||
|
_prefs = {}
|
||||||
|
_regs = {
|
||||||
|
None: {}
|
||||||
|
}
|
||||||
|
_globals = {
|
||||||
|
'platform': platform.uname()[0],
|
||||||
|
'appVersionStr': '0.0.0a1',
|
||||||
|
'clientIdentifier': str(hex(uuid.getnode())),
|
||||||
|
'platformVersion': platform.uname()[2],
|
||||||
|
'product': 'PlexNet.API',
|
||||||
|
'provides': 'player',
|
||||||
|
'device': platform.uname()[0],
|
||||||
|
'model': 'Unknown',
|
||||||
|
'friendlyName': 'PlexNet.API',
|
||||||
|
'deviceInfo': DeviceInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
def getPreference(self, pref, default=None):
|
||||||
|
return self._prefs.get(pref, default)
|
||||||
|
|
||||||
|
def setPreference(self, pref, value):
|
||||||
|
self._prefs[pref] = value
|
||||||
|
|
||||||
|
def getRegistry(self, reg, default=None, sec=None):
|
||||||
|
section = self._regs.get(sec)
|
||||||
|
if section:
|
||||||
|
return section.get(reg, default)
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
def setRegistry(self, reg, value, sec=None):
|
||||||
|
if sec and sec not in self._regs:
|
||||||
|
self._regs[sec] = {}
|
||||||
|
self._regs[sec][reg] = value
|
||||||
|
|
||||||
|
def clearRegistry(self, reg, sec=None):
|
||||||
|
del self._regs[sec][reg]
|
||||||
|
|
||||||
|
def getGlobal(self, glbl, default=None):
|
||||||
|
return self._globals.get(glbl, default)
|
||||||
|
|
||||||
|
def getCapabilities(self):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def LOG(self, msg):
|
||||||
|
print 'PlexNet.API: {0}'.format(msg)
|
||||||
|
|
||||||
|
def DEBUG_LOG(self, msg):
|
||||||
|
self.LOG('DEBUG: {0}'.format(msg))
|
||||||
|
|
||||||
|
def WARN_LOG(self, msg):
|
||||||
|
self.LOG('WARNING: {0}'.format(msg))
|
||||||
|
|
||||||
|
def ERROR_LOG(self, msg):
|
||||||
|
self.LOG('ERROR: {0}'.format(msg))
|
||||||
|
|
||||||
|
def ERROR(self, msg=None, err=None):
|
||||||
|
if err:
|
||||||
|
self.LOG('ERROR: {0} - {1}'.format(msg, err.message))
|
||||||
|
else:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
class CompatEvent(threading._Event):
|
||||||
|
def wait(self, timeout):
|
||||||
|
threading._Event.wait(self, timeout)
|
||||||
|
return self.isSet()
|
||||||
|
|
||||||
|
|
||||||
|
class Timer(object):
|
||||||
|
def __init__(self, timeout, function, repeat=False, *args, **kwargs):
|
||||||
|
self.function = function
|
||||||
|
self.timeout = timeout
|
||||||
|
self.repeat = repeat
|
||||||
|
self.args = args
|
||||||
|
self.kwargs = kwargs
|
||||||
|
self._reset = False
|
||||||
|
self.event = CompatEvent()
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.event.clear()
|
||||||
|
self.thread = threading.Thread(target=self.run, name='TIMER:{0}'.format(self.function), *self.args, **self.kwargs)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
util.DEBUG_LOG('Timer {0}: {1}'.format(repr(self.function), self._reset and 'RESET'or 'STARTED'))
|
||||||
|
try:
|
||||||
|
while not self.event.isSet() and not self.shouldAbort():
|
||||||
|
while not self.event.wait(self.timeout) and not self.shouldAbort():
|
||||||
|
if self._reset:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.function(*self.args, **self.kwargs)
|
||||||
|
if not self.repeat:
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
if not self._reset:
|
||||||
|
if self in APP.timers:
|
||||||
|
APP.timers.remove(self)
|
||||||
|
|
||||||
|
util.DEBUG_LOG('Timer {0}: FINISHED'.format(repr(self.function)))
|
||||||
|
|
||||||
|
self._reset = False
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self.event.set()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self._reset = True
|
||||||
|
self.cancel()
|
||||||
|
if self.thread and self.thread.isAlive():
|
||||||
|
self.thread.join()
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def shouldAbort(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def join(self):
|
||||||
|
if self.thread.isAlive():
|
||||||
|
self.thread.join()
|
||||||
|
|
||||||
|
def isExpired(self):
|
||||||
|
return self.event.isSet()
|
||||||
|
|
||||||
|
|
||||||
|
TIMER = Timer
|
||||||
|
|
||||||
|
|
||||||
|
def createTimer(timeout, function, repeat=False, *args, **kwargs):
|
||||||
|
if isinstance(function, basestring):
|
||||||
|
def dummy(*args, **kwargs):
|
||||||
|
pass
|
||||||
|
dummy.__name__ = function
|
||||||
|
function = dummy
|
||||||
|
timer = TIMER(timeout / 1000.0, function, repeat=repeat, *args, **kwargs)
|
||||||
|
return timer
|
||||||
|
|
||||||
|
|
||||||
|
def setTimer(timer):
|
||||||
|
global TIMER
|
||||||
|
TIMER = timer
|
||||||
|
|
||||||
|
|
||||||
|
def setInterface(interface):
|
||||||
|
global INTERFACE
|
||||||
|
INTERFACE = interface
|
||||||
|
|
||||||
|
|
||||||
|
def setApp(app):
|
||||||
|
global APP
|
||||||
|
APP = app
|
||||||
|
|
||||||
|
|
||||||
|
def setUserAgent(agent):
|
||||||
|
util.USER_AGENT = agent
|
||||||
|
util.BASE_HEADERS = util.resetBaseHeaders()
|
||||||
|
|
||||||
|
|
||||||
|
def setAbortFlagFunction(func):
|
||||||
|
import asyncadapter
|
||||||
|
asyncadapter.ABORT_FLAG_FUNCTION = func
|
||||||
|
|
||||||
|
|
||||||
|
def refreshResources(force=False):
|
||||||
|
import gdm
|
||||||
|
gdm.DISCOVERY.discover()
|
||||||
|
MANAGER.refreshResources(force)
|
||||||
|
SERVERMANAGER.refreshManualConnections()
|
||||||
|
|
||||||
|
|
||||||
|
setApp(App())
|
||||||
|
setInterface(DumbInterface())
|
213
resources/lib/plexnet/plexconnection.py
Normal file
213
resources/lib/plexnet/plexconnection.py
Normal file
|
@ -0,0 +1,213 @@
|
||||||
|
import random
|
||||||
|
|
||||||
|
import http
|
||||||
|
import plexapp
|
||||||
|
import callback
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionSource(int):
|
||||||
|
def init(self, name):
|
||||||
|
self.name = name
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class PlexConnection(object):
|
||||||
|
# Constants
|
||||||
|
STATE_UNKNOWN = "unknown"
|
||||||
|
STATE_UNREACHABLE = "unreachable"
|
||||||
|
STATE_REACHABLE = "reachable"
|
||||||
|
STATE_UNAUTHORIZED = "unauthorized"
|
||||||
|
STATE_INSECURE = "insecure_untested"
|
||||||
|
|
||||||
|
SOURCE_MANUAL = ConnectionSource(1).init('MANUAL')
|
||||||
|
SOURCE_DISCOVERED = ConnectionSource(2).init('DISCOVERED')
|
||||||
|
SOURCE_MANUAL_AND_DISCOVERED = ConnectionSource(3).init('MANUAL, DISCOVERED')
|
||||||
|
SOURCE_MYPLEX = ConnectionSource(4).init('MYPLEX')
|
||||||
|
SOURCE_MANUAL_AND_MYPLEX = ConnectionSource(5).init('MANUAL, MYPLEX')
|
||||||
|
SOURCE_DISCOVERED_AND_MYPLEX = ConnectionSource(6).init('DISCOVERED, MYPLEX')
|
||||||
|
SOURCE_ALL = ConnectionSource(7).init('ALL')
|
||||||
|
|
||||||
|
SCORE_REACHABLE = 4
|
||||||
|
SCORE_LOCAL = 2
|
||||||
|
SCORE_SECURE = 1
|
||||||
|
|
||||||
|
SOURCE_BY_VAL = {
|
||||||
|
1: SOURCE_MANUAL,
|
||||||
|
2: SOURCE_DISCOVERED,
|
||||||
|
3: SOURCE_MANUAL_AND_DISCOVERED,
|
||||||
|
4: SOURCE_MYPLEX,
|
||||||
|
5: SOURCE_MANUAL_AND_MYPLEX,
|
||||||
|
6: SOURCE_DISCOVERED_AND_MYPLEX,
|
||||||
|
7: SOURCE_ALL
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, source, address, isLocal, token, isFallback=False):
|
||||||
|
self.state = self.STATE_UNKNOWN
|
||||||
|
self.sources = source
|
||||||
|
self.address = address
|
||||||
|
self.isLocal = isLocal
|
||||||
|
self.isSecure = address[:5] == 'https'
|
||||||
|
self.isFallback = isFallback
|
||||||
|
self.token = token
|
||||||
|
self.refreshed = True
|
||||||
|
self.score = 0
|
||||||
|
self.request = None
|
||||||
|
|
||||||
|
self.lastTestedAt = 0
|
||||||
|
self.hasPendingRequest = False
|
||||||
|
|
||||||
|
self.getScore(True)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if not other:
|
||||||
|
return False
|
||||||
|
if self.__class__ != other.__class__:
|
||||||
|
return False
|
||||||
|
return self.address == other.address
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Connection: {0} local: {1} token: {2} sources: {3} state: {4}".format(
|
||||||
|
self.address,
|
||||||
|
self.isLocal,
|
||||||
|
util.hideToken(self.token),
|
||||||
|
repr(self.sources),
|
||||||
|
self.state
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.__str__()
|
||||||
|
|
||||||
|
def merge(self, other):
|
||||||
|
# plex.tv trumps all, otherwise assume newer is better
|
||||||
|
# ROKU: if (other.sources and self.SOURCE_MYPLEX) <> 0 then
|
||||||
|
if other.sources == self.SOURCE_MYPLEX:
|
||||||
|
self.token = other.token
|
||||||
|
else:
|
||||||
|
self.token = self.token or other.token
|
||||||
|
|
||||||
|
self.address = other.address
|
||||||
|
self.sources = self.SOURCE_BY_VAL[self.sources | other.sources]
|
||||||
|
self.isLocal = self.isLocal | other.isLocal
|
||||||
|
self.isSecure = other.isSecure
|
||||||
|
self.isFallback = self.isFallback or other.isFallback
|
||||||
|
self.refreshed = True
|
||||||
|
|
||||||
|
self.getScore(True)
|
||||||
|
|
||||||
|
def testReachability(self, server, allowFallback=False):
|
||||||
|
# Check if we will allow the connection test. If this is a fallback connection,
|
||||||
|
# then we will defer it until we "allowFallback" (test insecure connections
|
||||||
|
# after secure tests have completed and failed). Insecure connections will be
|
||||||
|
# tested if the policy "always" allows them, or if set to "same_network" and
|
||||||
|
# the current connection is local and server has (publicAddressMatches=1).
|
||||||
|
|
||||||
|
allowConnectionTest = not self.isFallback
|
||||||
|
if not allowConnectionTest:
|
||||||
|
insecurePolicy = plexapp.INTERFACE.getPreference("allow_insecure")
|
||||||
|
if insecurePolicy == "always" or (insecurePolicy == "same_network" and server.sameNetwork and self.isLocal):
|
||||||
|
allowConnectionTest = allowFallback
|
||||||
|
server.hasFallback = not allowConnectionTest
|
||||||
|
util.LOG(
|
||||||
|
'{0} for {1}'.format(
|
||||||
|
allowConnectionTest and "Continuing with insecure connection testing" or "Insecure connection testing is deferred", server
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
util.LOG("Insecure connections not allowed. Ignore insecure connection test for {0}".format(server))
|
||||||
|
self.state = self.STATE_INSECURE
|
||||||
|
callable = callback.Callable(server.onReachabilityResult, [self], random.randint(0, 256))
|
||||||
|
callable.deferCall()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if allowConnectionTest:
|
||||||
|
if not self.isSecure and (
|
||||||
|
not allowFallback and
|
||||||
|
server.hasSecureConnections() or
|
||||||
|
server.activeConnection and
|
||||||
|
server.activeConnection.state != self.STATE_REACHABLE and
|
||||||
|
server.activeConnection.isSecure
|
||||||
|
):
|
||||||
|
util.DEBUG_LOG("Invalid insecure connection test in progress")
|
||||||
|
self.request = http.HttpRequest(self.buildUrl(server, "/"))
|
||||||
|
context = self.request.createRequestContext("reachability", callback.Callable(self.onReachabilityResponse))
|
||||||
|
context.server = server
|
||||||
|
util.addPlexHeaders(self.request, server.getToken())
|
||||||
|
self.hasPendingRequest = plexapp.APP.startRequest(self.request, context)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cancelReachability(self):
|
||||||
|
if self.request:
|
||||||
|
self.request.ignoreResponse = True
|
||||||
|
self.request.cancel()
|
||||||
|
|
||||||
|
def onReachabilityResponse(self, request, response, context):
|
||||||
|
self.hasPendingRequest = False
|
||||||
|
# It's possible we may have a result pending before we were able
|
||||||
|
# to cancel it, so we'll just ignore it.
|
||||||
|
|
||||||
|
# if request.ignoreResponse:
|
||||||
|
# return
|
||||||
|
|
||||||
|
if response.isSuccess():
|
||||||
|
data = response.getBodyXml()
|
||||||
|
if data is not None and context.server.collectDataFromRoot(data):
|
||||||
|
self.state = self.STATE_REACHABLE
|
||||||
|
else:
|
||||||
|
# This is unexpected, but treat it as unreachable
|
||||||
|
util.ERROR_LOG("Unable to parse root response from {0}".format(context.server))
|
||||||
|
self.state = self.STATE_UNREACHABLE
|
||||||
|
elif response.getStatus() == 401:
|
||||||
|
self.state = self.STATE_UNAUTHORIZED
|
||||||
|
else:
|
||||||
|
self.state = self.STATE_UNREACHABLE
|
||||||
|
|
||||||
|
self.getScore(True)
|
||||||
|
|
||||||
|
context.server.onReachabilityResult(self)
|
||||||
|
|
||||||
|
def buildUrl(self, server, path, includeToken=False):
|
||||||
|
if '://' in path:
|
||||||
|
url = path
|
||||||
|
else:
|
||||||
|
url = self.address + path
|
||||||
|
|
||||||
|
if includeToken:
|
||||||
|
# If we have a token, use it. Otherwise see if any other connections
|
||||||
|
# for this server have one. That will let us use a plex.tv token for
|
||||||
|
# something like a manually configured connection.
|
||||||
|
|
||||||
|
token = self.token or server.getToken()
|
||||||
|
|
||||||
|
if token:
|
||||||
|
url = http.addUrlParam(url, "X-Plex-Token=" + token)
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
def simpleBuildUrl(self, server, path):
|
||||||
|
token = (self.token or server.getToken())
|
||||||
|
param = ''
|
||||||
|
if token:
|
||||||
|
param = '&X-Plex-Token={0}'.format(token)
|
||||||
|
|
||||||
|
return '{0}{1}{2}'.format(self.address, path, param)
|
||||||
|
|
||||||
|
def getScore(self, recalc=False):
|
||||||
|
if recalc:
|
||||||
|
self.score = 0
|
||||||
|
if self.state == self.STATE_REACHABLE:
|
||||||
|
self.score += self.SCORE_REACHABLE
|
||||||
|
if self.isSecure:
|
||||||
|
self.score += self.SCORE_SECURE
|
||||||
|
if self.isLocal:
|
||||||
|
self.score += self.SCORE_LOCAL
|
||||||
|
|
||||||
|
return self.score
|
577
resources/lib/plexnet/plexlibrary.py
Normal file
577
resources/lib/plexnet/plexlibrary.py
Normal file
|
@ -0,0 +1,577 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
PlexLibrary
|
||||||
|
"""
|
||||||
|
import plexobjects
|
||||||
|
import playlist
|
||||||
|
import media
|
||||||
|
import exceptions
|
||||||
|
import util
|
||||||
|
import signalsmixin
|
||||||
|
|
||||||
|
|
||||||
|
class Library(plexobjects.PlexObject):
|
||||||
|
def __repr__(self):
|
||||||
|
return '<Library:{0}>'.format(self.title1.encode('utf8'))
|
||||||
|
|
||||||
|
def sections(self):
|
||||||
|
items = []
|
||||||
|
|
||||||
|
path = '/library/sections'
|
||||||
|
for elem in self.server.query(path):
|
||||||
|
stype = elem.attrib['type']
|
||||||
|
if stype in SECTION_TYPES:
|
||||||
|
cls = SECTION_TYPES[stype]
|
||||||
|
items.append(cls(elem, initpath=path, server=self.server, container=self))
|
||||||
|
return items
|
||||||
|
|
||||||
|
def section(self, title=None):
|
||||||
|
for item in self.sections():
|
||||||
|
if item.title == title:
|
||||||
|
return item
|
||||||
|
raise exceptions.NotFound('Invalid library section: %s' % title)
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return plexobjects.listItems(self.server, '/library/all')
|
||||||
|
|
||||||
|
def onDeck(self):
|
||||||
|
return plexobjects.listItems(self.server, '/library/onDeck')
|
||||||
|
|
||||||
|
def recentlyAdded(self):
|
||||||
|
return plexobjects.listItems(self.server, '/library/recentlyAdded')
|
||||||
|
|
||||||
|
def get(self, title):
|
||||||
|
return plexobjects.findItem(self.server, '/library/all', title)
|
||||||
|
|
||||||
|
def getByKey(self, key):
|
||||||
|
return plexobjects.findKey(self.server, key)
|
||||||
|
|
||||||
|
def search(self, title, libtype=None, **kwargs):
|
||||||
|
""" Searching within a library section is much more powerful. It seems certain attributes on the media
|
||||||
|
objects can be targeted to filter this search down a bit, but I havent found the documentation for
|
||||||
|
it. For example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items
|
||||||
|
such as actor=<id> seem to work, but require you already know the id of the actor.
|
||||||
|
TLDR: This is untested but seems to work. Use library section search when you can.
|
||||||
|
"""
|
||||||
|
args = {}
|
||||||
|
if title:
|
||||||
|
args['title'] = title
|
||||||
|
if libtype:
|
||||||
|
args['type'] = plexobjects.searchType(libtype)
|
||||||
|
for attr, value in kwargs.items():
|
||||||
|
args[attr] = value
|
||||||
|
query = '/library/all%s' % util.joinArgs(args)
|
||||||
|
return plexobjects.listItems(self.server, query)
|
||||||
|
|
||||||
|
def cleanBundles(self):
|
||||||
|
self.server.query('/library/clean/bundles')
|
||||||
|
|
||||||
|
def emptyTrash(self):
|
||||||
|
for section in self.sections():
|
||||||
|
section.emptyTrash()
|
||||||
|
|
||||||
|
def optimize(self):
|
||||||
|
self.server.query('/library/optimize')
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
self.server.query('/library/sections/all/refresh')
|
||||||
|
|
||||||
|
|
||||||
|
class LibrarySection(plexobjects.PlexObject):
|
||||||
|
ALLOWED_FILTERS = ()
|
||||||
|
ALLOWED_SORT = ()
|
||||||
|
BOOLEAN_FILTERS = ('unwatched', 'duplicate')
|
||||||
|
|
||||||
|
isLibraryPQ = True
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
title = self.title.replace(' ', '.')[0:20]
|
||||||
|
return '<%s:%s>' % (self.__class__.__name__, title.encode('utf8'))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fromFilter(filter_):
|
||||||
|
cls = SECTION_IDS.get(filter_.getLibrarySectionType())
|
||||||
|
if not cls:
|
||||||
|
return
|
||||||
|
section = cls(None, initpath=filter_.initpath, server=filter_.server, container=filter_.container)
|
||||||
|
section.key = filter_.getLibrarySectionId()
|
||||||
|
section.title = filter_.reasonTitle
|
||||||
|
section.type = cls.TYPE
|
||||||
|
return section
|
||||||
|
|
||||||
|
def reload(self, **kwargs):
|
||||||
|
""" Reload the data for this object from PlexServer XML. """
|
||||||
|
initpath = '/library/sections/{0}'.format(self.key)
|
||||||
|
key = self.key
|
||||||
|
try:
|
||||||
|
data = self.server.query(initpath, params=kwargs)
|
||||||
|
except Exception, e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
util.ERROR(err=e)
|
||||||
|
self.initpath = self.key
|
||||||
|
return
|
||||||
|
|
||||||
|
self._setData(data[0])
|
||||||
|
self.initpath = self.key = key
|
||||||
|
|
||||||
|
def isDirectory(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def isLibraryItem(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def getAbsolutePath(self, key):
|
||||||
|
if key == 'key':
|
||||||
|
return '/library/sections/{0}/all'.format(self.key)
|
||||||
|
|
||||||
|
return plexobjects.PlexObject.getAbsolutePath(self, key)
|
||||||
|
|
||||||
|
def all(self, start=None, size=None, filter_=None, sort=None, unwatched=False, type_=None):
|
||||||
|
if self.key.startswith('/'):
|
||||||
|
path = '{0}/all'.format(self.key)
|
||||||
|
else:
|
||||||
|
path = '/library/sections/{0}/all'.format(self.key)
|
||||||
|
|
||||||
|
args = {}
|
||||||
|
|
||||||
|
if size is not None:
|
||||||
|
args['X-Plex-Container-Start'] = start
|
||||||
|
args['X-Plex-Container-Size'] = size
|
||||||
|
|
||||||
|
if filter_:
|
||||||
|
args[filter_[0]] = filter_[1]
|
||||||
|
|
||||||
|
if sort:
|
||||||
|
args['sort'] = '{0}:{1}'.format(*sort)
|
||||||
|
|
||||||
|
if type_:
|
||||||
|
args['type'] = str(type_)
|
||||||
|
|
||||||
|
if unwatched:
|
||||||
|
args[self.TYPE == 'movie' and 'unwatched' or 'unwatchedLeaves'] = 1
|
||||||
|
|
||||||
|
if args:
|
||||||
|
path += util.joinArgs(args)
|
||||||
|
|
||||||
|
return plexobjects.listItems(self.server, path)
|
||||||
|
|
||||||
|
def jumpList(self, filter_=None, sort=None, unwatched=False, type_=None):
|
||||||
|
if self.key.startswith('/'):
|
||||||
|
path = '{0}/firstCharacter'.format(self.key)
|
||||||
|
else:
|
||||||
|
path = '/library/sections/{0}/firstCharacter'.format(self.key)
|
||||||
|
|
||||||
|
args = {}
|
||||||
|
|
||||||
|
if filter_:
|
||||||
|
args[filter_[0]] = filter_[1]
|
||||||
|
|
||||||
|
if sort:
|
||||||
|
args['sort'] = '{0}:{1}'.format(*sort)
|
||||||
|
|
||||||
|
if type_:
|
||||||
|
args['type'] = str(type_)
|
||||||
|
|
||||||
|
if unwatched:
|
||||||
|
args[self.TYPE == 'movie' and 'unwatched' or 'unwatchedLeaves'] = 1
|
||||||
|
|
||||||
|
if args:
|
||||||
|
path += util.joinArgs(args)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return plexobjects.listItems(self.server, path, bytag=True)
|
||||||
|
except exceptions.BadRequest:
|
||||||
|
util.ERROR('jumpList() request error for path: {0}'.format(repr(path)))
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def onDeck(self):
|
||||||
|
return plexobjects.listItems(self.server, '/library/sections/%s/onDeck' % self.key)
|
||||||
|
|
||||||
|
def analyze(self):
|
||||||
|
self.server.query('/library/sections/%s/analyze' % self.key)
|
||||||
|
|
||||||
|
def emptyTrash(self):
|
||||||
|
self.server.query('/library/sections/%s/emptyTrash' % self.key)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
self.server.query('/library/sections/%s/refresh' % self.key)
|
||||||
|
|
||||||
|
def listChoices(self, category, libtype=None, **kwargs):
|
||||||
|
""" List choices for the specified filter category. kwargs can be any of the same
|
||||||
|
kwargs in self.search() to help narrow down the choices to only those that
|
||||||
|
matter in your current context.
|
||||||
|
"""
|
||||||
|
if category in kwargs:
|
||||||
|
raise exceptions.BadRequest('Cannot include kwarg equal to specified category: %s' % category)
|
||||||
|
args = {}
|
||||||
|
for subcategory, value in kwargs.items():
|
||||||
|
args[category] = self._cleanSearchFilter(subcategory, value)
|
||||||
|
if libtype is not None:
|
||||||
|
args['type'] = plexobjects.searchType(libtype)
|
||||||
|
query = '/library/sections/%s/%s%s' % (self.key, category, util.joinArgs(args))
|
||||||
|
|
||||||
|
return plexobjects.listItems(self.server, query, bytag=True)
|
||||||
|
|
||||||
|
def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwargs):
|
||||||
|
""" Search the library. If there are many results, they will be fetched from the server
|
||||||
|
in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first <num>
|
||||||
|
results, it would be wise to set the maxresults option to that amount so this functions
|
||||||
|
doesn't iterate over all results on the server.
|
||||||
|
title: General string query to search for.
|
||||||
|
sort: column:dir; column can be any of {addedAt, originallyAvailableAt, lastViewedAt,
|
||||||
|
titleSort, rating, mediaHeight, duration}. dir can be asc or desc.
|
||||||
|
maxresults: Only return the specified number of results
|
||||||
|
libtype: Filter results to a spcifiec libtype {movie, show, episode, artist, album, track}
|
||||||
|
kwargs: Any of the available filters for the current library section. Partial string
|
||||||
|
matches allowed. Multiple matches OR together. All inputs will be compared with the
|
||||||
|
available options and a warning logged if the option does not appear valid.
|
||||||
|
'unwatched': Display or hide unwatched content (True, False). [all]
|
||||||
|
'duplicate': Display or hide duplicate items (True, False). [movie]
|
||||||
|
'actor': List of actors to search ([actor_or_id, ...]). [movie]
|
||||||
|
'collection': List of collections to search within ([collection_or_id, ...]). [all]
|
||||||
|
'contentRating': List of content ratings to search within ([rating_or_key, ...]). [movie, tv]
|
||||||
|
'country': List of countries to search within ([country_or_key, ...]). [movie, music]
|
||||||
|
'decade': List of decades to search within ([yyy0, ...]). [movie]
|
||||||
|
'director': List of directors to search ([director_or_id, ...]). [movie]
|
||||||
|
'genre': List Genres to search within ([genere_or_id, ...]). [all]
|
||||||
|
'network': List of TV networks to search within ([resolution_or_key, ...]). [tv]
|
||||||
|
'resolution': List of video resolutions to search within ([resolution_or_key, ...]). [movie]
|
||||||
|
'studio': List of studios to search within ([studio_or_key, ...]). [music]
|
||||||
|
'year': List of years to search within ([yyyy, ...]). [all]
|
||||||
|
"""
|
||||||
|
# Cleanup the core arguments
|
||||||
|
args = {}
|
||||||
|
for category, value in kwargs.items():
|
||||||
|
args[category] = self._cleanSearchFilter(category, value, libtype)
|
||||||
|
if title is not None:
|
||||||
|
args['title'] = title
|
||||||
|
if sort is not None:
|
||||||
|
args['sort'] = self._cleanSearchSort(sort)
|
||||||
|
if libtype is not None:
|
||||||
|
args['type'] = plexobjects.searchType(libtype)
|
||||||
|
# Iterate over the results
|
||||||
|
results, subresults = [], '_init'
|
||||||
|
args['X-Plex-Container-Start'] = 0
|
||||||
|
args['X-Plex-Container-Size'] = min(util.X_PLEX_CONTAINER_SIZE, maxresults)
|
||||||
|
while subresults and maxresults > len(results):
|
||||||
|
query = '/library/sections/%s/all%s' % (self.key, util.joinArgs(args))
|
||||||
|
subresults = plexobjects.listItems(self.server, query)
|
||||||
|
results += subresults[:maxresults - len(results)]
|
||||||
|
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _cleanSearchFilter(self, category, value, libtype=None):
|
||||||
|
# check a few things before we begin
|
||||||
|
if category not in self.ALLOWED_FILTERS:
|
||||||
|
raise exceptions.BadRequest('Unknown filter category: %s' % category)
|
||||||
|
if category in self.BOOLEAN_FILTERS:
|
||||||
|
return '1' if value else '0'
|
||||||
|
if not isinstance(value, (list, tuple)):
|
||||||
|
value = [value]
|
||||||
|
# convert list of values to list of keys or ids
|
||||||
|
result = set()
|
||||||
|
choices = self.listChoices(category, libtype)
|
||||||
|
lookup = {}
|
||||||
|
for c in choices:
|
||||||
|
lookup[c.title.lower()] = c.key
|
||||||
|
|
||||||
|
allowed = set(c.key for c in choices)
|
||||||
|
for item in value:
|
||||||
|
item = str(item.id if isinstance(item, media.MediaTag) else item).lower()
|
||||||
|
# find most logical choice(s) to use in url
|
||||||
|
if item in allowed:
|
||||||
|
result.add(item)
|
||||||
|
continue
|
||||||
|
if item in lookup:
|
||||||
|
result.add(lookup[item])
|
||||||
|
continue
|
||||||
|
matches = [k for t, k in lookup.items() if item in t]
|
||||||
|
if matches:
|
||||||
|
map(result.add, matches)
|
||||||
|
continue
|
||||||
|
# nothing matched; use raw item value
|
||||||
|
util.LOG('Filter value not listed, using raw item value: {0}'.format(item))
|
||||||
|
result.add(item)
|
||||||
|
return ','.join(result)
|
||||||
|
|
||||||
|
def _cleanSearchSort(self, sort):
|
||||||
|
sort = '%s:asc' % sort if ':' not in sort else sort
|
||||||
|
scol, sdir = sort.lower().split(':')
|
||||||
|
lookup = {}
|
||||||
|
for s in self.ALLOWED_SORT:
|
||||||
|
lookup[s.lower()] = s
|
||||||
|
if scol not in lookup:
|
||||||
|
raise exceptions.BadRequest('Unknown sort column: %s' % scol)
|
||||||
|
if sdir not in ('asc', 'desc'):
|
||||||
|
raise exceptions.BadRequest('Unknown sort dir: %s' % sdir)
|
||||||
|
return '%s:%s' % (lookup[scol], sdir)
|
||||||
|
|
||||||
|
|
||||||
|
class MovieSection(LibrarySection):
|
||||||
|
ALLOWED_FILTERS = (
|
||||||
|
'unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating', 'collection',
|
||||||
|
'director', 'actor', 'country', 'studio', 'resolution'
|
||||||
|
)
|
||||||
|
ALLOWED_SORT = (
|
||||||
|
'addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
|
||||||
|
'mediaHeight', 'duration'
|
||||||
|
)
|
||||||
|
TYPE = 'movie'
|
||||||
|
ID = '1'
|
||||||
|
|
||||||
|
|
||||||
|
class ShowSection(LibrarySection):
|
||||||
|
ALLOWED_FILTERS = ('unwatched', 'year', 'genre', 'contentRating', 'network', 'collection')
|
||||||
|
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort', 'rating', 'unwatched')
|
||||||
|
TYPE = 'show'
|
||||||
|
ID = '2'
|
||||||
|
|
||||||
|
def searchShows(self, **kwargs):
|
||||||
|
return self.search(libtype='show', **kwargs)
|
||||||
|
|
||||||
|
def searchEpisodes(self, **kwargs):
|
||||||
|
return self.search(libtype='episode', **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class MusicSection(LibrarySection):
|
||||||
|
ALLOWED_FILTERS = ('genre', 'country', 'collection')
|
||||||
|
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
|
||||||
|
TYPE = 'artist'
|
||||||
|
ID = '8'
|
||||||
|
|
||||||
|
def searchShows(self, **kwargs):
|
||||||
|
return self.search(libtype='artist', **kwargs)
|
||||||
|
|
||||||
|
def searchEpisodes(self, **kwargs):
|
||||||
|
return self.search(libtype='album', **kwargs)
|
||||||
|
|
||||||
|
def searchTracks(self, **kwargs):
|
||||||
|
return self.search(libtype='track', **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoSection(LibrarySection):
|
||||||
|
ALLOWED_FILTERS = ()
|
||||||
|
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
|
||||||
|
TYPE = 'photo'
|
||||||
|
ID = 'None'
|
||||||
|
|
||||||
|
def isPhotoOrDirectoryItem(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@plexobjects.registerLibType
|
||||||
|
class Generic(plexobjects.PlexObject):
|
||||||
|
TYPE = 'Directory'
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
title = self.title.replace(' ', '.')[0:20]
|
||||||
|
return '<{0}:{1}:{2}>'.format(self.__class__.__name__, self.key, title)
|
||||||
|
|
||||||
|
|
||||||
|
@plexobjects.registerLibType
|
||||||
|
class Playlist(playlist.BasePlaylist, signalsmixin.SignalsMixin):
|
||||||
|
TYPE = 'playlist'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
playlist.BasePlaylist.__init__(self, *args, **kwargs)
|
||||||
|
self._itemsLoaded = False
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
title = self.title.replace(' ', '.')[0:20]
|
||||||
|
return '<{0}:{1}:{2}>'.format(self.__class__.__name__, self.key, title)
|
||||||
|
|
||||||
|
def exists(self):
|
||||||
|
try:
|
||||||
|
self.server.query('/playlists/{0}'.format(self.ratingKey))
|
||||||
|
return True
|
||||||
|
except exceptions.BadRequest:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def isMusicOrDirectoryItem(self):
|
||||||
|
return self.playlistType == 'audio'
|
||||||
|
|
||||||
|
def isVideoOrDirectoryItem(self):
|
||||||
|
return self.playlistType == 'video'
|
||||||
|
|
||||||
|
def items(self):
|
||||||
|
if not self._itemsLoaded:
|
||||||
|
path = '/playlists/{0}/items'.format(self.ratingKey)
|
||||||
|
self._items = plexobjects.listItems(self.server, path)
|
||||||
|
self._itemsLoaded = True
|
||||||
|
|
||||||
|
return playlist.BasePlaylist.items(self)
|
||||||
|
|
||||||
|
def extend(self, start=0, size=0):
|
||||||
|
if not self._items:
|
||||||
|
self._items = [None] * self.leafCount.asInt()
|
||||||
|
|
||||||
|
args = {}
|
||||||
|
|
||||||
|
if size is not None:
|
||||||
|
args['X-Plex-Container-Start'] = start
|
||||||
|
args['X-Plex-Container-Size'] = size
|
||||||
|
|
||||||
|
path = '/playlists/{0}/items'.format(self.ratingKey)
|
||||||
|
if args:
|
||||||
|
path += util.joinArgs(args) if '?' not in path else '&' + util.joinArgs(args).lstrip('?')
|
||||||
|
|
||||||
|
items = plexobjects.listItems(self.server, path)
|
||||||
|
self._items[start:start + len(items)] = items
|
||||||
|
|
||||||
|
self.trigger('items.added')
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
def unshuffledItems(self):
|
||||||
|
if not self._itemsLoaded:
|
||||||
|
self.items()
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultThumb(self):
|
||||||
|
return self.composite
|
||||||
|
|
||||||
|
def buildComposite(self, **kwargs):
|
||||||
|
if kwargs:
|
||||||
|
params = '?' + '&'.join('{0}={1}'.format(k, v) for k, v in kwargs.items())
|
||||||
|
else:
|
||||||
|
params = ''
|
||||||
|
|
||||||
|
path = self.composite + params
|
||||||
|
return self.getServer().buildUrl(path, True)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseHub(plexobjects.PlexObject):
|
||||||
|
def reset(self):
|
||||||
|
self.set('offset', 0)
|
||||||
|
self.set('size', len(self.items))
|
||||||
|
totalSize = self.items[0].container.totalSize.asInt()
|
||||||
|
if totalSize: # Hubs from a list of hubs don't have this, so it it's not here this is intital and we can leave as is
|
||||||
|
self.set(
|
||||||
|
'more',
|
||||||
|
(self.items[0].container.offset.asInt() + self.items[0].container.size.asInt() < totalSize) and '1' or ''
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Hub(BaseHub):
|
||||||
|
TYPE = "Hub"
|
||||||
|
|
||||||
|
def init(self, data):
|
||||||
|
self.items = []
|
||||||
|
|
||||||
|
container = plexobjects.PlexContainer(data, self.key, self.server, self.key or '')
|
||||||
|
|
||||||
|
if self.type == 'genre':
|
||||||
|
self.items = [media.Genre(elem, initpath='/hubs', server=self.server, container=container) for elem in data]
|
||||||
|
elif self.type == 'director':
|
||||||
|
self.items = [media.Director(elem, initpath='/hubs', server=self.server, container=container) for elem in data]
|
||||||
|
elif self.type == 'actor':
|
||||||
|
self.items = [media.Role(elem, initpath='/hubs', server=self.server, container=container) for elem in data]
|
||||||
|
else:
|
||||||
|
for elem in data:
|
||||||
|
try:
|
||||||
|
self.items.append(plexobjects.buildItem(self.server, elem, '/hubs', container=container, tag_fallback=True))
|
||||||
|
except exceptions.UnknownType:
|
||||||
|
util.DEBUG_LOG('Unkown hub item type({1}): {0}'.format(elem, elem.attrib.get('type')))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<{0}:{1}>'.format(self.__class__.__name__, self.hubIdentifier)
|
||||||
|
|
||||||
|
def reload(self, **kwargs):
|
||||||
|
""" Reload the data for this object from PlexServer XML. """
|
||||||
|
try:
|
||||||
|
data = self.server.query(self.key, params=kwargs)
|
||||||
|
except Exception, e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
util.ERROR(err=e)
|
||||||
|
self.initpath = self.key
|
||||||
|
return
|
||||||
|
|
||||||
|
self.initpath = self.key
|
||||||
|
self._setData(data)
|
||||||
|
self.init(data)
|
||||||
|
|
||||||
|
def extend(self, start=None, size=None):
|
||||||
|
path = self.key
|
||||||
|
|
||||||
|
args = {}
|
||||||
|
|
||||||
|
if size is not None:
|
||||||
|
args['X-Plex-Container-Start'] = start
|
||||||
|
args['X-Plex-Container-Size'] = size
|
||||||
|
|
||||||
|
if args:
|
||||||
|
path += util.joinArgs(args) if '?' not in path else '&' + util.joinArgs(args).lstrip('?')
|
||||||
|
|
||||||
|
items = plexobjects.listItems(self.server, path)
|
||||||
|
self.offset = plexobjects.PlexValue(start)
|
||||||
|
self.size = plexobjects.PlexValue(len(items))
|
||||||
|
self.more = plexobjects.PlexValue(
|
||||||
|
(items[0].container.offset.asInt() + items[0].container.size.asInt() < items[0].container.totalSize.asInt()) and '1' or ''
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistHub(BaseHub):
|
||||||
|
TYPE = "Hub"
|
||||||
|
type = None
|
||||||
|
hubIdentifier = None
|
||||||
|
|
||||||
|
def init(self, data):
|
||||||
|
try:
|
||||||
|
self.items = self.extend(0, 10)
|
||||||
|
except exceptions.BadRequest:
|
||||||
|
util.DEBUG_LOG('AudioPlaylistHub: Bad request: {0}'.format(self))
|
||||||
|
self.items = []
|
||||||
|
|
||||||
|
def extend(self, start=None, size=None):
|
||||||
|
path = '/playlists/all?playlistType={0}'.format(self.type)
|
||||||
|
|
||||||
|
args = {}
|
||||||
|
|
||||||
|
if size is not None:
|
||||||
|
args['X-Plex-Container-Start'] = start
|
||||||
|
args['X-Plex-Container-Size'] = size
|
||||||
|
else:
|
||||||
|
start = 0
|
||||||
|
|
||||||
|
if args:
|
||||||
|
path += '&' + util.joinArgs(args).lstrip('?')
|
||||||
|
|
||||||
|
items = plexobjects.listItems(self.server, path)
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.set('offset', start)
|
||||||
|
self.set('size', len(items))
|
||||||
|
self.set('more', (items[0].container.offset.asInt() + items[0].container.size.asInt() < items[0].container.totalSize.asInt()) and '1' or '')
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
class AudioPlaylistHub(PlaylistHub):
|
||||||
|
type = 'audio'
|
||||||
|
hubIdentifier = 'playlists.audio'
|
||||||
|
|
||||||
|
|
||||||
|
class VideoPlaylistHub(PlaylistHub):
|
||||||
|
type = 'video'
|
||||||
|
hubIdentifier = 'playlists.video'
|
||||||
|
|
||||||
|
|
||||||
|
SECTION_TYPES = {
|
||||||
|
MovieSection.TYPE: MovieSection,
|
||||||
|
ShowSection.TYPE: ShowSection,
|
||||||
|
MusicSection.TYPE: MusicSection,
|
||||||
|
PhotoSection.TYPE: PhotoSection
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION_IDS = {
|
||||||
|
MovieSection.ID: MovieSection,
|
||||||
|
ShowSection.ID: ShowSection,
|
||||||
|
MusicSection.ID: MusicSection,
|
||||||
|
PhotoSection.ID: PhotoSection
|
||||||
|
}
|
159
resources/lib/plexnet/plexmedia.py
Normal file
159
resources/lib/plexnet/plexmedia.py
Normal file
|
@ -0,0 +1,159 @@
|
||||||
|
import locks
|
||||||
|
import http
|
||||||
|
import plexobjects
|
||||||
|
import plexpart
|
||||||
|
import plexrequest
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class PlexMedia(plexobjects.PlexObject):
|
||||||
|
def __init__(self, data, initpath=None, server=None, container=None):
|
||||||
|
self._data = data.attrib
|
||||||
|
plexobjects.PlexObject.__init__(self, data, initpath, server)
|
||||||
|
self.container_ = self.get('container')
|
||||||
|
self.container = container
|
||||||
|
self.indirectHeaders = None
|
||||||
|
self.parts = []
|
||||||
|
# If we weren't given any data, this is a synthetic media
|
||||||
|
if data is not None:
|
||||||
|
self.parts = [plexpart.PlexPart(elem, initpath=self.initpath, server=self.server, media=self) for elem in data]
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return self._data.get(key, default)
|
||||||
|
|
||||||
|
def hasStreams(self):
|
||||||
|
return len(self.parts) > 0 and self.parts[0].hasStreams()
|
||||||
|
|
||||||
|
def isIndirect(self):
|
||||||
|
return self.get('indirect') == '1'
|
||||||
|
|
||||||
|
def isAccessible(self):
|
||||||
|
for part in self.parts:
|
||||||
|
if not part.isAccessible():
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def isAvailable(self):
|
||||||
|
for part in self.parts:
|
||||||
|
if not part.isAvailable():
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def resolveIndirect(self):
|
||||||
|
if not self.isIndirect() or locks.LOCKS.isLocked("resolve_indirect"):
|
||||||
|
return self
|
||||||
|
|
||||||
|
part = self.parts[0]
|
||||||
|
if part is None:
|
||||||
|
util.DEBUG("Failed to resolve indirect media: missing valid part")
|
||||||
|
return None
|
||||||
|
|
||||||
|
postBody = None
|
||||||
|
postUrl = part.postURL
|
||||||
|
request = plexrequest.PlexRequest(self.getServer(), part.key, postUrl is not None and "POST" or "GET")
|
||||||
|
|
||||||
|
if postUrl is not None:
|
||||||
|
util.DEBUG("Fetching content for indirect media POST URL: {0}".format(postUrl))
|
||||||
|
# Force setting the certificate to handle following https redirects
|
||||||
|
postRequest = http.HttpRequest(postUrl, None, True)
|
||||||
|
postResponse = postRequest.getToStringWithTimeout(30)
|
||||||
|
if len(postResponse) > 0 and type(postRequest.event) == "roUrlEvent":
|
||||||
|
util.DEBUG("Retrieved data from postURL, posting to resolve container")
|
||||||
|
crlf = chr(13) + chr(10)
|
||||||
|
postBody = ""
|
||||||
|
for header in postRequest.event.getResponseHeadersArray():
|
||||||
|
for name in header:
|
||||||
|
postBody = postBody + name + ": " + header[name] + crlf
|
||||||
|
postBody = postBody + crlf + postResponse
|
||||||
|
else:
|
||||||
|
util.DEBUG("Failed to resolve indirect media postUrl")
|
||||||
|
self.Set("indirect", "-1")
|
||||||
|
return self
|
||||||
|
|
||||||
|
request.addParam("postURL", postUrl)
|
||||||
|
|
||||||
|
response = request.doRequestWithTimeout(30, postBody)
|
||||||
|
|
||||||
|
item = response.items[0]
|
||||||
|
if item is None or item.mediaItems[0] is None:
|
||||||
|
util.DEBUG("Failed to resolve indirect media: no media items")
|
||||||
|
self.indirect = -1
|
||||||
|
return self
|
||||||
|
|
||||||
|
media = item.mediaItems[0]
|
||||||
|
|
||||||
|
# Add indirect headers to the media item
|
||||||
|
media.indirectHeaders = util.AttributeDict()
|
||||||
|
for header in (item.container.httpHeaders or '').split("&"):
|
||||||
|
arr = header.split("=")
|
||||||
|
if len(arr) == 2:
|
||||||
|
media.indirectHeaders[arr[0]] = arr[1]
|
||||||
|
|
||||||
|
# Reset the fallback media id if applicable
|
||||||
|
if self.id.asInt() < 0:
|
||||||
|
media.id = self.id
|
||||||
|
|
||||||
|
return media.resolveIndirect()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
extra = []
|
||||||
|
attrs = ("videoCodec", "audioCodec", "audioChannels", "protocol", "id")
|
||||||
|
if self.get('container'):
|
||||||
|
extra.append("container={0}".format(self.get('container')))
|
||||||
|
|
||||||
|
for astr in attrs:
|
||||||
|
if hasattr(self, astr):
|
||||||
|
attr = getattr(self, astr)
|
||||||
|
if attr and not attr.NA:
|
||||||
|
extra.append("{0}={1}".format(astr, attr))
|
||||||
|
|
||||||
|
return self.versionString(log_safe=True) + " " + ' '.join(extra)
|
||||||
|
|
||||||
|
def versionString(self, log_safe=False):
|
||||||
|
details = []
|
||||||
|
details.append(self.getVideoResolutionString())
|
||||||
|
if self.bitrate.asInt() > 0:
|
||||||
|
details.append(util.bitrateToString(self.bitrate.asInt() * 1000))
|
||||||
|
|
||||||
|
detailString = ', '.join(details)
|
||||||
|
return (log_safe and ' * ' or u" \u2022 ").join(filter(None, [self.title, detailString]))
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if not other:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.__class__ != other.__class__:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.id == other.id
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.__str__()
|
||||||
|
|
||||||
|
def getVideoResolution(self):
|
||||||
|
if self.videoResolution:
|
||||||
|
standardDefinitionHeight = 480
|
||||||
|
if str(util.validInt(filter(unicode.isdigit, self.videoResolution))) != self.videoResolution:
|
||||||
|
return self.height.asInt() > standardDefinitionHeight and self.height.asInt() or standardDefinitionHeight
|
||||||
|
else:
|
||||||
|
return self.videoResolution.asInt(standardDefinitionHeight)
|
||||||
|
|
||||||
|
return self.height.asInt()
|
||||||
|
|
||||||
|
def getVideoResolutionString(self):
|
||||||
|
resNumber = util.validInt(filter(unicode.isdigit, self.videoResolution))
|
||||||
|
if resNumber > 0 and str(resNumber) == self.videoResolution:
|
||||||
|
return self.videoResolution + "p"
|
||||||
|
|
||||||
|
return self.videoResolution.upper()
|
||||||
|
|
||||||
|
def isSelected(self):
|
||||||
|
import plexapp
|
||||||
|
return self.selected.asBool() or self.id == plexapp.INTERFACE.getPreference("local_mediaId")
|
||||||
|
|
||||||
|
# TODO(schuyler): getParts
|
542
resources/lib/plexnet/plexobjects.py
Normal file
542
resources/lib/plexnet/plexobjects.py
Normal file
|
@ -0,0 +1,542 @@
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import exceptions
|
||||||
|
import util
|
||||||
|
import plexapp
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Search Types - Plex uses these to filter specific media types when searching.
|
||||||
|
SEARCHTYPES = {
|
||||||
|
'movie': 1,
|
||||||
|
'show': 2,
|
||||||
|
'season': 3,
|
||||||
|
'episode': 4,
|
||||||
|
'artist': 8,
|
||||||
|
'album': 9,
|
||||||
|
'track': 10
|
||||||
|
}
|
||||||
|
|
||||||
|
LIBRARY_TYPES = {}
|
||||||
|
|
||||||
|
|
||||||
|
def registerLibType(cls):
|
||||||
|
LIBRARY_TYPES[cls.TYPE] = cls
|
||||||
|
return cls
|
||||||
|
|
||||||
|
|
||||||
|
def registerLibFactory(ftype):
|
||||||
|
def wrap(func):
|
||||||
|
LIBRARY_TYPES[ftype] = func
|
||||||
|
return func
|
||||||
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
|
class PlexValue(unicode):
|
||||||
|
def __new__(cls, value, parent=None):
|
||||||
|
self = super(PlexValue, cls).__new__(cls, value)
|
||||||
|
self.parent = parent
|
||||||
|
self.NA = False
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __call__(self, default):
|
||||||
|
return not self.NA and self or PlexValue(default, self.parent)
|
||||||
|
|
||||||
|
def asBool(self):
|
||||||
|
return self == '1'
|
||||||
|
|
||||||
|
def asInt(self, default=0):
|
||||||
|
return int(self or default)
|
||||||
|
|
||||||
|
def asFloat(self, default=0):
|
||||||
|
return float(self or default)
|
||||||
|
|
||||||
|
def asDatetime(self, format_=None):
|
||||||
|
if not self:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.isdigit():
|
||||||
|
dt = datetime.fromtimestamp(int(self))
|
||||||
|
else:
|
||||||
|
dt = datetime.strptime(self, '%Y-%m-%d')
|
||||||
|
|
||||||
|
if not format_:
|
||||||
|
return dt
|
||||||
|
|
||||||
|
return dt.strftime(format_)
|
||||||
|
|
||||||
|
def asURL(self):
|
||||||
|
return self.parent.server.url(self)
|
||||||
|
|
||||||
|
def asTranscodedImageURL(self, w, h, **extras):
|
||||||
|
return self.parent.server.getImageTranscodeURL(self, w, h, **extras)
|
||||||
|
|
||||||
|
|
||||||
|
class JEncoder(json.JSONEncoder):
|
||||||
|
def default(self, o):
|
||||||
|
try:
|
||||||
|
return json.JSONEncoder.default(self, o)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def asFullObject(func):
|
||||||
|
def wrap(self, *args, **kwargs):
|
||||||
|
if not self.isFullObject():
|
||||||
|
self.reload()
|
||||||
|
return func(self, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
|
class Checks:
|
||||||
|
def isLibraryItem(self):
|
||||||
|
return "/library/metadata" in self.get('key', '') or ("/playlists/" in self.get('key', '') and self.get("type", "") == "playlist")
|
||||||
|
|
||||||
|
def isVideoItem(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def isMusicItem(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def isOnlineItem(self):
|
||||||
|
return self.isChannelItem() or self.isMyPlexItem() or self.isVevoItem() or self.isIvaItem()
|
||||||
|
|
||||||
|
def isMyPlexItem(self):
|
||||||
|
return self.container.server.TYPE == 'MYPLEXSERVER' or self.container.identifier == 'com.plexapp.plugins.myplex'
|
||||||
|
|
||||||
|
def isChannelItem(self):
|
||||||
|
identifier = self.getIdentifier() or "com.plexapp.plugins.library"
|
||||||
|
return not self.isLibraryItem() and not self.isMyPlexItem() and identifier != "com.plexapp.plugins.library"
|
||||||
|
|
||||||
|
def isVevoItem(self):
|
||||||
|
return 'vevo://' in self.get('guid')
|
||||||
|
|
||||||
|
def isIvaItem(self):
|
||||||
|
return 'iva://' in self.get('guid')
|
||||||
|
|
||||||
|
def isGracenoteCollection(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def isIPhoto(self):
|
||||||
|
return (self.title == "iPhoto" or self.container.title == "iPhoto" or (self.mediaType == "Image" or self.mediaType == "Movie"))
|
||||||
|
|
||||||
|
def isDirectory(self):
|
||||||
|
return self.name == "Directory" or self.name == "Playlist"
|
||||||
|
|
||||||
|
def isPhotoOrDirectoryItem(self):
|
||||||
|
return self.type == "photoalbum" # or self.isPhotoItem()
|
||||||
|
|
||||||
|
def isMusicOrDirectoryItem(self):
|
||||||
|
return self.type in ('artist', 'album', 'track')
|
||||||
|
|
||||||
|
def isVideoOrDirectoryItem(self):
|
||||||
|
return self.type in ('movie', 'show', 'episode')
|
||||||
|
|
||||||
|
def isSettings(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class PlexObject(object, Checks):
|
||||||
|
def __init__(self, data, initpath=None, server=None, container=None):
|
||||||
|
self.initpath = initpath
|
||||||
|
self.key = None
|
||||||
|
self.server = server
|
||||||
|
self.container = container
|
||||||
|
self.mediaChoice = None
|
||||||
|
self.titleSort = PlexValue('')
|
||||||
|
self.deleted = False
|
||||||
|
self._reloaded = False
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._setData(data)
|
||||||
|
|
||||||
|
self.init(data)
|
||||||
|
|
||||||
|
def _setData(self, data):
|
||||||
|
if data is False:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.name = data.tag
|
||||||
|
for k, v in data.attrib.items():
|
||||||
|
setattr(self, k, PlexValue(v, self))
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
a = PlexValue('', self)
|
||||||
|
a.NA = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
setattr(self, attr, a)
|
||||||
|
except AttributeError:
|
||||||
|
util.LOG('Failed to set attribute: {0} ({1})'.format(attr, self.__class__))
|
||||||
|
|
||||||
|
return a
|
||||||
|
|
||||||
|
def exists(self):
|
||||||
|
# Used for media items - for others we just return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get(self, attr, default=''):
|
||||||
|
ret = self.__dict__.get(attr)
|
||||||
|
return ret is not None and ret or PlexValue(default, self)
|
||||||
|
|
||||||
|
def set(self, attr, value):
|
||||||
|
setattr(self, attr, PlexValue(unicode(value), self))
|
||||||
|
|
||||||
|
def init(self, data):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def isFullObject(self):
|
||||||
|
return self.initpath is None or self.key is None or self.initpath == self.key
|
||||||
|
|
||||||
|
def getAddress(self):
|
||||||
|
return self.server.activeConnection.address
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultTitle(self):
|
||||||
|
return self.get('title')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultThumb(self):
|
||||||
|
return self.__dict__.get('thumb') and self.thumb or PlexValue('', self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultArt(self):
|
||||||
|
return self.__dict__.get('art') and self.art or PlexValue('', self)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
import requests
|
||||||
|
self.server.query('%s/refresh' % self.key, method=requests.put)
|
||||||
|
|
||||||
|
def reload(self, _soft=False, **kwargs):
|
||||||
|
""" Reload the data for this object from PlexServer XML. """
|
||||||
|
if _soft and self._reloaded:
|
||||||
|
return self
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.get('ratingKey'):
|
||||||
|
data = self.server.query('/library/metadata/{0}'.format(self.ratingKey), params=kwargs)
|
||||||
|
else:
|
||||||
|
data = self.server.query(self.key, params=kwargs)
|
||||||
|
self._reloaded = True
|
||||||
|
except Exception, e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
util.ERROR(err=e)
|
||||||
|
self.initpath = self.key
|
||||||
|
return self
|
||||||
|
|
||||||
|
self.initpath = self.key
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._setData(data[0])
|
||||||
|
except IndexError:
|
||||||
|
util.DEBUG_LOG('No data on reload: {0}'.format(self))
|
||||||
|
return self
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def softReload(self, **kwargs):
|
||||||
|
return self.reload(_soft=True, **kwargs)
|
||||||
|
|
||||||
|
def getLibrarySectionId(self):
|
||||||
|
ID = self.get('librarySectionID')
|
||||||
|
|
||||||
|
if not ID:
|
||||||
|
ID = self.container.get("librarySectionID", '')
|
||||||
|
|
||||||
|
return ID
|
||||||
|
|
||||||
|
def getLibrarySectionTitle(self):
|
||||||
|
title = self.get('librarySectionTitle')
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
title = self.container.get("librarySectionTitle", '')
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
lsid = self.getLibrarySectionId()
|
||||||
|
if lsid:
|
||||||
|
data = self.server.query('/library/sections/{0}'.format(lsid))
|
||||||
|
title = data.attrib.get('title1')
|
||||||
|
if title:
|
||||||
|
self.librarySectionTitle = title
|
||||||
|
return title
|
||||||
|
|
||||||
|
def getLibrarySectionType(self):
|
||||||
|
type_ = self.get('librarySectionType')
|
||||||
|
|
||||||
|
if not type_:
|
||||||
|
type_ = self.container.get("librarySectionType", '')
|
||||||
|
|
||||||
|
if not type_:
|
||||||
|
lsid = self.getLibrarySectionId()
|
||||||
|
if lsid:
|
||||||
|
data = self.server.query('/library/sections/{0}'.format(lsid))
|
||||||
|
type_ = data.attrib.get('type')
|
||||||
|
if type_:
|
||||||
|
self.librarySectionTitle = type_
|
||||||
|
return type_
|
||||||
|
|
||||||
|
def getLibrarySectionUuid(self):
|
||||||
|
uuid = self.get("uuid") or self.get("librarySectionUUID")
|
||||||
|
|
||||||
|
if not uuid:
|
||||||
|
uuid = self.container.get("librarySectionUUID", "")
|
||||||
|
|
||||||
|
return uuid
|
||||||
|
|
||||||
|
def _findLocation(self, data):
|
||||||
|
elem = data.find('Location')
|
||||||
|
if elem is not None:
|
||||||
|
return elem.attrib.get('path')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _findPlayer(self, data):
|
||||||
|
elem = data.find('Player')
|
||||||
|
if elem is not None:
|
||||||
|
from plexapi.client import Client
|
||||||
|
return Client(self.server, elem)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _findTranscodeSession(self, data):
|
||||||
|
elem = data.find('TranscodeSession')
|
||||||
|
if elem is not None:
|
||||||
|
from plexapi import media
|
||||||
|
return media.TranscodeSession(self.server, elem)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _findUser(self, data):
|
||||||
|
elem = data.find('User')
|
||||||
|
if elem is not None:
|
||||||
|
from plexapi.myplex import MyPlexUser
|
||||||
|
return MyPlexUser(elem, self.initpath)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getAbsolutePath(self, attr):
|
||||||
|
path = getattr(self, attr, None)
|
||||||
|
if path is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return self.container._getAbsolutePath(path)
|
||||||
|
|
||||||
|
def _getAbsolutePath(self, path):
|
||||||
|
if path.startswith('/'):
|
||||||
|
return path
|
||||||
|
elif "://" in path:
|
||||||
|
return path
|
||||||
|
else:
|
||||||
|
return self.getAddress() + "/" + path
|
||||||
|
|
||||||
|
def getParentPath(self, key):
|
||||||
|
# Some containers have /children on its key while others (such as playlists) use /items
|
||||||
|
path = self.getAbsolutePath(key)
|
||||||
|
if path is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
for suffix in ("/children", "/items"):
|
||||||
|
path = path.replace(suffix, "")
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
def getServer(self):
|
||||||
|
return self.server
|
||||||
|
|
||||||
|
def getTranscodeServer(self, localServerRequired=False, transcodeType=None):
|
||||||
|
server = self.server
|
||||||
|
|
||||||
|
# If the server is myPlex, try to use a different PMS for transcoding
|
||||||
|
import myplexserver
|
||||||
|
if server == myplexserver.MyPlexServer:
|
||||||
|
fallbackServer = plexapp.SERVERMANAGER.getChannelServer()
|
||||||
|
|
||||||
|
if fallbackServer:
|
||||||
|
server = fallbackServer
|
||||||
|
elif localServerRequired:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return server
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def deSerialize(cls, jstring):
|
||||||
|
import plexserver
|
||||||
|
obj = json.loads(jstring)
|
||||||
|
server = plexserver.PlexServer.deSerialize(obj['server'])
|
||||||
|
server.identifier = None
|
||||||
|
ad = util.AttributeDict()
|
||||||
|
ad.attrib = obj['obj']
|
||||||
|
ad.find = lambda x: None
|
||||||
|
po = buildItem(server, ad, ad.initpath, container=server)
|
||||||
|
|
||||||
|
return po
|
||||||
|
|
||||||
|
def serialize(self, full=False):
|
||||||
|
import json
|
||||||
|
odict = {}
|
||||||
|
if full:
|
||||||
|
for k, v in self.__dict__.items():
|
||||||
|
if k not in ('server', 'container', 'media', 'initpath', '_data') and v:
|
||||||
|
odict[k] = v
|
||||||
|
else:
|
||||||
|
odict['key'] = self.key
|
||||||
|
odict['type'] = self.type
|
||||||
|
|
||||||
|
odict['initpath'] = '/none'
|
||||||
|
obj = {'obj': odict, 'server': self.server.serialize(full=full)}
|
||||||
|
|
||||||
|
return json.dumps(obj, cls=JEncoder)
|
||||||
|
|
||||||
|
|
||||||
|
class PlexContainer(PlexObject):
|
||||||
|
def __init__(self, data, initpath=None, server=None, address=None):
|
||||||
|
PlexObject.__init__(self, data, initpath, server)
|
||||||
|
self.setAddress(address)
|
||||||
|
|
||||||
|
def setAddress(self, address):
|
||||||
|
if address != "/" and address.endswith("/"):
|
||||||
|
self.address = address[:-1]
|
||||||
|
else:
|
||||||
|
self.address = address
|
||||||
|
|
||||||
|
# TODO(schuyler): Do we need to make sure that we only hang onto the path here and not a full URL?
|
||||||
|
if not self.address.startswith("/") and "node.plexapp.com" not in self.address:
|
||||||
|
util.FATAL("Container address is not an expected path: {0}".format(address))
|
||||||
|
|
||||||
|
def getAbsolutePath(self, path):
|
||||||
|
if path.startswith('/'):
|
||||||
|
return path
|
||||||
|
elif "://" in path:
|
||||||
|
return path
|
||||||
|
else:
|
||||||
|
return self.address + "/" + path
|
||||||
|
|
||||||
|
|
||||||
|
class PlexServerContainer(PlexContainer):
|
||||||
|
def __init__(self, data, initpath=None, server=None, address=None):
|
||||||
|
PlexContainer.__init__(self, data, initpath, server, address)
|
||||||
|
import plexserver
|
||||||
|
self.resources = [plexserver.PlexServer(elem) for elem in data]
|
||||||
|
|
||||||
|
def __getitem__(self, idx):
|
||||||
|
return self.resources[idx]
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
for i in self.resources:
|
||||||
|
yield i
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.resources)
|
||||||
|
|
||||||
|
|
||||||
|
class PlexItemList(object):
|
||||||
|
def __init__(self, data, item_cls, tag, server=None, container=None):
|
||||||
|
self._data = data
|
||||||
|
self._itemClass = item_cls
|
||||||
|
self._itemTag = tag
|
||||||
|
self._server = server
|
||||||
|
self._container = container
|
||||||
|
self._items = None
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
for i in self.items:
|
||||||
|
yield i
|
||||||
|
|
||||||
|
def __getitem__(self, idx):
|
||||||
|
return self.items[idx]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def items(self):
|
||||||
|
if self._items is None:
|
||||||
|
if self._data is not None:
|
||||||
|
if self._server:
|
||||||
|
self._items = [self._itemClass(elem, server=self._server, container=self._container) for elem in self._data if elem.tag == self._itemTag]
|
||||||
|
else:
|
||||||
|
self._items = [self._itemClass(elem) for elem in self._data if elem.tag == self._itemTag]
|
||||||
|
else:
|
||||||
|
self._items = []
|
||||||
|
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
def __call__(self, *args):
|
||||||
|
return self.items
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.items)
|
||||||
|
|
||||||
|
def append(self, item):
|
||||||
|
self.items.append(item)
|
||||||
|
|
||||||
|
|
||||||
|
class PlexMediaItemList(PlexItemList):
|
||||||
|
def __init__(self, data, item_cls, tag, initpath=None, server=None, media=None):
|
||||||
|
PlexItemList.__init__(self, data, item_cls, tag, server)
|
||||||
|
self._initpath = initpath
|
||||||
|
self._media = media
|
||||||
|
self._items = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def items(self):
|
||||||
|
if self._items is None:
|
||||||
|
if self._data is not None:
|
||||||
|
self._items = [self._itemClass(elem, self._initpath, self._server, self._media) for elem in self._data if elem.tag == self._itemTag]
|
||||||
|
else:
|
||||||
|
self._items = []
|
||||||
|
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
|
||||||
|
def findItem(server, path, title):
|
||||||
|
for elem in server.query(path):
|
||||||
|
if elem.attrib.get('title').lower() == title.lower():
|
||||||
|
return buildItem(server, elem, path)
|
||||||
|
raise exceptions.NotFound('Unable to find item: {0}'.format(title))
|
||||||
|
|
||||||
|
|
||||||
|
def buildItem(server, elem, initpath, bytag=False, container=None, tag_fallback=False):
|
||||||
|
libtype = elem.tag if bytag else elem.attrib.get('type')
|
||||||
|
if not libtype and tag_fallback:
|
||||||
|
libtype = elem.tag
|
||||||
|
|
||||||
|
if libtype in LIBRARY_TYPES:
|
||||||
|
cls = LIBRARY_TYPES[libtype]
|
||||||
|
return cls(elem, initpath=initpath, server=server, container=container)
|
||||||
|
raise exceptions.UnknownType('Unknown library type: {0}'.format(libtype))
|
||||||
|
|
||||||
|
|
||||||
|
class ItemContainer(list):
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return getattr(self.container, attr)
|
||||||
|
|
||||||
|
def init(self, container):
|
||||||
|
self.container = container
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def listItems(server, path, libtype=None, watched=None, bytag=False, data=None, container=None):
|
||||||
|
data = data if data is not None else server.query(path)
|
||||||
|
container = container or PlexContainer(data, path, server, path)
|
||||||
|
items = ItemContainer().init(container)
|
||||||
|
|
||||||
|
for elem in data:
|
||||||
|
if libtype and elem.attrib.get('type') != libtype:
|
||||||
|
continue
|
||||||
|
if watched is True and elem.attrib.get('viewCount', 0) == 0:
|
||||||
|
continue
|
||||||
|
if watched is False and elem.attrib.get('viewCount', 0) >= 1:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
items.append(buildItem(server, elem, path, bytag, container))
|
||||||
|
except exceptions.UnknownType:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def searchType(libtype):
|
||||||
|
searchtypesstrs = [str(k) for k in SEARCHTYPES.keys()]
|
||||||
|
if libtype in SEARCHTYPES + searchtypesstrs:
|
||||||
|
return libtype
|
||||||
|
stype = SEARCHTYPES.get(libtype.lower())
|
||||||
|
if not stype:
|
||||||
|
raise exceptions.NotFound('Unknown libtype: %s' % libtype)
|
||||||
|
return stype
|
177
resources/lib/plexnet/plexpart.py
Normal file
177
resources/lib/plexnet/plexpart.py
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
import plexobjects
|
||||||
|
import plexstream
|
||||||
|
import plexrequest
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class PlexPart(plexobjects.PlexObject):
|
||||||
|
def reload(self):
|
||||||
|
self.initpath = self.key
|
||||||
|
|
||||||
|
def __init__(self, data, initpath=None, server=None, media=None):
|
||||||
|
plexobjects.PlexObject.__init__(self, data, initpath, server)
|
||||||
|
self.container_ = self.container
|
||||||
|
self.container = media
|
||||||
|
self.streams = []
|
||||||
|
|
||||||
|
# If we weren't given any data, this is a synthetic part
|
||||||
|
if data is not None:
|
||||||
|
self.streams = [plexstream.PlexStream(e, initpath=self.initpath, server=self.server) for e in data if e.tag == 'Stream']
|
||||||
|
if self.indexes:
|
||||||
|
indexKeys = self.indexes('').split(",")
|
||||||
|
self.indexes = util.AttributeDict()
|
||||||
|
for indexKey in indexKeys:
|
||||||
|
self.indexes[indexKey] = True
|
||||||
|
|
||||||
|
def getAddress(self):
|
||||||
|
address = self.key
|
||||||
|
|
||||||
|
if address != "":
|
||||||
|
# TODO(schuyler): Do we need to add a token? Or will it be taken care of via header else:where?
|
||||||
|
address = self.container.getAbsolutePath(address)
|
||||||
|
|
||||||
|
return address
|
||||||
|
|
||||||
|
def isAccessible(self):
|
||||||
|
# If we haven't fetched accessibility info, assume it's accessible.
|
||||||
|
return self.accessible.asBool() if self.accessible else True
|
||||||
|
|
||||||
|
def isAvailable(self):
|
||||||
|
# If we haven't fetched availability info, assume it's available
|
||||||
|
return not self.exists or self.exists.asBool()
|
||||||
|
|
||||||
|
def getStreamsOfType(self, streamType):
|
||||||
|
streams = []
|
||||||
|
|
||||||
|
foundSelected = False
|
||||||
|
|
||||||
|
for stream in self.streams:
|
||||||
|
if stream.streamType.asInt() == streamType:
|
||||||
|
streams.append(stream)
|
||||||
|
|
||||||
|
if stream.isSelected():
|
||||||
|
foundSelected = True
|
||||||
|
|
||||||
|
# If this is subtitles, add the none option
|
||||||
|
if streamType == plexstream.PlexStream.TYPE_SUBTITLE:
|
||||||
|
none = plexstream.NoneStream()
|
||||||
|
streams.insert(0, none)
|
||||||
|
none.setSelected(not foundSelected)
|
||||||
|
|
||||||
|
return streams
|
||||||
|
|
||||||
|
# def getSelectedStreamStringOfType(self, streamType):
|
||||||
|
# default = None
|
||||||
|
# availableStreams = 0
|
||||||
|
# for stream in self.streams:
|
||||||
|
# if stream.streamType.asInt() == streamType:
|
||||||
|
# availableStreams = availableStreams + 1
|
||||||
|
# if stream.isSelected() or (default is None and streamType != stream.TYPE_SUBTITLE):
|
||||||
|
# default = stream
|
||||||
|
|
||||||
|
# if default is not None:
|
||||||
|
# availableStreams = availableStreams - 1
|
||||||
|
# title = default.getTitle()
|
||||||
|
# suffix = "More"
|
||||||
|
# else:
|
||||||
|
# title = "None"
|
||||||
|
# suffix = "Available"
|
||||||
|
|
||||||
|
# if availableStreams > 0 and streamType != stream.TYPE_VIDEO:
|
||||||
|
# # Indicate available streams to choose from, excluding video
|
||||||
|
# # streams until the server supports multiple videos streams.
|
||||||
|
|
||||||
|
# return u"{0} : {1} {2}".format(title, availableStreams, suffix)
|
||||||
|
# else:
|
||||||
|
# return title
|
||||||
|
|
||||||
|
def getSelectedStreamOfType(self, streamType):
|
||||||
|
# Video streams, in particular, may not be selected. Pretend like the
|
||||||
|
# first one was selected.
|
||||||
|
|
||||||
|
default = None
|
||||||
|
|
||||||
|
for stream in self.streams:
|
||||||
|
if stream.streamType.asInt() == streamType:
|
||||||
|
if stream.isSelected():
|
||||||
|
return stream
|
||||||
|
elif default is None and streamType != stream.TYPE_SUBTITLE:
|
||||||
|
default = stream
|
||||||
|
|
||||||
|
return default
|
||||||
|
|
||||||
|
def setSelectedStream(self, streamType, streamId, async):
|
||||||
|
if streamType == plexstream.PlexStream.TYPE_AUDIO:
|
||||||
|
typeString = "audio"
|
||||||
|
elif streamType == plexstream.PlexStream.TYPE_SUBTITLE:
|
||||||
|
typeString = "subtitle"
|
||||||
|
elif streamType == plexstream.PlexStream.TYPE_VIDEO:
|
||||||
|
typeString = "video"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
path = "/library/parts/{0}?{1}StreamID={2}".format(self.id(''), typeString, streamId)
|
||||||
|
|
||||||
|
if self.getServer().supportsFeature("allPartsStreamSelection"):
|
||||||
|
path = path + "&allParts=1"
|
||||||
|
|
||||||
|
request = plexrequest.PlexRequest(self.getServer(), path, "PUT")
|
||||||
|
|
||||||
|
if async:
|
||||||
|
context = request.createRequestContext("ignored")
|
||||||
|
import plexapp
|
||||||
|
plexapp.APP.startRequest(request, context, "")
|
||||||
|
else:
|
||||||
|
request.postToStringWithTimeout()
|
||||||
|
|
||||||
|
matching = plexstream.NoneStream()
|
||||||
|
|
||||||
|
# Update any affected streams
|
||||||
|
for stream in self.streams:
|
||||||
|
if stream.streamType.asInt() == streamType:
|
||||||
|
if stream.id == streamId:
|
||||||
|
stream.setSelected(True)
|
||||||
|
matching = stream
|
||||||
|
elif stream.isSelected():
|
||||||
|
stream.setSelected(False)
|
||||||
|
|
||||||
|
return matching
|
||||||
|
|
||||||
|
def isIndexed(self):
|
||||||
|
return bool(self.indexes)
|
||||||
|
|
||||||
|
def getIndexUrl(self, indexKey):
|
||||||
|
path = self.getIndexPath(indexKey)
|
||||||
|
if path is not None:
|
||||||
|
return self.container.server.buildUrl(path + "?interval=10000", True)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getIndexPath(self, indexKey, interval=None):
|
||||||
|
if self.indexes is not None and indexKey in self.indexes:
|
||||||
|
return "/library/parts/{0}/indexes/{1}".format(self.id, indexKey)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def hasStreams(self):
|
||||||
|
return bool(self.streams)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Part {0} {1}".format(self.id("NaN"), self.key)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if other is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.__class__ != other.__class__:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return self.id == other.id
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.__str__()
|
||||||
|
|
||||||
|
# TODO(schuyler): getStreams, getIndexThumbUrl
|
619
resources/lib/plexnet/plexplayer.py
Normal file
619
resources/lib/plexnet/plexplayer.py
Normal file
|
@ -0,0 +1,619 @@
|
||||||
|
import re
|
||||||
|
import util
|
||||||
|
import captions
|
||||||
|
import http
|
||||||
|
import plexrequest
|
||||||
|
import mediadecisionengine
|
||||||
|
import serverdecision
|
||||||
|
|
||||||
|
DecisionFailure = serverdecision.DecisionFailure
|
||||||
|
|
||||||
|
|
||||||
|
class PlexPlayer(object):
|
||||||
|
DECISION_ENDPOINT = "/video/:/transcode/universal/decision"
|
||||||
|
|
||||||
|
def __init__(self, item, seekValue=0, forceUpdate=False):
|
||||||
|
self.decision = None
|
||||||
|
self.seekValue = seekValue
|
||||||
|
self.metadata = None
|
||||||
|
self.init(item, forceUpdate)
|
||||||
|
|
||||||
|
def init(self, item, forceUpdate=False):
|
||||||
|
self.item = item
|
||||||
|
self.choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item, forceUpdate=forceUpdate)
|
||||||
|
if self.choice:
|
||||||
|
self.media = self.choice.media
|
||||||
|
|
||||||
|
def terminate(self, code, reason):
|
||||||
|
util.LOG('TERMINATE PLAYER: ({0}, {1})'.format(code, reason))
|
||||||
|
# TODO: Handle this? ---------------------------------------------------------------------------------------------------------- TODO
|
||||||
|
|
||||||
|
def rebuild(self, item, decision=None):
|
||||||
|
# item.settings = self.item.settings
|
||||||
|
oldChoice = self.choice
|
||||||
|
self.init(item, True)
|
||||||
|
util.LOG("Replacing '{0}' with '{1}' and rebuilding.".format(oldChoice, self.choice))
|
||||||
|
self.build()
|
||||||
|
self.decision = decision
|
||||||
|
|
||||||
|
def build(self, forceTranscode=False):
|
||||||
|
if self.item.settings.getPreference("playback_directplay", False):
|
||||||
|
directPlayPref = self.item.settings.getPreference("playback_directplay_force", False) and 'forced' or 'allow'
|
||||||
|
else:
|
||||||
|
directPlayPref = 'disabled'
|
||||||
|
|
||||||
|
if forceTranscode or directPlayPref == "disabled" or self.choice.hasBurnedInSubtitles is True:
|
||||||
|
directPlay = False
|
||||||
|
else:
|
||||||
|
directPlay = directPlayPref == "forced" and True or None
|
||||||
|
|
||||||
|
return self._build(directPlay, self.item.settings.getPreference("playback_remux", False))
|
||||||
|
|
||||||
|
def _build(self, directPlay=None, directStream=True, currentPartIndex=None):
|
||||||
|
isForced = directPlay is not None
|
||||||
|
if isForced:
|
||||||
|
util.LOG(directPlay and "Forced Direct Play" or "Forced Transcode; allowDirectStream={0}".format(directStream))
|
||||||
|
|
||||||
|
directPlay = directPlay or self.choice.isDirectPlayable
|
||||||
|
server = self.item.getServer()
|
||||||
|
|
||||||
|
# A lot of our content metadata is independent of the direct play decision.
|
||||||
|
# Add that first.
|
||||||
|
|
||||||
|
obj = util.AttributeDict()
|
||||||
|
obj.duration = self.media.duration.asInt()
|
||||||
|
|
||||||
|
videoRes = self.media.getVideoResolution()
|
||||||
|
obj.fullHD = videoRes >= 1080
|
||||||
|
obj.streamQualities = (videoRes >= 480 and self.item.settings.getGlobal("IsHD")) and ["HD"] or ["SD"]
|
||||||
|
|
||||||
|
frameRate = self.media.videoFrameRate or "24p"
|
||||||
|
if frameRate == "24p":
|
||||||
|
obj.frameRate = 24
|
||||||
|
elif frameRate == "NTSC":
|
||||||
|
obj.frameRate = 30
|
||||||
|
|
||||||
|
# Add soft subtitle info
|
||||||
|
if self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_ANY:
|
||||||
|
obj.subtitleUrl = server.buildUrl(self.choice.subtitleStream.getSubtitlePath(), True)
|
||||||
|
elif self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_DP:
|
||||||
|
obj.subtitleConfig = {'TrackName': "mkv/" + str(self.choice.subtitleStream.index.asInt() + 1)}
|
||||||
|
|
||||||
|
# Create one content metadata object for each part and store them as a
|
||||||
|
# linked list. We probably want a doubly linked list, except that it
|
||||||
|
# becomes a circular reference nuisance, so we make the current item the
|
||||||
|
# base object and singly link in each direction from there.
|
||||||
|
|
||||||
|
baseObj = obj
|
||||||
|
prevObj = None
|
||||||
|
startOffset = 0
|
||||||
|
|
||||||
|
startPartIndex = currentPartIndex or 0
|
||||||
|
for partIndex in range(startPartIndex, len(self.media.parts)):
|
||||||
|
isCurrentPart = (currentPartIndex is not None and partIndex == currentPartIndex)
|
||||||
|
partObj = util.AttributeDict()
|
||||||
|
partObj.update(baseObj)
|
||||||
|
|
||||||
|
partObj.live = False
|
||||||
|
partObj.partIndex = partIndex
|
||||||
|
partObj.startOffset = startOffset
|
||||||
|
|
||||||
|
part = self.media.parts[partIndex]
|
||||||
|
|
||||||
|
partObj.partDuration = part.duration.asInt()
|
||||||
|
|
||||||
|
if part.isIndexed():
|
||||||
|
partObj.sdBifPath = part.getIndexPath("sd")
|
||||||
|
partObj.hdBifPath = part.getIndexPath("hd")
|
||||||
|
|
||||||
|
# We have to evaluate every part before playback. Normally we'd expect
|
||||||
|
# all parts to be identical, but in reality they can be different.
|
||||||
|
|
||||||
|
if partIndex > 0 and (not isForced and directPlay or not isCurrentPart):
|
||||||
|
choice = mediadecisionengine.MediaDecisionEngine().evaluateMediaVideo(self.item, self.media, partIndex)
|
||||||
|
canDirectPlay = (choice.isDirectPlayable is True)
|
||||||
|
else:
|
||||||
|
canDirectPlay = directPlay
|
||||||
|
|
||||||
|
if canDirectPlay:
|
||||||
|
partObj = self.buildDirectPlay(partObj, partIndex)
|
||||||
|
else:
|
||||||
|
transcodeServer = self.item.getTranscodeServer(True, "video")
|
||||||
|
if transcodeServer is None:
|
||||||
|
return None
|
||||||
|
partObj = self.buildTranscode(transcodeServer, partObj, partIndex, directStream, isCurrentPart)
|
||||||
|
|
||||||
|
# Set up our linked list references. If we couldn't build an actual
|
||||||
|
# object: fail fast. Otherwise, see if we're at our start offset
|
||||||
|
# yet in order to decide if we need to link forwards or backwards.
|
||||||
|
# We also need to account for parts missing a duration, by verifying
|
||||||
|
# the prevObj is None or if the startOffset has incremented.
|
||||||
|
|
||||||
|
if partObj is None:
|
||||||
|
obj = None
|
||||||
|
break
|
||||||
|
elif prevObj is None or (startOffset > 0 and int(self.seekValue / 1000) >= startOffset):
|
||||||
|
obj = partObj
|
||||||
|
partObj.prevObj = prevObj
|
||||||
|
elif prevObj is not None:
|
||||||
|
prevObj.nextPart = partObj
|
||||||
|
|
||||||
|
startOffset = startOffset + int(part.duration.asInt() / 1000)
|
||||||
|
|
||||||
|
prevObj = partObj
|
||||||
|
|
||||||
|
# Only set PlayStart for the initial part, and adjust for the part's offset
|
||||||
|
if obj is not None:
|
||||||
|
if obj.live:
|
||||||
|
# Start the stream at the end. Per Roku, this can be achieved using
|
||||||
|
# a number higher than the duration. Using the current time should
|
||||||
|
# ensure it's definitely high enough.
|
||||||
|
|
||||||
|
obj.playStart = util.now() + 1800
|
||||||
|
else:
|
||||||
|
obj.playStart = int(self.seekValue / 1000) - obj.startOffset
|
||||||
|
|
||||||
|
self.metadata = obj
|
||||||
|
|
||||||
|
util.LOG("Constructed video item for playback: {0}".format(dict(obj)))
|
||||||
|
|
||||||
|
return self.metadata
|
||||||
|
|
||||||
|
@property
|
||||||
|
def startOffset(self):
|
||||||
|
return self.metadata and self.metadata.startOffset or 0
|
||||||
|
|
||||||
|
def offsetIsValid(self, offset_seconds):
|
||||||
|
return self.metadata.startOffset <= offset_seconds < self.metadata.startOffset + (self.metadata.partDuration / 1000)
|
||||||
|
|
||||||
|
def isLiveHls(url=None, headers=None):
|
||||||
|
# Check to see if this is a live HLS playlist to fix two issues. One is a
|
||||||
|
# Roku workaround since it doesn't obey the absence of EXT-X-ENDLIST to
|
||||||
|
# start playback at the END of the playlist. The second is for us to know
|
||||||
|
# if it's live to modify the functionality and player UI.
|
||||||
|
|
||||||
|
# if IsString(url):
|
||||||
|
# request = createHttpRequest(url, "GET", true)
|
||||||
|
# AddRequestHeaders(request.request, headers)
|
||||||
|
# response = request.GetToStringWithTimeout(10)
|
||||||
|
|
||||||
|
# ' Inspect one of the media playlist streams if this is a master playlist.
|
||||||
|
# if response.instr("EXT-X-STREAM-INF") > -1 then
|
||||||
|
# Info("Identify live HLS: inspecting the master playlist")
|
||||||
|
# mediaUrl = CreateObject("roRegex", "(^https?://.*$)", "m").Match(response)[1]
|
||||||
|
# if mediaUrl <> invalid then
|
||||||
|
# request = createHttpRequest(mediaUrl, "GET", true)
|
||||||
|
# AddRequestHeaders(request.request, headers)
|
||||||
|
# response = request.GetToStringWithTimeout(10)
|
||||||
|
# end if
|
||||||
|
# end if
|
||||||
|
|
||||||
|
# isLiveHls = (response.Trim().Len() > 0 and response.instr("EXT-X-ENDLIST") = -1 and response.instr("EXT-X-STREAM-INF") = -1)
|
||||||
|
# Info("Identify live HLS: live=" + isLiveHls.toStr())
|
||||||
|
# return isLiveHls
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def getServerDecision(self):
|
||||||
|
directPlay = not (self.metadata and self.metadata.isTranscoded)
|
||||||
|
decisionPath = self.getDecisionPath(directPlay)
|
||||||
|
newDecision = None
|
||||||
|
|
||||||
|
if decisionPath:
|
||||||
|
server = self.metadata.transcodeServer or self.item.getServer()
|
||||||
|
request = plexrequest.PlexRequest(server, decisionPath)
|
||||||
|
response = request.getWithTimeout(10)
|
||||||
|
|
||||||
|
if response.isSuccess() and response.container:
|
||||||
|
decision = serverdecision.ServerDecision(self, response, self)
|
||||||
|
|
||||||
|
if decision.isSuccess():
|
||||||
|
util.LOG("MDE: Server was happy with client's original decision. {0}".format(decision))
|
||||||
|
elif decision.isDecision(True):
|
||||||
|
util.WARN_LOG("MDE: Server was unhappy with client's original decision. {0}".format(decision))
|
||||||
|
return decision.getDecision()
|
||||||
|
else:
|
||||||
|
util.LOG("MDE: Server was unbiased about the decision. {0}".format(decision))
|
||||||
|
|
||||||
|
# Check if the server has provided a new media item to use it. If
|
||||||
|
# there is no item, then we'll continue along as if there was no
|
||||||
|
# decision made.
|
||||||
|
newDecision = decision.getDecision(False)
|
||||||
|
else:
|
||||||
|
util.WARN_LOG("MDE: Server failed to provide a decision")
|
||||||
|
else:
|
||||||
|
util.WARN_LOG("MDE: Server or item does not support decisions")
|
||||||
|
|
||||||
|
return newDecision or self
|
||||||
|
|
||||||
|
def getDecisionPath(self, directPlay=False):
|
||||||
|
if not self.item or not self.metadata:
|
||||||
|
return None
|
||||||
|
|
||||||
|
decisionPath = self.metadata.decisionPath
|
||||||
|
if not decisionPath:
|
||||||
|
server = self.metadata.transcodeServer or self.item.getServer()
|
||||||
|
decisionPath = self.buildTranscode(server, util.AttributeDict(), self.metadata.partIndex, True, False).decisionPath
|
||||||
|
|
||||||
|
util.TEST(decisionPath)
|
||||||
|
|
||||||
|
# Modify the decision params based on the transcode url
|
||||||
|
if decisionPath:
|
||||||
|
if directPlay:
|
||||||
|
decisionPath = decisionPath.replace("directPlay=0", "directPlay=1")
|
||||||
|
|
||||||
|
# Clear all subtitle parameters and add the a valid subtitle type based
|
||||||
|
# on the video player. This will let the server decide if it can supply
|
||||||
|
# sidecar subs, burn or embed w/ an optional transcode.
|
||||||
|
for key in ("subtitles", "advancedSubtitles"):
|
||||||
|
decisionPath = re.sub('([?&]{0}=)\w+'.format(key), '', decisionPath)
|
||||||
|
subType = 'sidecar' # AppSettings().getBoolPreference("custom_video_player"), "embedded", "sidecar")
|
||||||
|
decisionPath = http.addUrlParam(decisionPath, "subtitles=" + subType)
|
||||||
|
|
||||||
|
# Global variables for all decisions
|
||||||
|
decisionPath = http.addUrlParam(decisionPath, "mediaBufferSize=20971") # Kodi default is 20971520 (20MB)
|
||||||
|
decisionPath = http.addUrlParam(decisionPath, "hasMDE=1")
|
||||||
|
decisionPath = http.addUrlParam(decisionPath, 'X-Plex-Platform=Chrome')
|
||||||
|
|
||||||
|
return decisionPath
|
||||||
|
|
||||||
|
def getTranscodeReason(self):
|
||||||
|
# Combine the server and local MDE decisions
|
||||||
|
obj = []
|
||||||
|
if self.decision:
|
||||||
|
obj.append(self.decision.getDecisionText())
|
||||||
|
if self.item:
|
||||||
|
obj.append(self.item.transcodeReason)
|
||||||
|
reason = ' '.join(obj)
|
||||||
|
if not reason:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return reason
|
||||||
|
|
||||||
|
def buildTranscodeHls(self, obj):
|
||||||
|
util.DEBUG_LOG('buildTranscodeHls()')
|
||||||
|
obj.streamFormat = "hls"
|
||||||
|
obj.streamBitrates = [0]
|
||||||
|
obj.switchingStrategy = "no-adaptation"
|
||||||
|
obj.transcodeEndpoint = "/video/:/transcode/universal/start.m3u8"
|
||||||
|
|
||||||
|
builder = http.HttpRequest(obj.transcodeServer.buildUrl(obj.transcodeEndpoint, True))
|
||||||
|
builder.extras = []
|
||||||
|
builder.addParam("protocol", "hls")
|
||||||
|
|
||||||
|
if self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_ANY:
|
||||||
|
builder.addParam("skipSubtitles", "1")
|
||||||
|
else: # elif self.choice.hasBurnedInSubtitles is True: # Must burn transcoded because we can't set offset
|
||||||
|
captionSize = captions.CAPTIONS.getBurnedSize()
|
||||||
|
if captionSize is not None:
|
||||||
|
builder.addParam("subtitleSize", captionSize)
|
||||||
|
|
||||||
|
# Augment the server's profile for things that depend on the Roku's configuration.
|
||||||
|
if self.item.settings.supportsAudioStream("ac3", 6):
|
||||||
|
builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=ac3)")
|
||||||
|
builder.extras.append("add-direct-play-profile(type=videoProfile&container=matroska&videoCodec=*&audioCodec=ac3)")
|
||||||
|
|
||||||
|
return builder
|
||||||
|
|
||||||
|
def buildTranscodeMkv(self, obj):
|
||||||
|
util.DEBUG_LOG('buildTranscodeMkv()')
|
||||||
|
obj.streamFormat = "mkv"
|
||||||
|
obj.streamBitrates = [0]
|
||||||
|
obj.transcodeEndpoint = "/video/:/transcode/universal/start.mkv"
|
||||||
|
|
||||||
|
builder = http.HttpRequest(obj.transcodeServer.buildUrl(obj.transcodeEndpoint, True))
|
||||||
|
builder.extras = []
|
||||||
|
# builder.addParam("protocol", "http")
|
||||||
|
builder.addParam("copyts", "1")
|
||||||
|
|
||||||
|
obj.subtitleUrl = None
|
||||||
|
if True: # if self.choice.subtitleDecision == self.choice.SUBTITLES_BURN: # Must burn transcoded because we can't set offset
|
||||||
|
builder.addParam("subtitles", "burn")
|
||||||
|
captionSize = captions.CAPTIONS.getBurnedSize()
|
||||||
|
if captionSize is not None:
|
||||||
|
builder.addParam("subtitleSize", captionSize)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# TODO(rob): can we safely assume the id will also be 3 (one based index).
|
||||||
|
# If not, we will have to get tricky and select the subtitle stream after
|
||||||
|
# video playback starts via roCaptionRenderer: GetSubtitleTracks() and
|
||||||
|
# ChangeSubtitleTrack()
|
||||||
|
|
||||||
|
obj.subtitleConfig = {'TrackName': "mkv/3"}
|
||||||
|
|
||||||
|
# Allow text conversion of subtitles if we only burn image formats
|
||||||
|
if self.item.settings.getPreference("burn_subtitles") == "image":
|
||||||
|
builder.addParam("advancedSubtitles", "text")
|
||||||
|
|
||||||
|
builder.addParam("subtitles", "auto")
|
||||||
|
|
||||||
|
# Augment the server's profile for things that depend on the Roku's configuration.
|
||||||
|
if self.item.settings.supportsSurroundSound():
|
||||||
|
if self.choice.audioStream is not None:
|
||||||
|
numChannels = self.choice.audioStream.channels.asInt(6)
|
||||||
|
else:
|
||||||
|
numChannels = 6
|
||||||
|
|
||||||
|
for codec in ("ac3", "eac3", "dca"):
|
||||||
|
if self.item.settings.supportsAudioStream(codec, numChannels):
|
||||||
|
builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&audioCodec=" + codec + ")")
|
||||||
|
builder.extras.append("add-direct-play-profile(type=videoProfile&container=matroska&videoCodec=*&audioCodec=" + codec + ")")
|
||||||
|
if codec == "dca":
|
||||||
|
builder.extras.append(
|
||||||
|
"add-limitation(scope=videoAudioCodec&scopeName=dca&type=upperBound&name=audio.channels&value=6&isRequired=false)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# AAC sample rate cannot be less than 22050hz (HLS is capable).
|
||||||
|
if self.choice.audioStream is not None and self.choice.audioStream.samplingRate.asInt(22050) < 22050:
|
||||||
|
builder.extras.append("add-limitation(scope=videoAudioCodec&scopeName=aac&type=lowerBound&name=audio.samplingRate&value=22050&isRequired=false)")
|
||||||
|
|
||||||
|
# HEVC and VP9 support!
|
||||||
|
if self.item.settings.getGlobal("hevcSupport"):
|
||||||
|
builder.extras.append("append-transcode-target-codec(type=videoProfile&context=streaming&videoCodec=hevc)")
|
||||||
|
|
||||||
|
if self.item.settings.getGlobal("vp9Support"):
|
||||||
|
builder.extras.append("append-transcode-target-codec(type=videoProfile&context=streaming&videoCodec=vp9)")
|
||||||
|
|
||||||
|
return builder
|
||||||
|
|
||||||
|
def buildDirectPlay(self, obj, partIndex):
|
||||||
|
util.DEBUG_LOG('buildDirectPlay()')
|
||||||
|
part = self.media.parts[partIndex]
|
||||||
|
|
||||||
|
server = self.item.getServer()
|
||||||
|
|
||||||
|
# Check if we should include our token or not for this request
|
||||||
|
obj.isRequestToServer = server.isRequestToServer(server.buildUrl(part.getAbsolutePath("key")))
|
||||||
|
obj.streamUrls = [server.buildUrl(part.getAbsolutePath("key"), obj.isRequestToServer)]
|
||||||
|
obj.token = obj.isRequestToServer and server.getToken() or None
|
||||||
|
if self.media.protocol == "hls":
|
||||||
|
obj.streamFormat = "hls"
|
||||||
|
obj.switchingStrategy = "full-adaptation"
|
||||||
|
obj.live = self.isLiveHLS(obj.streamUrls[0], self.media.indirectHeaders)
|
||||||
|
else:
|
||||||
|
obj.streamFormat = self.media.get('container', 'mp4')
|
||||||
|
if obj.streamFormat == "mov" or obj.streamFormat == "m4v":
|
||||||
|
obj.streamFormat = "mp4"
|
||||||
|
|
||||||
|
obj.streamBitrates = [self.media.bitrate.asInt()]
|
||||||
|
obj.isTranscoded = False
|
||||||
|
|
||||||
|
if self.choice.audioStream is not None:
|
||||||
|
obj.audioLanguageSelected = self.choice.audioStream.languageCode
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def hasMoreParts(self):
|
||||||
|
return (self.metadata is not None and self.metadata.nextPart is not None)
|
||||||
|
|
||||||
|
def getNextPartOffset(self):
|
||||||
|
return self.metadata.nextPart.startOffset * 1000
|
||||||
|
|
||||||
|
def goToNextPart(self):
|
||||||
|
oldPart = self.metadata
|
||||||
|
if oldPart is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
newPart = oldPart.nextPart
|
||||||
|
if newPart is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
newPart.prevPart = oldPart
|
||||||
|
oldPart.nextPart = None
|
||||||
|
self.metadata = newPart
|
||||||
|
|
||||||
|
util.LOG("Next part set for playback: {0}".format(self.metadata))
|
||||||
|
|
||||||
|
def getBifUrl(self, offset=0):
|
||||||
|
server = self.item.getServer()
|
||||||
|
startOffset = 0
|
||||||
|
for part in self.media.parts:
|
||||||
|
duration = part.duration.asInt()
|
||||||
|
if startOffset <= offset < startOffset + duration:
|
||||||
|
bifUrl = part.getIndexPath("hd") or part.getIndexPath("sd")
|
||||||
|
if bifUrl is not None:
|
||||||
|
url = server.buildUrl('{0}/{1}'.format(bifUrl, offset - startOffset), True)
|
||||||
|
return url
|
||||||
|
|
||||||
|
startOffset += duration
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def buildTranscode(self, server, obj, partIndex, directStream, isCurrentPart):
|
||||||
|
util.DEBUG_LOG('buildTranscode()')
|
||||||
|
obj.transcodeServer = server
|
||||||
|
obj.isTranscoded = True
|
||||||
|
|
||||||
|
# if server.supportsFeature("mkvTranscode") and self.item.settings.getPreference("transcode_format", 'mkv') != "hls":
|
||||||
|
if server.supportsFeature("mkvTranscode"):
|
||||||
|
builder = self.buildTranscodeMkv(obj)
|
||||||
|
else:
|
||||||
|
builder = self.buildTranscodeHls(obj)
|
||||||
|
|
||||||
|
if self.item.getServer().TYPE == 'MYPLEXSERVER':
|
||||||
|
path = server.swizzleUrl(self.item.getAbsolutePath("key"))
|
||||||
|
else:
|
||||||
|
path = self.item.getAbsolutePath("key")
|
||||||
|
|
||||||
|
builder.addParam("path", path)
|
||||||
|
|
||||||
|
part = self.media.parts[partIndex]
|
||||||
|
seekOffset = int(self.seekValue / 1000)
|
||||||
|
|
||||||
|
# Disabled for HLS due to a Roku bug plexinc/roku-client-issues#776
|
||||||
|
if True: # obj.streamFormat == "mkv":
|
||||||
|
# Trust our seekOffset for this part if it's the current part (now playing) or
|
||||||
|
# the seekOffset is within the time frame. We have to trust the current part
|
||||||
|
# as we may have to rebuild the transcode when seeking, and not all parts
|
||||||
|
# have a valid duration.
|
||||||
|
|
||||||
|
if isCurrentPart or len(self.media.parts) <= 1 or (
|
||||||
|
seekOffset >= obj.startOffset and seekOffset <= obj.get('startOffset', 0) + int(part.duration.asInt() / 1000)
|
||||||
|
):
|
||||||
|
startOffset = seekOffset - (obj.startOffset or 0)
|
||||||
|
|
||||||
|
# Avoid a perfect storm of PMS and Roku quirks. If we pass an offset to
|
||||||
|
# the transcoder,: it'll start transcoding from that point. But if
|
||||||
|
# we try to start a few seconds into the video, the Roku seems to want
|
||||||
|
# to grab the first segment. The first segment doesn't exist, so PMS
|
||||||
|
# returns a 404 (but only if the offset is <= 12s, otherwise it returns
|
||||||
|
# a blank segment). If the Roku gets a 404 for the first segment,:
|
||||||
|
# it'll fail. So, if we're going to start playing from less than 12
|
||||||
|
# seconds, don't bother telling the transcoder. It's not worth the
|
||||||
|
# potential failure, let it transcode from the start so that the first
|
||||||
|
# segment will always exist.
|
||||||
|
|
||||||
|
# TODO: Probably can remove this (Rick)
|
||||||
|
if startOffset <= 12:
|
||||||
|
startOffset = 0
|
||||||
|
else:
|
||||||
|
startOffset = 0
|
||||||
|
|
||||||
|
builder.addParam("offset", str(startOffset))
|
||||||
|
|
||||||
|
builder.addParam("session", self.item.settings.getGlobal("clientIdentifier"))
|
||||||
|
builder.addParam("directStream", directStream and "1" or "0")
|
||||||
|
builder.addParam("directPlay", "0")
|
||||||
|
|
||||||
|
qualityIndex = self.item.settings.getQualityIndex(self.item.getQualityType(server))
|
||||||
|
builder.addParam("videoQuality", self.item.settings.getGlobal("transcodeVideoQualities")[qualityIndex])
|
||||||
|
builder.addParam("videoResolution", str(self.item.settings.getGlobal("transcodeVideoResolutions")[qualityIndex]))
|
||||||
|
builder.addParam("maxVideoBitrate", self.item.settings.getGlobal("transcodeVideoBitrates")[qualityIndex])
|
||||||
|
|
||||||
|
if self.media.mediaIndex is not None:
|
||||||
|
builder.addParam("mediaIndex", str(self.media.mediaIndex))
|
||||||
|
|
||||||
|
builder.addParam("partIndex", str(partIndex))
|
||||||
|
|
||||||
|
# Augment the server's profile for things that depend on the Roku's configuration.
|
||||||
|
if self.item.settings.getPreference("h264_level", "auto") != "auto":
|
||||||
|
builder.extras.append(
|
||||||
|
"add-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.level&value={0}&isRequired=true)".format(
|
||||||
|
self.item.settings.getPreference("h264_level")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.item.settings.getGlobal("supports1080p60") and self.item.settings.getGlobal("transcodeVideoResolutions")[qualityIndex][0] >= 1920:
|
||||||
|
builder.extras.append("add-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.frameRate&value=30&isRequired=false)")
|
||||||
|
|
||||||
|
if builder.extras:
|
||||||
|
builder.addParam("X-Plex-Client-Profile-Extra", '+'.join(builder.extras))
|
||||||
|
|
||||||
|
if server.isLocalConnection():
|
||||||
|
builder.addParam("location", "lan")
|
||||||
|
|
||||||
|
obj.streamUrls = [builder.getUrl()]
|
||||||
|
|
||||||
|
# Build the decision path now that we have build our stream url, and only if the server supports it.
|
||||||
|
if server.supportsFeature("streamingBrain"):
|
||||||
|
util.TEST("TEST==========================")
|
||||||
|
decisionPath = builder.getRelativeUrl().replace(obj.transcodeEndpoint, self.DECISION_ENDPOINT)
|
||||||
|
if decisionPath.startswith(self.DECISION_ENDPOINT):
|
||||||
|
obj.decisionPath = decisionPath
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
class PlexAudioPlayer(object):
|
||||||
|
def __init__(self, item):
|
||||||
|
self.containerFormats = {
|
||||||
|
'aac': "es.aac-adts"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.item = item
|
||||||
|
self.choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item)
|
||||||
|
if self.choice:
|
||||||
|
self.media = self.choice.media
|
||||||
|
self.lyrics = None # createLyrics(item, self.media)
|
||||||
|
|
||||||
|
def build(self, directPlay=None):
|
||||||
|
directPlay = directPlay or self.choice.isDirectPlayable
|
||||||
|
|
||||||
|
obj = util.AttributeDict()
|
||||||
|
|
||||||
|
# TODO(schuyler): Do we want/need to add anything generic here? Title? Duration?
|
||||||
|
|
||||||
|
if directPlay:
|
||||||
|
obj = self.buildDirectPlay(obj)
|
||||||
|
else:
|
||||||
|
obj = self.buildTranscode(obj)
|
||||||
|
|
||||||
|
self.metadata = obj
|
||||||
|
|
||||||
|
util.LOG("Constructed audio item for playback: {0}".format(dict(obj)))
|
||||||
|
|
||||||
|
return self.metadata
|
||||||
|
|
||||||
|
def buildTranscode(self, obj):
|
||||||
|
transcodeServer = self.item.getTranscodeServer(True, "audio")
|
||||||
|
if not transcodeServer:
|
||||||
|
return None
|
||||||
|
|
||||||
|
obj.streamFormat = "mp3"
|
||||||
|
obj.isTranscoded = True
|
||||||
|
obj.transcodeServer = transcodeServer
|
||||||
|
obj.transcodeEndpoint = "/music/:/transcode/universal/start.m3u8"
|
||||||
|
|
||||||
|
builder = http.HttpRequest(transcodeServer.buildUrl(obj.transcodeEndpoint, True))
|
||||||
|
# builder.addParam("protocol", "http")
|
||||||
|
builder.addParam("path", self.item.getAbsolutePath("key"))
|
||||||
|
builder.addParam("session", self.item.getGlobal("clientIdentifier"))
|
||||||
|
builder.addParam("directPlay", "0")
|
||||||
|
builder.addParam("directStream", "0")
|
||||||
|
|
||||||
|
obj.url = builder.getUrl()
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def buildDirectPlay(self, obj):
|
||||||
|
if self.choice.part:
|
||||||
|
obj.url = self.item.getServer().buildUrl(self.choice.part.getAbsolutePath("key"), True)
|
||||||
|
|
||||||
|
# Set and override the stream format if applicable
|
||||||
|
obj.streamFormat = self.choice.media.get('container', 'mp3')
|
||||||
|
if self.containerFormats.get(obj.streamFormat):
|
||||||
|
obj.streamFormat = self.containerFormats[obj.streamFormat]
|
||||||
|
|
||||||
|
# If we're direct playing a FLAC, bitrate can be required, and supposedly
|
||||||
|
# this is the only way to do it. plexinc/roku-client#48
|
||||||
|
#
|
||||||
|
bitrate = self.choice.media.bitrate.asInt()
|
||||||
|
if bitrate > 0:
|
||||||
|
obj.streams = [{'url': obj.url, 'bitrate': bitrate}]
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
# We may as well fallback to transcoding if we could not direct play
|
||||||
|
return self.buildTranscode(obj)
|
||||||
|
|
||||||
|
def getLyrics(self):
|
||||||
|
return self.lyrics
|
||||||
|
|
||||||
|
def hasLyrics(self):
|
||||||
|
return False
|
||||||
|
return self.lyrics.isAvailable()
|
||||||
|
|
||||||
|
|
||||||
|
class PlexPhotoPlayer(object):
|
||||||
|
def __init__(self, item):
|
||||||
|
self.item = item
|
||||||
|
self.choice = item
|
||||||
|
self.media = item.media()[0]
|
||||||
|
self.metadata = None
|
||||||
|
|
||||||
|
def build(self):
|
||||||
|
if self.media.parts and self.media.parts[0]:
|
||||||
|
obj = util.AttributeDict()
|
||||||
|
|
||||||
|
part = self.media.parts[0]
|
||||||
|
path = part.key or part.thumb
|
||||||
|
server = self.item.getServer()
|
||||||
|
|
||||||
|
obj.url = server.buildUrl(path, True)
|
||||||
|
obj.enableBlur = server.supportsPhotoTranscoding
|
||||||
|
|
||||||
|
util.DEBUG_LOG("Constructed photo item for playback: {0}".format(dict(obj)))
|
||||||
|
|
||||||
|
self.metadata = obj
|
||||||
|
|
||||||
|
return self.metadata
|
45
resources/lib/plexnet/plexrequest.py
Normal file
45
resources/lib/plexnet/plexrequest.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
import plexserver
|
||||||
|
import plexresult
|
||||||
|
import http
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class PlexRequest(http.HttpRequest):
|
||||||
|
def __init__(self, server, path, method=None):
|
||||||
|
server = server or plexserver.dummyPlexServer()
|
||||||
|
|
||||||
|
http.HttpRequest.__init__(self, server.buildUrl(path, includeToken=True), method)
|
||||||
|
|
||||||
|
self.server = server
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
util.addPlexHeaders(self, server.getToken())
|
||||||
|
|
||||||
|
def onResponse(self, event, context):
|
||||||
|
if context.get('completionCallback'):
|
||||||
|
result = plexresult.PlexResult(self.server, self.path)
|
||||||
|
result.setResponse(event)
|
||||||
|
context['completionCallback'](self, result, context)
|
||||||
|
|
||||||
|
def doRequestWithTimeout(self, timeout=10, postBody=None):
|
||||||
|
# non async request/response
|
||||||
|
if postBody:
|
||||||
|
data = ElementTree.fromstring(self.postToStringWithTimeout(postBody, timeout))
|
||||||
|
else:
|
||||||
|
data = ElementTree.fromstring(self.getToStringWithTimeout(timeout))
|
||||||
|
|
||||||
|
response = plexresult.PlexResult(self.server, self.path)
|
||||||
|
response.setResponse(self.event)
|
||||||
|
response.parseFakeXMLResponse(data)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class PlexServerRequest(PlexRequest):
|
||||||
|
def onResponse(self, event, context):
|
||||||
|
if context.get('completionCallback'):
|
||||||
|
result = plexresult.PlexServerResult(self.server, self.path)
|
||||||
|
result.setResponse(event)
|
||||||
|
context['completionCallback'](self, result, context)
|
201
resources/lib/plexnet/plexresource.py
Normal file
201
resources/lib/plexnet/plexresource.py
Normal file
|
@ -0,0 +1,201 @@
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
import http
|
||||||
|
import exceptions
|
||||||
|
import plexobjects
|
||||||
|
import plexconnection
|
||||||
|
import util
|
||||||
|
|
||||||
|
RESOURCES = 'https://plex.tv/api/resources?includeHttps=1'
|
||||||
|
|
||||||
|
|
||||||
|
class PlexResource(object):
|
||||||
|
def __init__(self, data):
|
||||||
|
self.connection = None
|
||||||
|
self.connections = []
|
||||||
|
self.accessToken = None
|
||||||
|
self.sourceType = None
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.accessToken = data.attrib.get('accessToken')
|
||||||
|
self.httpsRequired = data.attrib.get('httpsRequired') == '1'
|
||||||
|
self.type = data.attrib.get('type')
|
||||||
|
self.clientIdentifier = data.attrib.get('clientIdentifier')
|
||||||
|
self.product = data.attrib.get('product')
|
||||||
|
self.provides = data.attrib.get('provides')
|
||||||
|
self.serverClass = data.attrib.get('serverClass')
|
||||||
|
self.sourceType = data.attrib.get('sourceType')
|
||||||
|
self.uuid = self.clientIdentifier
|
||||||
|
|
||||||
|
hasSecureConn = False
|
||||||
|
|
||||||
|
for conn in data.findall('Connection'):
|
||||||
|
if conn.attrib.get('protocol') == "https":
|
||||||
|
hasSecureConn = True
|
||||||
|
break
|
||||||
|
|
||||||
|
for conn in data.findall('Connection'):
|
||||||
|
connection = plexconnection.PlexConnection(
|
||||||
|
plexconnection.PlexConnection.SOURCE_MYPLEX,
|
||||||
|
conn.attrib.get('uri'),
|
||||||
|
conn.attrib.get('local') == '1',
|
||||||
|
self.accessToken,
|
||||||
|
hasSecureConn and conn.attrib.get('protocol') != "https"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep the secure connection on top
|
||||||
|
if connection.isSecure:
|
||||||
|
self.connections.insert(0, connection)
|
||||||
|
else:
|
||||||
|
self.connections.append(connection)
|
||||||
|
|
||||||
|
# If the connection is one of our plex.direct secure connections, add
|
||||||
|
# the nonsecure variant as well, unless https is required.
|
||||||
|
#
|
||||||
|
if self.httpsRequired and conn.attrib.get('protocol') == "https" and conn.attrib.get('address') not in conn.attrib.get('uri'):
|
||||||
|
self.connections.append(
|
||||||
|
plexconnection.PlexConnection(
|
||||||
|
plexconnection.PlexConnection.SOURCE_MYPLEX,
|
||||||
|
"http://{0}:{1}".format(conn.attrib.get('address'), conn.attrib.get('port')),
|
||||||
|
conn.attrib.get('local') == '1',
|
||||||
|
self.accessToken,
|
||||||
|
True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<{0}:{1}>'.format(self.__class__.__name__, self.name.encode('utf8'))
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceConnection(plexobjects.PlexObject):
|
||||||
|
# Constants
|
||||||
|
STATE_UNKNOWN = "unknown"
|
||||||
|
STATE_UNREACHABLE = "unreachable"
|
||||||
|
STATE_REACHABLE = "reachable"
|
||||||
|
STATE_UNAUTHORIZED = "unauthorized"
|
||||||
|
STATE_INSECURE = "insecure_untested"
|
||||||
|
|
||||||
|
SOURCE_MANUAL = 1
|
||||||
|
SOURCE_DISCOVERED = 2
|
||||||
|
SOURCE_MYPLEX = 4
|
||||||
|
|
||||||
|
SCORE_REACHABLE = 4
|
||||||
|
SCORE_LOCAL = 2
|
||||||
|
SCORE_SECURE = 1
|
||||||
|
|
||||||
|
def init(self, data):
|
||||||
|
self.secure = True
|
||||||
|
self.reachable = False
|
||||||
|
self.data = None
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<{0}:{1}>'.format(self.__class__.__name__, self.uri.encode('utf8'))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def http_uri(self):
|
||||||
|
return 'http://{0}:{1}'.format(self.address, self.port)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def URL(self):
|
||||||
|
if self.secure:
|
||||||
|
return self.uri
|
||||||
|
else:
|
||||||
|
return self.http_url
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
util.LOG('Connecting: {0}'.format(util.cleanToken(self.URL)))
|
||||||
|
try:
|
||||||
|
self.data = self.query('/')
|
||||||
|
self.reachable = True
|
||||||
|
return True
|
||||||
|
except Exception as err:
|
||||||
|
util.ERROR(util.cleanToken(self.URL), err)
|
||||||
|
|
||||||
|
util.LOG('Connecting: Secure failed, trying insecure...')
|
||||||
|
self.secure = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.data = self.query('/')
|
||||||
|
self.reachable = True
|
||||||
|
return True
|
||||||
|
except Exception as err:
|
||||||
|
util.ERROR(util.cleanToken(self.URL), err)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def headers(self, token=None):
|
||||||
|
headers = util.BASE_HEADERS.copy()
|
||||||
|
if token:
|
||||||
|
headers['X-Plex-Token'] = token
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def query(self, path, method=None, token=None, **kwargs):
|
||||||
|
method = method or http.requests.get
|
||||||
|
url = self.getURL(path)
|
||||||
|
util.LOG('{0} {1}'.format(method.__name__.upper(), url))
|
||||||
|
response = method(url, headers=self.headers(token), timeout=util.TIMEOUT, **kwargs)
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
codename = http.status_codes.get(response.status_code)[0]
|
||||||
|
raise exceptions.BadRequest('({0}) {1}'.format(response.status_code, codename))
|
||||||
|
data = response.text.encode('utf8')
|
||||||
|
|
||||||
|
return ElementTree.fromstring(data) if data else None
|
||||||
|
|
||||||
|
def getURL(self, path, token=None):
|
||||||
|
if token:
|
||||||
|
delim = '&' if '?' in path else '?'
|
||||||
|
return '{base}{path}{delim}X-Plex-Token={token}'.format(base=self.URL, path=path, delim=delim, token=util.hideToken(token))
|
||||||
|
return '{0}{1}'.format(self.URL, path)
|
||||||
|
|
||||||
|
|
||||||
|
class PlexResourceList(plexobjects.PlexItemList):
|
||||||
|
def __init__(self, data, initpath=None, server=None):
|
||||||
|
self._data = data
|
||||||
|
self.initpath = initpath
|
||||||
|
self._server = server
|
||||||
|
self._items = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def items(self):
|
||||||
|
if self._items is None:
|
||||||
|
if self._data is not None:
|
||||||
|
self._items = [PlexResource(elem, initpath=self.initpath, server=self._server) for elem in self._data]
|
||||||
|
else:
|
||||||
|
self._items = []
|
||||||
|
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
|
||||||
|
def fetchResources(token):
|
||||||
|
headers = util.BASE_HEADERS.copy()
|
||||||
|
headers['X-Plex-Token'] = token
|
||||||
|
util.LOG('GET {0}?X-Plex-Token={1}'.format(RESOURCES, util.hideToken(token)))
|
||||||
|
response = http.GET(RESOURCES)
|
||||||
|
data = ElementTree.fromstring(response.text.encode('utf8'))
|
||||||
|
import plexserver
|
||||||
|
return [plexserver.PlexServer(elem) for elem in data]
|
||||||
|
|
||||||
|
|
||||||
|
def findResource(resources, search, port=32400):
|
||||||
|
""" Searches server.name """
|
||||||
|
search = search.lower()
|
||||||
|
util.LOG('Looking for server: {0}'.format(search))
|
||||||
|
for server in resources:
|
||||||
|
if search == server.name.lower():
|
||||||
|
util.LOG('Server found: {0}'.format(server))
|
||||||
|
return server
|
||||||
|
util.LOG('Unable to find server: {0}'.format(search))
|
||||||
|
raise exceptions.NotFound('Unable to find server: {0}'.format(search))
|
||||||
|
|
||||||
|
|
||||||
|
def findResourceByID(resources, ID):
|
||||||
|
""" Searches server.clientIdentifier """
|
||||||
|
util.LOG('Looking for server by ID: {0}'.format(ID))
|
||||||
|
for server in resources:
|
||||||
|
if ID == server.clientIdentifier:
|
||||||
|
util.LOG('Server found by ID: {0}'.format(server))
|
||||||
|
return server
|
||||||
|
util.LOG('Unable to find server by ID: {0}'.format(ID))
|
||||||
|
raise exceptions.NotFound('Unable to find server by ID: {0}'.format(ID))
|
101
resources/lib/plexnet/plexresult.py
Normal file
101
resources/lib/plexnet/plexresult.py
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import http
|
||||||
|
import plexobjects
|
||||||
|
|
||||||
|
|
||||||
|
class PlexResult(http.HttpResponse):
|
||||||
|
def __init__(self, server, address):
|
||||||
|
self.server = server
|
||||||
|
self.address = address
|
||||||
|
self.container = None
|
||||||
|
self.parsed = None
|
||||||
|
self.items = []
|
||||||
|
|
||||||
|
def setResponse(self, event):
|
||||||
|
self.event = event
|
||||||
|
|
||||||
|
def parseResponse(self):
|
||||||
|
if self.parsed:
|
||||||
|
return self.parsed
|
||||||
|
|
||||||
|
self.parsed = False
|
||||||
|
|
||||||
|
if self.isSuccess():
|
||||||
|
data = self.getBodyXml()
|
||||||
|
if data is not None:
|
||||||
|
self.container = plexobjects.PlexContainer(data, initpath=self.address, server=self.server, address=self.address)
|
||||||
|
|
||||||
|
for node in data:
|
||||||
|
self.addItem(self.container, node)
|
||||||
|
|
||||||
|
self.parsed = True
|
||||||
|
|
||||||
|
return self.parsed
|
||||||
|
|
||||||
|
def parseFakeXMLResponse(self, data):
|
||||||
|
if self.parsed:
|
||||||
|
return self.parsed
|
||||||
|
|
||||||
|
self.parsed = False
|
||||||
|
|
||||||
|
if data is not None:
|
||||||
|
self.container = plexobjects.PlexContainer(data, initpath=self.address, server=self.server, address=self.address)
|
||||||
|
|
||||||
|
for node in data:
|
||||||
|
self.addItem(self.container, node)
|
||||||
|
|
||||||
|
self.parsed = True
|
||||||
|
|
||||||
|
return self.parsed
|
||||||
|
|
||||||
|
def addItem(self, container, node):
|
||||||
|
if node.attrib.get('type') in ('track', 'movie', 'episode', 'photo') and node.tag != 'PlayQueue':
|
||||||
|
item = plexobjects.buildItem(self.server, node, self.address, container=self.container)
|
||||||
|
else:
|
||||||
|
item = plexobjects.PlexObject(node, server=self.container.server, container=self.container)
|
||||||
|
|
||||||
|
# TODO(rob): handle channel settings. We should be able to utilize
|
||||||
|
# the settings component with some modifications.
|
||||||
|
if not item.isSettings():
|
||||||
|
self.items.append(item)
|
||||||
|
else:
|
||||||
|
# Decrement the size and total size if applicable
|
||||||
|
if self.container.get("size"):
|
||||||
|
self.container.size = plexobjects.PlexValue(str(self.container.size.asInt() - 1))
|
||||||
|
if self.container.get("totalSize"):
|
||||||
|
self.container.totalSize = plexobjects.PlexValue(str(self.container.totalSize.asInt() - 1))
|
||||||
|
|
||||||
|
|
||||||
|
class PlexServerResult(PlexResult):
|
||||||
|
def parseResponse(self):
|
||||||
|
if self.parsed:
|
||||||
|
return self.parsed
|
||||||
|
|
||||||
|
self.parsed = False
|
||||||
|
|
||||||
|
if self.isSuccess():
|
||||||
|
data = self.getBodyXml()
|
||||||
|
if data is not None:
|
||||||
|
self.container = plexobjects.PlexServerContainer(data, initpath=self.address, server=self.server, address=self.address)
|
||||||
|
|
||||||
|
for node in data:
|
||||||
|
self.addItem(self.container, node)
|
||||||
|
|
||||||
|
self.parsed = True
|
||||||
|
|
||||||
|
return self.parsed
|
||||||
|
|
||||||
|
def parseFakeXMLResponse(self, data):
|
||||||
|
if self.parsed:
|
||||||
|
return self.parsed
|
||||||
|
|
||||||
|
self.parsed = False
|
||||||
|
|
||||||
|
if data is not None:
|
||||||
|
self.container = plexobjects.PlexServerContainer(data, initpath=self.address, server=self.server, address=self.address)
|
||||||
|
|
||||||
|
for node in data:
|
||||||
|
self.addItem(self.container, node)
|
||||||
|
|
||||||
|
self.parsed = True
|
||||||
|
|
||||||
|
return self.parsed
|
623
resources/lib/plexnet/plexserver.py
Normal file
623
resources/lib/plexnet/plexserver.py
Normal file
|
@ -0,0 +1,623 @@
|
||||||
|
# -*- 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
|
619
resources/lib/plexnet/plexservermanager.py
Normal file
619
resources/lib/plexnet/plexservermanager.py
Normal file
|
@ -0,0 +1,619 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
import http
|
||||||
|
import plexconnection
|
||||||
|
import plexresource
|
||||||
|
import plexserver
|
||||||
|
import myplexserver
|
||||||
|
import signalsmixin
|
||||||
|
import callback
|
||||||
|
import plexapp
|
||||||
|
import gdm
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class SearchContext(dict):
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return self.get(attr)
|
||||||
|
|
||||||
|
def __setattr__(self, attr, value):
|
||||||
|
self[attr] = value
|
||||||
|
|
||||||
|
|
||||||
|
class PlexServerManager(signalsmixin.SignalsMixin):
|
||||||
|
def __init__(self):
|
||||||
|
signalsmixin.SignalsMixin.__init__(self)
|
||||||
|
# obj.Append(ListenersMixin())
|
||||||
|
self.serversByUuid = {}
|
||||||
|
self.selectedServer = None
|
||||||
|
self.transcodeServer = None
|
||||||
|
self.channelServer = None
|
||||||
|
self.deferReachabilityTimer = None
|
||||||
|
|
||||||
|
self.startSelectedServerSearch()
|
||||||
|
self.loadState()
|
||||||
|
|
||||||
|
plexapp.APP.on("change:user", callback.Callable(self.onAccountChange))
|
||||||
|
plexapp.APP.on("change:allow_insecure", callback.Callable(self.onSecurityChange))
|
||||||
|
plexapp.APP.on("change:manual_connections", callback.Callable(self.onManualConnectionChange))
|
||||||
|
|
||||||
|
def getSelectedServer(self):
|
||||||
|
return self.selectedServer
|
||||||
|
|
||||||
|
def setSelectedServer(self, server, force=False):
|
||||||
|
# Don't do anything if the server is already selected.
|
||||||
|
if self.selectedServer and self.selectedServer == server:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if server:
|
||||||
|
# Don't select servers that don't have connections.
|
||||||
|
if not server.activeConnection:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Don't select servers that are not supported
|
||||||
|
if not server.isSupported:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.selectedServer or force:
|
||||||
|
util.LOG("Setting selected server to {0}".format(server))
|
||||||
|
self.selectedServer = server
|
||||||
|
|
||||||
|
# Update our saved state.
|
||||||
|
self.saveState()
|
||||||
|
|
||||||
|
# Notify anyone who might care.
|
||||||
|
plexapp.APP.trigger("change:selectedServer", server=server)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def getServer(self, uuid=None):
|
||||||
|
if uuid is None:
|
||||||
|
return None
|
||||||
|
elif uuid == "myplex":
|
||||||
|
return myplexserver.MyPlexServer()
|
||||||
|
else:
|
||||||
|
return self.serversByUuid[uuid]
|
||||||
|
|
||||||
|
def getServers(self):
|
||||||
|
servers = []
|
||||||
|
for uuid in self.serversByUuid:
|
||||||
|
if uuid != "myplex":
|
||||||
|
servers.append(self.serversByUuid[uuid])
|
||||||
|
|
||||||
|
return servers
|
||||||
|
|
||||||
|
def hasPendingRequests(self):
|
||||||
|
for server in self.getServers():
|
||||||
|
if server.pendingReachabilityRequests:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def removeServer(self, server):
|
||||||
|
del self.serversByUuid[server.uuid]
|
||||||
|
|
||||||
|
self.trigger('remove:server')
|
||||||
|
|
||||||
|
if server == self.selectedServer:
|
||||||
|
util.LOG("The selected server went away")
|
||||||
|
self.setSelectedServer(None, force=True)
|
||||||
|
|
||||||
|
if server == self.transcodeServer:
|
||||||
|
util.LOG("The selected transcode server went away")
|
||||||
|
self.transcodeServer = None
|
||||||
|
|
||||||
|
if server == self.channelServer:
|
||||||
|
util.LOG("The selected channel server went away")
|
||||||
|
self.channelServer = None
|
||||||
|
|
||||||
|
def updateFromConnectionType(self, servers, source):
|
||||||
|
self.markDevicesAsRefreshing()
|
||||||
|
|
||||||
|
for server in servers:
|
||||||
|
self.mergeServer(server)
|
||||||
|
|
||||||
|
if self.searchContext and source == plexresource.ResourceConnection.SOURCE_MYPLEX:
|
||||||
|
self.searchContext.waitingForResources = False
|
||||||
|
|
||||||
|
self.deviceRefreshComplete(source)
|
||||||
|
self.updateReachability(True, True)
|
||||||
|
self.saveState()
|
||||||
|
|
||||||
|
def updateFromDiscovery(self, server):
|
||||||
|
merged = self.mergeServer(server)
|
||||||
|
|
||||||
|
if not merged.activeConnection:
|
||||||
|
merged.updateReachability(False, True)
|
||||||
|
else:
|
||||||
|
# self.notifyAboutDevice(merged, True)
|
||||||
|
pass
|
||||||
|
|
||||||
|
def markDevicesAsRefreshing(self):
|
||||||
|
for uuid in self.serversByUuid.keys():
|
||||||
|
self.serversByUuid[uuid].markAsRefreshing()
|
||||||
|
|
||||||
|
def mergeServer(self, server):
|
||||||
|
if server.uuid in self.serversByUuid:
|
||||||
|
existing = self.serversByUuid[server.uuid]
|
||||||
|
existing.merge(server)
|
||||||
|
util.DEBUG_LOG("Merged {0}".format(repr(server.name)))
|
||||||
|
return existing
|
||||||
|
else:
|
||||||
|
self.serversByUuid[server.uuid] = server
|
||||||
|
util.DEBUG_LOG("Added new server {0}".format(repr(server.name)))
|
||||||
|
self.trigger("new:server", server=server)
|
||||||
|
return server
|
||||||
|
|
||||||
|
def deviceRefreshComplete(self, source):
|
||||||
|
toRemove = []
|
||||||
|
for uuid in self.serversByUuid:
|
||||||
|
if not self.serversByUuid[uuid].markUpdateFinished(source):
|
||||||
|
toRemove.append(uuid)
|
||||||
|
|
||||||
|
for uuid in toRemove:
|
||||||
|
server = self.serversByUuid[uuid]
|
||||||
|
|
||||||
|
util.DEBUG_LOG("Server {0} has no more connections - removing".format(repr(server.name)))
|
||||||
|
# self.notifyAboutDevice(server, False)
|
||||||
|
self.removeServer(server)
|
||||||
|
|
||||||
|
def updateReachability(self, force=False, preferSearch=False, defer=False):
|
||||||
|
# We don't need to test any servers unless we are signed in and authenticated.
|
||||||
|
if not plexapp.ACCOUNT.isAuthenticated and plexapp.ACCOUNT.isActive():
|
||||||
|
util.LOG("Ignore testing server reachability until we're authenticated")
|
||||||
|
return
|
||||||
|
|
||||||
|
# To improve reachability performance and app startup, we'll try to test the
|
||||||
|
# preferred server first, and defer the connection tests for a few seconds.
|
||||||
|
|
||||||
|
hasPreferredServer = bool(self.searchContext.preferredServer)
|
||||||
|
preferredServerExists = hasPreferredServer and self.searchContext.preferredServer in self.serversByUuid
|
||||||
|
|
||||||
|
if preferSearch and hasPreferredServer and preferredServerExists:
|
||||||
|
# Update the preferred server immediately if requested and exits
|
||||||
|
util.LOG("Updating reachability for preferred server: force={0}".format(force))
|
||||||
|
self.serversByUuid[self.searchContext.preferredServer].updateReachability(force)
|
||||||
|
self.deferUpdateReachability()
|
||||||
|
elif defer:
|
||||||
|
self.deferUpdateReachability()
|
||||||
|
elif hasPreferredServer and not preferredServerExists and gdm.DISCOVERY.isActive():
|
||||||
|
# Defer the update if requested or if GDM discovery is enabled and
|
||||||
|
# active while the preferred server doesn't exist.
|
||||||
|
|
||||||
|
util.LOG("Defer update reachability until GDM has finished to help locate the preferred server")
|
||||||
|
self.deferUpdateReachability(True, False)
|
||||||
|
else:
|
||||||
|
if self.deferReachabilityTimer:
|
||||||
|
self.deferReachabilityTimer.cancel()
|
||||||
|
self.deferReachabilityTimer = None
|
||||||
|
|
||||||
|
util.LOG("Updating reachability for all devices: force={0}".format(force))
|
||||||
|
for uuid in self.serversByUuid:
|
||||||
|
self.serversByUuid[uuid].updateReachability(force)
|
||||||
|
|
||||||
|
def cancelReachability(self):
|
||||||
|
if self.deferReachabilityTimer:
|
||||||
|
self.deferReachabilityTimer.cancel()
|
||||||
|
self.deferReachabilityTimer = None
|
||||||
|
|
||||||
|
for uuid in self.serversByUuid:
|
||||||
|
self.serversByUuid[uuid].cancelReachability()
|
||||||
|
|
||||||
|
def updateReachabilityResult(self, server, reachable=False):
|
||||||
|
searching = not self.selectedServer and self.searchContext
|
||||||
|
|
||||||
|
if reachable:
|
||||||
|
# If we're in the middle of a search for our selected server, see if
|
||||||
|
# this is a candidate.
|
||||||
|
self.trigger('reachable:server', server=server)
|
||||||
|
if searching:
|
||||||
|
# If this is what we were hoping for, select it
|
||||||
|
if server.uuid == self.searchContext.preferredServer:
|
||||||
|
self.setSelectedServer(server, True)
|
||||||
|
elif server.synced:
|
||||||
|
self.searchContext.fallbackServer = server
|
||||||
|
elif self.compareServers(self.searchContext.bestServer, server) < 0:
|
||||||
|
self.searchContext.bestServer = server
|
||||||
|
else:
|
||||||
|
# If this is what we were hoping for, see if there are any more pending
|
||||||
|
# requests to hope for.
|
||||||
|
|
||||||
|
if searching and server.uuid == self.searchContext.preferredServer and server.pendingReachabilityRequests <= 0:
|
||||||
|
self.searchContext.preferredServer = None
|
||||||
|
|
||||||
|
if server == self.selectedServer:
|
||||||
|
util.LOG("Selected server is not reachable")
|
||||||
|
self.setSelectedServer(None, True)
|
||||||
|
|
||||||
|
if server == self.transcodeServer:
|
||||||
|
util.LOG("The selected transcode server is not reachable")
|
||||||
|
self.transcodeServer = None
|
||||||
|
|
||||||
|
if server == self.channelServer:
|
||||||
|
util.LOG("The selected channel server is not reachable")
|
||||||
|
self.channelServer = None
|
||||||
|
|
||||||
|
# See if we should settle for the best we've found so far.
|
||||||
|
self.checkSelectedServerSearch()
|
||||||
|
|
||||||
|
def checkSelectedServerSearch(self, skip_preferred=False, skip_owned=False):
|
||||||
|
if self.selectedServer:
|
||||||
|
return self.selectedServer
|
||||||
|
elif self.searchContext:
|
||||||
|
# If we're still waiting on the resources response then there's no
|
||||||
|
# reason to settle, so don't even iterate over our servers.
|
||||||
|
|
||||||
|
if self.searchContext.waitingForResources:
|
||||||
|
util.DEBUG_LOG("Still waiting for plex.tv resources")
|
||||||
|
return
|
||||||
|
|
||||||
|
waitingForPreferred = False
|
||||||
|
waitingForOwned = False
|
||||||
|
waitingForAnything = False
|
||||||
|
waitingToTestAll = bool(self.deferReachabilityTimer)
|
||||||
|
|
||||||
|
if skip_preferred:
|
||||||
|
self.searchContext.preferredServer = None
|
||||||
|
if self.deferReachabilityTimer:
|
||||||
|
self.deferReachabilityTimer.cancel()
|
||||||
|
self.deferReachabilityTimer = None
|
||||||
|
|
||||||
|
if not skip_owned:
|
||||||
|
# Iterate over all our servers and see if we're waiting on any results
|
||||||
|
servers = self.getServers()
|
||||||
|
pendingCount = 0
|
||||||
|
for server in servers:
|
||||||
|
if server.pendingReachabilityRequests > 0:
|
||||||
|
pendingCount += server.pendingReachabilityRequests
|
||||||
|
if server.uuid == self.searchContext.preferredServer:
|
||||||
|
waitingForPreferred = True
|
||||||
|
elif server.owned:
|
||||||
|
waitingForOwned = True
|
||||||
|
else:
|
||||||
|
waitingForAnything = True
|
||||||
|
|
||||||
|
pendingString = "{0} pending reachability tests".format(pendingCount)
|
||||||
|
|
||||||
|
if waitingForPreferred:
|
||||||
|
util.LOG("Still waiting for preferred server: " + pendingString)
|
||||||
|
elif waitingToTestAll:
|
||||||
|
util.LOG("Preferred server not reachable, testing all servers now")
|
||||||
|
self.updateReachability(True, False, False)
|
||||||
|
elif waitingForOwned and (not self.searchContext.bestServer or not self.searchContext.bestServer.owned):
|
||||||
|
util.LOG("Still waiting for an owned server: " + pendingString)
|
||||||
|
elif waitingForAnything and not self.searchContext.bestServer:
|
||||||
|
util.LOG("Still waiting for any server: {0}".format(pendingString))
|
||||||
|
else:
|
||||||
|
# No hope for anything better, let's select what we found
|
||||||
|
util.LOG("Settling for the best server we found")
|
||||||
|
self.setSelectedServer(self.searchContext.bestServer or self.searchContext.fallbackServer, True)
|
||||||
|
return self.selectedServer
|
||||||
|
|
||||||
|
def compareServers(self, first, second):
|
||||||
|
if not first or not first.isSupported:
|
||||||
|
return second and -1 or 0
|
||||||
|
elif not second:
|
||||||
|
return 1
|
||||||
|
elif first.owned != second.owned:
|
||||||
|
return first.owned and 1 or -1
|
||||||
|
elif first.isLocalConnection() != second.isLocalConnection():
|
||||||
|
return first.isLocalConnection() and 1 or -1
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def loadState(self):
|
||||||
|
jstring = plexapp.INTERFACE.getRegistry("PlexServerManager")
|
||||||
|
if not jstring:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
obj = json.loads(jstring)
|
||||||
|
except:
|
||||||
|
util.ERROR()
|
||||||
|
obj = None
|
||||||
|
|
||||||
|
if not obj:
|
||||||
|
util.ERROR_LOG("Failed to parse PlexServerManager JSON")
|
||||||
|
return
|
||||||
|
|
||||||
|
for serverObj in obj['servers']:
|
||||||
|
server = plexserver.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)
|
||||||
|
|
||||||
|
self.serversByUuid[server.uuid] = server
|
||||||
|
|
||||||
|
util.LOG("Loaded {0} servers from registry".format(len(obj['servers'])))
|
||||||
|
self.updateReachability(False, True)
|
||||||
|
|
||||||
|
def saveState(self):
|
||||||
|
# Serialize our important information to JSON and save it to the registry.
|
||||||
|
# We'll always update server info upon connecting, so we don't need much
|
||||||
|
# info here. We do have to use roArray instead of roList, because Brightscript.
|
||||||
|
|
||||||
|
obj = {}
|
||||||
|
|
||||||
|
servers = self.getServers()
|
||||||
|
obj['servers'] = []
|
||||||
|
|
||||||
|
for server in servers:
|
||||||
|
# Don't save secondary servers. They should be discovered through GDM or myPlex.
|
||||||
|
if not server.isSecondary():
|
||||||
|
serverObj = {
|
||||||
|
'name': server.name,
|
||||||
|
'uuid': server.uuid,
|
||||||
|
'owned': server.owned,
|
||||||
|
'sameNetwork': server.sameNetwork,
|
||||||
|
'connections': []
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in range(len(server.connections)):
|
||||||
|
conn = server.connections[i]
|
||||||
|
serverObj['connections'].append({
|
||||||
|
'sources': conn.sources,
|
||||||
|
'address': conn.address,
|
||||||
|
'isLocal': conn.isLocal,
|
||||||
|
'isSecure': conn.isSecure,
|
||||||
|
'token': conn.token
|
||||||
|
})
|
||||||
|
|
||||||
|
obj['servers'].append(serverObj)
|
||||||
|
|
||||||
|
if self.selectedServer and not self.selectedServer.synced and not self.selectedServer.isSecondary():
|
||||||
|
plexapp.INTERFACE.setPreference("lastServerId", self.selectedServer.uuid)
|
||||||
|
|
||||||
|
plexapp.INTERFACE.setRegistry("PlexServerManager", json.dumps(obj))
|
||||||
|
|
||||||
|
def clearState(self):
|
||||||
|
plexapp.INTERFACE.setRegistry("PlexServerManager", '')
|
||||||
|
|
||||||
|
def isValidForTranscoding(self, server):
|
||||||
|
return server and server.activeConnection and server.owned and not server.synced and not server.isSecondary()
|
||||||
|
|
||||||
|
def getChannelServer(self):
|
||||||
|
if not self.channelServer or not self.channelServer.isReachable():
|
||||||
|
self.channelServer = None
|
||||||
|
|
||||||
|
# Attempt to find a server that supports channels and transcoding
|
||||||
|
for s in self.getServers():
|
||||||
|
if s.supportsVideoTranscoding and s.allowChannelAccess and s.isReachable() and self.compareServers(self.channelServer, s) < 0:
|
||||||
|
self.channelServer = s
|
||||||
|
|
||||||
|
# Fallback to any server that supports channels
|
||||||
|
if not self.channelServer:
|
||||||
|
for s in self.getServers():
|
||||||
|
if s.allowChannelAccess and s.isReachable() and self.compareServers(self.channelServer, s) < 0:
|
||||||
|
self.channelServer = s
|
||||||
|
|
||||||
|
if self.channelServer:
|
||||||
|
util.LOG("Setting channel server to {0}".format(self.channelServer))
|
||||||
|
|
||||||
|
return self.channelServer
|
||||||
|
|
||||||
|
def getTranscodeServer(self, transcodeType=None):
|
||||||
|
if not self.selectedServer:
|
||||||
|
return None
|
||||||
|
|
||||||
|
transcodeMap = {
|
||||||
|
'audio': "supportsAudioTranscoding",
|
||||||
|
'video': "supportsVideoTranscoding",
|
||||||
|
'photo': "supportsPhotoTranscoding"
|
||||||
|
}
|
||||||
|
transcodeSupport = transcodeMap[transcodeType]
|
||||||
|
|
||||||
|
# Try to use a better transcoding server for synced or secondary servers
|
||||||
|
if self.selectedServer.synced or self.selectedServer.isSecondary():
|
||||||
|
if self.transcodeServer and self.transcodeServer.isReachable():
|
||||||
|
return self.transcodeServer
|
||||||
|
|
||||||
|
self.transcodeServer = None
|
||||||
|
for server in self.getServers():
|
||||||
|
if not server.synced and server.isReachable() and self.compareServers(self.transcodeServer, server) < 0:
|
||||||
|
if not transcodeSupport or server.transcodeSupport:
|
||||||
|
self.transcodeServer = server
|
||||||
|
|
||||||
|
if self.transcodeServer:
|
||||||
|
transcodeTypeString = transcodeType or ''
|
||||||
|
util.LOG("Found a better {0} transcode server than {1}, using: {2}".format(transcodeTypeString, self.selectedserver, self.transcodeServer))
|
||||||
|
return self.transcodeServer
|
||||||
|
|
||||||
|
return self.selectedServer
|
||||||
|
|
||||||
|
def startSelectedServerSearch(self, reset=False):
|
||||||
|
if reset:
|
||||||
|
self.selectedServer = None
|
||||||
|
self.transcodeServer = None
|
||||||
|
self.channelServer = None
|
||||||
|
|
||||||
|
# Keep track of some information during our search
|
||||||
|
self.searchContext = SearchContext({
|
||||||
|
'bestServer': None,
|
||||||
|
'preferredServer': plexapp.INTERFACE.getPreference('lastServerId', ''),
|
||||||
|
'waitingForResources': plexapp.ACCOUNT.isSignedIn
|
||||||
|
})
|
||||||
|
|
||||||
|
util.LOG("Starting selected server search, hoping for {0}".format(self.searchContext.preferredServer))
|
||||||
|
|
||||||
|
def onAccountChange(self, account, reallyChanged=False):
|
||||||
|
# Clear any AudioPlayer data before invalidating the active server
|
||||||
|
if reallyChanged:
|
||||||
|
# AudioPlayer().Cleanup()
|
||||||
|
# PhotoPlayer().Cleanup()
|
||||||
|
|
||||||
|
# Clear selected and transcode servers on user change
|
||||||
|
self.selectedServer = None
|
||||||
|
self.transcodeServer = None
|
||||||
|
self.channelServer = None
|
||||||
|
self.cancelReachability()
|
||||||
|
|
||||||
|
if account.isSignedIn:
|
||||||
|
# If the user didn't really change, such as selecting the previous user
|
||||||
|
# on the lock screen, then we don't need to clear anything. We can
|
||||||
|
# avoid a costly round of reachability checks.
|
||||||
|
|
||||||
|
if not reallyChanged:
|
||||||
|
return
|
||||||
|
|
||||||
|
# A request to refresh resources has already been kicked off. We need
|
||||||
|
# to clear out any connections for the previous user and then start
|
||||||
|
# our selected server search.
|
||||||
|
|
||||||
|
self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MYPLEX)
|
||||||
|
self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_DISCOVERED)
|
||||||
|
self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MANUAL)
|
||||||
|
|
||||||
|
self.startSelectedServerSearch(True)
|
||||||
|
else:
|
||||||
|
# Clear servers/connections from plex.tv
|
||||||
|
self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MYPLEX)
|
||||||
|
|
||||||
|
def deferUpdateReachability(self, addTimer=True, logInfo=True):
|
||||||
|
if addTimer and not self.deferReachabilityTimer:
|
||||||
|
self.deferReachabilityTimer = plexapp.createTimer(1000, callback.Callable(self.onDeferUpdateReachabilityTimer), repeat=True)
|
||||||
|
plexapp.APP.addTimer(self.deferReachabilityTimer)
|
||||||
|
else:
|
||||||
|
if self.deferReachabilityTimer:
|
||||||
|
self.deferReachabilityTimer.reset()
|
||||||
|
|
||||||
|
if self.deferReachabilityTimer and logInfo:
|
||||||
|
util.LOG('Defer update reachability for all devices a few seconds: GDMactive={0}'.format(gdm.DISCOVERY.isActive()))
|
||||||
|
|
||||||
|
def onDeferUpdateReachabilityTimer(self):
|
||||||
|
if not self.selectedServer and self.searchContext:
|
||||||
|
for server in self.getServers():
|
||||||
|
if server.pendingReachabilityRequests > 0 and server.uuid == self.searchContext.preferredServer:
|
||||||
|
util.DEBUG_LOG(
|
||||||
|
'Still waiting on {0} responses from preferred server: {1}'.format(
|
||||||
|
server.pendingReachabilityRequests, self.searchContext.preferredServer
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.deferReachabilityTimer.cancel()
|
||||||
|
self.deferReachabilityTimer = None
|
||||||
|
self.updateReachability(True, False, False)
|
||||||
|
|
||||||
|
def resetLastTest(self):
|
||||||
|
for uuid in self.serversByUuid:
|
||||||
|
self.serversByUuid[uuid].resetLastTest()
|
||||||
|
|
||||||
|
def clearServers(self):
|
||||||
|
self.cancelReachability()
|
||||||
|
self.serversByUuid = {}
|
||||||
|
self.saveState()
|
||||||
|
|
||||||
|
def onSecurityChange(self, value=None):
|
||||||
|
# If the security policy changes, then we will need to allow all
|
||||||
|
# connections to be retested by resetting the last test. We can
|
||||||
|
# simply call `self.resetLastTest()` to allow all connection to be
|
||||||
|
# tested when the server dropdown is enable, but we may as well
|
||||||
|
# test all the connections immediately.
|
||||||
|
|
||||||
|
plexapp.refreshResources(True)
|
||||||
|
|
||||||
|
def onManualConnectionChange(self, value=None):
|
||||||
|
# Clear all manual connections on change. We will keep the selected
|
||||||
|
# server around temporarily if it's a manual connection regardless
|
||||||
|
# if it's been removed.
|
||||||
|
|
||||||
|
# Remember the current server in case it's removed
|
||||||
|
server = self.getSelectedServer()
|
||||||
|
activeConn = []
|
||||||
|
if server and server.activeConnection:
|
||||||
|
activeConn.append(server.activeConnection)
|
||||||
|
|
||||||
|
# Clear all manual connections
|
||||||
|
self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MANUAL)
|
||||||
|
|
||||||
|
# Reused the previous selected server if our manual connection has gone away
|
||||||
|
if not self.getSelectedServer() and activeConn.sources == plexresource.ResourceConnection.SOURCE_MANUAL:
|
||||||
|
server.activeConnection = activeConn
|
||||||
|
server.connections.append(activeConn)
|
||||||
|
self.setSelectedServer(server, True)
|
||||||
|
|
||||||
|
def refreshManualConnections(self):
|
||||||
|
manualConnections = self.getManualConnections()
|
||||||
|
if not manualConnections:
|
||||||
|
return
|
||||||
|
|
||||||
|
util.LOG("Refreshing {0} manual connections".format(len(manualConnections)))
|
||||||
|
|
||||||
|
for conn in manualConnections:
|
||||||
|
# Default to http, as the server will need to be signed in for https to work,
|
||||||
|
# so the client should too. We'd also have to allow hostname entry, instead of
|
||||||
|
# IP address for the cert to validate.
|
||||||
|
|
||||||
|
proto = "http"
|
||||||
|
port = conn.port or "32400"
|
||||||
|
serverAddress = "{0}://{1}:{2}".format(proto, conn.connection, port)
|
||||||
|
|
||||||
|
request = http.HttpRequest(serverAddress + "/identity")
|
||||||
|
context = request.createRequestContext("manual_connections", callback.Callable(self.onManualConnectionsResponse))
|
||||||
|
context.serverAddress = serverAddress
|
||||||
|
context.address = conn.connection
|
||||||
|
context.proto = proto
|
||||||
|
context.port = port
|
||||||
|
plexapp.APP.startRequest(request, context)
|
||||||
|
|
||||||
|
def onManualConnectionsResponse(self, request, response, context):
|
||||||
|
if not response.isSuccess():
|
||||||
|
return
|
||||||
|
|
||||||
|
data = response.getBodyXml()
|
||||||
|
if data is not None:
|
||||||
|
serverAddress = context.serverAddress
|
||||||
|
util.DEBUG_LOG("Received manual connection response for {0}".format(serverAddress))
|
||||||
|
|
||||||
|
machineID = data.attrib.get('machineIdentifier')
|
||||||
|
name = context.address
|
||||||
|
if not name or not machineID:
|
||||||
|
return
|
||||||
|
|
||||||
|
# TODO(rob): Do we NOT want to consider manual connections local?
|
||||||
|
conn = plexconnection.PlexConnection(plexresource.ResourceConnection.SOURCE_MANUAL, serverAddress, True, None)
|
||||||
|
server = plexserver.createPlexServerForConnection(conn)
|
||||||
|
server.uuid = machineID
|
||||||
|
server.name = name
|
||||||
|
server.sourceType = plexresource.ResourceConnection.SOURCE_MANUAL
|
||||||
|
self.updateFromConnectionType([server], plexresource.ResourceConnection.SOURCE_MANUAL)
|
||||||
|
|
||||||
|
def getManualConnections(self):
|
||||||
|
manualConnections = []
|
||||||
|
|
||||||
|
jstring = plexapp.INTERFACE.getPreference('manual_connections')
|
||||||
|
if jstring:
|
||||||
|
connections = json.loads(jstring)
|
||||||
|
if isinstance(connections, list):
|
||||||
|
for conn in connections:
|
||||||
|
conn = util.AttributeDict(conn)
|
||||||
|
if conn.connection:
|
||||||
|
manualConnections.append(conn)
|
||||||
|
|
||||||
|
return manualConnections
|
||||||
|
|
||||||
|
# TODO(schuyler): Notifications
|
||||||
|
# TODO(schuyler): Transcode (and primary) server selection
|
||||||
|
|
||||||
|
|
||||||
|
MANAGER = PlexServerManager()
|
149
resources/lib/plexnet/plexstream.py
Normal file
149
resources/lib/plexnet/plexstream.py
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
import plexobjects
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class PlexStream(plexobjects.PlexObject):
|
||||||
|
# Constants
|
||||||
|
TYPE_UNKNOWN = 0
|
||||||
|
TYPE_VIDEO = 1
|
||||||
|
TYPE_AUDIO = 2
|
||||||
|
TYPE_SUBTITLE = 3
|
||||||
|
TYPE_LYRICS = 4
|
||||||
|
|
||||||
|
# We have limited font support, so make a very modest effort at using
|
||||||
|
# English names for common unsupported languages.
|
||||||
|
|
||||||
|
SAFE_LANGUAGE_NAMES = {
|
||||||
|
'ara': "Arabic",
|
||||||
|
'arm': "Armenian",
|
||||||
|
'bel': "Belarusian",
|
||||||
|
'ben': "Bengali",
|
||||||
|
'bul': "Bulgarian",
|
||||||
|
'chi': "Chinese",
|
||||||
|
'cze': "Czech",
|
||||||
|
'gre': "Greek",
|
||||||
|
'heb': "Hebrew",
|
||||||
|
'hin': "Hindi",
|
||||||
|
'jpn': "Japanese",
|
||||||
|
'kor': "Korean",
|
||||||
|
'rus': "Russian",
|
||||||
|
'srp': "Serbian",
|
||||||
|
'tha': "Thai",
|
||||||
|
'ukr': "Ukrainian",
|
||||||
|
'yid': "Yiddish"
|
||||||
|
}
|
||||||
|
|
||||||
|
def reload(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def getTitle(self, translate_func=util.dummyTranslate):
|
||||||
|
title = self.getLanguageName(translate_func)
|
||||||
|
streamType = self.streamType.asInt()
|
||||||
|
|
||||||
|
if streamType == self.TYPE_VIDEO:
|
||||||
|
title = self.getCodec() or translate_func("Unknown")
|
||||||
|
elif streamType == self.TYPE_AUDIO:
|
||||||
|
codec = self.getCodec()
|
||||||
|
channels = self.getChannels(translate_func)
|
||||||
|
|
||||||
|
if codec != "" and channels != "":
|
||||||
|
title += u" ({0} {1})".format(codec, channels)
|
||||||
|
elif codec != "" or channels != "":
|
||||||
|
title += u" ({0}{1})".format(codec, channels)
|
||||||
|
elif streamType == self.TYPE_SUBTITLE:
|
||||||
|
extras = []
|
||||||
|
|
||||||
|
codec = self.getCodec()
|
||||||
|
if codec:
|
||||||
|
extras.append(codec)
|
||||||
|
|
||||||
|
if not self.key:
|
||||||
|
extras.append(translate_func("Embedded"))
|
||||||
|
|
||||||
|
if self.forced.asBool():
|
||||||
|
extras.append(translate_func("Forced"))
|
||||||
|
|
||||||
|
if len(extras) > 0:
|
||||||
|
title += u" ({0})".format('/'.join(extras))
|
||||||
|
elif streamType == self.TYPE_LYRICS:
|
||||||
|
title = translate_func("Lyrics")
|
||||||
|
if self.format:
|
||||||
|
title += u" ({0})".format(self.format)
|
||||||
|
|
||||||
|
return title
|
||||||
|
|
||||||
|
def getCodec(self):
|
||||||
|
codec = (self.codec or '').lower()
|
||||||
|
|
||||||
|
if codec in ('dca', 'dca-ma', 'dts-hd', 'dts-es', 'dts-hra'):
|
||||||
|
codec = "DTS"
|
||||||
|
else:
|
||||||
|
codec = codec.upper()
|
||||||
|
|
||||||
|
return codec
|
||||||
|
|
||||||
|
def getChannels(self, translate_func=util.dummyTranslate):
|
||||||
|
channels = self.channels.asInt()
|
||||||
|
|
||||||
|
if channels == 1:
|
||||||
|
return translate_func("Mono")
|
||||||
|
elif channels == 2:
|
||||||
|
return translate_func("Stereo")
|
||||||
|
elif channels > 0:
|
||||||
|
return "{0}.1".format(channels - 1)
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def getLanguageName(self, translate_func=util.dummyTranslate):
|
||||||
|
code = self.languageCode
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
return translate_func("Unknown")
|
||||||
|
|
||||||
|
return self.SAFE_LANGUAGE_NAMES.get(code) or self.language or "Unknown"
|
||||||
|
|
||||||
|
def getSubtitlePath(self):
|
||||||
|
query = "?encoding=utf-8"
|
||||||
|
|
||||||
|
if self.codec == "smi":
|
||||||
|
query += "&format=srt"
|
||||||
|
|
||||||
|
return self.key + query
|
||||||
|
|
||||||
|
def getSubtitleServerPath(self):
|
||||||
|
if not self.key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.getServer().buildUrl(self.getSubtitlePath(), True)
|
||||||
|
|
||||||
|
def isSelected(self):
|
||||||
|
return self.selected.asBool()
|
||||||
|
|
||||||
|
def setSelected(self, selected):
|
||||||
|
self.selected = plexobjects.PlexValue(selected and '1' or '0')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.getTitle()
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if not other:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.__class__ != other.__class__:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for attr in ("streamType", "language", "codec", "channels", "index"):
|
||||||
|
if getattr(self, attr) != getattr(other, attr):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Synthetic subtitle stream for 'none'
|
||||||
|
|
||||||
|
class NoneStream(PlexStream):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
PlexStream.__init__(self, None, *args, **kwargs)
|
||||||
|
self.id = plexobjects.PlexValue("0")
|
||||||
|
self.streamType = plexobjects.PlexValue(str(self.TYPE_SUBTITLE))
|
||||||
|
|
||||||
|
def getTitle(self, translate_func=util.dummyTranslate):
|
||||||
|
return translate_func("None")
|
101
resources/lib/plexnet/serverdecision.py
Normal file
101
resources/lib/plexnet/serverdecision.py
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import mediachoice
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionFailure(Exception):
|
||||||
|
def __init__(self, code, reason):
|
||||||
|
self.code = code
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
|
class ServerDecision(object):
|
||||||
|
DECISION_DIRECT_PLAY = "directplay"
|
||||||
|
DECISION_COPY = "copy"
|
||||||
|
DECISION_TRANSCODE = "transcode"
|
||||||
|
DIRECT_PLAY_OK = 1000
|
||||||
|
TRANSCODE_OK = 1001
|
||||||
|
|
||||||
|
def __init__(self, original, response, player):
|
||||||
|
self.original = original
|
||||||
|
self.response = response
|
||||||
|
self.player = player
|
||||||
|
self.item = None
|
||||||
|
|
||||||
|
self.init()
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
self.isSupported = self.response.server.supportsFeature("streamingBrain")
|
||||||
|
for item in self.response.items:
|
||||||
|
if item and item.media:
|
||||||
|
self.item = item
|
||||||
|
self.original.transcodeDecision = mediachoice.MediaChoice(self.item.media[0])
|
||||||
|
|
||||||
|
# Decision codes and text
|
||||||
|
self.decisionsCodes = {}
|
||||||
|
self.decisionsTexts = {}
|
||||||
|
for key in ["directPlayDecision", "generalDecision", "mdeDecision", "transcodeDecision", "termination"]:
|
||||||
|
self.decisionsCodes[key] = self.response.container.get(key + "Code", "-1").asInt()
|
||||||
|
self.decisionsTexts[key] = self.response.container.get(key + "Text")
|
||||||
|
|
||||||
|
util.DEBUG_LOG("Decision codes: {0}".format(self.decisionsCodes))
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.isSupported:
|
||||||
|
obj = []
|
||||||
|
for v in self.decisionsTexts.values():
|
||||||
|
if v:
|
||||||
|
obj.append(v)
|
||||||
|
return ' '.join(obj)
|
||||||
|
else:
|
||||||
|
return "Server version does not support decisions."
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.__str__()
|
||||||
|
|
||||||
|
def getDecision(self, requireDecision=True):
|
||||||
|
if not self.item:
|
||||||
|
# Return no decision. The player will either continue with the original
|
||||||
|
# or terminate if a valid decision was required.
|
||||||
|
|
||||||
|
if requireDecision:
|
||||||
|
# Terminate the player by default if there was no decision returned.
|
||||||
|
code = self.decisionsCodes["generalDecision"]
|
||||||
|
reason = ' '.join([self.decisionsTexts["transcodeDecision"], self.decisionsTexts["generalDecision"]])
|
||||||
|
raise DecisionFailure(code, reason)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Rebuild the original item with the new item.
|
||||||
|
util.WARN_LOG("Server requested new playback decision: {0}".format(self))
|
||||||
|
self.original.rebuild(self.item, self)
|
||||||
|
return self.original
|
||||||
|
|
||||||
|
def isSuccess(self):
|
||||||
|
code = self.decisionsCodes["mdeDecision"]
|
||||||
|
return not self.isSupported or 1000 <= code < 2000
|
||||||
|
|
||||||
|
def isDecision(self, requireItem=False):
|
||||||
|
# Server has provided a valid decision if there was a valid decision code
|
||||||
|
# or if the response returned zero items (could not play).
|
||||||
|
return self.isSupported and (self.decisionsCodes["mdeDecision"] > -1 or requireItem and not self.item)
|
||||||
|
|
||||||
|
def isTimelineDecision(self):
|
||||||
|
return self.isSupported and self.item
|
||||||
|
|
||||||
|
def isTermination(self):
|
||||||
|
return self.isSupported and self.decisionsCodes["termination"] > -1
|
||||||
|
|
||||||
|
def directPlayOK(self):
|
||||||
|
return self.decisionsCodes["mdeDecision"] == 1000
|
||||||
|
|
||||||
|
def getTermination(self):
|
||||||
|
return {
|
||||||
|
'code': str(self.decisionsCodes["termination"]),
|
||||||
|
'text': self.decisionsTexts["termination"] or "Unknown" # TODO: Translate Unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
def getDecisionText(self):
|
||||||
|
for key in ["mdeDecision", "directPlayDecision", "generalDecision", "transcodeDecision"]:
|
||||||
|
if self.decisionsTexts.get(key):
|
||||||
|
return self.decisionsTexts[key]
|
||||||
|
return None
|
10
resources/lib/plexnet/signalslot/__init__.py
Normal file
10
resources/lib/plexnet/signalslot/__init__.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
try:
|
||||||
|
from .signal import Signal
|
||||||
|
from .slot import Slot
|
||||||
|
from .exceptions import *
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
# Possible we are running from setup.py, in which case we're after
|
||||||
|
# the __version__ string only.
|
||||||
|
pass
|
||||||
|
|
||||||
|
__version__ = '0.1.1'
|
0
resources/lib/plexnet/signalslot/contrib/__init__.py
Normal file
0
resources/lib/plexnet/signalslot/contrib/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from .task import Task
|
75
resources/lib/plexnet/signalslot/contrib/task/task.py
Normal file
75
resources/lib/plexnet/signalslot/contrib/task/task.py
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import sys
|
||||||
|
import eventlet
|
||||||
|
import contexter
|
||||||
|
import six
|
||||||
|
|
||||||
|
|
||||||
|
class Task(object):
|
||||||
|
@classmethod
|
||||||
|
def get_or_create(cls, signal, kwargs=None, logger=None):
|
||||||
|
if not hasattr(cls, '_registry'):
|
||||||
|
cls._registry = []
|
||||||
|
|
||||||
|
task = cls(signal, kwargs, logger=logger)
|
||||||
|
|
||||||
|
if task not in cls._registry:
|
||||||
|
cls._registry.append(task)
|
||||||
|
|
||||||
|
return cls._registry[cls._registry.index(task)]
|
||||||
|
|
||||||
|
def __init__(self, signal, kwargs=None, logger=None):
|
||||||
|
self.signal = signal
|
||||||
|
self.kwargs = kwargs or {}
|
||||||
|
self.logger = logger
|
||||||
|
self.failures = 0
|
||||||
|
self.task_semaphore = eventlet.semaphore.BoundedSemaphore(1)
|
||||||
|
|
||||||
|
def __call__(self, semaphores=None):
|
||||||
|
semaphores = semaphores or []
|
||||||
|
|
||||||
|
with contexter.Contexter(self.task_semaphore, *semaphores):
|
||||||
|
result = self._do()
|
||||||
|
|
||||||
|
if result:
|
||||||
|
self.failures = 0
|
||||||
|
else:
|
||||||
|
self.failures += 1
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _do(self):
|
||||||
|
try:
|
||||||
|
self._emit()
|
||||||
|
except Exception:
|
||||||
|
self._exception(*sys.exc_info())
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self._completed()
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
self._clean()
|
||||||
|
|
||||||
|
def _clean(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _completed(self):
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info('[%s] Completed' % self)
|
||||||
|
|
||||||
|
def _exception(self, e_type, e_value, e_traceback):
|
||||||
|
if self.logger:
|
||||||
|
self.logger.exception('[%s] Raised exception: %s' % (
|
||||||
|
self, e_value))
|
||||||
|
else:
|
||||||
|
six.reraise(e_type, e_value, e_traceback)
|
||||||
|
|
||||||
|
def _emit(self):
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info('[%s] Running' % self)
|
||||||
|
self.signal.emit(**self.kwargs)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (self.signal == other.signal and self.kwargs == other.kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return '%s: %s' % (self.signal.__class__.__name__, self.kwargs)
|
184
resources/lib/plexnet/signalslot/contrib/task/test.py
Normal file
184
resources/lib/plexnet/signalslot/contrib/task/test.py
Normal file
|
@ -0,0 +1,184 @@
|
||||||
|
import pytest
|
||||||
|
import mock
|
||||||
|
import logging
|
||||||
|
import eventlet
|
||||||
|
import time
|
||||||
|
from signalslot import Signal
|
||||||
|
from signalslot.contrib.task import Task
|
||||||
|
|
||||||
|
eventlet.monkey_patch(time=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTask(object):
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.signal = mock.Mock()
|
||||||
|
|
||||||
|
def get_task_mock(self, *methods, **kwargs):
|
||||||
|
if kwargs.get('logger'):
|
||||||
|
log = logging.getLogger('TestTask')
|
||||||
|
else:
|
||||||
|
log = None
|
||||||
|
task_mock = Task(self.signal, logger=log)
|
||||||
|
|
||||||
|
for method in methods:
|
||||||
|
setattr(task_mock, method, mock.Mock())
|
||||||
|
|
||||||
|
return task_mock
|
||||||
|
|
||||||
|
def test_eq(self):
|
||||||
|
x = Task(self.signal, dict(some_kwarg='foo'),
|
||||||
|
logger=logging.getLogger('TaskX'))
|
||||||
|
y = Task(self.signal, dict(some_kwarg='foo'),
|
||||||
|
logger=logging.getLogger('TaskY'))
|
||||||
|
|
||||||
|
assert x == y
|
||||||
|
|
||||||
|
def test_not_eq(self):
|
||||||
|
x = Task(self.signal, dict(some_kwarg='foo',
|
||||||
|
logger=logging.getLogger('TaskX')))
|
||||||
|
y = Task(self.signal, dict(some_kwarg='bar',
|
||||||
|
logger=logging.getLogger('TaskY')))
|
||||||
|
|
||||||
|
assert x != y
|
||||||
|
|
||||||
|
def test_unicode(self):
|
||||||
|
t = Task(self.signal, dict(some_kwarg='foo'),
|
||||||
|
logger=logging.getLogger('TaskT'))
|
||||||
|
|
||||||
|
assert str(t) == "Mock: {'some_kwarg': 'foo'}"
|
||||||
|
|
||||||
|
def test_get_or_create_gets(self):
|
||||||
|
x = Task.get_or_create(self.signal, dict(some_kwarg='foo'),
|
||||||
|
logger=logging.getLogger('TaskX'))
|
||||||
|
y = Task.get_or_create(self.signal, dict(some_kwarg='foo'),
|
||||||
|
logger=logging.getLogger('TaskY'))
|
||||||
|
|
||||||
|
assert x is y
|
||||||
|
|
||||||
|
def test_get_or_create_creates(self):
|
||||||
|
x = Task.get_or_create(self.signal, dict(some_kwarg='foo'),
|
||||||
|
logger=logging.getLogger('TaskX'))
|
||||||
|
y = Task.get_or_create(self.signal, dict(some_kwarg='bar'),
|
||||||
|
logger=logging.getLogger('TaskY'))
|
||||||
|
|
||||||
|
assert x is not y
|
||||||
|
|
||||||
|
def test_get_or_create_without_kwargs(self):
|
||||||
|
t = Task.get_or_create(self.signal)
|
||||||
|
|
||||||
|
assert t.kwargs == {}
|
||||||
|
|
||||||
|
def test_get_or_create_uses_cls(self):
|
||||||
|
class Foo(Task):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert isinstance(Foo.get_or_create(self.signal), Foo)
|
||||||
|
|
||||||
|
def test_do_emit(self):
|
||||||
|
task_mock = self.get_task_mock('_clean', '_exception', '_completed')
|
||||||
|
|
||||||
|
task_mock._do()
|
||||||
|
|
||||||
|
self.signal.emit.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_do_emit_nolog(self):
|
||||||
|
task_mock = self.get_task_mock(
|
||||||
|
'_clean', '_exception', '_completed', logging=True)
|
||||||
|
|
||||||
|
task_mock._do()
|
||||||
|
|
||||||
|
self.signal.emit.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_do_emit_no_log(self):
|
||||||
|
task_mock = self.get_task_mock('_clean', '_exception', '_completed')
|
||||||
|
|
||||||
|
task_mock._do()
|
||||||
|
|
||||||
|
self.signal.emit.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_do_complete(self):
|
||||||
|
task_mock = self.get_task_mock('_clean', '_exception', '_completed')
|
||||||
|
|
||||||
|
task_mock._do()
|
||||||
|
|
||||||
|
task_mock._exception.assert_not_called()
|
||||||
|
task_mock._completed.assert_called_once_with()
|
||||||
|
task_mock._clean.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_do_success(self):
|
||||||
|
task_mock = self.get_task_mock()
|
||||||
|
assert task_mock._do() is True
|
||||||
|
|
||||||
|
def test_do_failure_nolog(self):
|
||||||
|
# Our dummy exception
|
||||||
|
class DummyError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
task_mock = self.get_task_mock('_emit')
|
||||||
|
task_mock._emit.side_effect = DummyError()
|
||||||
|
|
||||||
|
# This will throw an exception at us, be ready to catch it.
|
||||||
|
try:
|
||||||
|
task_mock._do()
|
||||||
|
assert False
|
||||||
|
except DummyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_do_failure_withlog(self):
|
||||||
|
task_mock = self.get_task_mock('_emit', logger=True)
|
||||||
|
task_mock._emit.side_effect = Exception()
|
||||||
|
assert task_mock._do() is False
|
||||||
|
|
||||||
|
def test_do_exception(self):
|
||||||
|
task_mock = self.get_task_mock(
|
||||||
|
'_clean', '_exception', '_completed', '_emit')
|
||||||
|
|
||||||
|
task_mock._emit.side_effect = Exception()
|
||||||
|
|
||||||
|
task_mock._do()
|
||||||
|
|
||||||
|
task_mock._exception.assert_called_once_with(
|
||||||
|
Exception, task_mock._emit.side_effect, mock.ANY)
|
||||||
|
|
||||||
|
task_mock._completed.assert_not_called()
|
||||||
|
task_mock._clean.assert_called_once_with()
|
||||||
|
|
||||||
|
@mock.patch('signalslot.signal.inspect')
|
||||||
|
def test_semaphore(self, inspect):
|
||||||
|
slot = mock.Mock()
|
||||||
|
slot.side_effect = lambda **k: time.sleep(.3)
|
||||||
|
|
||||||
|
signal = Signal('tost')
|
||||||
|
signal.connect(slot)
|
||||||
|
|
||||||
|
x = Task.get_or_create(signal, dict(some_kwarg='foo'),
|
||||||
|
logger=logging.getLogger('TaskX'))
|
||||||
|
y = Task.get_or_create(signal, dict(some_kwarg='foo'),
|
||||||
|
logger=logging.getLogger('TaskY'))
|
||||||
|
|
||||||
|
eventlet.spawn(x)
|
||||||
|
time.sleep(.1)
|
||||||
|
eventlet.spawn(y)
|
||||||
|
time.sleep(.1)
|
||||||
|
|
||||||
|
assert slot.call_count == 1
|
||||||
|
time.sleep(.4)
|
||||||
|
assert slot.call_count == 2
|
||||||
|
|
||||||
|
def test_call_context(self):
|
||||||
|
task_mock = self.get_task_mock('_clean', '_exception', '_completed',
|
||||||
|
'_emit')
|
||||||
|
|
||||||
|
task_mock._emit.side_effect = Exception()
|
||||||
|
|
||||||
|
assert task_mock.failures == 0
|
||||||
|
task_mock()
|
||||||
|
assert task_mock.failures == 1
|
||||||
|
|
||||||
|
def test_call_success(self):
|
||||||
|
task_mock = self.get_task_mock('_clean', '_exception', '_completed',
|
||||||
|
'_emit')
|
||||||
|
|
||||||
|
assert task_mock.failures == 0
|
||||||
|
task_mock()
|
||||||
|
assert task_mock.failures == 0
|
28
resources/lib/plexnet/signalslot/exceptions.py
Normal file
28
resources/lib/plexnet/signalslot/exceptions.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
class SignalSlotException(Exception):
|
||||||
|
"""Base class for all exceptions of this module."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SlotMustAcceptKeywords(SignalSlotException):
|
||||||
|
"""
|
||||||
|
Raised when connecting a slot that does not accept ``**kwargs`` in its
|
||||||
|
signature.
|
||||||
|
"""
|
||||||
|
def __init__(self, signal, slot):
|
||||||
|
m = 'Cannot connect %s to %s because it does not accept **kwargs' % (
|
||||||
|
slot, signal)
|
||||||
|
|
||||||
|
super(SlotMustAcceptKeywords, self).__init__(m)
|
||||||
|
|
||||||
|
|
||||||
|
# Not yet being used.
|
||||||
|
class QueueCantQueueNonSignalInstance(SignalSlotException): # pragma: no cover
|
||||||
|
"""
|
||||||
|
Raised when trying to queue something else than a
|
||||||
|
:py:class:`~signalslot.signal.Signal` instance.
|
||||||
|
"""
|
||||||
|
def __init__(self, queue, arg):
|
||||||
|
m = 'Cannot queue %s to %s because it is not a Signal instance' % (
|
||||||
|
arg, queue)
|
||||||
|
|
||||||
|
super(QueueCantQueueNonSignalInstance, self).__init__(m)
|
167
resources/lib/plexnet/signalslot/signal.py
Normal file
167
resources/lib/plexnet/signalslot/signal.py
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
"""
|
||||||
|
Module defining the Signal class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from . import exceptions
|
||||||
|
|
||||||
|
|
||||||
|
class DummyLock(object):
|
||||||
|
"""
|
||||||
|
Class that implements a no-op instead of a re-entrant lock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BaseSlot(object):
|
||||||
|
"""
|
||||||
|
Slot abstract class for type resolution purposes.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Signal(object):
|
||||||
|
"""
|
||||||
|
Define a signal by instanciating a :py:class:`Signal` object, ie.:
|
||||||
|
|
||||||
|
>>> conf_pre_load = Signal()
|
||||||
|
|
||||||
|
Optionaly, you can declare a list of argument names for this signal, ie.:
|
||||||
|
|
||||||
|
>>> conf_pre_load = Signal(args=['conf'])
|
||||||
|
|
||||||
|
Any callable can be connected to a Signal, it **must** accept keywords
|
||||||
|
(``**kwargs``), ie.:
|
||||||
|
|
||||||
|
>>> def yourmodule_conf(conf, **kwargs):
|
||||||
|
... conf['yourmodule_option'] = 'foo'
|
||||||
|
...
|
||||||
|
|
||||||
|
Connect your function to the signal using :py:meth:`connect`:
|
||||||
|
|
||||||
|
>>> conf_pre_load.connect(yourmodule_conf)
|
||||||
|
|
||||||
|
Emit the signal to call all connected callbacks using
|
||||||
|
:py:meth:`emit`:
|
||||||
|
|
||||||
|
>>> conf = {}
|
||||||
|
>>> conf_pre_load.emit(conf=conf)
|
||||||
|
>>> conf
|
||||||
|
{'yourmodule_option': 'foo'}
|
||||||
|
|
||||||
|
Note that you may disconnect a callback from a signal if it is already
|
||||||
|
connected:
|
||||||
|
|
||||||
|
>>> conf_pre_load.is_connected(yourmodule_conf)
|
||||||
|
True
|
||||||
|
>>> conf_pre_load.disconnect(yourmodule_conf)
|
||||||
|
>>> conf_pre_load.is_connected(yourmodule_conf)
|
||||||
|
False
|
||||||
|
"""
|
||||||
|
def __init__(self, args=None, name=None, threadsafe=False):
|
||||||
|
self._slots = []
|
||||||
|
self._slots_lk = threading.RLock() if threadsafe else DummyLock()
|
||||||
|
self.args = args or []
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slots(self):
|
||||||
|
"""
|
||||||
|
Return a list of slots for this signal.
|
||||||
|
"""
|
||||||
|
with self._slots_lk:
|
||||||
|
# Do a slot clean-up
|
||||||
|
slots = []
|
||||||
|
for s in self._slots:
|
||||||
|
if isinstance(s, BaseSlot) and (not s.is_alive):
|
||||||
|
continue
|
||||||
|
slots.append(s)
|
||||||
|
self._slots = slots
|
||||||
|
return list(slots)
|
||||||
|
|
||||||
|
def connect(self, slot):
|
||||||
|
"""
|
||||||
|
Connect a callback ``slot`` to this signal.
|
||||||
|
"""
|
||||||
|
if not isinstance(slot, BaseSlot):
|
||||||
|
try:
|
||||||
|
if inspect.getargspec(slot).keywords is None:
|
||||||
|
raise exceptions.SlotMustAcceptKeywords(self, slot)
|
||||||
|
except TypeError:
|
||||||
|
if inspect.getargspec(slot.__call__).keywords is None:
|
||||||
|
raise exceptions.SlotMustAcceptKeywords(self, slot)
|
||||||
|
|
||||||
|
with self._slots_lk:
|
||||||
|
if not self.is_connected(slot):
|
||||||
|
self._slots.append(slot)
|
||||||
|
|
||||||
|
def is_connected(self, slot):
|
||||||
|
"""
|
||||||
|
Check if a callback ``slot`` is connected to this signal.
|
||||||
|
"""
|
||||||
|
with self._slots_lk:
|
||||||
|
return slot in self._slots
|
||||||
|
|
||||||
|
def disconnect(self, slot):
|
||||||
|
"""
|
||||||
|
Disconnect a slot from a signal if it is connected else do nothing.
|
||||||
|
"""
|
||||||
|
with self._slots_lk:
|
||||||
|
if self.is_connected(slot):
|
||||||
|
self._slots.pop(self._slots.index(slot))
|
||||||
|
|
||||||
|
def emit(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Emit this signal which will execute every connected callback ``slot``,
|
||||||
|
passing keyword arguments.
|
||||||
|
|
||||||
|
If a slot returns anything other than None, then :py:meth:`emit` will
|
||||||
|
return that value preventing any other slot from being called.
|
||||||
|
|
||||||
|
>>> need_something = Signal()
|
||||||
|
>>> def get_something(**kwargs):
|
||||||
|
... return 'got something'
|
||||||
|
...
|
||||||
|
>>> def make_something(**kwargs):
|
||||||
|
... print('I will not be called')
|
||||||
|
...
|
||||||
|
>>> need_something.connect(get_something)
|
||||||
|
>>> need_something.connect(make_something)
|
||||||
|
>>> need_something.emit()
|
||||||
|
'got something'
|
||||||
|
"""
|
||||||
|
for slot in reversed(self.slots):
|
||||||
|
result = slot(**kwargs)
|
||||||
|
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
"""
|
||||||
|
Return True if other has the same slots connected.
|
||||||
|
|
||||||
|
>>> a = Signal()
|
||||||
|
>>> b = Signal()
|
||||||
|
>>> a == b
|
||||||
|
True
|
||||||
|
>>> def slot(**kwargs):
|
||||||
|
... pass
|
||||||
|
...
|
||||||
|
>>> a.connect(slot)
|
||||||
|
>>> a == b
|
||||||
|
False
|
||||||
|
>>> b.connect(slot)
|
||||||
|
>>> a == b
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
return self.slots == other.slots
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<signalslot.Signal: %s>' % (self.name or 'NO_NAME')
|
73
resources/lib/plexnet/signalslot/slot.py
Normal file
73
resources/lib/plexnet/signalslot/slot.py
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
"""
|
||||||
|
Module defining the Slot class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import types
|
||||||
|
import weakref
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .signal import BaseSlot
|
||||||
|
|
||||||
|
# We cannot test a branch for Python >= 3.4 in Python < 3.4.
|
||||||
|
if sys.version_info < (3, 4): # pragma: no cover
|
||||||
|
from weakrefmethod import WeakMethod
|
||||||
|
else: # pragma: no cover
|
||||||
|
from weakref import WeakMethod
|
||||||
|
|
||||||
|
|
||||||
|
class Slot(BaseSlot):
|
||||||
|
"""
|
||||||
|
A slot is a callable object that manages a connection to a signal.
|
||||||
|
If weak is true or the slot is a subclass of weakref.ref, the slot
|
||||||
|
is automatically de-referenced to the called function.
|
||||||
|
"""
|
||||||
|
def __init__(self, slot, weak=False):
|
||||||
|
self._weak = weak or isinstance(slot, weakref.ref)
|
||||||
|
if weak and not isinstance(slot, weakref.ref):
|
||||||
|
if isinstance(slot, types.MethodType):
|
||||||
|
slot = WeakMethod(slot)
|
||||||
|
else:
|
||||||
|
slot = weakref.ref(slot)
|
||||||
|
self._slot = slot
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_alive(self):
|
||||||
|
"""
|
||||||
|
Return True if this slot is "alive".
|
||||||
|
"""
|
||||||
|
return (not self._weak) or (self._slot() is not None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def func(self):
|
||||||
|
"""
|
||||||
|
Return the function that is called by this slot.
|
||||||
|
"""
|
||||||
|
if self._weak:
|
||||||
|
return self._slot()
|
||||||
|
else:
|
||||||
|
return self._slot
|
||||||
|
|
||||||
|
def __call__(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Execute this slot.
|
||||||
|
"""
|
||||||
|
func = self.func
|
||||||
|
if func is not None:
|
||||||
|
return func(**kwargs)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
"""
|
||||||
|
Compare this slot to another.
|
||||||
|
"""
|
||||||
|
if isinstance(other, BaseSlot):
|
||||||
|
return self.func == other.func
|
||||||
|
else:
|
||||||
|
return self.func == other
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
fn = self.func
|
||||||
|
if fn is None:
|
||||||
|
fn = 'dead'
|
||||||
|
else:
|
||||||
|
fn = repr(fn)
|
||||||
|
return '<signalslot.Slot: %s>' % fn
|
205
resources/lib/plexnet/signalslot/tests.py
Normal file
205
resources/lib/plexnet/signalslot/tests.py
Normal file
|
@ -0,0 +1,205 @@
|
||||||
|
import pytest
|
||||||
|
import mock
|
||||||
|
|
||||||
|
from signalslot import Signal, SlotMustAcceptKeywords, Slot
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('signalslot.signal.inspect')
|
||||||
|
class TestSignal(object):
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.signal_a = Signal(threadsafe=True)
|
||||||
|
self.signal_b = Signal(args=['foo'])
|
||||||
|
|
||||||
|
self.slot_a = mock.Mock(spec=lambda **kwargs: None)
|
||||||
|
self.slot_a.return_value = None
|
||||||
|
self.slot_b = mock.Mock(spec=lambda **kwargs: None)
|
||||||
|
self.slot_b.return_value = None
|
||||||
|
|
||||||
|
def test_is_connected(self, inspect):
|
||||||
|
self.signal_a.connect(self.slot_a)
|
||||||
|
|
||||||
|
assert self.signal_a.is_connected(self.slot_a)
|
||||||
|
assert not self.signal_a.is_connected(self.slot_b)
|
||||||
|
assert not self.signal_b.is_connected(self.slot_a)
|
||||||
|
assert not self.signal_b.is_connected(self.slot_b)
|
||||||
|
|
||||||
|
def test_emit_one_slot(self, inspect):
|
||||||
|
self.signal_a.connect(self.slot_a)
|
||||||
|
|
||||||
|
self.signal_a.emit()
|
||||||
|
|
||||||
|
self.slot_a.assert_called_once_with()
|
||||||
|
assert self.slot_b.call_count == 0
|
||||||
|
|
||||||
|
def test_emit_two_slots(self, inspect):
|
||||||
|
self.signal_a.connect(self.slot_a)
|
||||||
|
self.signal_a.connect(self.slot_b)
|
||||||
|
|
||||||
|
self.signal_a.emit()
|
||||||
|
|
||||||
|
self.slot_a.assert_called_once_with()
|
||||||
|
self.slot_b.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_emit_one_slot_with_arguments(self, inspect):
|
||||||
|
self.signal_b.connect(self.slot_a)
|
||||||
|
|
||||||
|
self.signal_b.emit(foo='bar')
|
||||||
|
|
||||||
|
self.slot_a.assert_called_once_with(foo='bar')
|
||||||
|
assert self.slot_b.call_count == 0
|
||||||
|
|
||||||
|
def test_emit_two_slots_with_arguments(self, inspect):
|
||||||
|
self.signal_b.connect(self.slot_a)
|
||||||
|
self.signal_b.connect(self.slot_b)
|
||||||
|
|
||||||
|
self.signal_b.emit(foo='bar')
|
||||||
|
|
||||||
|
self.slot_a.assert_called_once_with(foo='bar')
|
||||||
|
self.slot_b.assert_called_once_with(foo='bar')
|
||||||
|
|
||||||
|
def test_reconnect_does_not_duplicate(self, inspect):
|
||||||
|
self.signal_a.connect(self.slot_a)
|
||||||
|
self.signal_a.connect(self.slot_a)
|
||||||
|
self.signal_a.emit()
|
||||||
|
|
||||||
|
self.slot_a.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_disconnect_does_not_fail_on_not_connected_slot(self, inspect):
|
||||||
|
self.signal_a.disconnect(self.slot_b)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_signal_has_nice_repr():
|
||||||
|
signal = Signal()
|
||||||
|
assert repr(signal) == '<signalslot.Signal: NO_NAME>'
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_signal_has_a_nice_repr():
|
||||||
|
signal = Signal(name='update_stuff')
|
||||||
|
assert repr(signal) == '<signalslot.Signal: update_stuff>'
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignalConnect(object):
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.signal = Signal()
|
||||||
|
|
||||||
|
def test_connect_with_kwargs(self):
|
||||||
|
def cb(**kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.signal.connect(cb)
|
||||||
|
|
||||||
|
def test_connect_without_kwargs(self):
|
||||||
|
def cb():
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(SlotMustAcceptKeywords):
|
||||||
|
self.signal.connect(cb)
|
||||||
|
|
||||||
|
|
||||||
|
class MyTestError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestException(object):
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.signal = Signal(threadsafe=False)
|
||||||
|
self.seen_exception = False
|
||||||
|
|
||||||
|
def failing_slot(**args):
|
||||||
|
raise MyTestError('die!')
|
||||||
|
|
||||||
|
self.signal.connect(failing_slot)
|
||||||
|
|
||||||
|
def test_emit_exception(self):
|
||||||
|
try:
|
||||||
|
self.signal.emit()
|
||||||
|
except MyTestError:
|
||||||
|
self.seen_exception = True
|
||||||
|
|
||||||
|
assert self.seen_exception
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrongSlot(object):
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.called = False
|
||||||
|
|
||||||
|
def slot(**kwargs):
|
||||||
|
self.called = True
|
||||||
|
|
||||||
|
self.slot = Slot(slot)
|
||||||
|
|
||||||
|
def test_alive(self):
|
||||||
|
assert self.slot.is_alive
|
||||||
|
|
||||||
|
def test_call(self):
|
||||||
|
self.slot(testing=1234)
|
||||||
|
assert self.called
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeakFuncSlot(object):
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.called = False
|
||||||
|
|
||||||
|
def slot(**kwargs):
|
||||||
|
self.called = True
|
||||||
|
|
||||||
|
self.slot = Slot(slot, weak=True)
|
||||||
|
self.slot_ref = slot
|
||||||
|
|
||||||
|
def test_alive(self):
|
||||||
|
assert self.slot.is_alive
|
||||||
|
assert repr(self.slot) == '<signalslot.Slot: %s>' % repr(self.slot_ref)
|
||||||
|
|
||||||
|
def test_call(self):
|
||||||
|
self.slot(testing=1234)
|
||||||
|
assert self.called
|
||||||
|
|
||||||
|
def test_gc(self):
|
||||||
|
self.slot_ref = None
|
||||||
|
assert not self.slot.is_alive
|
||||||
|
assert repr(self.slot) == '<signalslot.Slot: dead>'
|
||||||
|
self.slot(testing=1234)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeakMethodSlot(object):
|
||||||
|
def setup_method(self, method):
|
||||||
|
|
||||||
|
class MyObject(object):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.called = False
|
||||||
|
|
||||||
|
def slot(self, **kwargs):
|
||||||
|
self.called = True
|
||||||
|
|
||||||
|
self.obj_ref = MyObject()
|
||||||
|
self.slot = Slot(self.obj_ref.slot, weak=True)
|
||||||
|
self.signal = Signal()
|
||||||
|
self.signal.connect(self.slot)
|
||||||
|
|
||||||
|
def test_alive(self):
|
||||||
|
assert self.slot.is_alive
|
||||||
|
|
||||||
|
def test_call(self):
|
||||||
|
self.signal.emit(testing=1234)
|
||||||
|
assert self.obj_ref.called
|
||||||
|
|
||||||
|
def test_gc(self):
|
||||||
|
self.obj_ref = None
|
||||||
|
assert not self.slot.is_alive
|
||||||
|
self.signal.emit(testing=1234)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSlotEq(object):
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.slot_a = Slot(self.slot, weak=False)
|
||||||
|
self.slot_b = Slot(self.slot, weak=True)
|
||||||
|
|
||||||
|
def slot(self, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_eq_other(self):
|
||||||
|
assert self.slot_a == self.slot_b
|
||||||
|
|
||||||
|
def test_eq_func(self):
|
||||||
|
assert self.slot_a == self.slot
|
40
resources/lib/plexnet/signalsmixin.py
Normal file
40
resources/lib/plexnet/signalsmixin.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import signalslot
|
||||||
|
|
||||||
|
|
||||||
|
class SignalsMixin(object):
|
||||||
|
def __init__(self):
|
||||||
|
self._signals = {}
|
||||||
|
|
||||||
|
def on(self, signalName, callback):
|
||||||
|
if signalName not in self._signals:
|
||||||
|
self._signals[signalName] = signalslot.Signal(threadsafe=True)
|
||||||
|
|
||||||
|
signal = self._signals[signalName]
|
||||||
|
|
||||||
|
signal.connect(callback)
|
||||||
|
|
||||||
|
def off(self, signalName, callback):
|
||||||
|
if not self._signals:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not signalName:
|
||||||
|
if not callback:
|
||||||
|
self._signals = {}
|
||||||
|
else:
|
||||||
|
for name in self._signals:
|
||||||
|
self.off(name, callback)
|
||||||
|
else:
|
||||||
|
if not callback:
|
||||||
|
if signalName in self._signals:
|
||||||
|
del self._signals[signalName]
|
||||||
|
else:
|
||||||
|
self._signals[signalName].disconnect(callback)
|
||||||
|
|
||||||
|
def trigger(self, signalName, **kwargs):
|
||||||
|
if not self._signals:
|
||||||
|
return
|
||||||
|
|
||||||
|
if signalName not in self._signals:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._signals[signalName].emit(**kwargs)
|
21
resources/lib/plexnet/simpleobjects.py
Normal file
21
resources/lib/plexnet/simpleobjects.py
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
class Res(tuple):
|
||||||
|
def __str__(self):
|
||||||
|
return '{0}x{1}'.format(*self[:2])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def fromString(cls, res_string):
|
||||||
|
try:
|
||||||
|
return cls(map(lambda n: int(n), res_string.split('x')))
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AttributeDict(dict):
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return self.get(attr)
|
||||||
|
|
||||||
|
def __setattr__(self, attr, value):
|
||||||
|
self[attr] = value
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<{0}:{1}:{2}>'.format(self.__class__.__name__, self.id, self.get('title', 'None').encode('utf8'))
|
93
resources/lib/plexnet/threadutils.py
Normal file
93
resources/lib/plexnet/threadutils.py
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
# import inspect
|
||||||
|
# import ctypes
|
||||||
|
import threading
|
||||||
|
# import time
|
||||||
|
|
||||||
|
|
||||||
|
# def _async_raise(tid, exctype):
|
||||||
|
# '''Raises an exception in the threads with id tid'''
|
||||||
|
# if not inspect.isclass(exctype):
|
||||||
|
# raise TypeError("Only types can be raised (not instances)")
|
||||||
|
|
||||||
|
# try:
|
||||||
|
# res = ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), ctypes.py_object(exctype))
|
||||||
|
# except AttributeError:
|
||||||
|
# # To catch: undefined symbol: PyThreadState_SetAsyncExc
|
||||||
|
# return
|
||||||
|
|
||||||
|
# if res == 0:
|
||||||
|
# raise ValueError("invalid thread id")
|
||||||
|
# elif res != 1:
|
||||||
|
# # "if it returns a number greater than one, you're in trouble,
|
||||||
|
# # and you should call it again with exc=NULL to revert the effect"
|
||||||
|
# ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), 0)
|
||||||
|
# raise SystemError("PyThreadState_SetAsyncExc failed")
|
||||||
|
|
||||||
|
|
||||||
|
# class KillThreadException(Exception):
|
||||||
|
# pass
|
||||||
|
|
||||||
|
|
||||||
|
class KillableThread(threading.Thread):
|
||||||
|
pass
|
||||||
|
'''A thread class that supports raising exception in the thread from
|
||||||
|
another thread.
|
||||||
|
'''
|
||||||
|
# def _get_my_tid(self):
|
||||||
|
# """determines this (self's) thread id
|
||||||
|
|
||||||
|
# CAREFUL : this function is executed in the context of the caller
|
||||||
|
# thread, to get the identity of the thread represented by this
|
||||||
|
# instance.
|
||||||
|
# """
|
||||||
|
# if not self.isAlive():
|
||||||
|
# raise threading.ThreadError("the thread is not active")
|
||||||
|
|
||||||
|
# return self.ident
|
||||||
|
|
||||||
|
# def _raiseExc(self, exctype):
|
||||||
|
# """Raises the given exception type in the context of this thread.
|
||||||
|
|
||||||
|
# If the thread is busy in a system call (time.sleep(),
|
||||||
|
# socket.accept(), ...), the exception is simply ignored.
|
||||||
|
|
||||||
|
# If you are sure that your exception should terminate the thread,
|
||||||
|
# one way to ensure that it works is:
|
||||||
|
|
||||||
|
# t = ThreadWithExc( ... )
|
||||||
|
# ...
|
||||||
|
# t.raiseExc( SomeException )
|
||||||
|
# while t.isAlive():
|
||||||
|
# time.sleep( 0.1 )
|
||||||
|
# t.raiseExc( SomeException )
|
||||||
|
|
||||||
|
# If the exception is to be caught by the thread, you need a way to
|
||||||
|
# check that your thread has caught it.
|
||||||
|
|
||||||
|
# CAREFUL : this function is executed in the context of the
|
||||||
|
# caller thread, to raise an excpetion in the context of the
|
||||||
|
# thread represented by this instance.
|
||||||
|
# """
|
||||||
|
# _async_raise(self._get_my_tid(), exctype)
|
||||||
|
|
||||||
|
def kill(self, force_and_wait=False):
|
||||||
|
pass
|
||||||
|
# try:
|
||||||
|
# self._raiseExc(KillThreadException)
|
||||||
|
|
||||||
|
# if force_and_wait:
|
||||||
|
# time.sleep(0.1)
|
||||||
|
# while self.isAlive():
|
||||||
|
# self._raiseExc(KillThreadException)
|
||||||
|
# time.sleep(0.1)
|
||||||
|
# except threading.ThreadError:
|
||||||
|
# pass
|
||||||
|
|
||||||
|
# def onKilled(self):
|
||||||
|
# pass
|
||||||
|
|
||||||
|
# def run(self):
|
||||||
|
# try:
|
||||||
|
# self._Thread__target(*self._Thread__args, **self._Thread__kwargs)
|
||||||
|
# except KillThreadException:
|
||||||
|
# self.onKilled()
|
181
resources/lib/plexnet/util.py
Normal file
181
resources/lib/plexnet/util.py
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
import simpleobjects
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import platform
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import verlib
|
||||||
|
import compat
|
||||||
|
import plexapp
|
||||||
|
|
||||||
|
BASE_HEADERS = ''
|
||||||
|
|
||||||
|
|
||||||
|
def resetBaseHeaders():
|
||||||
|
return {
|
||||||
|
'X-Plex-Platform': X_PLEX_PLATFORM,
|
||||||
|
'X-Plex-Platform-Version': X_PLEX_PLATFORM_VERSION,
|
||||||
|
'X-Plex-Provides': X_PLEX_PROVIDES,
|
||||||
|
'X-Plex-Product': X_PLEX_PRODUCT,
|
||||||
|
'X-Plex-Version': X_PLEX_VERSION,
|
||||||
|
'X-Plex-Device': X_PLEX_DEVICE,
|
||||||
|
'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER,
|
||||||
|
'Accept-Encoding': 'gzip,deflate',
|
||||||
|
'User-Agent': USER_AGENT
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Core Settings
|
||||||
|
PROJECT = 'PlexNet' # name provided to plex server
|
||||||
|
VERSION = '0.0.0a1' # version of this api
|
||||||
|
TIMEOUT = 10 # request timeout
|
||||||
|
X_PLEX_CONTAINER_SIZE = 50 # max results to return in a single search page
|
||||||
|
|
||||||
|
# Plex Header Configuation
|
||||||
|
X_PLEX_PROVIDES = 'player,controller' # one or more of [player, controller, server]
|
||||||
|
X_PLEX_PLATFORM = platform.uname()[0] # Platform name, eg iOS, MacOSX, Android, LG, etc
|
||||||
|
X_PLEX_PLATFORM_VERSION = platform.uname()[2] # Operating system version, eg 4.3.1, 10.6.7, 3.2
|
||||||
|
X_PLEX_PRODUCT = PROJECT # Plex application name, eg Laika, Plex Media Server, Media Link
|
||||||
|
X_PLEX_VERSION = VERSION # Plex application version number
|
||||||
|
USER_AGENT = '{0}/{1}'.format(PROJECT, VERSION)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_platform = platform.system()
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
_platform = platform.platform(terse=True)
|
||||||
|
except:
|
||||||
|
_platform = sys.platform
|
||||||
|
|
||||||
|
X_PLEX_DEVICE = _platform # Device name and model number, eg iPhone3,2, Motorola XOOM, LG5200TV
|
||||||
|
X_PLEX_IDENTIFIER = str(hex(uuid.getnode())) # UUID, serial number, or other number unique per device
|
||||||
|
|
||||||
|
BASE_HEADERS = resetBaseHeaders()
|
||||||
|
|
||||||
|
QUALITY_LOCAL = 0
|
||||||
|
QUALITY_REMOTE = 1
|
||||||
|
QUALITY_ONLINE = 2
|
||||||
|
|
||||||
|
Res = simpleobjects.Res
|
||||||
|
AttributeDict = simpleobjects.AttributeDict
|
||||||
|
|
||||||
|
|
||||||
|
def LOG(msg):
|
||||||
|
plexapp.INTERFACE.LOG(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def DEBUG_LOG(msg):
|
||||||
|
plexapp.INTERFACE.DEBUG_LOG(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def ERROR_LOG(msg):
|
||||||
|
plexapp.INTERFACE.ERROR_LOG(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def WARN_LOG(msg):
|
||||||
|
plexapp.INTERFACE.WARN_LOG(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def ERROR(msg=None, err=None):
|
||||||
|
plexapp.INTERFACE.ERROR(msg, err)
|
||||||
|
|
||||||
|
|
||||||
|
def FATAL(msg=None):
|
||||||
|
plexapp.INTERFACE.FATAL(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def TEST(msg):
|
||||||
|
plexapp.INTERFACE.LOG(' ---TEST: {0}'.format(msg))
|
||||||
|
|
||||||
|
|
||||||
|
def userAgent():
|
||||||
|
return plexapp.INTERFACE.getGlobal("userAgent")
|
||||||
|
|
||||||
|
|
||||||
|
def dummyTranslate(string):
|
||||||
|
return string
|
||||||
|
|
||||||
|
|
||||||
|
def hideToken(token):
|
||||||
|
# return 'X' * len(token)
|
||||||
|
if not token:
|
||||||
|
return token
|
||||||
|
return '****' + token[-4:]
|
||||||
|
|
||||||
|
|
||||||
|
def cleanToken(url):
|
||||||
|
return re.sub('X-Plex-Token=[^&]+', 'X-Plex-Token=****', url)
|
||||||
|
|
||||||
|
|
||||||
|
def now(local=False):
|
||||||
|
if local:
|
||||||
|
return time.time()
|
||||||
|
else:
|
||||||
|
return time.mktime(time.gmtime())
|
||||||
|
|
||||||
|
|
||||||
|
def joinArgs(args):
|
||||||
|
if not args:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
arglist = []
|
||||||
|
for key in sorted(args, key=lambda x: x.lower()):
|
||||||
|
value = str(args[key])
|
||||||
|
arglist.append('{0}={1}'.format(key, compat.quote(value)))
|
||||||
|
|
||||||
|
return '?{0}'.format('&'.join(arglist))
|
||||||
|
|
||||||
|
|
||||||
|
def addPlexHeaders(transferObj, token=None):
|
||||||
|
transferObj.addHeader("X-Plex-Platform", plexapp.INTERFACE.getGlobal("platform"))
|
||||||
|
transferObj.addHeader("X-Plex-Version", plexapp.INTERFACE.getGlobal("appVersionStr"))
|
||||||
|
transferObj.addHeader("X-Plex-Client-Identifier", plexapp.INTERFACE.getGlobal("clientIdentifier"))
|
||||||
|
transferObj.addHeader("X-Plex-Platform-Version", plexapp.INTERFACE.getGlobal("platformVersion", "unknown"))
|
||||||
|
transferObj.addHeader("X-Plex-Product", plexapp.INTERFACE.getGlobal("product"))
|
||||||
|
transferObj.addHeader("X-Plex-Provides", not plexapp.INTERFACE.getPreference("remotecontrol", False) and 'player' or '')
|
||||||
|
transferObj.addHeader("X-Plex-Device", plexapp.INTERFACE.getGlobal("device"))
|
||||||
|
transferObj.addHeader("X-Plex-Model", plexapp.INTERFACE.getGlobal("model"))
|
||||||
|
transferObj.addHeader("X-Plex-Device-Name", plexapp.INTERFACE.getGlobal("friendlyName"))
|
||||||
|
|
||||||
|
# Adding the X-Plex-Client-Capabilities header causes node.plexapp.com to 500
|
||||||
|
if not type(transferObj) == "roUrlTransfer" or 'node.plexapp.com' not in transferObj.getUrl():
|
||||||
|
transferObj.addHeader("X-Plex-Client-Capabilities", plexapp.INTERFACE.getCapabilities())
|
||||||
|
|
||||||
|
addAccountHeaders(transferObj, token)
|
||||||
|
|
||||||
|
|
||||||
|
def addAccountHeaders(transferObj, token=None):
|
||||||
|
if token:
|
||||||
|
transferObj.addHeader("X-Plex-Token", token)
|
||||||
|
|
||||||
|
# TODO(schuyler): Add username?
|
||||||
|
|
||||||
|
|
||||||
|
def validInt(int_str):
|
||||||
|
try:
|
||||||
|
return int(int_str)
|
||||||
|
except:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def bitrateToString(bits):
|
||||||
|
if not bits:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
speed = bits / 1000000.0
|
||||||
|
if speed < 1:
|
||||||
|
speed = int(round(bits / 1000.0))
|
||||||
|
return '{0} Kbps'.format(speed)
|
||||||
|
else:
|
||||||
|
return '{0:.1f} Mbps'.format(speed)
|
||||||
|
|
||||||
|
|
||||||
|
def normalizedVersion(ver):
|
||||||
|
try:
|
||||||
|
modv = '.'.join(ver.split('.')[:4]).split('-', 1)[0] # Clean the version i.e. Turn 1.2.3.4-asdf8-ads7f into 1.2.3.4
|
||||||
|
return verlib.NormalizedVersion(verlib.suggest_normalized_version(modv))
|
||||||
|
except:
|
||||||
|
if ver:
|
||||||
|
ERROR()
|
||||||
|
return verlib.NormalizedVersion(verlib.suggest_normalized_version('0.0.0'))
|
328
resources/lib/plexnet/verlib.py
Normal file
328
resources/lib/plexnet/verlib.py
Normal file
|
@ -0,0 +1,328 @@
|
||||||
|
"""
|
||||||
|
"Rational" version definition and parsing for DistutilsVersionFight
|
||||||
|
discussion at PyCon 2009.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
class IrrationalVersionError(Exception):
|
||||||
|
"""This is an irrational version."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HugeMajorVersionNumError(IrrationalVersionError):
|
||||||
|
"""An irrational version because the major version number is huge
|
||||||
|
(often because a year or date was used).
|
||||||
|
|
||||||
|
See `error_on_huge_major_num` option in `NormalizedVersion` for details.
|
||||||
|
This guard can be disabled by setting that option False.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# A marker used in the second and third parts of the `parts` tuple, for
|
||||||
|
# versions that don't have those segments, to sort properly. An example
|
||||||
|
# of versions in sort order ('highest' last):
|
||||||
|
# 1.0b1 ((1,0), ('b',1), ('f',))
|
||||||
|
# 1.0.dev345 ((1,0), ('f',), ('dev', 345))
|
||||||
|
# 1.0 ((1,0), ('f',), ('f',))
|
||||||
|
# 1.0.post256.dev345 ((1,0), ('f',), ('f', 'post', 256, 'dev', 345))
|
||||||
|
# 1.0.post345 ((1,0), ('f',), ('f', 'post', 345, 'f'))
|
||||||
|
# ^ ^ ^
|
||||||
|
# 'b' < 'f' ---------------------/ | |
|
||||||
|
# | |
|
||||||
|
# 'dev' < 'f' < 'post' -------------------/ |
|
||||||
|
# |
|
||||||
|
# 'dev' < 'f' ----------------------------------------------/
|
||||||
|
# Other letters would do, but 'f' for 'final' is kind of nice.
|
||||||
|
FINAL_MARKER = ('f',)
|
||||||
|
|
||||||
|
VERSION_RE = re.compile(r'''
|
||||||
|
^
|
||||||
|
(?P<version>\d+\.\d+) # minimum 'N.N'
|
||||||
|
(?P<extraversion>(?:\.\d+)*) # any number of extra '.N' segments
|
||||||
|
(?:
|
||||||
|
(?P<prerel>[abc]|rc) # 'a'=alpha, 'b'=beta, 'c'=release candidate
|
||||||
|
# 'rc'= alias for release candidate
|
||||||
|
(?P<prerelversion>\d+(?:\.\d+)*)
|
||||||
|
)?
|
||||||
|
(?P<postdev>(\.post(?P<post>\d+))?(\.dev(?P<dev>\d+))?)?
|
||||||
|
$''', re.VERBOSE)
|
||||||
|
|
||||||
|
|
||||||
|
class NormalizedVersion(object):
|
||||||
|
"""A rational version.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
1.2 # equivalent to "1.2.0"
|
||||||
|
1.2.0
|
||||||
|
1.2a1
|
||||||
|
1.2.3a2
|
||||||
|
1.2.3b1
|
||||||
|
1.2.3c1
|
||||||
|
1.2.3.4
|
||||||
|
TODO: fill this out
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
1 # mininum two numbers
|
||||||
|
1.2a # release level must have a release serial
|
||||||
|
1.2.3b
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, s, error_on_huge_major_num=True):
|
||||||
|
"""Create a NormalizedVersion instance from a version string.
|
||||||
|
|
||||||
|
@param s {str} The version string.
|
||||||
|
@param error_on_huge_major_num {bool} Whether to consider an
|
||||||
|
apparent use of a year or full date as the major version number
|
||||||
|
an error. Default True. One of the observed patterns on PyPI before
|
||||||
|
the introduction of `NormalizedVersion` was version numbers like this:
|
||||||
|
2009.01.03
|
||||||
|
20040603
|
||||||
|
2005.01
|
||||||
|
This guard is here to strongly encourage the package author to
|
||||||
|
use an alternate version, because a release deployed into PyPI
|
||||||
|
and, e.g. downstream Linux package managers, will forever remove
|
||||||
|
the possibility of using a version number like "1.0" (i.e.
|
||||||
|
where the major number is less than that huge major number).
|
||||||
|
"""
|
||||||
|
self._parse(s, error_on_huge_major_num)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_parts(cls, version, prerelease=FINAL_MARKER,
|
||||||
|
devpost=FINAL_MARKER):
|
||||||
|
return cls(cls.parts_to_str((version, prerelease, devpost)))
|
||||||
|
|
||||||
|
def _parse(self, s, error_on_huge_major_num=True):
|
||||||
|
"""Parses a string version into parts."""
|
||||||
|
match = VERSION_RE.search(s)
|
||||||
|
if not match:
|
||||||
|
raise IrrationalVersionError(s)
|
||||||
|
|
||||||
|
groups = match.groupdict()
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# main version
|
||||||
|
block = self._parse_numdots(groups['version'], s, False, 2)
|
||||||
|
extraversion = groups.get('extraversion')
|
||||||
|
if extraversion not in ('', None):
|
||||||
|
block += self._parse_numdots(extraversion[1:], s)
|
||||||
|
parts.append(tuple(block))
|
||||||
|
|
||||||
|
# prerelease
|
||||||
|
prerel = groups.get('prerel')
|
||||||
|
if prerel is not None:
|
||||||
|
block = [prerel]
|
||||||
|
block += self._parse_numdots(groups.get('prerelversion'), s,
|
||||||
|
pad_zeros_length=1)
|
||||||
|
parts.append(tuple(block))
|
||||||
|
else:
|
||||||
|
parts.append(FINAL_MARKER)
|
||||||
|
|
||||||
|
# postdev
|
||||||
|
if groups.get('postdev'):
|
||||||
|
post = groups.get('post')
|
||||||
|
dev = groups.get('dev')
|
||||||
|
postdev = []
|
||||||
|
if post is not None:
|
||||||
|
postdev.extend([FINAL_MARKER[0], 'post', int(post)])
|
||||||
|
if dev is None:
|
||||||
|
postdev.append(FINAL_MARKER[0])
|
||||||
|
if dev is not None:
|
||||||
|
postdev.extend(['dev', int(dev)])
|
||||||
|
parts.append(tuple(postdev))
|
||||||
|
else:
|
||||||
|
parts.append(FINAL_MARKER)
|
||||||
|
self.parts = tuple(parts)
|
||||||
|
if error_on_huge_major_num and self.parts[0][0] > 1980:
|
||||||
|
raise HugeMajorVersionNumError("huge major version number, %r, "
|
||||||
|
"which might cause future problems: %r" % (self.parts[0][0], s))
|
||||||
|
|
||||||
|
def _parse_numdots(self, s, full_ver_str, drop_trailing_zeros=True,
|
||||||
|
pad_zeros_length=0):
|
||||||
|
"""Parse 'N.N.N' sequences, return a list of ints.
|
||||||
|
|
||||||
|
@param s {str} 'N.N.N..." sequence to be parsed
|
||||||
|
@param full_ver_str {str} The full version string from which this
|
||||||
|
comes. Used for error strings.
|
||||||
|
@param drop_trailing_zeros {bool} Whether to drop trailing zeros
|
||||||
|
from the returned list. Default True.
|
||||||
|
@param pad_zeros_length {int} The length to which to pad the
|
||||||
|
returned list with zeros, if necessary. Default 0.
|
||||||
|
"""
|
||||||
|
nums = []
|
||||||
|
for n in s.split("."):
|
||||||
|
if len(n) > 1 and n[0] == '0':
|
||||||
|
raise IrrationalVersionError("cannot have leading zero in "
|
||||||
|
"version number segment: '%s' in %r" % (n, full_ver_str))
|
||||||
|
nums.append(int(n))
|
||||||
|
if drop_trailing_zeros:
|
||||||
|
while nums and nums[-1] == 0:
|
||||||
|
nums.pop()
|
||||||
|
while len(nums) < pad_zeros_length:
|
||||||
|
nums.append(0)
|
||||||
|
return nums
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.parts_to_str(self.parts)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parts_to_str(cls, parts):
|
||||||
|
"""Transforms a version expressed in tuple into its string
|
||||||
|
representation."""
|
||||||
|
# XXX This doesn't check for invalid tuples
|
||||||
|
main, prerel, postdev = parts
|
||||||
|
s = '.'.join(str(v) for v in main)
|
||||||
|
if prerel is not FINAL_MARKER:
|
||||||
|
s += prerel[0]
|
||||||
|
s += '.'.join(str(v) for v in prerel[1:])
|
||||||
|
if postdev and postdev is not FINAL_MARKER:
|
||||||
|
if postdev[0] == 'f':
|
||||||
|
postdev = postdev[1:]
|
||||||
|
i = 0
|
||||||
|
while i < len(postdev):
|
||||||
|
if i % 2 == 0:
|
||||||
|
s += '.'
|
||||||
|
s += str(postdev[i])
|
||||||
|
i += 1
|
||||||
|
return s
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "%s('%s')" % (self.__class__.__name__, self)
|
||||||
|
|
||||||
|
def _cannot_compare(self, other):
|
||||||
|
raise TypeError("cannot compare %s and %s"
|
||||||
|
% (type(self).__name__, type(other).__name__))
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if not isinstance(other, NormalizedVersion):
|
||||||
|
self._cannot_compare(other)
|
||||||
|
return self.parts == other.parts
|
||||||
|
|
||||||
|
def __lt__(self, other):
|
||||||
|
if not isinstance(other, NormalizedVersion):
|
||||||
|
self._cannot_compare(other)
|
||||||
|
return self.parts < other.parts
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
def __gt__(self, other):
|
||||||
|
return not (self.__lt__(other) or self.__eq__(other))
|
||||||
|
|
||||||
|
def __le__(self, other):
|
||||||
|
return self.__eq__(other) or self.__lt__(other)
|
||||||
|
|
||||||
|
def __ge__(self, other):
|
||||||
|
return self.__eq__(other) or self.__gt__(other)
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_normalized_version(s):
|
||||||
|
"""Suggest a normalized version close to the given version string.
|
||||||
|
|
||||||
|
If you have a version string that isn't rational (i.e. NormalizedVersion
|
||||||
|
doesn't like it) then you might be able to get an equivalent (or close)
|
||||||
|
rational version from this function.
|
||||||
|
|
||||||
|
This does a number of simple normalizations to the given string, based
|
||||||
|
on observation of versions currently in use on PyPI. Given a dump of
|
||||||
|
those version during PyCon 2009, 4287 of them:
|
||||||
|
- 2312 (53.93%) match NormalizedVersion without change
|
||||||
|
- with the automatic suggestion
|
||||||
|
- 3474 (81.04%) match when using this suggestion method
|
||||||
|
|
||||||
|
@param s {str} An irrational version string.
|
||||||
|
@returns A rational version string, or None, if couldn't determine one.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
NormalizedVersion(s)
|
||||||
|
return s # already rational
|
||||||
|
except IrrationalVersionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
rs = s.lower()
|
||||||
|
|
||||||
|
# part of this could use maketrans
|
||||||
|
for orig, repl in (('-alpha', 'a'), ('-beta', 'b'), ('alpha', 'a'),
|
||||||
|
('beta', 'b'), ('rc', 'c'), ('-final', ''),
|
||||||
|
('-pre', 'c'),
|
||||||
|
('-release', ''), ('.release', ''), ('-stable', ''),
|
||||||
|
('+', '.'), ('_', '.'), (' ', ''), ('.final', ''),
|
||||||
|
('final', '')):
|
||||||
|
rs = rs.replace(orig, repl)
|
||||||
|
|
||||||
|
# if something ends with dev or pre, we add a 0
|
||||||
|
rs = re.sub(r"pre$", r"pre0", rs)
|
||||||
|
rs = re.sub(r"dev$", r"dev0", rs)
|
||||||
|
|
||||||
|
# if we have something like "b-2" or "a.2" at the end of the
|
||||||
|
# version, that is pobably beta, alpha, etc
|
||||||
|
# let's remove the dash or dot
|
||||||
|
rs = re.sub(r"([abc|rc])[\-\.](\d+)$", r"\1\2", rs)
|
||||||
|
|
||||||
|
# 1.0-dev-r371 -> 1.0.dev371
|
||||||
|
# 0.1-dev-r79 -> 0.1.dev79
|
||||||
|
rs = re.sub(r"[\-\.](dev)[\-\.]?r?(\d+)$", r".\1\2", rs)
|
||||||
|
|
||||||
|
# Clean: 2.0.a.3, 2.0.b1, 0.9.0~c1
|
||||||
|
rs = re.sub(r"[.~]?([abc])\.?", r"\1", rs)
|
||||||
|
|
||||||
|
# Clean: v0.3, v1.0
|
||||||
|
if rs.startswith('v'):
|
||||||
|
rs = rs[1:]
|
||||||
|
|
||||||
|
# Clean leading '0's on numbers.
|
||||||
|
# TODO: unintended side-effect on, e.g., "2003.05.09"
|
||||||
|
# PyPI stats: 77 (~2%) better
|
||||||
|
rs = re.sub(r"\b0+(\d+)(?!\d)", r"\1", rs)
|
||||||
|
|
||||||
|
# Clean a/b/c with no version. E.g. "1.0a" -> "1.0a0". Setuptools infers
|
||||||
|
# zero.
|
||||||
|
# PyPI stats: 245 (7.56%) better
|
||||||
|
rs = re.sub(r"(\d+[abc])$", r"\g<1>0", rs)
|
||||||
|
|
||||||
|
# the 'dev-rNNN' tag is a dev tag
|
||||||
|
rs = re.sub(r"\.?(dev-r|dev\.r)\.?(\d+)$", r".dev\2", rs)
|
||||||
|
|
||||||
|
# clean the - when used as a pre delimiter
|
||||||
|
rs = re.sub(r"-(a|b|c)(\d+)$", r"\1\2", rs)
|
||||||
|
|
||||||
|
# a terminal "dev" or "devel" can be changed into ".dev0"
|
||||||
|
rs = re.sub(r"[\.\-](dev|devel)$", r".dev0", rs)
|
||||||
|
|
||||||
|
# a terminal "dev" can be changed into ".dev0"
|
||||||
|
rs = re.sub(r"(?![\.\-])dev$", r".dev0", rs)
|
||||||
|
|
||||||
|
# a terminal "final" or "stable" can be removed
|
||||||
|
rs = re.sub(r"(final|stable)$", "", rs)
|
||||||
|
|
||||||
|
# The 'r' and the '-' tags are post release tags
|
||||||
|
# 0.4a1.r10 -> 0.4a1.post10
|
||||||
|
# 0.9.33-17222 -> 0.9.3.post17222
|
||||||
|
# 0.9.33-r17222 -> 0.9.3.post17222
|
||||||
|
rs = re.sub(r"\.?(r|-|-r)\.?(\d+)$", r".post\2", rs)
|
||||||
|
|
||||||
|
# Clean 'r' instead of 'dev' usage:
|
||||||
|
# 0.9.33+r17222 -> 0.9.3.dev17222
|
||||||
|
# 1.0dev123 -> 1.0.dev123
|
||||||
|
# 1.0.git123 -> 1.0.dev123
|
||||||
|
# 1.0.bzr123 -> 1.0.dev123
|
||||||
|
# 0.1a0dev.123 -> 0.1a0.dev123
|
||||||
|
# PyPI stats: ~150 (~4%) better
|
||||||
|
rs = re.sub(r"\.?(dev|git|bzr)\.?(\d+)$", r".dev\2", rs)
|
||||||
|
|
||||||
|
# Clean '.pre' (normalized from '-pre' above) instead of 'c' usage:
|
||||||
|
# 0.2.pre1 -> 0.2c1
|
||||||
|
# 0.2-c1 -> 0.2c1
|
||||||
|
# 1.0preview123 -> 1.0c123
|
||||||
|
# PyPI stats: ~21 (0.62%) better
|
||||||
|
rs = re.sub(r"\.?(pre|preview|-c)(\d+)$", r"c\g<2>", rs)
|
||||||
|
|
||||||
|
# Tcl/Tk uses "px" for their post release markers
|
||||||
|
rs = re.sub(r"p(\d+)$", r".post\1", rs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
NormalizedVersion(rs)
|
||||||
|
return rs # already rational
|
||||||
|
except IrrationalVersionError:
|
||||||
|
pass
|
||||||
|
return None
|
474
resources/lib/plexnet/video.py
Normal file
474
resources/lib/plexnet/video.py
Normal file
|
@ -0,0 +1,474 @@
|
||||||
|
import plexobjects
|
||||||
|
import media
|
||||||
|
import plexmedia
|
||||||
|
import plexstream
|
||||||
|
import exceptions
|
||||||
|
import compat
|
||||||
|
import plexlibrary
|
||||||
|
import util
|
||||||
|
|
||||||
|
|
||||||
|
class PlexVideoItemList(plexobjects.PlexItemList):
|
||||||
|
def __init__(self, data, initpath=None, server=None, container=None):
|
||||||
|
self._data = data
|
||||||
|
self._initpath = initpath
|
||||||
|
self._server = server
|
||||||
|
self._container = container
|
||||||
|
self._items = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def items(self):
|
||||||
|
if self._items is None:
|
||||||
|
if self._data is not None:
|
||||||
|
self._items = [plexobjects.buildItem(self._server, elem, self._initpath, container=self._container) for elem in self._data]
|
||||||
|
else:
|
||||||
|
self._items = []
|
||||||
|
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
|
||||||
|
class Video(media.MediaItem):
|
||||||
|
TYPE = None
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self._settings = None
|
||||||
|
media.MediaItem.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return other and self.ratingKey == other.ratingKey
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return not self.__eq__(other)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def settings(self):
|
||||||
|
if not self._settings:
|
||||||
|
import plexapp
|
||||||
|
self._settings = plexapp.PlayerSettingsInterface()
|
||||||
|
|
||||||
|
return self._settings
|
||||||
|
|
||||||
|
@settings.setter
|
||||||
|
def settings(self, value):
|
||||||
|
self._settings = value
|
||||||
|
|
||||||
|
def selectedAudioStream(self):
|
||||||
|
if self.audioStreams:
|
||||||
|
for stream in self.audioStreams:
|
||||||
|
if stream.isSelected():
|
||||||
|
return stream
|
||||||
|
return None
|
||||||
|
|
||||||
|
def selectedSubtitleStream(self):
|
||||||
|
if self.subtitleStreams:
|
||||||
|
for stream in self.subtitleStreams:
|
||||||
|
if stream.isSelected():
|
||||||
|
return stream
|
||||||
|
return None
|
||||||
|
|
||||||
|
def selectStream(self, stream, async=True):
|
||||||
|
self.mediaChoice.part.setSelectedStream(stream.streamType.asInt(), stream.id, async)
|
||||||
|
|
||||||
|
def isVideoItem(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _findStreams(self, streamtype):
|
||||||
|
idx = 0
|
||||||
|
streams = []
|
||||||
|
for media_ in self.media():
|
||||||
|
for part in media_.parts:
|
||||||
|
for stream in part.streams:
|
||||||
|
if stream.streamType.asInt() == streamtype:
|
||||||
|
stream.typeIndex = idx
|
||||||
|
streams.append(stream)
|
||||||
|
idx += 1
|
||||||
|
return streams
|
||||||
|
|
||||||
|
def analyze(self):
|
||||||
|
""" The primary purpose of media analysis is to gather information about that media
|
||||||
|
item. All of the media you add to a Library has properties that are useful to
|
||||||
|
know - whether it's a video file, a music track, or one of your photos.
|
||||||
|
"""
|
||||||
|
self.server.query('/%s/analyze' % self.key)
|
||||||
|
|
||||||
|
def markWatched(self):
|
||||||
|
path = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||||
|
self.server.query(path)
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def markUnwatched(self):
|
||||||
|
path = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||||
|
self.server.query(path)
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
# def play(self, client):
|
||||||
|
# client.playMedia(self)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
self.server.query('%s/refresh' % self.key, method=self.server.session.put)
|
||||||
|
|
||||||
|
def _getStreamURL(self, **params):
|
||||||
|
if self.TYPE not in ('movie', 'episode', 'track'):
|
||||||
|
raise exceptions.Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
|
||||||
|
mvb = params.get('maxVideoBitrate')
|
||||||
|
vr = params.get('videoResolution')
|
||||||
|
|
||||||
|
# import plexapp
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'path': self.key,
|
||||||
|
'offset': params.get('offset', 0),
|
||||||
|
'copyts': params.get('copyts', 1),
|
||||||
|
'protocol': params.get('protocol', 'hls'),
|
||||||
|
'mediaIndex': params.get('mediaIndex', 0),
|
||||||
|
'directStream': '1',
|
||||||
|
'directPlay': '0',
|
||||||
|
'X-Plex-Platform': params.get('platform', 'Chrome'),
|
||||||
|
# 'X-Plex-Platform': params.get('platform', plexapp.INTERFACE.getGlobal('platform')),
|
||||||
|
'maxVideoBitrate': max(mvb, 64) if mvb else None,
|
||||||
|
'videoResolution': '{0}x{1}'.format(*vr) if vr else None
|
||||||
|
}
|
||||||
|
|
||||||
|
final = {}
|
||||||
|
|
||||||
|
for k, v in params.items():
|
||||||
|
if v is not None: # remove None values
|
||||||
|
final[k] = v
|
||||||
|
|
||||||
|
streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video'
|
||||||
|
server = self.getTranscodeServer(True, self.TYPE)
|
||||||
|
|
||||||
|
return server.buildUrl('/{0}/:/transcode/universal/start.m3u8?{1}'.format(streamtype, compat.urlencode(final)), includeToken=True)
|
||||||
|
# path = "/video/:/transcode/universal/" + command + "?session=" + AppSettings().GetGlobal("clientIdentifier")
|
||||||
|
|
||||||
|
def resolutionString(self):
|
||||||
|
res = self.media[0].videoResolution
|
||||||
|
if not res:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
if res.isdigit():
|
||||||
|
return '{0}p'.format(self.media[0].videoResolution)
|
||||||
|
else:
|
||||||
|
return res.upper()
|
||||||
|
|
||||||
|
def audioCodecString(self):
|
||||||
|
codec = (self.media[0].audioCodec or '').lower()
|
||||||
|
|
||||||
|
if codec in ('dca', 'dca-ma', 'dts-hd', 'dts-es', 'dts-hra'):
|
||||||
|
codec = "DTS"
|
||||||
|
else:
|
||||||
|
codec = codec.upper()
|
||||||
|
|
||||||
|
return codec
|
||||||
|
|
||||||
|
def audioChannelsString(self, translate_func=util.dummyTranslate):
|
||||||
|
channels = self.media[0].audioChannels.asInt()
|
||||||
|
|
||||||
|
if channels == 1:
|
||||||
|
return translate_func("Mono")
|
||||||
|
elif channels == 2:
|
||||||
|
return translate_func("Stereo")
|
||||||
|
elif channels > 0:
|
||||||
|
return "{0}.1".format(channels - 1)
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def available(self):
|
||||||
|
return self.media()[0].isAccessible()
|
||||||
|
|
||||||
|
|
||||||
|
class PlayableVideo(Video):
|
||||||
|
TYPE = None
|
||||||
|
|
||||||
|
def _setData(self, data):
|
||||||
|
Video._setData(self, data)
|
||||||
|
if self.isFullObject():
|
||||||
|
self.extras = PlexVideoItemList(data.find('Extras'), initpath=self.initpath, server=self.server, container=self)
|
||||||
|
|
||||||
|
def reload(self, *args, **kwargs):
|
||||||
|
if not kwargs.get('_soft'):
|
||||||
|
if self.get('viewCount'):
|
||||||
|
del self.viewCount
|
||||||
|
if self.get('viewOffset'):
|
||||||
|
del self.viewOffset
|
||||||
|
Video.reload(self, *args, **kwargs)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def postPlay(self, **params):
|
||||||
|
query = '/hubs/metadata/{0}/postplay'.format(self.ratingKey)
|
||||||
|
data = self.server.query(query, params=params)
|
||||||
|
container = plexobjects.PlexContainer(data, initpath=query, server=self.server, address=query)
|
||||||
|
|
||||||
|
hubs = {}
|
||||||
|
for elem in data:
|
||||||
|
hub = plexlibrary.Hub(elem, server=self.server, container=container)
|
||||||
|
hubs[hub.hubIdentifier] = hub
|
||||||
|
return hubs
|
||||||
|
|
||||||
|
|
||||||
|
@plexobjects.registerLibType
|
||||||
|
class Movie(PlayableVideo):
|
||||||
|
TYPE = 'movie'
|
||||||
|
|
||||||
|
def _setData(self, data):
|
||||||
|
PlayableVideo._setData(self, data)
|
||||||
|
if self.isFullObject():
|
||||||
|
self.collections = plexobjects.PlexItemList(data, media.Collection, media.Collection.TYPE, server=self.server)
|
||||||
|
self.countries = plexobjects.PlexItemList(data, media.Country, media.Country.TYPE, server=self.server)
|
||||||
|
self.directors = plexobjects.PlexItemList(data, media.Director, media.Director.TYPE, server=self.server)
|
||||||
|
self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server)
|
||||||
|
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||||
|
self.producers = plexobjects.PlexItemList(data, media.Producer, media.Producer.TYPE, server=self.server)
|
||||||
|
self.roles = plexobjects.PlexItemList(data, media.Role, media.Role.TYPE, server=self.server, container=self.container)
|
||||||
|
self.writers = plexobjects.PlexItemList(data, media.Writer, media.Writer.TYPE, server=self.server)
|
||||||
|
self.related = plexobjects.PlexItemList(data.find('Related'), plexlibrary.Hub, plexlibrary.Hub.TYPE, server=self.server, container=self)
|
||||||
|
else:
|
||||||
|
if data.find(media.Media.TYPE) is not None:
|
||||||
|
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||||
|
|
||||||
|
self._videoStreams = None
|
||||||
|
self._audioStreams = None
|
||||||
|
self._subtitleStreams = None
|
||||||
|
|
||||||
|
# data for active sessions
|
||||||
|
self.sessionKey = plexobjects.PlexValue(data.attrib.get('sessionKey', ''), self)
|
||||||
|
self.user = self._findUser(data)
|
||||||
|
self.player = self._findPlayer(data)
|
||||||
|
self.transcodeSession = self._findTranscodeSession(data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def maxHeight(self):
|
||||||
|
height = 0
|
||||||
|
for m in self.media:
|
||||||
|
if m.height.asInt() > height:
|
||||||
|
height = m.height.asInt()
|
||||||
|
return height
|
||||||
|
|
||||||
|
@property
|
||||||
|
def videoStreams(self):
|
||||||
|
if self._videoStreams is None:
|
||||||
|
self._videoStreams = self._findStreams(plexstream.PlexStream.TYPE_VIDEO)
|
||||||
|
return self._videoStreams
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audioStreams(self):
|
||||||
|
if self._audioStreams is None:
|
||||||
|
self._audioStreams = self._findStreams(plexstream.PlexStream.TYPE_AUDIO)
|
||||||
|
return self._audioStreams
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitleStreams(self):
|
||||||
|
if self._subtitleStreams is None:
|
||||||
|
self._subtitleStreams = self._findStreams(plexstream.PlexStream.TYPE_SUBTITLE)
|
||||||
|
return self._subtitleStreams
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actors(self):
|
||||||
|
return self.roles
|
||||||
|
|
||||||
|
@property
|
||||||
|
def isWatched(self):
|
||||||
|
return self.get('viewCount').asInt() > 0
|
||||||
|
|
||||||
|
def getStreamURL(self, **params):
|
||||||
|
return self._getStreamURL(**params)
|
||||||
|
|
||||||
|
|
||||||
|
@plexobjects.registerLibType
|
||||||
|
class Show(Video):
|
||||||
|
TYPE = 'show'
|
||||||
|
|
||||||
|
def _setData(self, data):
|
||||||
|
Video._setData(self, data)
|
||||||
|
if self.isFullObject():
|
||||||
|
self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server)
|
||||||
|
self.roles = plexobjects.PlexItemList(data, media.Role, media.Role.TYPE, server=self.server, container=self.container)
|
||||||
|
self.related = plexobjects.PlexItemList(data.find('Related'), plexlibrary.Hub, plexlibrary.Hub.TYPE, server=self.server, container=self)
|
||||||
|
self.extras = PlexVideoItemList(data.find('Extras'), initpath=self.initpath, server=self.server, container=self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unViewedLeafCount(self):
|
||||||
|
return self.leafCount.asInt() - self.viewedLeafCount.asInt()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def isWatched(self):
|
||||||
|
return self.viewedLeafCount == self.leafCount
|
||||||
|
|
||||||
|
def seasons(self):
|
||||||
|
path = self.key
|
||||||
|
return plexobjects.listItems(self.server, path, Season.TYPE)
|
||||||
|
|
||||||
|
def season(self, title):
|
||||||
|
path = self.key
|
||||||
|
return plexobjects.findItem(self.server, path, title)
|
||||||
|
|
||||||
|
def episodes(self, watched=None):
|
||||||
|
leavesKey = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||||
|
return plexobjects.listItems(self.server, leavesKey, watched=watched)
|
||||||
|
|
||||||
|
def episode(self, title):
|
||||||
|
path = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||||
|
return plexobjects.findItem(self.server, path, title)
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self.episodes()
|
||||||
|
|
||||||
|
def watched(self):
|
||||||
|
return self.episodes(watched=True)
|
||||||
|
|
||||||
|
def unwatched(self):
|
||||||
|
return self.episodes(watched=False)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
self.server.query('/library/metadata/%s/refresh' % self.ratingKey)
|
||||||
|
|
||||||
|
def sectionOnDeck(self):
|
||||||
|
query = '/library/sections/{0}/onDeck'.format(self.getLibrarySectionId())
|
||||||
|
return plexobjects.listItems(self.server, query)
|
||||||
|
|
||||||
|
|
||||||
|
@plexobjects.registerLibType
|
||||||
|
class Season(Video):
|
||||||
|
TYPE = 'season'
|
||||||
|
|
||||||
|
def _setData(self, data):
|
||||||
|
Video._setData(self, data)
|
||||||
|
if self.isFullObject():
|
||||||
|
self.extras = PlexVideoItemList(data.find('Extras'), initpath=self.initpath, server=self.server, container=self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultTitle(self):
|
||||||
|
return self.parentTitle or self.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unViewedLeafCount(self):
|
||||||
|
return self.leafCount.asInt() - self.viewedLeafCount.asInt()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def isWatched(self):
|
||||||
|
return self.viewedLeafCount == self.leafCount
|
||||||
|
|
||||||
|
def episodes(self, watched=None):
|
||||||
|
path = self.key
|
||||||
|
return plexobjects.listItems(self.server, path, watched=watched)
|
||||||
|
|
||||||
|
def episode(self, title):
|
||||||
|
path = self.key
|
||||||
|
return plexobjects.findItem(self.server, path, title)
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self.episodes()
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
return plexobjects.listItems(self.server, self.parentKey)[0]
|
||||||
|
|
||||||
|
def watched(self):
|
||||||
|
return self.episodes(watched=True)
|
||||||
|
|
||||||
|
def unwatched(self):
|
||||||
|
return self.episodes(watched=False)
|
||||||
|
|
||||||
|
|
||||||
|
@plexobjects.registerLibType
|
||||||
|
class Episode(PlayableVideo):
|
||||||
|
TYPE = 'episode'
|
||||||
|
|
||||||
|
def init(self, data):
|
||||||
|
self._show = None
|
||||||
|
self._season = None
|
||||||
|
|
||||||
|
def _setData(self, data):
|
||||||
|
PlayableVideo._setData(self, data)
|
||||||
|
if self.isFullObject():
|
||||||
|
self.directors = plexobjects.PlexItemList(data, media.Director, media.Director.TYPE, server=self.server)
|
||||||
|
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||||
|
self.writers = plexobjects.PlexItemList(data, media.Writer, media.Writer.TYPE, server=self.server)
|
||||||
|
else:
|
||||||
|
if data.find(media.Media.TYPE) is not None:
|
||||||
|
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||||
|
|
||||||
|
self._videoStreams = None
|
||||||
|
self._audioStreams = None
|
||||||
|
self._subtitleStreams = None
|
||||||
|
|
||||||
|
# data for active sessions
|
||||||
|
self.sessionKey = plexobjects.PlexValue(data.attrib.get('sessionKey', ''), self)
|
||||||
|
self.user = self._findUser(data)
|
||||||
|
self.player = self._findPlayer(data)
|
||||||
|
self.transcodeSession = self._findTranscodeSession(data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultTitle(self):
|
||||||
|
return self.grandparentTitle or self.parentTitle or self.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultThumb(self):
|
||||||
|
return self.grandparentThumb or self.parentThumb or self.thumb
|
||||||
|
|
||||||
|
@property
|
||||||
|
def videoStreams(self):
|
||||||
|
if self._videoStreams is None:
|
||||||
|
self._videoStreams = self._findStreams(plexstream.PlexStream.TYPE_VIDEO)
|
||||||
|
return self._videoStreams
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audioStreams(self):
|
||||||
|
if self._audioStreams is None:
|
||||||
|
self._audioStreams = self._findStreams(plexstream.PlexStream.TYPE_AUDIO)
|
||||||
|
return self._audioStreams
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitleStreams(self):
|
||||||
|
if self._subtitleStreams is None:
|
||||||
|
self._subtitleStreams = self._findStreams(plexstream.PlexStream.TYPE_SUBTITLE)
|
||||||
|
return self._subtitleStreams
|
||||||
|
|
||||||
|
@property
|
||||||
|
def isWatched(self):
|
||||||
|
return self.get('viewCount').asInt() > 0
|
||||||
|
|
||||||
|
def getStreamURL(self, **params):
|
||||||
|
return self._getStreamURL(**params)
|
||||||
|
|
||||||
|
def season(self):
|
||||||
|
if not self._season:
|
||||||
|
self._season = plexobjects.listItems(self.server, self.parentKey)[0]
|
||||||
|
return self._season
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
if not self._show:
|
||||||
|
self._show = plexobjects.listItems(self.server, self.grandparentKey)[0]
|
||||||
|
return self._show
|
||||||
|
|
||||||
|
@property
|
||||||
|
def genres(self):
|
||||||
|
return self.show().genres
|
||||||
|
|
||||||
|
@property
|
||||||
|
def roles(self):
|
||||||
|
return self.show().roles
|
||||||
|
|
||||||
|
@property
|
||||||
|
def related(self):
|
||||||
|
self.show().reload(_soft=True, includeRelated=1, includeRelatedCount=10)
|
||||||
|
return self.show().related
|
||||||
|
|
||||||
|
|
||||||
|
@plexobjects.registerLibType
|
||||||
|
class Clip(PlayableVideo):
|
||||||
|
TYPE = 'clip'
|
||||||
|
|
||||||
|
def _setData(self, data):
|
||||||
|
PlayableVideo._setData(self, data)
|
||||||
|
if self.isFullObject():
|
||||||
|
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||||
|
else:
|
||||||
|
if data.find(media.Media.TYPE) is not None:
|
||||||
|
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def isWatched(self):
|
||||||
|
return self.get('viewCount').asInt() > 0
|
||||||
|
|
||||||
|
def getStreamURL(self, **params):
|
||||||
|
return self._getStreamURL(**params)
|
9
resources/lib/user.py
Normal file
9
resources/lib/user.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
from logging import getLogger
|
||||||
|
import xbmc
|
||||||
|
import xbmcgui
|
||||||
|
|
||||||
|
from . import kodigui
|
||||||
|
from .. import backgroundthread, utils, plex_tv, variables as v
|
500
resources/lib/util.py
Normal file
500
resources/lib/util.py
Normal file
|
@ -0,0 +1,500 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
import gc
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
import contextlib
|
||||||
|
import urllib
|
||||||
|
|
||||||
|
from .kodijsonrpc import rpc
|
||||||
|
import xbmc
|
||||||
|
import xbmcgui
|
||||||
|
import xbmcaddon
|
||||||
|
|
||||||
|
from .plexnet import signalsmixin
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
|
_SHUTDOWN = False
|
||||||
|
|
||||||
|
ADDON = xbmcaddon.Addon()
|
||||||
|
|
||||||
|
PROFILE = xbmc.translatePath(ADDON.getAddonInfo('profile')).decode('utf-8')
|
||||||
|
|
||||||
|
SETTINGS_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class UtilityMonitor(xbmc.Monitor, signalsmixin.SignalsMixin):
|
||||||
|
def watchStatusChanged(self):
|
||||||
|
self.trigger('changed.watchstatus')
|
||||||
|
|
||||||
|
def onNotification(self, sender, method, data):
|
||||||
|
if (sender == 'plugin.video.plexkodiconnect' and
|
||||||
|
method.endswith('RESTORE')):
|
||||||
|
from .windows import kodigui
|
||||||
|
xbmc.executebuiltin('ActivateWindow({0})'.format(kodigui.BaseFunctions.lastWinID))
|
||||||
|
|
||||||
|
|
||||||
|
MONITOR = UtilityMonitor()
|
||||||
|
|
||||||
|
|
||||||
|
def T(ID, eng=''):
|
||||||
|
return ADDON.getLocalizedString(ID) or xbmc.getLocalizedString(ID)
|
||||||
|
|
||||||
|
|
||||||
|
def LOG(msg, level=xbmc.LOGNOTICE):
|
||||||
|
xbmc.log('PLEX: {0}'.format(msg), level)
|
||||||
|
|
||||||
|
|
||||||
|
def DEBUG_LOG(msg):
|
||||||
|
if _SHUTDOWN:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not getSetting('debug', False) and not xbmc.getCondVisibility('System.GetBool(debug.showloginfo)'):
|
||||||
|
return
|
||||||
|
|
||||||
|
LOG(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def ERROR(txt='', hide_tb=False, notify=False):
|
||||||
|
if isinstance(txt, str):
|
||||||
|
txt = txt.decode("utf-8")
|
||||||
|
short = str(sys.exc_info()[1])
|
||||||
|
if hide_tb:
|
||||||
|
xbmc.log('PLEX: {0} - {1}'.format(txt, short), xbmc.LOGERROR)
|
||||||
|
return short
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
xbmc.log("_________________________________________________________________________________", xbmc.LOGERROR)
|
||||||
|
xbmc.log('PLEX: ' + txt, xbmc.LOGERROR)
|
||||||
|
for l in tb.splitlines():
|
||||||
|
xbmc.log(' ' + l, xbmc.LOGERROR)
|
||||||
|
xbmc.log("_________________________________________________________________________________", xbmc.LOGERROR)
|
||||||
|
xbmc.log("`", xbmc.LOGERROR)
|
||||||
|
if notify:
|
||||||
|
showNotification('ERROR: {0}'.format(short))
|
||||||
|
return short
|
||||||
|
|
||||||
|
|
||||||
|
def TEST(msg):
|
||||||
|
xbmc.log('---TEST: {0}'.format(msg), xbmc.LOGNOTICE)
|
||||||
|
|
||||||
|
|
||||||
|
def getSetting(key, default=None):
|
||||||
|
with SETTINGS_LOCK:
|
||||||
|
setting = ADDON.getSetting(key)
|
||||||
|
return _processSetting(setting, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _processSetting(setting, default):
|
||||||
|
if not setting:
|
||||||
|
return default
|
||||||
|
if isinstance(default, bool):
|
||||||
|
return setting.lower() == 'true'
|
||||||
|
elif isinstance(default, float):
|
||||||
|
return float(setting)
|
||||||
|
elif isinstance(default, int):
|
||||||
|
return int(float(setting or 0))
|
||||||
|
elif isinstance(default, list):
|
||||||
|
if setting:
|
||||||
|
return json.loads(binascii.unhexlify(setting))
|
||||||
|
else:
|
||||||
|
return default
|
||||||
|
|
||||||
|
return setting
|
||||||
|
|
||||||
|
|
||||||
|
def setSetting(key, value):
|
||||||
|
with SETTINGS_LOCK:
|
||||||
|
value = _processSettingForWrite(value)
|
||||||
|
ADDON.setSetting(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _processSettingForWrite(value):
|
||||||
|
if isinstance(value, list):
|
||||||
|
value = binascii.hexlify(json.dumps(value))
|
||||||
|
elif isinstance(value, bool):
|
||||||
|
value = value and 'true' or 'false'
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def setGlobalProperty(key, val):
|
||||||
|
xbmcgui.Window(10000).setProperty(
|
||||||
|
'plugin.video.plexkodiconnect.{0}'.format(key), val)
|
||||||
|
|
||||||
|
|
||||||
|
def setGlobalBoolProperty(key, boolean):
|
||||||
|
xbmcgui.Window(10000).setProperty(
|
||||||
|
'plugin.video.plexkodiconnect.{0}'.format(key), boolean and '1' or '')
|
||||||
|
|
||||||
|
|
||||||
|
def getGlobalProperty(key):
|
||||||
|
return xbmc.getInfoLabel(
|
||||||
|
'Window(10000).Property(plugin.video.plexkodiconnect.{0})'.format(key))
|
||||||
|
|
||||||
|
|
||||||
|
def showNotification(message, time_ms=3000, icon_path=None, header=ADDON.getAddonInfo('name')):
|
||||||
|
try:
|
||||||
|
icon_path = icon_path or xbmc.translatePath(ADDON.getAddonInfo('icon')).decode('utf-8')
|
||||||
|
xbmc.executebuiltin('Notification({0},{1},{2},{3})'.format(header, message, time_ms, icon_path))
|
||||||
|
except RuntimeError: # Happens when disabling the addon
|
||||||
|
LOG(message)
|
||||||
|
|
||||||
|
|
||||||
|
def videoIsPlaying():
|
||||||
|
return xbmc.getCondVisibility('Player.HasVideo')
|
||||||
|
|
||||||
|
|
||||||
|
def messageDialog(heading='Message', msg=''):
|
||||||
|
from .windows import optionsdialog
|
||||||
|
optionsdialog.show(heading, msg, 'OK')
|
||||||
|
|
||||||
|
|
||||||
|
def showTextDialog(heading, text):
|
||||||
|
t = TextBox()
|
||||||
|
t.setControls(heading, text)
|
||||||
|
|
||||||
|
|
||||||
|
def sortTitle(title):
|
||||||
|
return title.startswith('The ') and title[4:] or title
|
||||||
|
|
||||||
|
|
||||||
|
def durationToText(seconds):
|
||||||
|
"""
|
||||||
|
Converts seconds to a short user friendly string
|
||||||
|
Example: 143 -> 2m 23s
|
||||||
|
"""
|
||||||
|
days = int(seconds / 86400000)
|
||||||
|
if days:
|
||||||
|
return '{0} day{1}'.format(days, days > 1 and 's' or '')
|
||||||
|
left = seconds % 86400000
|
||||||
|
hours = int(left / 3600000)
|
||||||
|
if hours:
|
||||||
|
hours = '{0} hr{1} '.format(hours, hours > 1 and 's' or '')
|
||||||
|
else:
|
||||||
|
hours = ''
|
||||||
|
left = left % 3600000
|
||||||
|
mins = int(left / 60000)
|
||||||
|
if mins:
|
||||||
|
return hours + '{0} min{1}'.format(mins, mins > 1 and 's' or '')
|
||||||
|
elif hours:
|
||||||
|
return hours.rstrip()
|
||||||
|
secs = int(left % 60000)
|
||||||
|
if secs:
|
||||||
|
secs /= 1000
|
||||||
|
return '{0} sec{1}'.format(secs, secs > 1 and 's' or '')
|
||||||
|
return '0 seconds'
|
||||||
|
|
||||||
|
|
||||||
|
def durationToShortText(seconds):
|
||||||
|
"""
|
||||||
|
Converts seconds to a short user friendly string
|
||||||
|
Example: 143 -> 2m 23s
|
||||||
|
"""
|
||||||
|
days = int(seconds / 86400000)
|
||||||
|
if days:
|
||||||
|
return '{0} d'.format(days)
|
||||||
|
left = seconds % 86400000
|
||||||
|
hours = int(left / 3600000)
|
||||||
|
if hours:
|
||||||
|
hours = '{0} h '.format(hours)
|
||||||
|
else:
|
||||||
|
hours = ''
|
||||||
|
left = left % 3600000
|
||||||
|
mins = int(left / 60000)
|
||||||
|
if mins:
|
||||||
|
return hours + '{0} m'.format(mins)
|
||||||
|
elif hours:
|
||||||
|
return hours.rstrip()
|
||||||
|
secs = int(left % 60000)
|
||||||
|
if secs:
|
||||||
|
secs /= 1000
|
||||||
|
return '{0} s'.format(secs)
|
||||||
|
return '0 s'
|
||||||
|
|
||||||
|
|
||||||
|
def cleanLeadingZeros(text):
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
return re.sub('(?<= )0(\d)', r'\1', text)
|
||||||
|
|
||||||
|
|
||||||
|
def removeDups(dlist):
|
||||||
|
return [ii for n, ii in enumerate(dlist) if ii not in dlist[:n]]
|
||||||
|
|
||||||
|
|
||||||
|
SIZE_NAMES = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
||||||
|
|
||||||
|
|
||||||
|
def simpleSize(size):
|
||||||
|
"""
|
||||||
|
Converts bytes to a short user friendly string
|
||||||
|
Example: 12345 -> 12.06 KB
|
||||||
|
"""
|
||||||
|
s = 0
|
||||||
|
if size > 0:
|
||||||
|
i = int(math.floor(math.log(size, 1024)))
|
||||||
|
p = math.pow(1024, i)
|
||||||
|
s = round(size / p, 2)
|
||||||
|
if (s > 0):
|
||||||
|
return '%s %s' % (s, SIZE_NAMES[i])
|
||||||
|
else:
|
||||||
|
return '0B'
|
||||||
|
|
||||||
|
|
||||||
|
def timeDisplay(ms):
|
||||||
|
h = ms / 3600000
|
||||||
|
m = (ms % 3600000) / 60000
|
||||||
|
s = (ms % 60000) / 1000
|
||||||
|
return '{0:0>2}:{1:0>2}:{2:0>2}'.format(h, m, s)
|
||||||
|
|
||||||
|
|
||||||
|
def simplifiedTimeDisplay(ms):
|
||||||
|
left, right = timeDisplay(ms).rsplit(':', 1)
|
||||||
|
left = left.lstrip('0:') or '0'
|
||||||
|
return left + ':' + right
|
||||||
|
|
||||||
|
|
||||||
|
def shortenText(text, size):
|
||||||
|
if len(text) < size:
|
||||||
|
return text
|
||||||
|
|
||||||
|
return u'{0}\u2026'.format(text[:size - 1])
|
||||||
|
|
||||||
|
|
||||||
|
class TextBox:
|
||||||
|
# constants
|
||||||
|
WINDOW = 10147
|
||||||
|
CONTROL_LABEL = 1
|
||||||
|
CONTROL_TEXTBOX = 5
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# activate the text viewer window
|
||||||
|
xbmc.executebuiltin("ActivateWindow(%d)" % (self.WINDOW, ))
|
||||||
|
# get window
|
||||||
|
self.win = xbmcgui.Window(self.WINDOW)
|
||||||
|
# give window time to initialize
|
||||||
|
xbmc.sleep(1000)
|
||||||
|
|
||||||
|
def setControls(self, heading, text):
|
||||||
|
# set heading
|
||||||
|
self.win.getControl(self.CONTROL_LABEL).setLabel(heading)
|
||||||
|
# set text
|
||||||
|
self.win.getControl(self.CONTROL_TEXTBOX).setText(text)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingControl:
|
||||||
|
def __init__(self, setting, log_display, disable_value=''):
|
||||||
|
self.setting = setting
|
||||||
|
self.logDisplay = log_display
|
||||||
|
self.disableValue = disable_value
|
||||||
|
self._originalMode = None
|
||||||
|
self.store()
|
||||||
|
|
||||||
|
def disable(self):
|
||||||
|
rpc.Settings.SetSettingValue(setting=self.setting, value=self.disableValue)
|
||||||
|
DEBUG_LOG('{0}: DISABLED'.format(self.logDisplay))
|
||||||
|
|
||||||
|
def set(self, value):
|
||||||
|
rpc.Settings.SetSettingValue(setting=self.setting, value=value)
|
||||||
|
DEBUG_LOG('{0}: SET={1}'.format(self.logDisplay, value))
|
||||||
|
|
||||||
|
def store(self):
|
||||||
|
try:
|
||||||
|
self._originalMode = rpc.Settings.GetSettingValue(setting=self.setting).get('value')
|
||||||
|
DEBUG_LOG('{0}: Mode stored ({1})'.format(self.logDisplay, self._originalMode))
|
||||||
|
except:
|
||||||
|
ERROR()
|
||||||
|
|
||||||
|
def restore(self):
|
||||||
|
if self._originalMode is None:
|
||||||
|
return
|
||||||
|
rpc.Settings.SetSettingValue(setting=self.setting, value=self._originalMode)
|
||||||
|
DEBUG_LOG('{0}: RESTORED'.format(self.logDisplay))
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def suspend(self):
|
||||||
|
self.disable()
|
||||||
|
yield
|
||||||
|
self.restore()
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def save(self):
|
||||||
|
yield
|
||||||
|
self.restore()
|
||||||
|
|
||||||
|
|
||||||
|
def timeInDayLocalSeconds():
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
sod = datetime.datetime(year=now.year, month=now.month, day=now.day)
|
||||||
|
sod = int(time.mktime(sod.timetuple()))
|
||||||
|
return int(time.time() - sod)
|
||||||
|
|
||||||
|
|
||||||
|
CRON = None
|
||||||
|
|
||||||
|
|
||||||
|
class CronReceiver():
|
||||||
|
def tick(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def halfHour(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def day(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Cron(threading.Thread):
|
||||||
|
def __init__(self, interval):
|
||||||
|
threading.Thread.__init__(self, name='CRON')
|
||||||
|
self.stopped = threading.Event()
|
||||||
|
self.force = threading.Event()
|
||||||
|
self.interval = interval
|
||||||
|
self._lastHalfHour = self._getHalfHour()
|
||||||
|
self._receivers = []
|
||||||
|
|
||||||
|
global CRON
|
||||||
|
|
||||||
|
CRON = self
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.start()
|
||||||
|
DEBUG_LOG('Cron started')
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
|
self.stop()
|
||||||
|
self.join()
|
||||||
|
|
||||||
|
def _wait(self):
|
||||||
|
ct = 0
|
||||||
|
while ct < self.interval:
|
||||||
|
xbmc.sleep(100)
|
||||||
|
ct += 0.1
|
||||||
|
if self.force.isSet():
|
||||||
|
self.force.clear()
|
||||||
|
return True
|
||||||
|
if xbmc.abortRequested or self.stopped.isSet():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def forceTick(self):
|
||||||
|
self.force.set()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.stopped.set()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
while self._wait():
|
||||||
|
self._tick()
|
||||||
|
DEBUG_LOG('Cron stopped')
|
||||||
|
|
||||||
|
def _getHalfHour(self):
|
||||||
|
tid = timeInDayLocalSeconds() / 60
|
||||||
|
return tid - (tid % 30)
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
receivers = list(self._receivers)
|
||||||
|
receivers = self._halfHour(receivers)
|
||||||
|
for r in receivers:
|
||||||
|
try:
|
||||||
|
r.tick()
|
||||||
|
except:
|
||||||
|
ERROR()
|
||||||
|
|
||||||
|
def _halfHour(self, receivers):
|
||||||
|
hh = self._getHalfHour()
|
||||||
|
if hh == self._lastHalfHour:
|
||||||
|
return receivers
|
||||||
|
try:
|
||||||
|
receivers = self._day(receivers, hh)
|
||||||
|
ret = []
|
||||||
|
for r in receivers:
|
||||||
|
try:
|
||||||
|
if not r.halfHour():
|
||||||
|
ret.append(r)
|
||||||
|
except:
|
||||||
|
ret.append(r)
|
||||||
|
ERROR()
|
||||||
|
return ret
|
||||||
|
finally:
|
||||||
|
self._lastHalfHour = hh
|
||||||
|
|
||||||
|
def _day(self, receivers, hh):
|
||||||
|
if hh >= self._lastHalfHour:
|
||||||
|
return receivers
|
||||||
|
ret = []
|
||||||
|
for r in receivers:
|
||||||
|
try:
|
||||||
|
if not r.day():
|
||||||
|
ret.append(r)
|
||||||
|
except:
|
||||||
|
ret.append(r)
|
||||||
|
ERROR()
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def registerReceiver(self, receiver):
|
||||||
|
if receiver not in self._receivers:
|
||||||
|
DEBUG_LOG('Cron: Receiver added: {0}'.format(receiver))
|
||||||
|
self._receivers.append(receiver)
|
||||||
|
|
||||||
|
def cancelReceiver(self, receiver):
|
||||||
|
if receiver in self._receivers:
|
||||||
|
DEBUG_LOG('Cron: Receiver canceled: {0}'.format(receiver))
|
||||||
|
self._receivers.pop(self._receivers.index(receiver))
|
||||||
|
|
||||||
|
|
||||||
|
def getPlatform():
|
||||||
|
for key in [
|
||||||
|
'System.Platform.Android',
|
||||||
|
'System.Platform.Linux.RaspberryPi',
|
||||||
|
'System.Platform.Linux',
|
||||||
|
'System.Platform.Windows',
|
||||||
|
'System.Platform.OSX',
|
||||||
|
'System.Platform.IOS',
|
||||||
|
'System.Platform.Darwin',
|
||||||
|
'System.Platform.ATV2'
|
||||||
|
]:
|
||||||
|
if xbmc.getCondVisibility(key):
|
||||||
|
return key.rsplit('.', 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def getProgressImage(obj):
|
||||||
|
if not obj.get('viewOffset'):
|
||||||
|
return ''
|
||||||
|
pct = int((obj.viewOffset.asInt() / obj.duration.asFloat()) * 100)
|
||||||
|
pct = pct - pct % 2 # Round to even number - we have even numbered progress only
|
||||||
|
return 'plugin.video.plexkodiconnect/progress/{0}.png'.format(pct)
|
||||||
|
|
||||||
|
|
||||||
|
def trackIsPlaying(track):
|
||||||
|
return xbmc.getCondVisibility('String.StartsWith(MusicPlayer.Comment,{0})'.format('PLEX-{0}:'.format(track.ratingKey)))
|
||||||
|
|
||||||
|
|
||||||
|
def addURLParams(url, params):
|
||||||
|
if '?' in url:
|
||||||
|
url += '&'
|
||||||
|
else:
|
||||||
|
url += '?'
|
||||||
|
url += urllib.urlencode(params)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def garbageCollect():
|
||||||
|
gc.collect(2)
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown():
|
||||||
|
global MONITOR, ADDON, T, _SHUTDOWN
|
||||||
|
_SHUTDOWN = True
|
||||||
|
del MONITOR
|
||||||
|
del T
|
||||||
|
del ADDON
|
|
@ -42,3 +42,27 @@ def setSplash(on=True):
|
||||||
|
|
||||||
def setShutdown(on=True):
|
def setShutdown(on=True):
|
||||||
utils.setGlobalProperty('background.shutdown', on and '1' or '')
|
utils.setGlobalProperty('background.shutdown', on and '1' or '')
|
||||||
|
|
||||||
|
|
||||||
|
class BackgroundContext(object):
|
||||||
|
"""
|
||||||
|
Context Manager to open a Plex background window - in the background. This
|
||||||
|
will e.g. ensure that you can capture key-presses
|
||||||
|
Use like this:
|
||||||
|
with BackgroundContext(function) as d:
|
||||||
|
<now function will be executed immediately. Get its results:>
|
||||||
|
result = d.result
|
||||||
|
"""
|
||||||
|
def __init__(self, function=None):
|
||||||
|
self.window = None
|
||||||
|
self.result = None
|
||||||
|
self.function = function
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.window = BackgroundWindow.create(function=self.function)
|
||||||
|
self.window.modal()
|
||||||
|
self.result = self.window.result
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
del self.window
|
||||||
|
|
202
resources/lib/windows/userselect-old.py
Normal file
202
resources/lib/windows/userselect-old.py
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
:module: plexkodiconnect.userselect
|
||||||
|
:synopsis: This module shows a dialog to let one choose between different Plex
|
||||||
|
(home) users
|
||||||
|
"""
|
||||||
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
from logging import getLogger
|
||||||
|
import xbmc
|
||||||
|
import xbmcgui
|
||||||
|
|
||||||
|
from . import kodigui
|
||||||
|
from .. import backgroundthread, utils, plex_tv, variables as v
|
||||||
|
|
||||||
|
LOG = getLogger('PLEX.' + __name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UserThumbTask(backgroundthread.Task):
|
||||||
|
def setup(self, users, callback):
|
||||||
|
self.users = users
|
||||||
|
self.callback = callback
|
||||||
|
return self
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
for user in self.users:
|
||||||
|
if self.isCanceled():
|
||||||
|
return
|
||||||
|
thumb, back = user.thumb, ''
|
||||||
|
self.callback(user, thumb, back)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSelectWindow(kodigui.BaseWindow):
|
||||||
|
xmlFile = 'script-plex-user_select.xml'
|
||||||
|
path = v.ADDON_PATH
|
||||||
|
theme = 'Main'
|
||||||
|
res = '1080i'
|
||||||
|
width = 1920
|
||||||
|
height = 1080
|
||||||
|
|
||||||
|
USER_LIST_ID = 101
|
||||||
|
PIN_ENTRY_GROUP_ID = 400
|
||||||
|
HOME_BUTTON_ID = 500
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.task = None
|
||||||
|
self.user = None
|
||||||
|
self.aborted = False
|
||||||
|
kodigui.BaseWindow.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
|
def onFirstInit(self):
|
||||||
|
self.userList = kodigui.ManagedControlList(self, self.USER_LIST_ID, 6)
|
||||||
|
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def onAction(self, action):
|
||||||
|
try:
|
||||||
|
ID = action.getId()
|
||||||
|
if 57 < ID < 68:
|
||||||
|
if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.PIN_ENTRY_GROUP_ID)):
|
||||||
|
item = self.userList.getSelectedItem()
|
||||||
|
if not item.dataSource.isProtected:
|
||||||
|
return
|
||||||
|
self.setFocusId(self.PIN_ENTRY_GROUP_ID)
|
||||||
|
self.pinEntryClicked(ID + 142)
|
||||||
|
return
|
||||||
|
elif 142 <= ID <= 149: # JumpSMS action
|
||||||
|
if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.PIN_ENTRY_GROUP_ID)):
|
||||||
|
item = self.userList.getSelectedItem()
|
||||||
|
if not item.dataSource.isProtected:
|
||||||
|
return
|
||||||
|
self.setFocusId(self.PIN_ENTRY_GROUP_ID)
|
||||||
|
self.pinEntryClicked(ID + 60)
|
||||||
|
return
|
||||||
|
elif ID in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_BACKSPACE):
|
||||||
|
if xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.PIN_ENTRY_GROUP_ID)):
|
||||||
|
self.pinEntryClicked(211)
|
||||||
|
return
|
||||||
|
except:
|
||||||
|
utils.ERROR()
|
||||||
|
|
||||||
|
kodigui.BaseWindow.onAction(self, action)
|
||||||
|
|
||||||
|
def onClick(self, controlID):
|
||||||
|
if controlID == self.USER_LIST_ID:
|
||||||
|
item = self.userList.getSelectedItem()
|
||||||
|
if item.dataSource.isProtected:
|
||||||
|
self.setFocusId(self.PIN_ENTRY_GROUP_ID)
|
||||||
|
else:
|
||||||
|
self.userSelected(item)
|
||||||
|
elif 200 < controlID < 212:
|
||||||
|
self.pinEntryClicked(controlID)
|
||||||
|
elif controlID == self.HOME_BUTTON_ID:
|
||||||
|
self.home_button_clicked()
|
||||||
|
|
||||||
|
def onFocus(self, controlID):
|
||||||
|
if controlID == self.USER_LIST_ID:
|
||||||
|
item = self.userList.getSelectedItem()
|
||||||
|
item.setProperty('editing.pin', '')
|
||||||
|
|
||||||
|
def userThumbCallback(self, user, thumb, back):
|
||||||
|
item = self.userList.getListItemByDataSource(user)
|
||||||
|
if item:
|
||||||
|
item.setThumbnailImage(thumb)
|
||||||
|
item.setProperty('back.image', back)
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.setProperty('busy', '1')
|
||||||
|
try:
|
||||||
|
users = plex_tv.plex_home_users(utils.settings('plexToken'))
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for user in users:
|
||||||
|
# thumb, back = image.getImage(user.thumb, user.id)
|
||||||
|
# mli = kodigui.ManagedListItem(user.title, thumbnailImage=thumb, data_source=user)
|
||||||
|
mli = kodigui.ManagedListItem(user.title, user.title[0].upper(), data_source=user)
|
||||||
|
mli.setProperty('pin', user.title)
|
||||||
|
# mli.setProperty('back.image', back)
|
||||||
|
mli.setProperty('protected', user.isProtected and '1' or '')
|
||||||
|
mli.setProperty('admin', user.isAdmin and '1' or '')
|
||||||
|
items.append(mli)
|
||||||
|
|
||||||
|
self.userList.addItems(items)
|
||||||
|
self.task = UserThumbTask().setup(users, self.userThumbCallback)
|
||||||
|
backgroundthread.BGThreader.addTask(self.task)
|
||||||
|
|
||||||
|
self.setFocusId(self.USER_LIST_ID)
|
||||||
|
self.setProperty('initialized', '1')
|
||||||
|
finally:
|
||||||
|
self.setProperty('busy', '')
|
||||||
|
|
||||||
|
def home_button_clicked(self):
|
||||||
|
"""
|
||||||
|
Action taken if user clicked the home button
|
||||||
|
"""
|
||||||
|
self.user = None
|
||||||
|
self.aborted = True
|
||||||
|
self.doClose()
|
||||||
|
|
||||||
|
def pinEntryClicked(self, controlID):
|
||||||
|
item = self.userList.getSelectedItem()
|
||||||
|
pin = item.getProperty('editing.pin') or ''
|
||||||
|
|
||||||
|
if len(pin) > 3:
|
||||||
|
return
|
||||||
|
|
||||||
|
if controlID < 210:
|
||||||
|
pin += str(controlID - 200)
|
||||||
|
elif controlID == 210:
|
||||||
|
pin += '0'
|
||||||
|
elif controlID == 211:
|
||||||
|
pin = pin[:-1]
|
||||||
|
|
||||||
|
if pin:
|
||||||
|
item.setProperty('pin', ' '.join(list(u"\u2022" * len(pin))))
|
||||||
|
item.setProperty('editing.pin', pin)
|
||||||
|
if len(pin) > 3:
|
||||||
|
self.userSelected(item, pin)
|
||||||
|
else:
|
||||||
|
item.setProperty('pin', item.dataSource.title)
|
||||||
|
item.setProperty('editing.pin', '')
|
||||||
|
|
||||||
|
def userSelected(self, item, pin=None):
|
||||||
|
self.user = item.dataSource
|
||||||
|
LOG.info('Home user selected: %s', self.user)
|
||||||
|
self.user.authToken = plex_tv.switch_home_user(
|
||||||
|
self.user.id,
|
||||||
|
pin,
|
||||||
|
utils.settings('plexToken'),
|
||||||
|
utils.settings('plex_machineIdentifier'))
|
||||||
|
if self.user.authToken is None:
|
||||||
|
self.user = None
|
||||||
|
item.setProperty('pin', item.dataSource.title)
|
||||||
|
item.setProperty('editing.pin', '')
|
||||||
|
# 'Error': 'Login failed with plex.tv for user'
|
||||||
|
utils.messageDialog(utils.lang(30135),
|
||||||
|
'%s %s' % (utils.lang(39229),
|
||||||
|
self.user.username))
|
||||||
|
return
|
||||||
|
self.doClose()
|
||||||
|
|
||||||
|
def finished(self):
|
||||||
|
if self.task:
|
||||||
|
self.task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def start():
|
||||||
|
"""
|
||||||
|
Hit this function to open a dialog to choose the Plex user
|
||||||
|
|
||||||
|
Returns
|
||||||
|
=======
|
||||||
|
tuple (user, aborted)
|
||||||
|
user : HomeUser
|
||||||
|
Or None if user switch failed or aborted by the user)
|
||||||
|
aborted : bool
|
||||||
|
True if the user cancelled the dialog
|
||||||
|
"""
|
||||||
|
w = UserSelectWindow.open()
|
||||||
|
user, aborted = w.user, w.aborted
|
||||||
|
del w
|
||||||
|
return user, aborted
|
|
@ -6,14 +6,14 @@
|
||||||
(home) users
|
(home) users
|
||||||
"""
|
"""
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
from logging import getLogger
|
|
||||||
import xbmc
|
import xbmc
|
||||||
import xbmcgui
|
import xbmcgui
|
||||||
|
|
||||||
from . import kodigui
|
from . import kodigui
|
||||||
from .. import backgroundthread, utils, plex_tv, variables as v
|
from .. import util, image, backgroundthread
|
||||||
|
from ..util import T
|
||||||
|
from ..plexnet import plexapp
|
||||||
|
|
||||||
LOG = getLogger('PLEX.' + __name__)
|
|
||||||
|
|
||||||
|
|
||||||
class UserThumbTask(backgroundthread.Task):
|
class UserThumbTask(backgroundthread.Task):
|
||||||
|
@ -26,13 +26,14 @@ class UserThumbTask(backgroundthread.Task):
|
||||||
for user in self.users:
|
for user in self.users:
|
||||||
if self.isCanceled():
|
if self.isCanceled():
|
||||||
return
|
return
|
||||||
thumb, back = user.thumb, ''
|
|
||||||
|
thumb, back = image.getImage(user.thumb, user.id)
|
||||||
self.callback(user, thumb, back)
|
self.callback(user, thumb, back)
|
||||||
|
|
||||||
|
|
||||||
class UserSelectWindow(kodigui.BaseWindow):
|
class UserSelectWindow(kodigui.BaseWindow):
|
||||||
xmlFile = 'script-plex-user_select.xml'
|
xmlFile = 'script-plex-user_select.xml'
|
||||||
path = v.ADDON_PATH
|
path = util.ADDON.getAddonInfo('path')
|
||||||
theme = 'Main'
|
theme = 'Main'
|
||||||
res = '1080i'
|
res = '1080i'
|
||||||
width = 1920
|
width = 1920
|
||||||
|
@ -44,8 +45,7 @@ class UserSelectWindow(kodigui.BaseWindow):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.task = None
|
self.task = None
|
||||||
self.user = None
|
self.selected = False
|
||||||
self.aborted = False
|
|
||||||
kodigui.BaseWindow.__init__(self, *args, **kwargs)
|
kodigui.BaseWindow.__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
def onFirstInit(self):
|
def onFirstInit(self):
|
||||||
|
@ -77,7 +77,7 @@ class UserSelectWindow(kodigui.BaseWindow):
|
||||||
self.pinEntryClicked(211)
|
self.pinEntryClicked(211)
|
||||||
return
|
return
|
||||||
except:
|
except:
|
||||||
utils.ERROR()
|
util.ERROR()
|
||||||
|
|
||||||
kodigui.BaseWindow.onAction(self, action)
|
kodigui.BaseWindow.onAction(self, action)
|
||||||
|
|
||||||
|
@ -91,7 +91,7 @@ class UserSelectWindow(kodigui.BaseWindow):
|
||||||
elif 200 < controlID < 212:
|
elif 200 < controlID < 212:
|
||||||
self.pinEntryClicked(controlID)
|
self.pinEntryClicked(controlID)
|
||||||
elif controlID == self.HOME_BUTTON_ID:
|
elif controlID == self.HOME_BUTTON_ID:
|
||||||
self.home_button_clicked()
|
self.shutdownClicked()
|
||||||
|
|
||||||
def onFocus(self, controlID):
|
def onFocus(self, controlID):
|
||||||
if controlID == self.USER_LIST_ID:
|
if controlID == self.USER_LIST_ID:
|
||||||
|
@ -107,7 +107,7 @@ class UserSelectWindow(kodigui.BaseWindow):
|
||||||
def start(self):
|
def start(self):
|
||||||
self.setProperty('busy', '1')
|
self.setProperty('busy', '1')
|
||||||
try:
|
try:
|
||||||
users = plex_tv.plex_home_users(utils.settings('plexToken'))
|
users = plexapp.ACCOUNT.homeUsers
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for user in users:
|
for user in users:
|
||||||
|
@ -133,13 +133,15 @@ class UserSelectWindow(kodigui.BaseWindow):
|
||||||
"""
|
"""
|
||||||
Action taken if user clicked the home button
|
Action taken if user clicked the home button
|
||||||
"""
|
"""
|
||||||
self.user = None
|
self.selected = False
|
||||||
self.aborted = True
|
|
||||||
self.doClose()
|
self.doClose()
|
||||||
|
|
||||||
def pinEntryClicked(self, controlID):
|
def pinEntryClicked(self, controlID):
|
||||||
item = self.userList.getSelectedItem()
|
item = self.userList.getSelectedItem()
|
||||||
pin = item.getProperty('editing.pin') or ''
|
if item.getProperty('editing.pin'):
|
||||||
|
pin = item.getProperty('editing.pin')
|
||||||
|
else:
|
||||||
|
pin = ''
|
||||||
|
|
||||||
if len(pin) > 3:
|
if len(pin) > 3:
|
||||||
return
|
return
|
||||||
|
@ -161,22 +163,24 @@ class UserSelectWindow(kodigui.BaseWindow):
|
||||||
item.setProperty('editing.pin', '')
|
item.setProperty('editing.pin', '')
|
||||||
|
|
||||||
def userSelected(self, item, pin=None):
|
def userSelected(self, item, pin=None):
|
||||||
self.user = item.dataSource
|
user = item.dataSource
|
||||||
LOG.info('Home user selected: %s', self.user)
|
# xbmc.sleep(500)
|
||||||
self.user.authToken = plex_tv.switch_home_user(
|
util.DEBUG_LOG('Home user selected: {0}'.format(user))
|
||||||
self.user.id,
|
|
||||||
pin,
|
from .. import plex
|
||||||
utils.settings('plexToken'),
|
with plex.CallbackEvent(plexapp.APP, 'account:response') as e:
|
||||||
utils.settings('plex_machineIdentifier'))
|
if plexapp.ACCOUNT.switchHomeUser(user.id, pin) and plexapp.ACCOUNT.switchUser:
|
||||||
if self.user.authToken is None:
|
util.DEBUG_LOG('Waiting for user change...')
|
||||||
self.user = None
|
else:
|
||||||
item.setProperty('pin', item.dataSource.title)
|
e.close()
|
||||||
item.setProperty('editing.pin', '')
|
item.setProperty('pin', item.dataSource.title)
|
||||||
# 'Error': 'Login failed with plex.tv for user'
|
item.setProperty('editing.pin', '')
|
||||||
utils.messageDialog(utils.lang(30135),
|
util.messageDialog(T(30135, 'Error'),
|
||||||
'%s %s' % (utils.lang(39229),
|
'%s %s' % (T(39229, 'Login failed with plex.tv for user'),
|
||||||
self.user.username))
|
self.user.username))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self.selected = True
|
||||||
self.doClose()
|
self.doClose()
|
||||||
|
|
||||||
def finished(self):
|
def finished(self):
|
||||||
|
@ -185,18 +189,7 @@ class UserSelectWindow(kodigui.BaseWindow):
|
||||||
|
|
||||||
|
|
||||||
def start():
|
def start():
|
||||||
"""
|
|
||||||
Hit this function to open a dialog to choose the Plex user
|
|
||||||
|
|
||||||
Returns
|
|
||||||
=======
|
|
||||||
tuple (user, aborted)
|
|
||||||
user : HomeUser
|
|
||||||
Or None if user switch failed or aborted by the user)
|
|
||||||
aborted : bool
|
|
||||||
True if the user cancelled the dialog
|
|
||||||
"""
|
|
||||||
w = UserSelectWindow.open()
|
w = UserSelectWindow.open()
|
||||||
user, aborted = w.user, w.aborted
|
selected = w.selected
|
||||||
del w
|
del w
|
||||||
return user, aborted
|
return selected
|
||||||
|
|
36
service.py
36
service.py
|
@ -1,8 +1,38 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
###############################################################################
|
###############################################################################
|
||||||
from __future__ import absolute_import, division, unicode_literals
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
from resources.lib import service_entry
|
import xbmc
|
||||||
|
import xbmcgui
|
||||||
|
import xbmcaddon
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def start():
|
||||||
service_entry.start()
|
# Safety net - Kodi starts PKC twice upon first installation!
|
||||||
|
if xbmc.getInfoLabel(
|
||||||
|
'Window(10000).Property(plugin.video.plexkodiconnect.running)').decode('utf-8') == '1':
|
||||||
|
xbmc.log('PLEX: PlexKodiConnect is already running',
|
||||||
|
level=xbmc.LOGWARNING)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
xbmcgui.Window(10000).setProperty(
|
||||||
|
'plugin.video.plexkodiconnect.running', '1')
|
||||||
|
try:
|
||||||
|
# We might have to wait a bit before starting PKC
|
||||||
|
delay = int(xbmcaddon.Addon(
|
||||||
|
id='plugin.video.plexkodiconnect').getSetting('startupDelay'))
|
||||||
|
xbmc.log('PLEX: Delaying PKC startup by: %s seconds'.format(delay),
|
||||||
|
level=xbmc.LOGNOTICE)
|
||||||
|
if delay and xbmc.Monitor().waitForAbort(delay):
|
||||||
|
xbmc.log('PLEX: Kodi shutdown while waiting for PKC startup',
|
||||||
|
level=xbmc.LOGWARNING)
|
||||||
|
return
|
||||||
|
from resources.lib import main
|
||||||
|
main.main()
|
||||||
|
finally:
|
||||||
|
xbmcgui.Window(10000).setProperty(
|
||||||
|
'plugin.video.plexkodiconnect.running', '')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
start()
|
||||||
|
|
Loading…
Reference in a new issue