Compare commits

...

18 Commits

Author SHA1 Message Date
croneter b988344c9b Basic recoding 2018-10-07 18:00:46 +02:00
croneter 517f41b534 Merge branch 'beta-version' into plex_for_kodi 2018-10-07 18:00:07 +02:00
croneter ae13a6f3cc Remove obsolete import 2018-10-07 12:10:00 +02:00
croneter b27a846292 Enable original Plex user select dialog 2018-10-07 12:09:27 +02:00
croneter c26e1283db Merge branch 'beta-version' into plex_for_kodi 2018-10-06 15:56:19 +02:00
croneter b6dc458b54 Streamline some code 2018-10-06 13:32:18 +02:00
croneter 9eba24485e Merge branch 'beta-version' into plex_for_kodi 2018-10-06 13:31:24 +02:00
croneter a547dd790c Clean code 2018-10-02 07:56:43 +02:00
croneter 3971e24b95 Nicer friendly name 2018-10-02 07:49:29 +02:00
croneter a58d284108 Switch to PKC 2018-10-02 07:48:34 +02:00
croneter 3388765b63 Switch global info to PKC 2018-10-02 07:46:30 +02:00
croneter 1a55301f24 Fix logging 2018-10-02 07:43:34 +02:00
croneter 504044b283 Fix passing back results from Background window 2018-10-02 07:37:31 +02:00
croneter 3045ecbccd Don't show pre-signin window 2018-10-01 07:57:55 +02:00
croneter dbe0339b71 Reprogram part 1 2018-09-30 17:35:23 +02:00
croneter 5cdda0e334 Revert "First attempt at server select dialog"
This reverts commit 59040f3b3e.
2018-09-30 13:16:51 +02:00
croneter 59040f3b3e First attempt at server select dialog 2018-09-30 13:16:48 +02:00
croneter 32927931c4 Add plexnet module 2018-09-30 13:16:17 +02:00
65 changed files with 12271 additions and 63 deletions

12
resources/lib/image.py Normal file
View 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, ''

View 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()

View File

@ -3,8 +3,15 @@
from __future__ import absolute_import, division, unicode_literals
import logging
import sys
import threading
import gc
import xbmc
from . import plex, util, backgroundthread
from .plexnet import plexapp, threadutils
from .windows import userselect
from . import utils
from . import userclient
from . import initialsetup
@ -23,7 +30,7 @@ from . import loghandler
###############################################################################
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)
def start():
# Safety net - Kody starts PKC twice upon first installation!
if utils.window('plex_service_started') == 'true':
EXIT = True
else:
utils.window('plex_service_started', value='true')
EXIT = False
def waitForThreads():
LOG.debug('Checking for any remaining threads')
while len(threading.enumerate()) > 1:
for t in threading.enumerate():
if t != threading.currentThread():
if t.isAlive():
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)
if EXIT:
LOG.error('PKC service.py already started - exiting this instance')
elif DELAY and xbmc.Monitor().waitForAbort(DELAY):
# Start the service
LOG.info("Abort requested while waiting. PKC not started.")
else:
Service().ServiceEntryPoint()
def signout():
util.setSetting('auth.token', '')
LOG.info('Signing out...')
plexapp.ACCOUNT.signOut()
def main():
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
View 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

View File

View 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()

View 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')

View 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()

View 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()

View 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()

View 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

View 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

View 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))
'''

View 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

View 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()

View 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'

View 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__()

View 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

View 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

View 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()

View 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()

View 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")

View 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)

View 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

View 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_')]

View 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

View 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)

View 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
knowwhether 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)

View 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

View 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

View 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())

View 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

View 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
}

View 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

View 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

View 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

View 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

View 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)

View 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))

View 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

View 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

View 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()

View 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")

View 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

View 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'

View File

@ -0,0 +1 @@
from .task import Task

View 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)

View 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

View 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)

View 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')

View 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

View 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

View 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)

View 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'))

View 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()

View 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'))

View 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

View 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
View 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
View 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

View File

@ -42,3 +42,27 @@ def setSplash(on=True):
def setShutdown(on=True):
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

View 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

View File

@ -6,14 +6,14 @@
(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
from .. import util, image, backgroundthread
from ..util import T
from ..plexnet import plexapp
LOG = getLogger('PLEX.' + __name__)
class UserThumbTask(backgroundthread.Task):
@ -26,13 +26,14 @@ class UserThumbTask(backgroundthread.Task):
for user in self.users:
if self.isCanceled():
return
thumb, back = user.thumb, ''
thumb, back = image.getImage(user.thumb, user.id)
self.callback(user, thumb, back)
class UserSelectWindow(kodigui.BaseWindow):
xmlFile = 'script-plex-user_select.xml'
path = v.ADDON_PATH
path = util.ADDON.getAddonInfo('path')
theme = 'Main'
res = '1080i'
width = 1920
@ -44,8 +45,7 @@ class UserSelectWindow(kodigui.BaseWindow):
def __init__(self, *args, **kwargs):
self.task = None
self.user = None
self.aborted = False
self.selected = False
kodigui.BaseWindow.__init__(self, *args, **kwargs)
def onFirstInit(self):
@ -77,7 +77,7 @@ class UserSelectWindow(kodigui.BaseWindow):
self.pinEntryClicked(211)
return
except:
utils.ERROR()
util.ERROR()
kodigui.BaseWindow.onAction(self, action)
@ -91,7 +91,7 @@ class UserSelectWindow(kodigui.BaseWindow):
elif 200 < controlID < 212:
self.pinEntryClicked(controlID)
elif controlID == self.HOME_BUTTON_ID:
self.home_button_clicked()
self.shutdownClicked()
def onFocus(self, controlID):
if controlID == self.USER_LIST_ID:
@ -107,7 +107,7 @@ class UserSelectWindow(kodigui.BaseWindow):
def start(self):
self.setProperty('busy', '1')
try:
users = plex_tv.plex_home_users(utils.settings('plexToken'))
users = plexapp.ACCOUNT.homeUsers
items = []
for user in users:
@ -133,13 +133,15 @@ class UserSelectWindow(kodigui.BaseWindow):
"""
Action taken if user clicked the home button
"""
self.user = None
self.aborted = True
self.selected = False
self.doClose()
def pinEntryClicked(self, controlID):
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:
return
@ -161,22 +163,24 @@ class UserSelectWindow(kodigui.BaseWindow):
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
user = item.dataSource
# xbmc.sleep(500)
util.DEBUG_LOG('Home user selected: {0}'.format(user))
from .. import plex
with plex.CallbackEvent(plexapp.APP, 'account:response') as e:
if plexapp.ACCOUNT.switchHomeUser(user.id, pin) and plexapp.ACCOUNT.switchUser:
util.DEBUG_LOG('Waiting for user change...')
else:
e.close()
item.setProperty('pin', item.dataSource.title)
item.setProperty('editing.pin', '')
util.messageDialog(T(30135, 'Error'),
'%s %s' % (T(39229, 'Login failed with plex.tv for user'),
self.user.username))
return
self.selected = True
self.doClose()
def finished(self):
@ -185,18 +189,7 @@ class UserSelectWindow(kodigui.BaseWindow):
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
selected = w.selected
del w
return user, aborted
return selected

View File

@ -1,8 +1,38 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
###############################################################################
from __future__ import absolute_import, division, unicode_literals
from resources.lib import service_entry
import xbmc
import xbmcgui
import xbmcaddon
if __name__ == "__main__":
service_entry.start()
def 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()