Add plexnet module
This commit is contained in:
parent
a20205f2b1
commit
32927931c4
55 changed files with 10859 additions and 0 deletions
0
resources/lib/plexnet/__init__.py
Normal file
0
resources/lib/plexnet/__init__.py
Normal file
321
resources/lib/plexnet/asyncadapter.py
Normal file
321
resources/lib/plexnet/asyncadapter.py
Normal file
|
@ -0,0 +1,321 @@
|
|||
import time
|
||||
import socket
|
||||
|
||||
import requests
|
||||
from requests.packages.urllib3 import HTTPConnectionPool, HTTPSConnectionPool
|
||||
from requests.packages.urllib3.poolmanager import PoolManager, proxy_from_url
|
||||
from requests.packages.urllib3.connectionpool import VerifiedHTTPSConnection
|
||||
from requests.adapters import HTTPAdapter
|
||||
from requests.compat import urlparse
|
||||
|
||||
from httplib import HTTPConnection
|
||||
import errno
|
||||
|
||||
DEFAULT_POOLBLOCK = False
|
||||
SSL_KEYWORDS = ('key_file', 'cert_file', 'cert_reqs', 'ca_certs',
|
||||
'ssl_version')
|
||||
|
||||
WIN_WSAEINVAL = 10022
|
||||
WIN_EWOULDBLOCK = 10035
|
||||
WIN_ECONNRESET = 10054
|
||||
WIN_EISCONN = 10056
|
||||
WIN_ENOTCONN = 10057
|
||||
WIN_EHOSTUNREACH = 10065
|
||||
|
||||
|
||||
def ABORT_FLAG_FUNCTION():
|
||||
return False
|
||||
|
||||
|
||||
class TimeoutException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CanceledException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AsyncTimeout(float):
|
||||
def __repr__(self):
|
||||
return '{0}({1})'.format(float(self), self.getConnectTimeout())
|
||||
|
||||
def __str__(self):
|
||||
return repr(self)
|
||||
|
||||
@classmethod
|
||||
def fromTimeout(cls, t):
|
||||
if isinstance(t, AsyncTimeout):
|
||||
return t
|
||||
|
||||
try:
|
||||
return AsyncTimeout(float(t)) or DEFAULT_TIMEOUT
|
||||
except TypeError:
|
||||
return DEFAULT_TIMEOUT
|
||||
|
||||
def setConnectTimeout(self, val):
|
||||
self._connectTimout = val
|
||||
return self
|
||||
|
||||
def getConnectTimeout(self):
|
||||
if hasattr(self, '_connectTimout'):
|
||||
return self._connectTimout
|
||||
|
||||
return self
|
||||
|
||||
|
||||
DEFAULT_TIMEOUT = AsyncTimeout(10).setConnectTimeout(10)
|
||||
|
||||
|
||||
class AsyncVerifiedHTTPSConnection(VerifiedHTTPSConnection):
|
||||
def __init__(self, *args, **kwargs):
|
||||
VerifiedHTTPSConnection.__init__(self, *args, **kwargs)
|
||||
self._canceled = False
|
||||
self.deadline = 0
|
||||
self._timeout = AsyncTimeout(DEFAULT_TIMEOUT)
|
||||
|
||||
def _check_timeout(self):
|
||||
if time.time() > self.deadline:
|
||||
raise TimeoutException('connection timed out')
|
||||
|
||||
def create_connection(self, address, timeout=None, source_address=None):
|
||||
"""Connect to *address* and return the socket object.
|
||||
|
||||
Convenience function. Connect to *address* (a 2-tuple ``(host,
|
||||
port)``) and return the socket object. Passing the optional
|
||||
*timeout* parameter will set the timeout on the socket instance
|
||||
before attempting to connect. If no *timeout* is supplied, the
|
||||
global default timeout setting returned by :func:`getdefaulttimeout`
|
||||
is used. If *source_address* is set it must be a tuple of (host, port)
|
||||
for the socket to bind as a source address before making the connection.
|
||||
An host of '' or port 0 tells the OS to use the default.
|
||||
"""
|
||||
timeout = AsyncTimeout.fromTimeout(timeout)
|
||||
self._timeout = timeout
|
||||
|
||||
host, port = address
|
||||
err = None
|
||||
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
|
||||
af, socktype, proto, canonname, sa = res
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(af, socktype, proto)
|
||||
sock.setblocking(False) # this is obviously critical
|
||||
self.deadline = time.time() + timeout.getConnectTimeout()
|
||||
# sock.settimeout(timeout)
|
||||
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
for msg in self._connect(sock, sa):
|
||||
if self._canceled or ABORT_FLAG_FUNCTION():
|
||||
raise CanceledException('Request canceled')
|
||||
sock.setblocking(True)
|
||||
return sock
|
||||
|
||||
except socket.error as _:
|
||||
err = _
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
|
||||
if err is not None:
|
||||
raise err
|
||||
else:
|
||||
raise socket.error("getaddrinfo returns an empty list")
|
||||
|
||||
def _connect(self, sock, sa):
|
||||
while not self._canceled and not ABORT_FLAG_FUNCTION():
|
||||
time.sleep(0.01)
|
||||
self._check_timeout() # this should be done at the beginning of each loop
|
||||
status = sock.connect_ex(sa)
|
||||
if not status or status in (errno.EISCONN, WIN_EISCONN):
|
||||
break
|
||||
elif status in (errno.EINPROGRESS, WIN_EWOULDBLOCK):
|
||||
self.deadline = time.time() + self._timeout.getConnectTimeout()
|
||||
# elif status in (errno.EWOULDBLOCK, errno.EALREADY) or (os.name == 'nt' and status == errno.WSAEINVAL):
|
||||
# pass
|
||||
yield
|
||||
|
||||
if self._canceled or ABORT_FLAG_FUNCTION():
|
||||
raise CanceledException('Request canceled')
|
||||
|
||||
error = sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR)
|
||||
if error:
|
||||
# TODO: determine when this case can actually happen
|
||||
raise socket.error((error,))
|
||||
|
||||
def _new_conn(self):
|
||||
sock = self.create_connection(
|
||||
address=(self.host, self.port),
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
return sock
|
||||
|
||||
def cancel(self):
|
||||
self._canceled = True
|
||||
|
||||
|
||||
class AsyncHTTPConnection(HTTPConnection):
|
||||
def __init__(self, *args, **kwargs):
|
||||
HTTPConnection.__init__(self, *args, **kwargs)
|
||||
self._canceled = False
|
||||
self.deadline = 0
|
||||
|
||||
def cancel(self):
|
||||
self._canceled = True
|
||||
|
||||
|
||||
class AsyncHTTPConnectionPool(HTTPConnectionPool):
|
||||
def __init__(self, *args, **kwargs):
|
||||
HTTPConnectionPool.__init__(self, *args, **kwargs)
|
||||
self.connections = []
|
||||
|
||||
def _new_conn(self):
|
||||
"""
|
||||
Return a fresh :class:`httplib.HTTPConnection`.
|
||||
"""
|
||||
self.num_connections += 1
|
||||
|
||||
extra_params = {}
|
||||
extra_params['strict'] = self.strict
|
||||
|
||||
conn = AsyncHTTPConnection(host=self.host, port=self.port, timeout=self.timeout.connect_timeout, **extra_params)
|
||||
|
||||
# Backport fix LP #1412545
|
||||
if getattr(conn, '_tunnel_host', None):
|
||||
# TODO: Fix tunnel so it doesn't depend on self.sock state.
|
||||
conn._tunnel()
|
||||
# Mark this connection as not reusable
|
||||
conn.auto_open = 0
|
||||
|
||||
self.connections.append(conn)
|
||||
|
||||
return conn
|
||||
|
||||
def cancel(self):
|
||||
for c in self.connections:
|
||||
c.cancel()
|
||||
|
||||
|
||||
class AsyncHTTPSConnectionPool(HTTPSConnectionPool):
|
||||
def __init__(self, *args, **kwargs):
|
||||
HTTPSConnectionPool.__init__(self, *args, **kwargs)
|
||||
self.connections = []
|
||||
|
||||
def _new_conn(self):
|
||||
"""
|
||||
Return a fresh :class:`httplib.HTTPSConnection`.
|
||||
"""
|
||||
self.num_connections += 1
|
||||
|
||||
actual_host = self.host
|
||||
actual_port = self.port
|
||||
if self.proxy is not None:
|
||||
actual_host = self.proxy.host
|
||||
actual_port = self.proxy.port
|
||||
|
||||
connection_class = AsyncVerifiedHTTPSConnection
|
||||
|
||||
extra_params = {}
|
||||
extra_params['strict'] = self.strict
|
||||
connection = connection_class(host=actual_host, port=actual_port, timeout=self.timeout.connect_timeout, **extra_params)
|
||||
|
||||
self.connections.append(connection)
|
||||
|
||||
return self._prepare_conn(connection)
|
||||
|
||||
def cancel(self):
|
||||
for c in self.connections:
|
||||
c.cancel()
|
||||
|
||||
|
||||
pool_classes_by_scheme = {
|
||||
'http': AsyncHTTPConnectionPool,
|
||||
'https': AsyncHTTPSConnectionPool,
|
||||
}
|
||||
|
||||
|
||||
class AsyncPoolManager(PoolManager):
|
||||
def _new_pool(self, scheme, host, port, request_context=None):
|
||||
"""
|
||||
Create a new :class:`ConnectionPool` based on host, port and scheme.
|
||||
|
||||
This method is used to actually create the connection pools handed out
|
||||
by :meth:`connection_from_url` and companion methods. It is intended
|
||||
to be overridden for customization.
|
||||
"""
|
||||
pool_cls = pool_classes_by_scheme[scheme]
|
||||
kwargs = self.connection_pool_kw
|
||||
if scheme == 'http':
|
||||
kwargs = self.connection_pool_kw.copy()
|
||||
for kw in SSL_KEYWORDS:
|
||||
kwargs.pop(kw, None)
|
||||
|
||||
return pool_cls(host, port, **kwargs)
|
||||
|
||||
|
||||
class AsyncHTTPAdapter(HTTPAdapter):
|
||||
def cancel(self):
|
||||
for c in self.connections:
|
||||
c.cancel()
|
||||
|
||||
def init_poolmanager(self, connections, maxsize, block=DEFAULT_POOLBLOCK):
|
||||
"""Initializes a urllib3 PoolManager. This method should not be called
|
||||
from user code, and is only exposed for use when subclassing the
|
||||
:class:`HTTPAdapter <requests.adapters.HTTPAdapter>`.
|
||||
|
||||
:param connections: The number of urllib3 connection pools to cache.
|
||||
:param maxsize: The maximum number of connections to save in the pool.
|
||||
:param block: Block when no free connections are available.
|
||||
"""
|
||||
# save these values for pickling
|
||||
self._pool_connections = connections
|
||||
self._pool_maxsize = maxsize
|
||||
self._pool_block = block
|
||||
|
||||
self.poolmanager = AsyncPoolManager(num_pools=connections, maxsize=maxsize, block=block)
|
||||
self.connections = []
|
||||
|
||||
def get_connection(self, url, proxies=None):
|
||||
"""Returns a urllib3 connection for the given URL. This should not be
|
||||
called from user code, and is only exposed for use when subclassing the
|
||||
:class:`HTTPAdapter <requests.adapters.HTTPAdapter>`.
|
||||
|
||||
:param url: The URL to connect to.
|
||||
:param proxies: (optional) A Requests-style dictionary of proxies used on this request.
|
||||
"""
|
||||
proxies = proxies or {}
|
||||
proxy = proxies.get(urlparse(url.lower()).scheme)
|
||||
|
||||
if proxy:
|
||||
proxy_headers = self.proxy_headers(proxy)
|
||||
|
||||
if proxy not in self.proxy_manager:
|
||||
self.proxy_manager[proxy] = proxy_from_url(
|
||||
proxy,
|
||||
proxy_headers=proxy_headers,
|
||||
num_pools=self._pool_connections,
|
||||
maxsize=self._pool_maxsize,
|
||||
block=self._pool_block
|
||||
)
|
||||
|
||||
conn = self.proxy_manager[proxy].connection_from_url(url)
|
||||
else:
|
||||
# Only scheme should be lower case
|
||||
parsed = urlparse(url)
|
||||
url = parsed.geturl()
|
||||
conn = self.poolmanager.connection_from_url(url)
|
||||
|
||||
self.connections.append(conn)
|
||||
return conn
|
||||
|
||||
|
||||
class Session(requests.Session):
|
||||
def __init__(self, *args, **kwargs):
|
||||
requests.Session.__init__(self, *args, **kwargs)
|
||||
self.mount('https://', AsyncHTTPAdapter())
|
||||
self.mount('http://', AsyncHTTPAdapter())
|
||||
|
||||
def cancel(self):
|
||||
for v in self.adapters.values():
|
||||
v.close()
|
||||
v.cancel()
|
152
resources/lib/plexnet/audio.py
Normal file
152
resources/lib/plexnet/audio.py
Normal file
|
@ -0,0 +1,152 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import plexobjects
|
||||
import plexmedia
|
||||
import media
|
||||
|
||||
|
||||
class Audio(media.MediaItem):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._settings = None
|
||||
media.MediaItem.__init__(self, *args, **kwargs)
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.ratingKey == other.ratingKey
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def _setData(self, data):
|
||||
for k, v in data.attrib.items():
|
||||
setattr(self, k, plexobjects.PlexValue(v, self))
|
||||
|
||||
self.key = plexobjects.PlexValue(self.key.replace('/children', ''), self)
|
||||
|
||||
def isMusicItem(self):
|
||||
return True
|
||||
|
||||
|
||||
@plexobjects.registerLibType
|
||||
class Artist(Audio):
|
||||
TYPE = 'artist'
|
||||
|
||||
def _setData(self, data):
|
||||
Audio._setData(self, data)
|
||||
if self.isFullObject():
|
||||
self.countries = plexobjects.PlexItemList(data, media.Country, media.Country.TYPE, server=self.server)
|
||||
self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server)
|
||||
self.similar = plexobjects.PlexItemList(data, media.Similar, media.Similar.TYPE, server=self.server)
|
||||
|
||||
def albums(self):
|
||||
path = '%s/children' % self.key
|
||||
return plexobjects.listItems(self.server, path, Album.TYPE)
|
||||
|
||||
def album(self, title):
|
||||
path = '%s/children' % self.key
|
||||
return plexobjects.findItem(self.server, path, title)
|
||||
|
||||
def tracks(self, watched=None):
|
||||
leavesKey = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return plexobjects.listItems(self.server, leavesKey, watched=watched)
|
||||
|
||||
def all(self):
|
||||
return self.tracks()
|
||||
|
||||
def track(self, title):
|
||||
path = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return plexobjects.findItem(self.server, path, title)
|
||||
|
||||
def isFullObject(self):
|
||||
# plex bug? http://bit.ly/1Sc2J3V
|
||||
fixed_key = self.key.replace('/children', '')
|
||||
return self.initpath == fixed_key
|
||||
|
||||
def refresh(self):
|
||||
self.server.query('/library/metadata/%s/refresh' % self.ratingKey)
|
||||
|
||||
|
||||
@plexobjects.registerLibType
|
||||
class Album(Audio):
|
||||
TYPE = 'album'
|
||||
|
||||
def _setData(self, data):
|
||||
Audio._setData(self, data)
|
||||
if self.isFullObject():
|
||||
self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server)
|
||||
|
||||
@property
|
||||
def defaultTitle(self):
|
||||
return self.parentTitle or self.title
|
||||
|
||||
def tracks(self, watched=None):
|
||||
path = '%s/children' % self.key
|
||||
return plexobjects.listItems(self.server, path, watched=watched)
|
||||
|
||||
def track(self, title):
|
||||
path = '%s/children' % self.key
|
||||
return plexobjects.findItem(self.server, path, title)
|
||||
|
||||
def all(self):
|
||||
return self.tracks()
|
||||
|
||||
def isFullObject(self):
|
||||
# plex bug? http://bit.ly/1Sc2J3V
|
||||
fixed_key = self.key.replace('/children', '')
|
||||
return self.initpath == fixed_key
|
||||
|
||||
def artist(self):
|
||||
return plexobjects.listItems(self.server, self.parentKey)[0]
|
||||
|
||||
def watched(self):
|
||||
return self.tracks(watched=True)
|
||||
|
||||
def unwatched(self):
|
||||
return self.tracks(watched=False)
|
||||
|
||||
|
||||
@plexobjects.registerLibType
|
||||
class Track(Audio):
|
||||
TYPE = 'track'
|
||||
|
||||
def _setData(self, data):
|
||||
Audio._setData(self, data)
|
||||
if self.isFullObject():
|
||||
self.moods = plexobjects.PlexItemList(data, media.Mood, media.Mood.TYPE, server=self.server)
|
||||
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||
|
||||
# data for active sessions
|
||||
self.user = self._findUser(data)
|
||||
self.player = self._findPlayer(data)
|
||||
self.transcodeSession = self._findTranscodeSession(data)
|
||||
|
||||
@property
|
||||
def defaultTitle(self):
|
||||
return self.parentTitle or self.title
|
||||
|
||||
@property
|
||||
def settings(self):
|
||||
if not self._settings:
|
||||
import plexapp
|
||||
self._settings = plexapp.PlayerSettingsInterface()
|
||||
|
||||
return self._settings
|
||||
|
||||
@property
|
||||
def thumbUrl(self):
|
||||
return self.server.url(self.parentThumb)
|
||||
|
||||
def album(self):
|
||||
return plexobjects.listItems(self.server, self.parentKey)[0]
|
||||
|
||||
def artist(self):
|
||||
return plexobjects.listItems(self.server, self.grandparentKey)[0]
|
||||
|
||||
def getStreamURL(self, **params):
|
||||
return self._getStreamURL(**params)
|
||||
|
||||
@property
|
||||
def defaultThumb(self):
|
||||
return self.__dict__.get('thumb') or self.__dict__.get('parentThumb') or self.get('grandparentThumb')
|
||||
|
||||
@property
|
||||
def defaultArt(self):
|
||||
return self.__dict__.get('art') or self.get('grandparentArt')
|
82
resources/lib/plexnet/audioobject.py
Normal file
82
resources/lib/plexnet/audioobject.py
Normal file
|
@ -0,0 +1,82 @@
|
|||
import http
|
||||
import mediadecisionengine
|
||||
import util
|
||||
|
||||
|
||||
class AudioObjectClass(object):
|
||||
def __init__(self, item):
|
||||
self.containerFormats = {
|
||||
'aac': "es.aac-adts"
|
||||
}
|
||||
|
||||
self.item = item
|
||||
self.choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item)
|
||||
if self.choice:
|
||||
self.media = self.choice.media
|
||||
self.lyrics = None # createLyrics(item, self.media)
|
||||
|
||||
def build(self, directPlay=None):
|
||||
directPlay = directPlay or self.choice.isDirectPlayable
|
||||
|
||||
obj = util.AttributeDict()
|
||||
|
||||
# TODO(schuyler): Do we want/need to add anything generic here? Title? Duration?
|
||||
|
||||
if directPlay:
|
||||
obj = self.buildDirectPlay(obj)
|
||||
else:
|
||||
obj = self.buildTranscode(obj)
|
||||
|
||||
self.metadata = obj
|
||||
|
||||
util.LOG("Constructed audio item for playback: {0}".format(obj))
|
||||
|
||||
return self.metadata
|
||||
|
||||
def buildTranscode(self, obj):
|
||||
transcodeServer = self.item.getTranscodeServer(True, "audio")
|
||||
if not transcodeServer:
|
||||
return None
|
||||
|
||||
obj.streamFormat = "mp3"
|
||||
obj.isTranscoded = True
|
||||
obj.transcodeServer = transcodeServer
|
||||
|
||||
builder = http.HttpRequest(transcodeServer.buildUrl("/music/:/transcode/universal/start.m3u8", True))
|
||||
builder.addParam("protocol", "http")
|
||||
builder.addParam("path", self.item.getAbsolutePath("key"))
|
||||
builder.addParam("session", self.item.getGlobal("clientIdentifier"))
|
||||
builder.addParam("directPlay", "0")
|
||||
builder.addParam("directStream", "0")
|
||||
|
||||
obj.url = builder.getUrl()
|
||||
|
||||
return obj
|
||||
|
||||
def buildDirectPlay(self, obj):
|
||||
if self.choice.part:
|
||||
obj.url = self.item.getServer().buildUrl(self.choice.part.getAbsolutePath("key"), True)
|
||||
|
||||
# Set and override the stream format if applicable
|
||||
obj.streamFormat = self.choice.media.container or 'mp3'
|
||||
if self.containerFormats.get(obj.streamFormat):
|
||||
obj.streamFormat = self.containerFormats[obj.streamFormat]
|
||||
|
||||
# If we're direct playing a FLAC, bitrate can be required, and supposedly
|
||||
# this is the only way to do it. plexinc/roku-client#48
|
||||
#
|
||||
bitrate = self.choice.media.getInt("bitrate")
|
||||
if bitrate > 0:
|
||||
obj.streams = [{'url': obj.url, 'bitrate': bitrate}]
|
||||
|
||||
return obj
|
||||
|
||||
# We may as well fallback to transcoding if we could not direct play
|
||||
return self.buildTranscode(obj)
|
||||
|
||||
def getLyrics(self):
|
||||
return self.lyrics
|
||||
|
||||
def hasLyrics(self):
|
||||
return False
|
||||
# return self.lyrics.isAvailable()
|
53
resources/lib/plexnet/callback.py
Normal file
53
resources/lib/plexnet/callback.py
Normal file
|
@ -0,0 +1,53 @@
|
|||
import threading
|
||||
|
||||
|
||||
class Callable(object):
|
||||
_currID = 0
|
||||
|
||||
def __init__(self, func, forcedArgs=None, ID=None):
|
||||
self.func = func
|
||||
self.forcedArgs = forcedArgs
|
||||
|
||||
self.ID = ID or id(func)
|
||||
|
||||
if not self.ID:
|
||||
self.ID = Callable.nextID()
|
||||
|
||||
def __repr__(self):
|
||||
return '<Callable:({0})>'.format(repr(self.func).strip('<>'))
|
||||
|
||||
def __eq__(self, other):
|
||||
if not other:
|
||||
return False
|
||||
|
||||
if self.__class__ != other.__class__:
|
||||
return False
|
||||
|
||||
return self.ID and self.ID == other.ID
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
args = args or []
|
||||
if self.forcedArgs:
|
||||
args = self.forcedArgs
|
||||
|
||||
self.func(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def context(self):
|
||||
return self.func.im_self
|
||||
|
||||
@classmethod
|
||||
def nextID(cls):
|
||||
cls._currID += 1
|
||||
return cls._currID
|
||||
|
||||
def deferCall(self, timeout=0.1):
|
||||
timer = threading.Timer(timeout, self.onDeferCallTimer)
|
||||
timer.name = 'ONDEFERCALLBACK-TIMER:{0}'.format(self.func)
|
||||
timer.start()
|
||||
|
||||
def onDeferCallTimer(self):
|
||||
self()
|
79
resources/lib/plexnet/captions.py
Normal file
79
resources/lib/plexnet/captions.py
Normal file
|
@ -0,0 +1,79 @@
|
|||
import plexapp
|
||||
import util
|
||||
|
||||
|
||||
class Captions(object):
|
||||
def __init__(self):
|
||||
self.deviceInfo = plexapp.INTERFACE.getGlobal("deviceInfo")
|
||||
|
||||
self.textSize = util.AttributeDict({
|
||||
'extrasmall': 15,
|
||||
'small': 20,
|
||||
'medium': 30,
|
||||
'large': 45,
|
||||
'extralarge': 65,
|
||||
})
|
||||
|
||||
self.burnedSize = util.AttributeDict({
|
||||
'extrasmall': "60",
|
||||
'small': "80",
|
||||
'medium': "100",
|
||||
'large': "135",
|
||||
'extralarge': "200"
|
||||
})
|
||||
|
||||
self.colors = util.AttributeDict({
|
||||
'white': 0xffffffff,
|
||||
'black': 0x000000ff,
|
||||
'red': 0xff0000ff,
|
||||
'green': 0x008000ff,
|
||||
'blue': 0x0000ffff,
|
||||
'yellow': 0xffff00ff,
|
||||
'magenta': 0xff00ffff,
|
||||
'cyan': 0x00ffffff,
|
||||
})
|
||||
|
||||
self.defaults = util.AttributeDict({
|
||||
'textSize': self.textSize.medium,
|
||||
'textColor': self.colors.white,
|
||||
'textOpacity': 80,
|
||||
'backgroundColor': self.colors.black,
|
||||
'backgroundOpacity': 70,
|
||||
'burnedSize': None
|
||||
})
|
||||
|
||||
def getTextSize(self):
|
||||
value = self.getOption("Text/Size")
|
||||
return self.textSize.get(value) or self.defaults.textSize
|
||||
|
||||
def getTextColor(self):
|
||||
value = self.getOption("Text/Color")
|
||||
return self.colors.get(value) or self.defaults.textColor
|
||||
|
||||
def getTextOpacity(self):
|
||||
value = self.getOption("Text/Opacity")
|
||||
if value is None or value == "default":
|
||||
return self.defaults.textOpacity
|
||||
else:
|
||||
return int(value)
|
||||
|
||||
def getBackgroundColor(self):
|
||||
value = self.getOption("Background/Color")
|
||||
return self.colors.get(value) or self.defaults.backgroundColor
|
||||
|
||||
def getBackgroundOpacity(self):
|
||||
value = self.getOption("Background/Opacity")
|
||||
if value is None or value == "default":
|
||||
return self.defaults.backgroundOpacity
|
||||
else:
|
||||
return int(value)
|
||||
|
||||
def getBurnedSize(self):
|
||||
value = self.getOption("Text/Size")
|
||||
return self.burnedSize.get(value) or self.defaults.burnedSize
|
||||
|
||||
def getOption(self, key):
|
||||
opt = self.deviceInfo.getCaptionsOption(key)
|
||||
return opt is not None and opt.lower().replace(' ', '') or None
|
||||
|
||||
CAPTIONS = Captions()
|
25
resources/lib/plexnet/compat.py
Normal file
25
resources/lib/plexnet/compat.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Python 2/3 compatability
|
||||
Always try Py3 first
|
||||
"""
|
||||
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
except ImportError:
|
||||
from urllib import urlencode
|
||||
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
except ImportError:
|
||||
from urllib import quote
|
||||
|
||||
try:
|
||||
from urllib.parse import quote_plus
|
||||
except ImportError:
|
||||
from urllib import quote_plus
|
||||
|
||||
try:
|
||||
from configparser import ConfigParser
|
||||
except ImportError:
|
||||
from ConfigParser import ConfigParser
|
18
resources/lib/plexnet/exceptions.py
Normal file
18
resources/lib/plexnet/exceptions.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
class BadRequest(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotFound(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownType(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Unsupported(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Unauthorized(Exception):
|
||||
pass
|
346
resources/lib/plexnet/gdm.py
Normal file
346
resources/lib/plexnet/gdm.py
Normal file
|
@ -0,0 +1,346 @@
|
|||
import threading
|
||||
import socket
|
||||
import traceback
|
||||
import time
|
||||
import util
|
||||
import netif
|
||||
|
||||
import plexconnection
|
||||
|
||||
DISCOVERY_PORT = 32414
|
||||
WIN_NL = chr(13) + chr(10)
|
||||
|
||||
|
||||
class GDMDiscovery(object):
|
||||
def __init__(self):
|
||||
self._close = False
|
||||
self.thread = None
|
||||
|
||||
# def isActive(self):
|
||||
# util.LOG('GDMDiscovery().isActive() - NOT IMPLEMENTED')
|
||||
# return False
|
||||
|
||||
# def discover(self):
|
||||
# util.LOG('GDMDiscovery().discover() - NOT IMPLEMENTED')
|
||||
|
||||
def isActive(self):
|
||||
import plexapp
|
||||
return plexapp.INTERFACE.getPreference("gdm_discovery", True) and self.thread and self.thread.isAlive()
|
||||
|
||||
'''
|
||||
def discover(self):
|
||||
# Only allow discovery if enabled and not currently running
|
||||
self._close = False
|
||||
import plexapp
|
||||
if not plexapp.INTERFACE.getPreference("gdm_discovery", True) or self.isActive():
|
||||
return
|
||||
|
||||
ifaces = netif.getInterfaces()
|
||||
|
||||
message = "M-SEARCH * HTTP/1.1" + WIN_NL + WIN_NL
|
||||
|
||||
# Broadcasting to 255.255.255.255 only works on some Rokus, but we
|
||||
# can't reliably determine the broadcast address for our current
|
||||
# interface. Try assuming a /24 network, and then fall back to the
|
||||
# multicast address if that doesn't work.
|
||||
|
||||
multicast = "239.0.0.250"
|
||||
ip = multicast
|
||||
subnetRegex = re.compile("((\d+)\.(\d+)\.(\d+)\.)(\d+)")
|
||||
addr = getFirstIPAddress() # TODO:: -------------------------------------------------------------------------------------------------------- HANDLE
|
||||
if addr:
|
||||
match = subnetRegex.search(addr)
|
||||
if match:
|
||||
ip = match.group(1) + "255"
|
||||
util.DEBUG_LOG("Using broadcast address {0}".format())
|
||||
|
||||
# Socket things sometimes fail for no good reason, so try a few times.
|
||||
attempt = 0
|
||||
success = False
|
||||
|
||||
while attempt < 5 and not success:
|
||||
udp = CreateObject("roDatagramSocket")
|
||||
udp.setMessagePort(Application().port)
|
||||
udp.setBroadcast(true)
|
||||
|
||||
# More things that have been observed to be flaky.
|
||||
for i in range(5):
|
||||
addr = CreateObject("roSocketAddress")
|
||||
addr.setHostName(ip)
|
||||
addr.setPort(32414)
|
||||
udp.setSendToAddress(addr)
|
||||
|
||||
sendTo = udp.getSendToAddress()
|
||||
if sendTo:
|
||||
sendToStr = str(sendTo.getAddress())
|
||||
addrStr = str(addr.getAddress())
|
||||
util.DEBUG_LOG("GDM sendto address: " + sendToStr + " / " + addrStr)
|
||||
if sendToStr == addrStr:
|
||||
break
|
||||
|
||||
util.ERROR_LOG("Failed to set GDM sendto address")
|
||||
|
||||
udp.notifyReadable(true)
|
||||
bytesSent = udp.sendStr(message)
|
||||
util.DEBUG_LOG("Sent " + str(bytesSent) + " bytes")
|
||||
if bytesSent > 0:
|
||||
success = udp.eOK()
|
||||
else:
|
||||
success = False
|
||||
if bytesSent == 0 and ip != multicast:
|
||||
util.LOG("Falling back to multicast address")
|
||||
ip = multicast
|
||||
attempt = 0
|
||||
|
||||
if success:
|
||||
break
|
||||
elif attempt == 4 and ip != multicast:
|
||||
util.LOG("Falling back to multicast address")
|
||||
ip = multicast
|
||||
attempt = 0
|
||||
else:
|
||||
time.sleep(500)
|
||||
util.WARN_LOG("Retrying GDM, errno=" + str(udp.status()))
|
||||
attempt += 1
|
||||
|
||||
if success:
|
||||
util.DEBUG_LOG("Successfully sent GDM discovery message, waiting for servers")
|
||||
self.servers = []
|
||||
self.timer = plexapp.createTimer(5000, self.onTimer)
|
||||
self.socket = udp
|
||||
Application().AddSocketCallback(udp, createCallable("OnSocketEvent", m))
|
||||
plexapp.APP.addTimer(self.timer)
|
||||
else:
|
||||
util.ERROR_LOG("Failed to send GDM discovery message")
|
||||
import plexapp
|
||||
import plexresource
|
||||
plexapp.SERVERMANAGER.UpdateFromConnectionType([], plexresource.ResourceConnection.SOURCE_DISCOVERED)
|
||||
self.socket = None
|
||||
self.timer = None
|
||||
'''
|
||||
|
||||
def discover(self):
|
||||
import plexapp
|
||||
if not plexapp.INTERFACE.getPreference("gdm_discovery", True) or self.isActive():
|
||||
return
|
||||
|
||||
self.thread = threading.Thread(target=self._discover)
|
||||
self.thread.start()
|
||||
|
||||
def _discover(self):
|
||||
ifaces = netif.getInterfaces()
|
||||
sockets = []
|
||||
self.servers = []
|
||||
|
||||
packet = "M-SEARCH * HTTP/1.1" + WIN_NL + WIN_NL
|
||||
|
||||
for i in ifaces:
|
||||
if not i.broadcast:
|
||||
continue
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(0.01) # 10ms
|
||||
s.bind((i.ip, 0))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
sockets.append((s, i))
|
||||
|
||||
success = False
|
||||
|
||||
for attempt in (0, 1):
|
||||
for s, i in sockets:
|
||||
if self._close:
|
||||
return
|
||||
util.DEBUG_LOG(' o-> Broadcasting to {0}: {1}'.format(i.name, i.broadcast))
|
||||
try:
|
||||
s.sendto(packet, (i.broadcast, DISCOVERY_PORT))
|
||||
success = True
|
||||
except:
|
||||
util.ERROR()
|
||||
|
||||
if success:
|
||||
break
|
||||
|
||||
end = time.time() + 5
|
||||
|
||||
while time.time() < end:
|
||||
for s, i in sockets:
|
||||
if self._close:
|
||||
return
|
||||
try:
|
||||
message, address = s.recvfrom(4096)
|
||||
self.onSocketEvent(message, address)
|
||||
except socket.timeout:
|
||||
pass
|
||||
except:
|
||||
traceback.print_exc()
|
||||
|
||||
self.discoveryFinished()
|
||||
|
||||
def onSocketEvent(self, message, addr):
|
||||
util.DEBUG_LOG('Received GDM message:\n' + str(message))
|
||||
|
||||
hostname = addr[0] # socket.gethostbyaddr(addr[0])[0]
|
||||
|
||||
name = parseFieldValue(message, "Name: ")
|
||||
port = parseFieldValue(message, "Port: ") or "32400"
|
||||
machineID = parseFieldValue(message, "Resource-Identifier: ")
|
||||
secureHost = parseFieldValue(message, "Host: ")
|
||||
|
||||
util.DEBUG_LOG("Received GDM response for " + repr(name) + " at http://" + hostname + ":" + port)
|
||||
|
||||
if not name or not machineID:
|
||||
return
|
||||
|
||||
import plexserver
|
||||
conn = plexconnection.PlexConnection(plexconnection.PlexConnection.SOURCE_DISCOVERED, "http://" + hostname + ":" + port, True, None, bool(secureHost))
|
||||
server = plexserver.createPlexServerForConnection(conn)
|
||||
server.uuid = machineID
|
||||
server.name = name
|
||||
server.sameNetwork = True
|
||||
|
||||
# If the server advertised a secure hostname, add a secure connection as well, and
|
||||
# set the http connection as a fallback.
|
||||
#
|
||||
if secureHost:
|
||||
server.connections.insert(
|
||||
0,
|
||||
plexconnection.PlexConnection(
|
||||
plexconnection.PlexConnection.SOURCE_DISCOVERED, "https://" + hostname.replace(".", "-") + "." + secureHost + ":" + port, True, None
|
||||
)
|
||||
)
|
||||
|
||||
self.servers.append(server)
|
||||
|
||||
def discoveryFinished(self, *args, **kwargs):
|
||||
# Time's up, report whatever we found
|
||||
self.close()
|
||||
|
||||
if self.servers:
|
||||
util.LOG("Finished GDM discovery, found {0} server(s)".format(len(self.servers)))
|
||||
import plexapp
|
||||
plexapp.SERVERMANAGER.updateFromConnectionType(self.servers, plexconnection.PlexConnection.SOURCE_DISCOVERED)
|
||||
self.servers = None
|
||||
|
||||
def close(self):
|
||||
self._close = True
|
||||
|
||||
|
||||
def parseFieldValue(message, label):
|
||||
if label not in message:
|
||||
return None
|
||||
|
||||
return message.split(label, 1)[-1].split(chr(13))[0]
|
||||
|
||||
|
||||
DISCOVERY = GDMDiscovery()
|
||||
|
||||
'''
|
||||
# GDM Advertising
|
||||
|
||||
class GDMAdvertiser(object):
|
||||
|
||||
def __init__(self):
|
||||
self.responseString = None
|
||||
|
||||
def createSocket()
|
||||
listenAddr = CreateObject("roSocketAddress")
|
||||
listenAddr.setPort(32412)
|
||||
listenAddr.setAddress("0.0.0.0")
|
||||
|
||||
udp = CreateObject("roDatagramSocket")
|
||||
|
||||
if not udp.setAddress(listenAddr) then
|
||||
Error("Failed to set address on GDM advertiser socket")
|
||||
return
|
||||
end if
|
||||
|
||||
if not udp.setBroadcast(true) then
|
||||
Error("Failed to set broadcast on GDM advertiser socket")
|
||||
return
|
||||
end if
|
||||
|
||||
udp.notifyReadable(true)
|
||||
udp.setMessagePort(Application().port)
|
||||
|
||||
m.socket = udp
|
||||
|
||||
Application().AddSocketCallback(udp, createCallable("OnSocketEvent", m))
|
||||
|
||||
Debug("Created GDM player advertiser")
|
||||
|
||||
|
||||
def refresh()
|
||||
# Always regenerate our response, even if it might not have changed, it's
|
||||
# just not that expensive.
|
||||
m.responseString = invalid
|
||||
|
||||
enabled = AppSettings().GetBoolPreference("remotecontrol")
|
||||
if enabled AND m.socket = invalid then
|
||||
m.CreateSocket()
|
||||
else if not enabled AND m.socket <> invalid then
|
||||
m.Close()
|
||||
end if
|
||||
|
||||
|
||||
def cleanup()
|
||||
m.Close()
|
||||
fn = function() :m.GDMAdvertiser = invalid :
|
||||
fn()
|
||||
|
||||
|
||||
def onSocketEvent(msg as object)
|
||||
# PMS polls every five seconds, so this is chatty when not debugging.
|
||||
# Debug("Got a GDM advertiser socket event, is readable: " + tostr(m.socket.isReadable()))
|
||||
|
||||
if m.socket.isReadable() then
|
||||
message = m.socket.receiveStr(4096)
|
||||
endIndex = instr(1, message, chr(13)) - 1
|
||||
if endIndex <= 0 then endIndex = message.Len()
|
||||
line = Mid(message, 1, endIndex)
|
||||
|
||||
if line = "M-SEARCH * HTTP/1.1" then
|
||||
response = m.GetResponseString()
|
||||
|
||||
# Respond directly to whoever sent the search message.
|
||||
sock = CreateObject("roDatagramSocket")
|
||||
sock.setSendToAddress(m.socket.getReceivedFromAddress())
|
||||
bytesSent = sock.sendStr(response)
|
||||
sock.Close()
|
||||
if bytesSent <> Len(response) then
|
||||
Error("GDM player response only sent " + tostr(bytesSent) + " bytes out of " + tostr(Len(response)))
|
||||
end if
|
||||
else
|
||||
Error("Received unexpected message on GDM advertiser socket: " + tostr(line) + ";")
|
||||
end if
|
||||
end if
|
||||
|
||||
|
||||
def getResponseString() as string
|
||||
if m.responseString = invalid then
|
||||
buf = box("HTTP/1.0 200 OK" + WinNL())
|
||||
|
||||
settings = AppSettings()
|
||||
|
||||
appendNameValue(buf, "Name", settings.GetGlobal("friendlyName"))
|
||||
appendNameValue(buf, "Port", WebServer().port.tostr())
|
||||
appendNameValue(buf, "Product", "Plex for Roku")
|
||||
appendNameValue(buf, "Content-Type", "plex/media-player")
|
||||
appendNameValue(buf, "Protocol", "plex")
|
||||
appendNameValue(buf, "Protocol-Version", "1")
|
||||
appendNameValue(buf, "Protocol-Capabilities", "timeline,playback,navigation,playqueues")
|
||||
appendNameValue(buf, "Version", settings.GetGlobal("appVersionStr"))
|
||||
appendNameValue(buf, "Resource-Identifier", settings.GetGlobal("clientIdentifier"))
|
||||
appendNameValue(buf, "Device-Class", "stb")
|
||||
|
||||
m.responseString = buf
|
||||
|
||||
Debug("Built GDM player response:" + m.responseString)
|
||||
end if
|
||||
|
||||
return m.responseString
|
||||
|
||||
|
||||
sub appendNameValue(buf, name, value)
|
||||
line = name + ": " + value + WinNL()
|
||||
buf.AppendString(line, Len(line))
|
||||
|
||||
'''
|
321
resources/lib/plexnet/http.py
Normal file
321
resources/lib/plexnet/http.py
Normal file
|
@ -0,0 +1,321 @@
|
|||
import sys
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
import requests
|
||||
import socket
|
||||
import threadutils
|
||||
import urllib
|
||||
import mimetypes
|
||||
import plexobjects
|
||||
from defusedxml import ElementTree
|
||||
|
||||
import asyncadapter
|
||||
|
||||
import callback
|
||||
import util
|
||||
|
||||
|
||||
codes = requests.codes
|
||||
status_codes = requests.status_codes._codes
|
||||
|
||||
|
||||
DEFAULT_TIMEOUT = asyncadapter.AsyncTimeout(10).setConnectTimeout(10)
|
||||
|
||||
|
||||
def GET(*args, **kwargs):
|
||||
return requests.get(*args, headers=util.BASE_HEADERS.copy(), timeout=util.TIMEOUT, **kwargs)
|
||||
|
||||
|
||||
def POST(*args, **kwargs):
|
||||
return requests.post(*args, headers=util.BASE_HEADERS.copy(), timeout=util.TIMEOUT, **kwargs)
|
||||
|
||||
|
||||
def Session():
|
||||
s = asyncadapter.Session()
|
||||
s.headers = util.BASE_HEADERS.copy()
|
||||
s.timeout = util.TIMEOUT
|
||||
|
||||
return s
|
||||
|
||||
|
||||
class RequestContext(dict):
|
||||
def __getattr__(self, attr):
|
||||
return self.get(attr)
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
self[attr] = value
|
||||
|
||||
|
||||
class HttpRequest(object):
|
||||
_cancel = False
|
||||
|
||||
def __init__(self, url, method=None, forceCertificate=False):
|
||||
self.server = None
|
||||
self.path = None
|
||||
self.hasParams = '?' in url
|
||||
self.ignoreResponse = False
|
||||
self.session = asyncadapter.Session()
|
||||
self.session.headers = util.BASE_HEADERS.copy()
|
||||
self.currentResponse = None
|
||||
self.method = method
|
||||
self.url = url
|
||||
self.thread = None
|
||||
|
||||
# Use our specific plex.direct CA cert if applicable to improve performance
|
||||
# if forceCertificate or url[:5] == "https": # TODO: ---------------------------------------------------------------------------------IMPLEMENT
|
||||
# certsPath = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'certs')
|
||||
# if "plex.direct" in url:
|
||||
# self.session.cert = os.path.join(certsPath, 'plex-bundle.crt')
|
||||
# else:
|
||||
# self.session.cert = os.path.join(certsPath, 'ca-bundle.crt')
|
||||
|
||||
def removeAsPending(self):
|
||||
import plexapp
|
||||
plexapp.APP.delRequest(self)
|
||||
|
||||
def startAsync(self, *args, **kwargs):
|
||||
self.thread = threadutils.KillableThread(target=self._startAsync, args=args, kwargs=kwargs, name='HTTP-ASYNC:{0}'.format(self.url))
|
||||
self.thread.start()
|
||||
return True
|
||||
|
||||
def _startAsync(self, body=None, contentType=None, context=None):
|
||||
timeout = context and context.timeout or DEFAULT_TIMEOUT
|
||||
self.logRequest(body, timeout)
|
||||
if self._cancel:
|
||||
return
|
||||
try:
|
||||
if self.method == 'PUT':
|
||||
res = self.session.put(self.url, timeout=timeout, stream=True)
|
||||
elif self.method == 'DELETE':
|
||||
res = self.session.delete(self.url, timeout=timeout, stream=True)
|
||||
elif self.method == 'HEAD':
|
||||
res = self.session.head(self.url, timeout=timeout, stream=True)
|
||||
elif self.method == 'POST' or body is not None:
|
||||
if not contentType:
|
||||
self.session.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
else:
|
||||
self.session.headers["Content-Type"] = mimetypes.guess_type(contentType)
|
||||
|
||||
res = self.session.post(self.url, data=body or None, timeout=timeout, stream=True)
|
||||
else:
|
||||
res = self.session.get(self.url, timeout=timeout, stream=True)
|
||||
self.currentResponse = res
|
||||
|
||||
if self._cancel:
|
||||
return
|
||||
except asyncadapter.TimeoutException:
|
||||
import plexapp
|
||||
plexapp.APP.onRequestTimeout(context)
|
||||
self.removeAsPending()
|
||||
return
|
||||
except Exception, e:
|
||||
util.ERROR('Request failed {0}'.format(util.cleanToken(self.url)), e)
|
||||
if not hasattr(e, 'response'):
|
||||
return
|
||||
res = e.response
|
||||
|
||||
self.onResponse(res, context)
|
||||
|
||||
self.removeAsPending()
|
||||
|
||||
def getWithTimeout(self, seconds=DEFAULT_TIMEOUT):
|
||||
return HttpObjectResponse(self.getPostWithTimeout(seconds), self.path, self.server)
|
||||
|
||||
def postWithTimeout(self, seconds=DEFAULT_TIMEOUT, body=None):
|
||||
self.method = 'POST'
|
||||
return HttpObjectResponse(self.getPostWithTimeout(seconds, body), self.path, self.server)
|
||||
|
||||
def getToStringWithTimeout(self, seconds=DEFAULT_TIMEOUT):
|
||||
res = self.getPostWithTimeout(seconds)
|
||||
if not res:
|
||||
return ''
|
||||
return res.text.encode('utf8')
|
||||
|
||||
def postToStringWithTimeout(self, body=None, seconds=DEFAULT_TIMEOUT):
|
||||
self.method = 'POST'
|
||||
res = self.getPostWithTimeout(seconds, body)
|
||||
if not res:
|
||||
return ''
|
||||
return res.text.encode('utf8')
|
||||
|
||||
def getPostWithTimeout(self, seconds=DEFAULT_TIMEOUT, body=None):
|
||||
if self._cancel:
|
||||
return
|
||||
|
||||
self.logRequest(body, seconds, False)
|
||||
try:
|
||||
if self.method == 'PUT':
|
||||
res = self.session.put(self.url, timeout=seconds, stream=True)
|
||||
elif self.method == 'DELETE':
|
||||
res = self.session.delete(self.url, timeout=seconds, stream=True)
|
||||
elif self.method == 'HEAD':
|
||||
res = self.session.head(self.url, timeout=seconds, stream=True)
|
||||
elif self.method == 'POST' or body is not None:
|
||||
res = self.session.post(self.url, data=body, timeout=seconds, stream=True)
|
||||
else:
|
||||
res = self.session.get(self.url, timeout=seconds, stream=True)
|
||||
|
||||
self.currentResponse = res
|
||||
|
||||
if self._cancel:
|
||||
return None
|
||||
|
||||
util.LOG("Got a {0} from {1}".format(res.status_code, util.cleanToken(self.url)))
|
||||
# self.event = msg
|
||||
return res
|
||||
except Exception, e:
|
||||
info = traceback.extract_tb(sys.exc_info()[2])[-1]
|
||||
util.WARN_LOG(
|
||||
"Request errored out - URL: {0} File: {1} Line: {2} Msg: {3}".format(util.cleanToken(self.url), os.path.basename(info[0]), info[1], e.message)
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def wasOK(self):
|
||||
return self.currentResponse and self.currentResponse.ok
|
||||
|
||||
def wasNotFound(self):
|
||||
return self.currentResponse is not None and self.currentResponse.status_code == requests.codes.not_found
|
||||
|
||||
def getIdentity(self):
|
||||
return str(id(self))
|
||||
|
||||
def getUrl(self):
|
||||
return self.url
|
||||
|
||||
def getRelativeUrl(self):
|
||||
url = self.getUrl()
|
||||
m = re.match('^\w+:\/\/.+?(\/.+)', url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return url
|
||||
|
||||
def killSocket(self):
|
||||
if not self.currentResponse:
|
||||
return
|
||||
|
||||
try:
|
||||
socket.fromfd(self.currentResponse.raw.fileno(), socket.AF_INET, socket.SOCK_STREAM).shutdown(socket.SHUT_RDWR)
|
||||
return
|
||||
except AttributeError:
|
||||
pass
|
||||
except Exception, e:
|
||||
util.ERROR(err=e)
|
||||
|
||||
try:
|
||||
self.currentResponse.raw._fp.fp._sock.shutdown(socket.SHUT_RDWR)
|
||||
except AttributeError:
|
||||
pass
|
||||
except Exception, e:
|
||||
util.ERROR(err=e)
|
||||
|
||||
def cancel(self):
|
||||
self._cancel = True
|
||||
self.session.cancel()
|
||||
self.removeAsPending()
|
||||
self.killSocket()
|
||||
|
||||
def addParam(self, encodedName, value):
|
||||
if self.hasParams:
|
||||
self.url += "&" + encodedName + "=" + urllib.quote_plus(value)
|
||||
else:
|
||||
self.hasParams = True
|
||||
self.url += "?" + encodedName + "=" + urllib.quote_plus(value)
|
||||
|
||||
def addHeader(self, name, value):
|
||||
self.session.headers[name] = value
|
||||
|
||||
def createRequestContext(self, requestType, callback_=None):
|
||||
context = RequestContext()
|
||||
context.requestType = requestType
|
||||
context.timeout = DEFAULT_TIMEOUT
|
||||
|
||||
if callback_:
|
||||
context.callback = callback.Callable(self.onResponse)
|
||||
context.completionCallback = callback_
|
||||
context.callbackCtx = callback_.context
|
||||
|
||||
return context
|
||||
|
||||
def onResponse(self, event, context):
|
||||
if context.completionCallback:
|
||||
response = HttpResponse(event)
|
||||
context.completionCallback(self, response, context)
|
||||
|
||||
def logRequest(self, body, timeout=None, async=True):
|
||||
# Log the real request method
|
||||
method = self.method
|
||||
if not method:
|
||||
method = body is not None and "POST" or "GET"
|
||||
util.LOG(
|
||||
"Starting request: {0} {1} (async={2} timeout={3})".format(method, util.cleanToken(self.url), async, timeout)
|
||||
)
|
||||
|
||||
|
||||
class HttpResponse(object):
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
if not self.event is None:
|
||||
self.event.content # force data to be read
|
||||
self.event.close()
|
||||
|
||||
def isSuccess(self):
|
||||
if not self.event:
|
||||
return False
|
||||
return self.event.status_code >= 200 and self.event.status_code < 300
|
||||
|
||||
def isError(self):
|
||||
return not self.isSuccess()
|
||||
|
||||
def getStatus(self):
|
||||
if self.event is None:
|
||||
return 0
|
||||
return self.event.status_code
|
||||
|
||||
def getBodyString(self):
|
||||
if self.event is None:
|
||||
return ''
|
||||
return self.event.text.encode('utf-8')
|
||||
|
||||
def getErrorString(self):
|
||||
if self.event is None:
|
||||
return ''
|
||||
return self.event.reason
|
||||
|
||||
def getBodyXml(self):
|
||||
if not self.event is None:
|
||||
return ElementTree.fromstring(self.getBodyString())
|
||||
|
||||
return None
|
||||
|
||||
def getResponseHeader(self, name):
|
||||
if self.event is None:
|
||||
return None
|
||||
return self.event.headers.get(name)
|
||||
|
||||
|
||||
class HttpObjectResponse(HttpResponse, plexobjects.PlexContainer):
|
||||
def __init__(self, response, path, server=None):
|
||||
self.event = response
|
||||
if self.event:
|
||||
self.event.content # force data to be read
|
||||
self.event.close()
|
||||
|
||||
data = self.getBodyXml()
|
||||
|
||||
plexobjects.PlexContainer.__init__(self, data, initpath=path, server=server, address=path)
|
||||
self.container = self
|
||||
|
||||
self.items = plexobjects.listItems(server, path, data=data, container=self)
|
||||
|
||||
|
||||
def addRequestHeaders(transferObj, headers=None):
|
||||
if isinstance(headers, dict):
|
||||
for header in headers:
|
||||
transferObj.addHeader(header, headers[header])
|
||||
util.DEBUG_LOG("Adding header to {0}: {1}: {2}".format(transferObj, header, headers[header]))
|
||||
|
||||
|
||||
def addUrlParam(url, param):
|
||||
return url + ('?' in url and '&' or '?') + param
|
66
resources/lib/plexnet/locks.py
Normal file
66
resources/lib/plexnet/locks.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
# Generic Locks. These are only virtual. You will need to check for the lock to
|
||||
# ignore processing depending on the lockName.
|
||||
# * Locks().Lock("lockName") : creates virtual lock
|
||||
# * Locks().IsLocked("lockName") : returns true if locked
|
||||
# * Locks().Unlock("lockName") : return true if existed & removed
|
||||
import util
|
||||
|
||||
|
||||
class Locks(object):
|
||||
def __init__(self):
|
||||
self.locks = {}
|
||||
self.oneTimeLocks = {}
|
||||
|
||||
def lock(self, name):
|
||||
self.locks[name] = (self.locks.get(name) or 0) + 1
|
||||
util.DEBUG_LOG("Lock {0}, total={0}".format(name, self.locks[name]))
|
||||
|
||||
def lockOnce(self, name):
|
||||
util.DEBUG_LOG("Locking once {0}".format(name))
|
||||
self.oneTimeLocks[name] = True
|
||||
|
||||
def unlock(self, name, forceUnlock=False):
|
||||
oneTime = False
|
||||
if name in self.oneTimeLocks:
|
||||
del self.oneTimeLocks[name]
|
||||
oneTime = True
|
||||
normal = (self.locks.get(name) or 0) > 0
|
||||
|
||||
if normal:
|
||||
if forceUnlock:
|
||||
self.locks[name] = 0
|
||||
else:
|
||||
self.locks[name] -= 1
|
||||
|
||||
if self.locks[name] <= 0:
|
||||
del self.locks[name]
|
||||
else:
|
||||
normal = False
|
||||
|
||||
unlocked = (normal or oneTime)
|
||||
util.DEBUG_LOG("Unlock {0}, total={1}, unlocked={2}".format(name, self.locks.get(name) or 0, unlocked))
|
||||
|
||||
return unlocked
|
||||
|
||||
def isLocked(self, name):
|
||||
return name in self.oneTimeLocks or name in self.locks
|
||||
# return (self.oneTimeLocks.Delete(name) or self.locks.DoesExist(name))
|
||||
|
||||
|
||||
# lock helpers
|
||||
def disableBackButton():
|
||||
LOCKS.lock("BackButton")
|
||||
|
||||
|
||||
def enableBackButton():
|
||||
LOCKS.unlock("BackButton", True)
|
||||
|
||||
|
||||
def disableRemoteControl():
|
||||
LOCKS.lock("roUniversalControlEvent")
|
||||
|
||||
|
||||
def enableRemoteControl():
|
||||
LOCKS.unlock("roUniversalControlEvent", True)
|
||||
|
||||
LOCKS = Locks()
|
228
resources/lib/plexnet/media.py
Normal file
228
resources/lib/plexnet/media.py
Normal file
|
@ -0,0 +1,228 @@
|
|||
import plexobjects
|
||||
import plexstream
|
||||
import plexrequest
|
||||
import util
|
||||
|
||||
METADATA_RELATED_TRAILER = 1
|
||||
METADATA_RELATED_DELETED_SCENE = 2
|
||||
METADATA_RELATED_INTERVIEW = 3
|
||||
METADATA_RELATED_MUSIC_VIDEO = 4
|
||||
METADATA_RELATED_BEHIND_THE_SCENES = 5
|
||||
METADATA_RELATED_SCENE_OR_SAMPLE = 6
|
||||
METADATA_RELATED_LIVE_MUSIC_VIDEO = 7
|
||||
METADATA_RELATED_LYRIC_MUSIC_VIDEO = 8
|
||||
METADATA_RELATED_CONCERT = 9
|
||||
METADATA_RELATED_FEATURETTE = 10
|
||||
METADATA_RELATED_SHORT = 11
|
||||
METADATA_RELATED_OTHER = 12
|
||||
|
||||
|
||||
class MediaItem(plexobjects.PlexObject):
|
||||
def getIdentifier(self):
|
||||
identifier = self.get('identifier') or None
|
||||
|
||||
if identifier is None:
|
||||
identifier = self.container.identifier
|
||||
|
||||
# HACK
|
||||
# PMS doesn't return an identifier for playlist items. If we haven't found
|
||||
# an identifier and the key looks like a library item, then we pretend like
|
||||
# the identifier was set.
|
||||
#
|
||||
if identifier is None: # Modified from Roku code which had no check for None with iPhoto - is that right?
|
||||
if self.key.startswith('/library/metadata'):
|
||||
identifier = "com.plexapp.plugins.library"
|
||||
elif self.isIPhoto():
|
||||
identifier = "com.plexapp.plugins.iphoto"
|
||||
|
||||
return identifier
|
||||
|
||||
def getQualityType(self, server=None):
|
||||
if self.isOnlineItem():
|
||||
return util.QUALITY_ONLINE
|
||||
|
||||
if not server:
|
||||
server = self.getServer()
|
||||
|
||||
return util.QUALITY_LOCAL if server.isLocalConnection() else util.QUALITY_REMOTE
|
||||
|
||||
def delete(self):
|
||||
if not self.ratingKey:
|
||||
return
|
||||
|
||||
req = plexrequest.PlexRequest(self.server, '/library/metadata/{0}'.format(self.ratingKey), method='DELETE')
|
||||
req.getToStringWithTimeout(10)
|
||||
self.deleted = req.wasOK()
|
||||
return self.deleted
|
||||
|
||||
def exists(self):
|
||||
if self.deleted:
|
||||
return False
|
||||
|
||||
data = self.server.query('/library/metadata/{0}'.format(self.ratingKey))
|
||||
return data.attrib.get('size') != '0'
|
||||
# req = plexrequest.PlexRequest(self.server, '/library/metadata/{0}'.format(self.ratingKey), method='HEAD')
|
||||
# req.getToStringWithTimeout(10)
|
||||
# return not req.wasNotFound()
|
||||
|
||||
def fixedDuration(self):
|
||||
duration = self.duration.asInt()
|
||||
if duration < 1000:
|
||||
duration *= 60000
|
||||
return duration
|
||||
|
||||
|
||||
class Media(plexobjects.PlexObject):
|
||||
TYPE = 'Media'
|
||||
|
||||
def __init__(self, data, initpath=None, server=None, video=None):
|
||||
plexobjects.PlexObject.__init__(self, data, initpath=initpath, server=server)
|
||||
self.video = video
|
||||
self.parts = [MediaPart(elem, initpath=self.initpath, server=self.server, media=self) for elem in data]
|
||||
|
||||
def __repr__(self):
|
||||
title = self.video.title.replace(' ', '.')[0:20]
|
||||
return '<%s:%s>' % (self.__class__.__name__, title.encode('utf8'))
|
||||
|
||||
|
||||
class MediaPart(plexobjects.PlexObject):
|
||||
TYPE = 'Part'
|
||||
|
||||
def __init__(self, data, initpath=None, server=None, media=None):
|
||||
plexobjects.PlexObject.__init__(self, data, initpath=initpath, server=server)
|
||||
self.media = media
|
||||
self.streams = [MediaPartStream.parse(e, initpath=self.initpath, server=server, part=self) for e in data if e.tag == 'Stream']
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s:%s>' % (self.__class__.__name__, self.id)
|
||||
|
||||
def selectedStream(self, stream_type):
|
||||
streams = filter(lambda x: stream_type == x.type, self.streams)
|
||||
selected = list(filter(lambda x: x.selected is True, streams))
|
||||
if len(selected) == 0:
|
||||
return None
|
||||
return selected[0]
|
||||
|
||||
|
||||
class MediaPartStream(plexstream.PlexStream):
|
||||
TYPE = None
|
||||
STREAMTYPE = None
|
||||
|
||||
def __init__(self, data, initpath=None, server=None, part=None):
|
||||
plexobjects.PlexObject.__init__(self, data, initpath, server)
|
||||
self.part = part
|
||||
|
||||
@staticmethod
|
||||
def parse(data, initpath=None, server=None, part=None):
|
||||
STREAMCLS = {
|
||||
1: VideoStream,
|
||||
2: AudioStream,
|
||||
3: SubtitleStream
|
||||
}
|
||||
stype = int(data.attrib.get('streamType'))
|
||||
cls = STREAMCLS.get(stype, MediaPartStream)
|
||||
return cls(data, initpath=initpath, server=server, part=part)
|
||||
|
||||
def __repr__(self):
|
||||
return '<%s:%s>' % (self.__class__.__name__, self.id)
|
||||
|
||||
|
||||
class VideoStream(MediaPartStream):
|
||||
TYPE = 'videostream'
|
||||
STREAMTYPE = plexstream.PlexStream.TYPE_VIDEO
|
||||
|
||||
|
||||
class AudioStream(MediaPartStream):
|
||||
TYPE = 'audiostream'
|
||||
STREAMTYPE = plexstream.PlexStream.TYPE_AUDIO
|
||||
|
||||
|
||||
class SubtitleStream(MediaPartStream):
|
||||
TYPE = 'subtitlestream'
|
||||
STREAMTYPE = plexstream.PlexStream.TYPE_SUBTITLE
|
||||
|
||||
|
||||
class TranscodeSession(plexobjects.PlexObject):
|
||||
TYPE = 'TranscodeSession'
|
||||
|
||||
|
||||
class MediaTag(plexobjects.PlexObject):
|
||||
TYPE = None
|
||||
ID = 'None'
|
||||
|
||||
def __repr__(self):
|
||||
tag = self.tag.replace(' ', '.')[0:20]
|
||||
return '<%s:%s:%s>' % (self.__class__.__name__, self.id, tag)
|
||||
|
||||
def __eq__(self, other):
|
||||
if other.__class__ != self.__class__:
|
||||
return False
|
||||
|
||||
return self.id == other.id
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
|
||||
class Collection(MediaTag):
|
||||
TYPE = 'Collection'
|
||||
FILTER = 'collection'
|
||||
|
||||
|
||||
class Country(MediaTag):
|
||||
TYPE = 'Country'
|
||||
FILTER = 'country'
|
||||
|
||||
|
||||
class Director(MediaTag):
|
||||
TYPE = 'Director'
|
||||
FILTER = 'director'
|
||||
ID = '4'
|
||||
|
||||
|
||||
class Genre(MediaTag):
|
||||
TYPE = 'Genre'
|
||||
FILTER = 'genre'
|
||||
ID = '1'
|
||||
|
||||
|
||||
class Mood(MediaTag):
|
||||
TYPE = 'Mood'
|
||||
FILTER = 'mood'
|
||||
|
||||
|
||||
class Producer(MediaTag):
|
||||
TYPE = 'Producer'
|
||||
FILTER = 'producer'
|
||||
|
||||
|
||||
class Role(MediaTag):
|
||||
TYPE = 'Role'
|
||||
FILTER = 'actor'
|
||||
ID = '6'
|
||||
|
||||
def sectionRoles(self):
|
||||
hubs = self.server.hubs(count=10, search_query=self.tag)
|
||||
for hub in hubs:
|
||||
if hub.type == 'actor':
|
||||
break
|
||||
else:
|
||||
return None
|
||||
|
||||
roles = []
|
||||
|
||||
for actor in hub.items:
|
||||
if actor.id == self.id:
|
||||
roles.append(actor)
|
||||
|
||||
return roles or None
|
||||
|
||||
|
||||
class Similar(MediaTag):
|
||||
TYPE = 'Similar'
|
||||
FILTER = 'similar'
|
||||
|
||||
|
||||
class Writer(MediaTag):
|
||||
TYPE = 'Writer'
|
||||
FILTER = 'writer'
|
49
resources/lib/plexnet/mediachoice.py
Normal file
49
resources/lib/plexnet/mediachoice.py
Normal file
|
@ -0,0 +1,49 @@
|
|||
import plexstream
|
||||
import util
|
||||
|
||||
|
||||
class MediaChoice(object):
|
||||
SUBTITLES_DEFAULT = 0
|
||||
SUBTITLES_BURN = 1
|
||||
SUBTITLES_SOFT_DP = 2
|
||||
SUBTITLES_SOFT_ANY = 3
|
||||
|
||||
def __init__(self, media=None, partIndex=0):
|
||||
self.media = media
|
||||
self.part = None
|
||||
self.forceTranscode = False
|
||||
self.isDirectPlayable = False
|
||||
self.videoStream = None
|
||||
self.audioStream = None
|
||||
self.subtitleStream = None
|
||||
self.isSelected = False
|
||||
self.subtitleDecision = self.SUBTITLES_DEFAULT
|
||||
|
||||
self.sorts = util.AttributeDict()
|
||||
|
||||
if media:
|
||||
self.indirectHeaders = media.indirectHeaders
|
||||
self.part = media.parts[partIndex]
|
||||
if self.part:
|
||||
# We generally just rely on PMS to have told us selected streams, so
|
||||
# initialize our streams accordingly.
|
||||
|
||||
self.videoStream = self.part.getSelectedStreamOfType(plexstream.PlexStream.TYPE_VIDEO)
|
||||
self.audioStream = self.part.getSelectedStreamOfType(plexstream.PlexStream.TYPE_AUDIO)
|
||||
self.subtitleStream = self.part.getSelectedStreamOfType(plexstream.PlexStream.TYPE_SUBTITLE)
|
||||
else:
|
||||
util.WARN_LOG("Media does not contain a valid part")
|
||||
|
||||
util.LOG("Choice media: {0} part:{1}".format(media, partIndex))
|
||||
for streamType in ("videoStream", "audioStream", "subtitleStream"):
|
||||
attr = getattr(self, streamType)
|
||||
if attr:
|
||||
util.LOG("Choice {0}: {1}".format(streamType, repr(attr)))
|
||||
else:
|
||||
util.WARN_LOG("Could not create media choice for invalid media")
|
||||
|
||||
def __str__(self):
|
||||
return "direct playable={0} version={1}".format(self.isDirectPlayable, self.media)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
474
resources/lib/plexnet/mediadecisionengine.py
Normal file
474
resources/lib/plexnet/mediadecisionengine.py
Normal file
|
@ -0,0 +1,474 @@
|
|||
import mediachoice
|
||||
import serverdecision
|
||||
import plexapp
|
||||
import util
|
||||
|
||||
|
||||
class MediaDecisionEngine(object):
|
||||
proxyTypes = util.AttributeDict({
|
||||
'NORMAL': 0,
|
||||
'LOCAL': 42,
|
||||
'CLOUD': 43
|
||||
})
|
||||
|
||||
def __init__(self):
|
||||
self.softSubLanguages = None
|
||||
|
||||
# TODO(schuyler): Do we need to allow this to be async? We may have to request
|
||||
# the media again to fetch details, and we may need to make multiple requests to
|
||||
# resolve an indirect. We can do it all async, we can block, or we can allow
|
||||
# both.
|
||||
|
||||
def chooseMedia(self, item, forceUpdate=False):
|
||||
# If we've already evaluated this item, use our previous choice.
|
||||
if not forceUpdate and item.mediaChoice is not None and item.mediaChoice.media is not None and not item.mediaChoice.media.isIndirect():
|
||||
return item.mediaChoice
|
||||
|
||||
# See if we're missing media/stream details for this item.
|
||||
if item.isLibraryItem() and item.isVideoItem() and len(item.media) > 0 and not item.media[0].hasStreams():
|
||||
# TODO(schuyler): Fetch the details
|
||||
util.WARN_LOG("Can't make media choice, missing details")
|
||||
|
||||
# Take a first pass through the media items to create an array of candidates
|
||||
# that we'll evaluate more completely. If we find a forced item, we use it.
|
||||
# If we find an indirect, we only keep a single candidate.
|
||||
indirect = False
|
||||
candidates = []
|
||||
maxResolution = item.settings.getMaxResolution(item.getQualityType())
|
||||
for mediaIndex in range(len(item.media)):
|
||||
media = item.media[mediaIndex]
|
||||
media.mediaIndex = mediaIndex
|
||||
if media.isSelected():
|
||||
candidates = []
|
||||
candidates.append(media)
|
||||
break
|
||||
if media.isIndirect():
|
||||
# Only add indirect media if the resolution fits. We cannot
|
||||
# exit early as the user may have selected media.
|
||||
|
||||
indirect = True
|
||||
if media.getVideoResolution() <= maxResolution:
|
||||
candidates.append(media)
|
||||
|
||||
elif media.isAccessible():
|
||||
# Only consider testing available media
|
||||
candidates.append(media)
|
||||
|
||||
# Only use the first indirect media item
|
||||
if indirect and candidates:
|
||||
candidates = candidates[0]
|
||||
|
||||
# Make sure we have at least one valid item, regardless of availability
|
||||
if len(candidates) == 0:
|
||||
candidates.append(item.media[0])
|
||||
|
||||
# Now that we have an array of candidates, evaluate them completely.
|
||||
choices = []
|
||||
for media in candidates:
|
||||
choice = None
|
||||
if media is not None:
|
||||
if item.isVideoItem():
|
||||
choice = self.evaluateMediaVideo(item, media)
|
||||
elif item.isMusicItem():
|
||||
choice = self.evaluateMediaMusic(item, media)
|
||||
else:
|
||||
choice = mediachoice.MediaChoice(media)
|
||||
choices.append(choice)
|
||||
item.mediaChoice = self.sortChoices(choices)[-1]
|
||||
util.LOG("MDE: MediaChoice: {0}".format(item.mediaChoice))
|
||||
return item.mediaChoice
|
||||
|
||||
def sortChoices(self, choices):
|
||||
if choices is None:
|
||||
return []
|
||||
|
||||
if len(choices) > 1:
|
||||
self.sort(choices, "bitrate")
|
||||
self.sort(choices, "audioChannels")
|
||||
self.sort(choices, "audioDS")
|
||||
self.sort(choices, "resolution")
|
||||
self.sort(choices, "videoDS")
|
||||
self.sort(choices, "directPlay")
|
||||
self.sort(choices, self.higherResIfCapable)
|
||||
self.sort(choices, self.cloudIfRemote)
|
||||
|
||||
return choices
|
||||
|
||||
def evaluateMediaVideo(self, item, media, partIndex=0):
|
||||
# Resolve indirects before doing anything else.
|
||||
if media.isIndirect():
|
||||
util.LOG("Resolve indirect media for {0}".format(item))
|
||||
media = media.resolveIndirect()
|
||||
|
||||
choice = mediachoice.MediaChoice(media, partIndex)
|
||||
server = item.getServer()
|
||||
|
||||
if not media:
|
||||
return choice
|
||||
|
||||
choice.isSelected = media.isSelected()
|
||||
choice.protocol = media.protocol("http")
|
||||
|
||||
maxResolution = item.settings.getMaxResolution(item.getQualityType(), self.isSupported4k(media, choice.videoStream))
|
||||
maxBitrate = item.settings.getMaxBitrate(item.getQualityType())
|
||||
|
||||
choice.resolution = media.getVideoResolution()
|
||||
if choice.resolution > maxResolution or media.bitrate.asInt() > maxBitrate:
|
||||
choice.forceTranscode = True
|
||||
|
||||
if choice.subtitleStream:
|
||||
choice.subtitleDecision = self.evaluateSubtitles(choice.subtitleStream)
|
||||
choice.hasBurnedInSubtitles = (choice.subtitleDecision != choice.SUBTITLES_SOFT_DP and choice.subtitleDecision != choice.SUBTITLES_SOFT_ANY)
|
||||
else:
|
||||
choice.hasBurnedInSubtitles = False
|
||||
|
||||
# For evaluation purposes, we only care about the first part
|
||||
part = media.parts[partIndex]
|
||||
if not part:
|
||||
return choice
|
||||
|
||||
# Although PMS has already told us which streams are selected, we can't
|
||||
# necessarily tell the video player which streams we want. So we need to
|
||||
# iterate over the streams and see if there are any red flags that would
|
||||
# prevent direct play. If there are multiple video streams, we're hosed.
|
||||
# For audio streams, we have a fighting chance if the selected stream can
|
||||
# be selected by language, but we need to be careful about guessing which
|
||||
# audio stream the Roku will pick for a given language.
|
||||
|
||||
numVideoStreams = 0
|
||||
problematicAudioStream = False
|
||||
|
||||
if part.get('hasChapterVideoStream').asBool():
|
||||
numVideoStreams = 1
|
||||
|
||||
for stream in part.streams:
|
||||
streamType = stream.streamType.asInt()
|
||||
if streamType == stream.TYPE_VIDEO:
|
||||
numVideoStreams = numVideoStreams + 1
|
||||
|
||||
if stream.codec == "h264" or (
|
||||
stream.codec == "hevc" and item.settings.getPreference("allow_hevc", False)
|
||||
) or (
|
||||
stream.codec == "vp9" and item.settings.getGlobal("vp9Support")
|
||||
):
|
||||
choice.sorts.videoDS = 1
|
||||
|
||||
# Special cases to force direct play
|
||||
forceDirectPlay = False
|
||||
if choice.protocol == "hls":
|
||||
util.LOG("MDE: Assuming HLS is direct playable")
|
||||
forceDirectPlay = True
|
||||
elif not server.supportsVideoTranscoding:
|
||||
# See if we can use another server to transcode, otherwise force direct play
|
||||
transcodeServer = item.getTranscodeServer(True, "video")
|
||||
if not transcodeServer or not transcodeServer.supportsVideoTranscoding:
|
||||
util.LOG("MDE: force direct play because the server does not support video transcoding")
|
||||
forceDirectPlay = True
|
||||
|
||||
# See if we found any red flags based on the streams. Otherwise, go ahead
|
||||
# with our codec checks.
|
||||
|
||||
if forceDirectPlay:
|
||||
# Consider the choice DP, but continue to allow the
|
||||
# choice to have the sorts set properly.
|
||||
choice.isDirectPlayable = True
|
||||
elif choice.hasBurnedInSubtitles:
|
||||
util.LOG("MDE: Need to burn in subtitles")
|
||||
elif choice.protocol != "http":
|
||||
util.LOG("MDE: " + choice.protocol + " not supported")
|
||||
elif numVideoStreams > 1:
|
||||
util.LOG("MDE: Multiple video streams, won't try to direct play")
|
||||
elif problematicAudioStream:
|
||||
util.LOG("MDE: Problematic AAC stream with more than 2 channels prevents direct play")
|
||||
elif self.canDirectPlay(item, choice):
|
||||
choice.isDirectPlayable = True
|
||||
elif item.isMediaSynthesized:
|
||||
util.LOG("MDE: assuming synthesized media can direct play")
|
||||
choice.isDirectPlayable = True
|
||||
|
||||
# Check for a server decision. This is authority as it's the only playback type
|
||||
# the server will allow. This will also support forcing direct play, overriding
|
||||
# only our local MDE checks based on the user pref, and only if the server
|
||||
# agrees.
|
||||
decision = part.get("decision")
|
||||
if decision:
|
||||
if decision != serverdecision.ServerDecision.DECISION_DIRECT_PLAY:
|
||||
util.LOG("MDE: Server has decided this cannot direct play")
|
||||
choice.isDirectPlayable = False
|
||||
else:
|
||||
util.LOG("MDE: Server has allowed direct play")
|
||||
choice.isDirectPlayable = True
|
||||
|
||||
# Setup sorts
|
||||
if choice.videoStream is not None:
|
||||
choice.sorts.bitrate = choice.videoStream.bitrate.asInt()
|
||||
elif choice.media is not None:
|
||||
choice.sorts.bitrate = choice.media.bitrate.asInt()
|
||||
else:
|
||||
choice.sorts.bitrate = 0
|
||||
|
||||
if choice.audioStream is not None:
|
||||
choice.sorts.audioChannels = choice.audioStream.channels.asInt()
|
||||
elif choice.media is not None:
|
||||
choice.sorts.audioChannels = choice.media.audioChannels.asInt()
|
||||
else:
|
||||
choice.sorts.audioChannels = 0
|
||||
|
||||
choice.sorts.videoDS = not (choice.sorts.videoDS is None or choice.forceTranscode is True) and choice.sorts.videoDS or 0
|
||||
choice.sorts.resolution = choice.resolution
|
||||
|
||||
# Server properties probably don't need to be associated with each choice
|
||||
choice.sorts.canTranscode = server.supportsVideoTranscoding and 1 or 0
|
||||
choice.sorts.canRemuxOnly = server.supportsVideoRemuxOnly and 1 or 0
|
||||
choice.sorts.directPlay = (choice.isDirectPlayable is True and choice.forceTranscode is not True) and 1 or 0
|
||||
choice.sorts.proxyType = choice.media.proxyType and choice.media.proxyType or self.proxyTypes.NORMAL
|
||||
|
||||
return choice
|
||||
|
||||
def canDirectPlay(self, item, choice):
|
||||
maxResolution = item.settings.getMaxResolution(item.getQualityType(), self.isSupported4k(choice.media, choice.videoStream))
|
||||
height = choice.media.getVideoResolution()
|
||||
if height > maxResolution:
|
||||
util.LOG("MDE: (DP) Video height is greater than max allowed: {0} > {1}".format(height, maxResolution))
|
||||
if height > 1088 and item.settings.getPreference("allow_4k", True):
|
||||
util.LOG("MDE: (DP) Unsupported 4k media")
|
||||
return False
|
||||
|
||||
maxBitrate = item.settings.getMaxBitrate(item.getQualityType())
|
||||
bitrate = choice.media.bitrate.asInt()
|
||||
if bitrate > maxBitrate:
|
||||
util.LOG("MDE: (DP) Video bitrate is greater than the allowed max: {0} > {1}".format(bitrate, maxBitrate))
|
||||
return False
|
||||
|
||||
if choice.videoStream is None:
|
||||
util.ERROR_LOG("MDE: (DP) No video stream")
|
||||
return True
|
||||
|
||||
if not item.settings.getGlobal("supports1080p60"):
|
||||
videoFrameRate = choice.videoStream.asInt()
|
||||
if videoFrameRate > 30 and height >= 1080:
|
||||
util.LOG("MDE: (DP) Frame rate is not supported for resolution: {0}@{1}".format(height, videoFrameRate))
|
||||
return False
|
||||
|
||||
if choice.videoStream.codec == "hevc" and not item.settings.getPreference("allow_hevc", False):
|
||||
util.LOG("MDE: (DP) Codec is HEVC, which is disabled")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# container = choice.media.get('container')
|
||||
# videoCodec = choice.videoStream.codec
|
||||
# if choice.audioStream is None:
|
||||
# audioCodec = None
|
||||
# numChannels = 0
|
||||
# else:
|
||||
# audioCodec = choice.audioStream.codec
|
||||
# numChannels = choice.audioStream.channels.asInt()
|
||||
|
||||
# Formats: https://support.roku.com/hc/en-us/articles/208754908-Roku-Media-Player-Playing-your-personal-videos-music-photos
|
||||
# All Models: H.264/AVC (MKV, MP4, MOV),
|
||||
# Roku 4 only: H.265/HEVC (MKV, MP4, MOV); VP9 (.MKV)
|
||||
|
||||
# if True: # container in ("mp4", "mov", "m4v", "mkv"):
|
||||
# util.LOG("MDE: {0} container looks OK, checking streams".format(container))
|
||||
|
||||
# isHEVC = videoCodec == "hevc" and item.settings.getPreference("allow_hevc", False)
|
||||
# isVP9 = videoCodec == "vp9" and container == "mkv" and item.settings.getGlobal("vp9Support")
|
||||
|
||||
# if videoCodec != "h264" and videoCodec != "mpeg4" and not isHEVC and not isVP9:
|
||||
# util.LOG("MDE: Unsupported video codec: {0}".format(videoCodec))
|
||||
# return False
|
||||
|
||||
# # TODO(schuyler): Fix ref frames check. It's more nuanced than this.
|
||||
# if choice.videoStream.refFrames.asInt() > 8:
|
||||
# util.LOG("MDE: Too many ref frames: {0}".format(choice.videoStream.refFrames))
|
||||
# return False
|
||||
|
||||
# # HEVC supports a bitDepth of 10, otherwise 8 is the limit
|
||||
# if choice.videoStream.bitDepth.asInt() > (isHEVC and 10 or 8):
|
||||
# util.LOG("MDE: Bit depth too high: {0}".format(choice.videoStream.bitDepth))
|
||||
# return False
|
||||
|
||||
# # We shouldn't have to whitelist particular audio codecs, we can just
|
||||
# # check to see if the Roku can decode this codec with the number of channels.
|
||||
# if not item.settings.supportsAudioStream(audioCodec, numChannels):
|
||||
# util.LOG("MDE: Unsupported audio track: {0} ({1} channels)".format(audioCodec, numChannels))
|
||||
# return False
|
||||
|
||||
# # # TODO(schuyler): We've reported this to Roku, they may fix it. If/when
|
||||
# # # they do, we should move this behind a firmware version check.
|
||||
# # if container == "mkv" and choice.videoStream.headerStripping.asBool() and audioCodec == "ac3":
|
||||
# # util.ERROR_LOG("MDE: Header stripping with AC3 audio")
|
||||
# # return False
|
||||
|
||||
# # Those were our problems, everything else should be OK.
|
||||
# return True
|
||||
# else:
|
||||
# util.LOG("MDE: Unsupported container: {0}".format(container))
|
||||
|
||||
# return False
|
||||
|
||||
def evaluateSubtitles(self, stream):
|
||||
if plexapp.INTERFACE.getPreference("burn_subtitles") == "always":
|
||||
# If the user prefers them burned, always burn
|
||||
return mediachoice.MediaChoice.SUBTITLES_BURN
|
||||
# elif stream.codec != "srt":
|
||||
# # We only support soft subtitles for SRT. Anything else has to use the
|
||||
# # transcoder, and we defer to it on whether the subs will have to be
|
||||
# # burned or can be converted to SRT and muxed.
|
||||
|
||||
# return mediachoice.MediaChoice.SUBTITLES_DEFAULT
|
||||
elif stream.key is None:
|
||||
# Embedded subs don't have keys and can only be direct played
|
||||
result = mediachoice.MediaChoice.SUBTITLES_SOFT_DP
|
||||
else:
|
||||
# Sidecar subs can be direct played or used alongside a transcode
|
||||
result = mediachoice.MediaChoice.SUBTITLES_SOFT_ANY
|
||||
|
||||
# # TODO(schuyler) If Roku adds support for non-Latin characters, remove
|
||||
# # this hackery. To the extent that we continue using this hackery, it
|
||||
# # seems that the Roku requires UTF-8 subtitles but only supports characters
|
||||
# # from Windows-1252. This should be the full set of languages that are
|
||||
# # completely representable in Windows-1252. PMS should specifically be
|
||||
# # returning ISO 639-2/B language codes.
|
||||
# # Update: Roku has added support for additional characters, but still only
|
||||
# # Latin characters. We can now basically support anything from the various
|
||||
# # ISO-8859 character sets, but nothing non-Latin.
|
||||
|
||||
# if not self.softSubLanguages:
|
||||
# self.softSubLanguages = frozenset((
|
||||
# 'afr',
|
||||
# 'alb',
|
||||
# 'baq',
|
||||
# 'bre',
|
||||
# 'cat',
|
||||
# 'cze',
|
||||
# 'dan',
|
||||
# 'dut',
|
||||
# 'eng',
|
||||
# 'epo',
|
||||
# 'est',
|
||||
# 'fao',
|
||||
# 'fin',
|
||||
# 'fre',
|
||||
# 'ger',
|
||||
# 'gla',
|
||||
# 'gle',
|
||||
# 'glg',
|
||||
# 'hrv',
|
||||
# 'hun',
|
||||
# 'ice',
|
||||
# 'ita',
|
||||
# 'lat',
|
||||
# 'lav',
|
||||
# 'lit',
|
||||
# 'ltz',
|
||||
# 'may',
|
||||
# 'mlt',
|
||||
# 'nno',
|
||||
# 'nob',
|
||||
# 'nor',
|
||||
# 'oci',
|
||||
# 'pol',
|
||||
# 'por',
|
||||
# 'roh',
|
||||
# 'rum',
|
||||
# 'slo',
|
||||
# 'slv',
|
||||
# 'spa',
|
||||
# 'srd',
|
||||
# 'swa',
|
||||
# 'swe',
|
||||
# 'tur',
|
||||
# 'vie',
|
||||
# 'wel',
|
||||
# 'wln'
|
||||
# ))
|
||||
|
||||
# if not (stream.languageCode or 'eng') in self.softSubLanguages:
|
||||
# # If the language is unsupported,: we need to force burning
|
||||
# result = mediachoice.MediaChoice.SUBTITLES_BURN
|
||||
|
||||
return result
|
||||
|
||||
def evaluateMediaMusic(self, item, media):
|
||||
# Resolve indirects before doing anything else.
|
||||
if media.isIndirect():
|
||||
util.LOG("Resolve indirect media for {0}".format(item))
|
||||
media = media.resolveIndirect()
|
||||
|
||||
choice = mediachoice.MediaChoice(media)
|
||||
if media is None:
|
||||
return choice
|
||||
|
||||
# Verify the server supports audio transcoding, otherwise force direct play
|
||||
if not item.getServer().supportsAudioTranscoding:
|
||||
util.LOG("MDE: force direct play because the server does not support audio transcoding")
|
||||
choice.isDirectPlayable = True
|
||||
return choice
|
||||
|
||||
# See if this part has a server decision to transcode and obey it
|
||||
if choice.part and choice.part.get(
|
||||
"decision", serverdecision.ServerDecision.DECISION_DIRECT_PLAY
|
||||
) != serverdecision.ServerDecision.DECISION_DIRECT_PLAY:
|
||||
util.WARN_LOG("MDE: Server has decided this cannot direct play")
|
||||
return choice
|
||||
|
||||
# Verify the codec and container are compatible
|
||||
codec = media.audioCodec
|
||||
container = media.get('container')
|
||||
canPlayCodec = item.settings.supportsAudioStream(codec, media.audioChannels.asInt())
|
||||
canPlayContainer = (codec == container) or True # (container in ("mp4", "mka", "mkv"))
|
||||
|
||||
choice.isDirectPlayable = (canPlayCodec and canPlayContainer)
|
||||
if choice.isDirectPlayable:
|
||||
# Inspect the audio stream attributes if the codec/container can direct
|
||||
# play. For now we only need to verify the sample rate.
|
||||
|
||||
if choice.audioStream is not None and choice.audioStream.samplingRate.asInt() >= 192000:
|
||||
util.LOG("MDE: sampling rate is not compatible")
|
||||
choice.isDirectPlayable = False
|
||||
else:
|
||||
util.LOG("MDE: container or codec is incompatible")
|
||||
|
||||
return choice
|
||||
|
||||
# Simple Quick sort function modeled after roku sdk function
|
||||
def sort(self, choices, key=None):
|
||||
if not isinstance(choices, list):
|
||||
return
|
||||
|
||||
if key is None:
|
||||
choices.sort()
|
||||
elif isinstance(key, basestring):
|
||||
choices.sort(key=lambda x: getattr(x.media, key))
|
||||
elif hasattr(key, '__call__'):
|
||||
choices.sort(key=key)
|
||||
|
||||
def higherResIfCapable(self, choice):
|
||||
if choice.media is not None:
|
||||
server = choice.media.getServer()
|
||||
if server.supportsVideoTranscoding and not server.supportsVideoRemuxOnly and (choice.sorts.directPlay == 1 or choice.sorts.videoDS == 1):
|
||||
return util.validInt(choice.sorts.resolution)
|
||||
|
||||
return 0
|
||||
|
||||
def cloudIfRemote(self, choice):
|
||||
if choice.media is not None and choice.media.getServer().isLocalConnection() and choice.media.proxyType != self.proxyTypes.CLOUD:
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
def isSupported4k(self, media, videoStream):
|
||||
if videoStream is None or not plexapp.INTERFACE.getPreference("allow_4k", True):
|
||||
return False
|
||||
|
||||
# # Roku 4 only: H.265/HEVC (MKV, MP4, MOV); VP9 (.MKV)
|
||||
# if media.get('container') in ("mp4", "mov", "m4v", "mkv"):
|
||||
# isHEVC = (videoStream.codec == "hevc" and plexapp.INTERFACE.getPreference("allow_hevc"))
|
||||
# isVP9 = (videoStream.codec == "vp9" and media.get('container') == "mkv" and plexapp.INTERFACE.getGlobal("vp9Support"))
|
||||
# return (isHEVC or isVP9)
|
||||
|
||||
# return False
|
||||
|
||||
return True
|
90
resources/lib/plexnet/myplex.py
Normal file
90
resources/lib/plexnet/myplex.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import util
|
||||
import http
|
||||
from threading import Thread
|
||||
from xml.etree import ElementTree
|
||||
import time
|
||||
|
||||
import exceptions
|
||||
|
||||
import video
|
||||
import audio
|
||||
import photo
|
||||
|
||||
video, audio, photo # Hides warning message
|
||||
|
||||
|
||||
class PinLogin(object):
|
||||
INIT = 'https://plex.tv/pins.xml'
|
||||
POLL = 'https://plex.tv/pins/{0}.xml'
|
||||
POLL_INTERVAL = 1
|
||||
|
||||
def __init__(self, callback=None):
|
||||
self._callback = callback
|
||||
self.id = None
|
||||
self.pin = None
|
||||
self.authenticationToken = None
|
||||
self._finished = False
|
||||
self._abort = False
|
||||
self._expired = False
|
||||
self._init()
|
||||
|
||||
def _init(self):
|
||||
response = http.POST(self.INIT)
|
||||
if response.status_code != http.codes.created:
|
||||
codename = http.status_codes.get(response.status_code)[0]
|
||||
raise exceptions.BadRequest('({0}) {1}'.format(response.status_code, codename))
|
||||
data = ElementTree.fromstring(response.text.encode('utf-8'))
|
||||
self.pin = data.find('code').text
|
||||
self.id = data.find('id').text
|
||||
|
||||
def _poll(self):
|
||||
try:
|
||||
start = time.time()
|
||||
while not self._abort and time.time() - start < 300:
|
||||
try:
|
||||
response = http.GET(self.POLL.format(self.id))
|
||||
except Exception, e:
|
||||
util.ERROR('PinLogin connection error: {0}'.format(e.__class__), err=e)
|
||||
time.sleep(self.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
if response.status_code != http.codes.ok:
|
||||
self._expired = True
|
||||
break
|
||||
try:
|
||||
data = ElementTree.fromstring(response.text.encode('utf-8'))
|
||||
except Exception, e:
|
||||
util.ERROR('PinLogin data error: {0}'.format(e.__class__), err=e)
|
||||
time.sleep(self.POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
token = data.find('auth_token').text
|
||||
if token:
|
||||
self.authenticationToken = token
|
||||
break
|
||||
time.sleep(self.POLL_INTERVAL)
|
||||
|
||||
if self._callback:
|
||||
self._callback(self.authenticationToken)
|
||||
finally:
|
||||
self._finished = True
|
||||
|
||||
def finished(self):
|
||||
return self._finished
|
||||
|
||||
def expired(self):
|
||||
return self._expired
|
||||
|
||||
def startTokenPolling(self):
|
||||
t = Thread(target=self._poll, name='PIN-LOGIN:Token-Poll')
|
||||
t.start()
|
||||
return t
|
||||
|
||||
def waitForToken(self):
|
||||
t = self.startTokenPolling()
|
||||
t.join()
|
||||
return self.authenticationToken
|
||||
|
||||
def abort(self):
|
||||
self._abort = True
|
311
resources/lib/plexnet/myplexaccount.py
Normal file
311
resources/lib/plexnet/myplexaccount.py
Normal file
|
@ -0,0 +1,311 @@
|
|||
import json
|
||||
import time
|
||||
import hashlib
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import plexapp
|
||||
import myplexrequest
|
||||
import locks
|
||||
import callback
|
||||
import asyncadapter
|
||||
|
||||
import util
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
if data.attrib.get('pin'):
|
||||
self.pin = data.attrib.get('pin')
|
||||
else:
|
||||
self.pin = None
|
||||
self.isProtected = bool(self.pin)
|
||||
|
||||
# update the list of users in the home
|
||||
self.updateHomeUsers()
|
||||
|
||||
# set admin attribute for the user
|
||||
self.isAdmin = False
|
||||
if self.homeUsers:
|
||||
for user in self.homeUsers:
|
||||
if self.ID == user.id:
|
||||
self.isAdmin = str(user.admin) == "1"
|
||||
break
|
||||
|
||||
if self.isAdmin and self.isPlexPass:
|
||||
self.adminHasPlexPass = True
|
||||
|
||||
# consider a single, unprotected user authenticated
|
||||
if not self.isAuthenticated and not self.isProtected and len(self.homeUsers) <= 1:
|
||||
self.isAuthenticated = True
|
||||
|
||||
self.logState()
|
||||
|
||||
self.saveState()
|
||||
plexapp.MANAGER.publish()
|
||||
plexapp.refreshResources()
|
||||
elif response.getStatus() >= 400 and response.getStatus() < 500:
|
||||
# The user is specifically unauthorized, clear everything
|
||||
util.WARN_LOG("Sign Out: User is unauthorized")
|
||||
self.signOut(True)
|
||||
else:
|
||||
# Unexpected error, keep using whatever we read from the registry
|
||||
util.WARN_LOG("Unexpected response from plex.tv ({0}), switching to OFFLINE mode".format(response.getStatus()))
|
||||
self.logState()
|
||||
self.isOffline = True
|
||||
# consider a single, unprotected user authenticated
|
||||
if not self.isAuthenticated and not self.isProtected:
|
||||
self.isAuthenticated = True
|
||||
|
||||
plexapp.APP.clearInitializer("myplex")
|
||||
# Logger().UpdateSyslogHeader() # TODO: ------------------------------------------------------------------------------------------------------IMPLEMENT
|
||||
|
||||
if oldId != self.ID or self.switchUser:
|
||||
self.switchUser = None
|
||||
plexapp.APP.trigger("change:user", account=self, reallyChanged=oldId != self.ID)
|
||||
|
||||
plexapp.APP.trigger("account:response")
|
||||
|
||||
def signOut(self, expired=False):
|
||||
# Strings
|
||||
self.ID = None
|
||||
self.title = None
|
||||
self.username = None
|
||||
self.email = None
|
||||
self.authToken = None
|
||||
self.pin = None
|
||||
self.lastHomeUserUpdate = None
|
||||
|
||||
# Booleans
|
||||
self.isSignedIn = False
|
||||
self.isPlexPass = False
|
||||
self.adminHasPlexPass = False
|
||||
self.isManaged = False
|
||||
self.isSecure = False
|
||||
self.isExpired = expired
|
||||
|
||||
# Clear the saved resources
|
||||
plexapp.INTERFACE.clearRegistry("mpaResources", "xml_cache")
|
||||
|
||||
# Remove all saved servers
|
||||
plexapp.SERVERMANAGER.clearServers()
|
||||
|
||||
# Enable the welcome screen again
|
||||
plexapp.INTERFACE.setPreference("show_welcome", True)
|
||||
|
||||
plexapp.APP.trigger("change:user", account=self, reallyChanged=True)
|
||||
|
||||
self.saveState()
|
||||
|
||||
def hasPlexPass(self):
|
||||
if self.isPlexPass or self.isManaged:
|
||||
return True
|
||||
|
||||
return self.adminHasPlexPass
|
||||
|
||||
def validateToken(self, token, switchUser=False):
|
||||
self.authToken = token
|
||||
self.switchUser = switchUser
|
||||
|
||||
request = myplexrequest.MyPlexRequest("/users/sign_in.xml")
|
||||
context = request.createRequestContext("sign_in", callback.Callable(self.onAccountResponse))
|
||||
if self.isOffline:
|
||||
context.timeout = self.isOffline and asyncadapter.AsyncTimeout(1).setConnectTimeout(1)
|
||||
plexapp.APP.startRequest(request, context, {})
|
||||
|
||||
def refreshAccount(self):
|
||||
if not self.authToken:
|
||||
return
|
||||
self.validateToken(self.authToken, False)
|
||||
|
||||
def updateHomeUsers(self):
|
||||
# Ignore request and clear any home users we are not signed in
|
||||
if not self.isSignedIn:
|
||||
self.homeUsers = []
|
||||
if self.isOffline:
|
||||
self.homeUsers.append(MyPlexAccount())
|
||||
|
||||
self.lastHomeUserUpdate = None
|
||||
return
|
||||
|
||||
# Cache home users for 60 seconds, mainly to stop back to back tests
|
||||
epoch = time.time()
|
||||
if not self.lastHomeUserUpdate:
|
||||
self.lastHomeUserUpdate = epoch
|
||||
elif self.lastHomeUserUpdate + 60 > epoch:
|
||||
util.DEBUG_LOG("Skipping home user update (updated {0} seconds ago)".format(epoch - self.lastHomeUserUpdate))
|
||||
return
|
||||
|
||||
req = myplexrequest.MyPlexRequest("/api/home/users")
|
||||
xml = req.getToStringWithTimeout()
|
||||
data = ElementTree.fromstring(xml)
|
||||
if data.attrib.get('size') and data.find('User') is not None:
|
||||
self.homeUsers = []
|
||||
for user in data.findall('User'):
|
||||
homeUser = HomeUser(user.attrib)
|
||||
homeUser.isAdmin = homeUser.admin == "1"
|
||||
homeUser.isManaged = homeUser.restricted == "1"
|
||||
homeUser.isProtected = homeUser.protected == "1"
|
||||
self.homeUsers.append(homeUser)
|
||||
|
||||
self.lastHomeUserUpdate = epoch
|
||||
|
||||
util.LOG("home users: {0}".format(self.homeUsers))
|
||||
|
||||
def switchHomeUser(self, userId, pin=''):
|
||||
if userId == self.ID and self.isAuthenticated:
|
||||
return True
|
||||
|
||||
# Offline support
|
||||
if self.isOffline:
|
||||
hashed = 'NONE'
|
||||
if pin and self.authToken:
|
||||
hashed = hashlib.sha256(pin + self.authToken).digest()
|
||||
|
||||
if not self.isProtected or self.isAuthenticated or hashed == (self.pin or ""):
|
||||
util.DEBUG_LOG("OFFLINE access granted")
|
||||
self.isAuthenticated = True
|
||||
self.validateToken(self.authToken, True)
|
||||
return True
|
||||
else:
|
||||
# build path and post to myplex to swith the user
|
||||
path = '/api/home/users/{0}/switch'.format(userId)
|
||||
req = myplexrequest.MyPlexRequest(path)
|
||||
xml = req.postToStringWithTimeout({'pin': pin})
|
||||
data = ElementTree.fromstring(xml)
|
||||
|
||||
if data.attrib.get('authenticationToken'):
|
||||
self.isAuthenticated = True
|
||||
# validate the token (trigger change:user) on user change or channel startup
|
||||
if userId != self.ID or not locks.LOCKS.isLocked("idleLock"):
|
||||
self.validateToken(data.attrib.get('authenticationToken'), True)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def isActive(self):
|
||||
return self.isSignedIn or self.isOffline
|
||||
|
||||
|
||||
ACCOUNT = MyPlexAccount()
|
76
resources/lib/plexnet/myplexmanager.py
Normal file
76
resources/lib/plexnet/myplexmanager.py
Normal file
|
@ -0,0 +1,76 @@
|
|||
from defusedxml import ElementTree
|
||||
import urllib
|
||||
|
||||
import plexapp
|
||||
import plexconnection
|
||||
import plexserver
|
||||
import myplexrequest
|
||||
import callback
|
||||
import util
|
||||
|
||||
|
||||
class MyPlexManager(object):
|
||||
def publish(self):
|
||||
util.LOG('MyPlexManager().publish() - NOT IMPLEMENTED')
|
||||
return # TODO: ----------------------------------------------------------------------------------------------------------------------------- IMPLEMENT?
|
||||
request = myplexrequest.MyPlexRequest("/devices/" + plexapp.INTERFACE.getGlobal("clientIdentifier"))
|
||||
context = request.createRequestContext("publish")
|
||||
|
||||
addrs = plexapp.INTERFACE.getGlobal("roDeviceInfo").getIPAddrs()
|
||||
|
||||
for iface in addrs:
|
||||
request.addParam(urllib.quote("Connection[][uri]"), "http://{0):8324".format(addrs[iface]))
|
||||
|
||||
plexapp.APP.startRequest(request, context, "_method=PUT")
|
||||
|
||||
def refreshResources(self, force=False):
|
||||
if force:
|
||||
plexapp.SERVERMANAGER.resetLastTest()
|
||||
|
||||
request = myplexrequest.MyPlexRequest("/pms/resources")
|
||||
context = request.createRequestContext("resources", callback.Callable(self.onResourcesResponse))
|
||||
|
||||
if plexapp.ACCOUNT.isSecure:
|
||||
request.addParam("includeHttps", "1")
|
||||
|
||||
plexapp.APP.startRequest(request, context)
|
||||
|
||||
def onResourcesResponse(self, request, response, context):
|
||||
servers = []
|
||||
|
||||
response.parseResponse()
|
||||
|
||||
# Save the last successful response to cache
|
||||
if response.isSuccess() and response.event:
|
||||
plexapp.INTERFACE.setRegistry("mpaResources", response.event.text.encode('utf-8'), "xml_cache")
|
||||
util.DEBUG_LOG("Saved resources response to registry")
|
||||
# Load the last successful response from cache
|
||||
elif plexapp.INTERFACE.getRegistry("mpaResources", None, "xml_cache"):
|
||||
data = ElementTree.fromstring(plexapp.INTERFACE.getRegistry("mpaResources", None, "xml_cache"))
|
||||
response.parseFakeXMLResponse(data)
|
||||
util.DEBUG_LOG("Using cached resources")
|
||||
|
||||
if response.container:
|
||||
for resource in response.container:
|
||||
util.DEBUG_LOG(
|
||||
"Parsed resource from plex.tv: type:{0} clientIdentifier:{1} name:{2} product:{3} provides:{4}".format(
|
||||
resource.type,
|
||||
resource.clientIdentifier,
|
||||
resource.name.encode('utf-8'),
|
||||
resource.product.encode('utf-8'),
|
||||
resource.provides.encode('utf-8')
|
||||
)
|
||||
)
|
||||
|
||||
for conn in resource.connections:
|
||||
util.DEBUG_LOG(' {0}'.format(conn))
|
||||
|
||||
if 'server' in resource.provides:
|
||||
server = plexserver.createPlexServerForResource(resource)
|
||||
util.DEBUG_LOG(' {0}'.format(server))
|
||||
servers.append(server)
|
||||
|
||||
plexapp.SERVERMANAGER.updateFromConnectionType(servers, plexconnection.PlexConnection.SOURCE_MYPLEX)
|
||||
|
||||
|
||||
MANAGER = MyPlexManager()
|
12
resources/lib/plexnet/myplexrequest.py
Normal file
12
resources/lib/plexnet/myplexrequest.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
# We don't particularly need a class definition here (yet?), it's just a
|
||||
# PlexRequest where the server is fixed.
|
||||
import plexrequest
|
||||
|
||||
|
||||
class MyPlexRequest(plexrequest.PlexServerRequest):
|
||||
def __init__(self, path):
|
||||
import myplexserver
|
||||
plexrequest.PlexServerRequest.__init__(self, myplexserver.MyPlexServer(), path)
|
||||
|
||||
# Make sure we're always getting XML
|
||||
self.addHeader("Accept", "application/xml")
|
35
resources/lib/plexnet/myplexserver.py
Normal file
35
resources/lib/plexnet/myplexserver.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
import plexapp
|
||||
import plexconnection
|
||||
import plexserver
|
||||
import plexresource
|
||||
import plexservermanager
|
||||
|
||||
|
||||
class MyPlexServer(plexserver.PlexServer):
|
||||
TYPE = 'MYPLEXSERVER'
|
||||
|
||||
def __init__(self):
|
||||
plexserver.PlexServer.__init__(self)
|
||||
self.uuid = 'myplex'
|
||||
self.name = 'plex.tv'
|
||||
conn = plexconnection.PlexConnection(plexresource.ResourceConnection.SOURCE_MYPLEX, "https://plex.tv", False, None)
|
||||
self.connections.append(conn)
|
||||
self.activeConnection = conn
|
||||
|
||||
def getToken(self):
|
||||
return plexapp.ACCOUNT.authToken
|
||||
|
||||
def buildUrl(self, path, includeToken=False):
|
||||
if "://node.plexapp.com" in path:
|
||||
# Locate the best fit server that supports channels, otherwise we'll
|
||||
# continue to use the node urls. Service code between the node and
|
||||
# PMS differs sometimes, so it's a toss up which one is actually
|
||||
# more accurate. Either way, we try to offload work from the node.
|
||||
|
||||
server = plexservermanager.MANAGER.getChannelServer()
|
||||
if server:
|
||||
url = server.swizzleUrl(path, includeToken)
|
||||
if url:
|
||||
return url
|
||||
|
||||
return plexserver.PlexServer.buildUrl(self, path, includeToken)
|
186
resources/lib/plexnet/netif/__init__.py
Normal file
186
resources/lib/plexnet/netif/__init__.py
Normal file
|
@ -0,0 +1,186 @@
|
|||
import socket
|
||||
import struct
|
||||
|
||||
class Interface:
|
||||
def __init__(self):
|
||||
self.name = ''
|
||||
self.ip = ''
|
||||
self.mask = ''
|
||||
|
||||
@property
|
||||
def broadcast(self):
|
||||
if self.name == 'FALLBACK': return '<broadcast>'
|
||||
if not self.ip or not self.mask: return None
|
||||
return calcBroadcast(self.ip,self.mask)
|
||||
|
||||
def getInterfaces():
|
||||
try:
|
||||
return _getInterfaces()
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
return _getInterfacesBSD()
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
return _getInterfacesWin()
|
||||
except:
|
||||
pass
|
||||
|
||||
i = Interface()
|
||||
i.name = 'FALLBACK'
|
||||
return [i]
|
||||
|
||||
def _getInterfaces():
|
||||
vals = all_interfaces()
|
||||
interfaces = []
|
||||
for name,ip in vals:
|
||||
i = Interface()
|
||||
i.name = name
|
||||
i.ip = ip
|
||||
try:
|
||||
mask = getSubnetMask(i.name)
|
||||
i.mask = mask
|
||||
except:
|
||||
i.mask = ''
|
||||
interfaces.append(i)
|
||||
return interfaces
|
||||
|
||||
def _getInterfacesBSD():
|
||||
#name flags family address netmask
|
||||
interfaces = []
|
||||
import getifaddrs
|
||||
for info in getifaddrs.getifaddrs():
|
||||
if info.family == 2:
|
||||
i = Interface()
|
||||
i.name = info.name
|
||||
i.ip = info.address
|
||||
i.mask = info.netmask
|
||||
interfaces.append(i)
|
||||
return interfaces
|
||||
|
||||
def _getInterfacesWin():
|
||||
import ipconfig
|
||||
interfaces = []
|
||||
adapters = ipconfig.parse()
|
||||
for a in adapters:
|
||||
if not 'IPv4 Address' in a: continue
|
||||
if not 'Subnet Mask' in a: continue
|
||||
i = Interface()
|
||||
i.name = a.get('name','UNKNOWN')
|
||||
i.ip = a['IPv4 Address']
|
||||
i.mask = a['Subnet Mask']
|
||||
interfaces.append(i)
|
||||
return interfaces
|
||||
|
||||
def all_interfaces():
|
||||
import sys
|
||||
import array
|
||||
import fcntl
|
||||
|
||||
is_64bits = sys.maxsize > 2**32
|
||||
struct_size = 40 if is_64bits else 32
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
max_possible = 8 # initial value
|
||||
while True:
|
||||
bytes = max_possible * struct_size
|
||||
names = array.array('B', '\0' * bytes)
|
||||
outbytes = struct.unpack('iL', fcntl.ioctl(
|
||||
s.fileno(),
|
||||
0x8912, # SIOCGIFCONF
|
||||
struct.pack('iL', bytes, names.buffer_info()[0])
|
||||
))[0]
|
||||
if outbytes == bytes:
|
||||
max_possible *= 2
|
||||
else:
|
||||
break
|
||||
namestr = names.tostring()
|
||||
return [(namestr[i:i+16].split('\0', 1)[0],
|
||||
socket.inet_ntoa(namestr[i+20:i+24]))
|
||||
for i in range(0, outbytes, struct_size)]
|
||||
|
||||
def getSubnetMask(name):
|
||||
import fcntl
|
||||
return socket.inet_ntoa(fcntl.ioctl(socket.socket(socket.AF_INET, socket.SOCK_DGRAM), 35099, struct.pack('256s', name))[20:24])
|
||||
|
||||
def calcIPValue(ipaddr):
|
||||
"""
|
||||
Calculates the binary
|
||||
value of the ip addresse
|
||||
"""
|
||||
ipaddr = ipaddr.split('.')
|
||||
value = 0
|
||||
for i in range(len(ipaddr)):
|
||||
value = value | (int(ipaddr[i]) << ( 8*(3-i) ))
|
||||
return value
|
||||
|
||||
def calcIPNotation(value):
|
||||
"""
|
||||
Calculates the notation
|
||||
of the ip addresse given its value
|
||||
"""
|
||||
notat = []
|
||||
for i in range(4):
|
||||
shift = 255 << ( 8*(3-i) )
|
||||
part = value & shift
|
||||
part = part >> ( 8*(3-i) )
|
||||
notat.append(str(part))
|
||||
notat = '.'.join(notat)
|
||||
return notat
|
||||
|
||||
def calcSubnet(cidr):
|
||||
"""
|
||||
Calculates the Subnet
|
||||
based on the CIDR
|
||||
"""
|
||||
subn = 4294967295 << (32-cidr) # 4294967295 = all bits set to 1
|
||||
subn = subn % 4294967296 # round it back to be 4 bytes
|
||||
subn = calcIPNotation(subn)
|
||||
return subn
|
||||
|
||||
def calcCIDR(subnet):
|
||||
"""
|
||||
Calculates the CIDR
|
||||
based on the SUbnet
|
||||
"""
|
||||
cidr = 0
|
||||
subnet = calcIPValue(subnet)
|
||||
while subnet != 0:
|
||||
subnet = subnet << 1
|
||||
subnet = subnet % 4294967296
|
||||
cidr += 1
|
||||
return cidr
|
||||
|
||||
def calcNetpart(ipaddr,subnet):
|
||||
ipaddr = calcIPValue(ipaddr)
|
||||
subnet = calcIPValue(subnet)
|
||||
netpart = ipaddr & subnet
|
||||
netpart = calcIPNotation(netpart)
|
||||
return netpart
|
||||
|
||||
def calcMacpart(subnet):
|
||||
macpart = ~calcIPValue(subnet)
|
||||
macpart = calcIPNotation(macpart)
|
||||
return macpart
|
||||
|
||||
def calcBroadcast(ipaddr,subnet):
|
||||
netpart = calcNetpart(ipaddr,subnet)
|
||||
macpart = calcMacpart(subnet)
|
||||
netpart = calcIPValue(netpart)
|
||||
macpart = calcIPValue(macpart)
|
||||
broadcast = netpart | macpart
|
||||
broadcast = calcIPNotation(broadcast)
|
||||
return broadcast
|
||||
|
||||
def calcDefaultGate(ipaddr,subnet):
|
||||
defaultgw = calcNetpart(ipaddr,subnet)
|
||||
defaultgw = calcIPValue(defaultgw) + 1
|
||||
defaultgw = calcIPNotation(defaultgw)
|
||||
return defaultgw
|
||||
|
||||
def calcHostNum(subnet):
|
||||
macpart = calcMacpart(subnet)
|
||||
hostnum = calcIPValue(macpart) - 1
|
||||
return hostnum
|
188
resources/lib/plexnet/netif/getifaddrs.py
Normal file
188
resources/lib/plexnet/netif/getifaddrs.py
Normal file
|
@ -0,0 +1,188 @@
|
|||
"""
|
||||
Wrapper for getifaddrs(3).
|
||||
"""
|
||||
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from collections import namedtuple
|
||||
from ctypes import *
|
||||
|
||||
class sockaddr_in(Structure):
|
||||
_fields_ = [
|
||||
('sin_len', c_uint8),
|
||||
('sin_family', c_uint8),
|
||||
('sin_port', c_uint16),
|
||||
('sin_addr', c_uint8 * 4),
|
||||
('sin_zero', c_uint8 * 8)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
try:
|
||||
assert self.sin_len >= sizeof(sockaddr_in)
|
||||
data = ''.join(map(chr, self.sin_addr))
|
||||
return socket.inet_ntop(socket.AF_INET, data)
|
||||
except:
|
||||
return ''
|
||||
|
||||
class sockaddr_in6(Structure):
|
||||
_fields_ = [
|
||||
('sin6_len', c_uint8),
|
||||
('sin6_family', c_uint8),
|
||||
('sin6_port', c_uint16),
|
||||
('sin6_flowinfo', c_uint32),
|
||||
('sin6_addr', c_uint8 * 16),
|
||||
('sin6_scope_id', c_uint32)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
try:
|
||||
assert self.sin6_len >= sizeof(sockaddr_in6)
|
||||
data = ''.join(map(chr, self.sin6_addr))
|
||||
return socket.inet_ntop(socket.AF_INET6, data)
|
||||
except:
|
||||
return ''
|
||||
|
||||
class sockaddr_dl(Structure):
|
||||
_fields_ = [
|
||||
('sdl_len', c_uint8),
|
||||
('sdl_family', c_uint8),
|
||||
('sdl_index', c_short),
|
||||
('sdl_type', c_uint8),
|
||||
('sdl_nlen', c_uint8),
|
||||
('sdl_alen', c_uint8),
|
||||
('sdl_slen', c_uint8),
|
||||
('sdl_data', c_uint8 * 12)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
assert self.sdl_len >= sizeof(sockaddr_dl)
|
||||
addrdata = self.sdl_data[self.sdl_nlen:self.sdl_nlen+self.sdl_alen]
|
||||
return ':'.join('%02x' % x for x in addrdata)
|
||||
|
||||
class sockaddr_storage(Structure):
|
||||
_fields_ = [
|
||||
('sa_len', c_uint8),
|
||||
('sa_family', c_uint8),
|
||||
('sa_data', c_uint8 * 254)
|
||||
]
|
||||
|
||||
class sockaddr(Union):
|
||||
_anonymous_ = ('sa_storage', )
|
||||
_fields_ = [
|
||||
('sa_storage', sockaddr_storage),
|
||||
('sa_sin', sockaddr_in),
|
||||
('sa_sin6', sockaddr_in6),
|
||||
('sa_sdl', sockaddr_dl),
|
||||
]
|
||||
|
||||
def family(self):
|
||||
return self.sa_storage.sa_family
|
||||
|
||||
def __str__(self):
|
||||
family = self.family()
|
||||
if family == socket.AF_INET:
|
||||
return str(self.sa_sin)
|
||||
elif family == socket.AF_INET6:
|
||||
return str(self.sa_sin6)
|
||||
elif family == 18: # AF_LINK
|
||||
return str(self.sa_sdl)
|
||||
else:
|
||||
print family
|
||||
raise NotImplementedError, "address family %d not supported" % family
|
||||
|
||||
|
||||
class ifaddrs(Structure):
|
||||
pass
|
||||
ifaddrs._fields_ = [
|
||||
('ifa_next', POINTER(ifaddrs)),
|
||||
('ifa_name', c_char_p),
|
||||
('ifa_flags', c_uint),
|
||||
('ifa_addr', POINTER(sockaddr)),
|
||||
('ifa_netmask', POINTER(sockaddr)),
|
||||
('ifa_dstaddr', POINTER(sockaddr)),
|
||||
('ifa_data', c_void_p)
|
||||
]
|
||||
|
||||
# Define constants for the most useful interface flags (from if.h).
|
||||
IFF_UP = 0x0001
|
||||
IFF_BROADCAST = 0x0002
|
||||
IFF_LOOPBACK = 0x0008
|
||||
IFF_POINTTOPOINT = 0x0010
|
||||
IFF_RUNNING = 0x0040
|
||||
if sys.platform == 'darwin' or 'bsd' in sys.platform:
|
||||
IFF_MULTICAST = 0x8000
|
||||
elif sys.platform == 'linux':
|
||||
IFF_MULTICAST = 0x1000
|
||||
|
||||
# Load library implementing getifaddrs and freeifaddrs.
|
||||
if sys.platform == 'darwin':
|
||||
libc = cdll.LoadLibrary('libc.dylib')
|
||||
else:
|
||||
libc = cdll.LoadLibrary('libc.so')
|
||||
|
||||
# Tell ctypes the argument and return types for the getifaddrs and
|
||||
# freeifaddrs functions so it can do marshalling for us.
|
||||
libc.getifaddrs.argtypes = [POINTER(POINTER(ifaddrs))]
|
||||
libc.getifaddrs.restype = c_int
|
||||
libc.freeifaddrs.argtypes = [POINTER(ifaddrs)]
|
||||
|
||||
|
||||
def getifaddrs():
|
||||
"""
|
||||
Get local interface addresses.
|
||||
|
||||
Returns generator of tuples consisting of interface name, interface flags,
|
||||
address family (e.g. socket.AF_INET, socket.AF_INET6), address, and netmask.
|
||||
The tuple members can also be accessed via the names 'name', 'flags',
|
||||
'family', 'address', and 'netmask', respectively.
|
||||
"""
|
||||
# Get address information for each interface.
|
||||
addrlist = POINTER(ifaddrs)()
|
||||
if libc.getifaddrs(pointer(addrlist)) < 0:
|
||||
raise OSError
|
||||
|
||||
X = namedtuple('ifaddrs', 'name flags family address netmask')
|
||||
|
||||
# Iterate through the address information.
|
||||
ifaddr = addrlist
|
||||
while ifaddr and ifaddr.contents:
|
||||
# The following is a hack to workaround a bug in FreeBSD
|
||||
# (PR kern/152036) and MacOSX wherein the netmask's sockaddr may be
|
||||
# truncated. Specifically, AF_INET netmasks may have their sin_addr
|
||||
# member truncated to the minimum number of bytes necessary to
|
||||
# represent the netmask. For example, a sockaddr_in with the netmask
|
||||
# 255.255.254.0 may be truncated to 7 bytes (rather than the normal
|
||||
# 16) such that the sin_addr field only contains 0xff, 0xff, 0xfe.
|
||||
# All bytes beyond sa_len bytes are assumed to be zero. Here we work
|
||||
# around this truncation by copying the netmask's sockaddr into a
|
||||
# zero-filled buffer.
|
||||
if ifaddr.contents.ifa_netmask:
|
||||
netmask = sockaddr()
|
||||
memmove(byref(netmask), ifaddr.contents.ifa_netmask,
|
||||
ifaddr.contents.ifa_netmask.contents.sa_len)
|
||||
if netmask.sa_family == socket.AF_INET and netmask.sa_len < sizeof(sockaddr_in):
|
||||
netmask.sa_len = sizeof(sockaddr_in)
|
||||
else:
|
||||
netmask = None
|
||||
|
||||
try:
|
||||
yield X(ifaddr.contents.ifa_name,
|
||||
ifaddr.contents.ifa_flags,
|
||||
ifaddr.contents.ifa_addr.contents.family(),
|
||||
str(ifaddr.contents.ifa_addr.contents),
|
||||
str(netmask) if netmask else None)
|
||||
except NotImplementedError:
|
||||
# Unsupported address family.
|
||||
yield X(ifaddr.contents.ifa_name,
|
||||
ifaddr.contents.ifa_flags,
|
||||
None,
|
||||
None,
|
||||
None)
|
||||
ifaddr = ifaddr.contents.ifa_next
|
||||
|
||||
# When we are done with the address list, ask libc to free whatever memory
|
||||
# it allocated for the list.
|
||||
libc.freeifaddrs(addrlist)
|
||||
|
||||
__all__ = ['getifaddrs'] + [n for n in dir() if n.startswith('IFF_')]
|
51
resources/lib/plexnet/netif/ipconfig.py
Normal file
51
resources/lib/plexnet/netif/ipconfig.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import subprocess
|
||||
|
||||
def parse(data=None):
|
||||
data = data or subprocess.check_output('ipconfig /all',startupinfo=getStartupInfo())
|
||||
dlist = [d.rstrip() for d in data.split('\n')]
|
||||
mode = None
|
||||
sections = []
|
||||
while dlist:
|
||||
d = dlist.pop(0)
|
||||
try:
|
||||
if not d:
|
||||
continue
|
||||
elif not d.startswith(' '):
|
||||
sections.append({'name':d.strip('.: ')})
|
||||
elif d.startswith(' '):
|
||||
if d.endswith(':'):
|
||||
k = d.strip(':. ')
|
||||
mode = 'VALUE:' + k
|
||||
sections[-1][k] = ''
|
||||
elif ':' in d:
|
||||
k,v = d.split(':',1)
|
||||
k = k.strip(':. ')
|
||||
mode = 'VALUE:' + k
|
||||
v = v.replace('(Preferred)','')
|
||||
sections[-1][k] = v.strip()
|
||||
elif mode and mode.startswith('VALUE:'):
|
||||
if not d.startswith(' '):
|
||||
mode = None
|
||||
dlist.insert(0,d)
|
||||
continue
|
||||
k = mode.split(':',1)[-1]
|
||||
v = d.replace('(Preferred)','')
|
||||
sections[-1][k] += ',' + v.strip()
|
||||
except:
|
||||
print d
|
||||
raise
|
||||
|
||||
return sections[1:]
|
||||
|
||||
def getStartupInfo():
|
||||
if hasattr(subprocess,'STARTUPINFO'): #Windows
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
try:
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW #Suppress terminal window
|
||||
except:
|
||||
startupinfo.dwFlags |= 1
|
||||
return startupinfo
|
||||
|
||||
return None
|
212
resources/lib/plexnet/nowplayingmanager.py
Normal file
212
resources/lib/plexnet/nowplayingmanager.py
Normal file
|
@ -0,0 +1,212 @@
|
|||
# Most of this is ported from Roku code and much of it is currently unused
|
||||
# TODO: Perhaps remove unnecessary code
|
||||
import time
|
||||
|
||||
import util
|
||||
import urllib
|
||||
import urlparse
|
||||
import plexapp
|
||||
import plexrequest
|
||||
import callback
|
||||
import http
|
||||
|
||||
|
||||
class ServerTimeline(util.AttributeDict):
|
||||
def reset(self):
|
||||
self.expires = time.time() + 15
|
||||
|
||||
def isExpired(self):
|
||||
return time.time() > self.get('expires', 0)
|
||||
|
||||
|
||||
class TimelineData(util.AttributeDict):
|
||||
def __init__(self, timelineType, *args, **kwargs):
|
||||
util.AttributeDict.__init__(self, *args, **kwargs)
|
||||
self.type = timelineType
|
||||
self.state = "stopped"
|
||||
self.item = None
|
||||
self.choice = None
|
||||
self.playQueue = None
|
||||
|
||||
self.controllable = util.AttributeDict()
|
||||
self.controllableStr = None
|
||||
|
||||
self.attrs = util.AttributeDict()
|
||||
|
||||
# Set default controllable for all content. Other controllable aspects
|
||||
# will be set based on the players content.
|
||||
#
|
||||
self.setControllable("playPause", True)
|
||||
self.setControllable("stop", True)
|
||||
|
||||
def setControllable(self, name, isControllable):
|
||||
if isControllable:
|
||||
self.controllable[name] = ""
|
||||
else:
|
||||
if name in self.controllable:
|
||||
del self.controllable[name]
|
||||
|
||||
self.controllableStr = None
|
||||
|
||||
def updateControllableStr(self):
|
||||
if not self.controllableStr:
|
||||
self.controllableStr = ""
|
||||
prependComma = False
|
||||
|
||||
for name in self.controllable:
|
||||
if prependComma:
|
||||
self.controllableStr += ','
|
||||
else:
|
||||
prependComma = True
|
||||
self.controllableStr += name
|
||||
|
||||
def toXmlAttributes(self, elem):
|
||||
self.updateControllableStr()
|
||||
elem.attrib["type"] = self.type
|
||||
elem.attrib["start"] = self.state
|
||||
elem.attrib["controllable"] = self.controllableStr
|
||||
|
||||
if self.item:
|
||||
if self.item.duration:
|
||||
elem.attrib['duration'] = self.item.duration
|
||||
if self.item.ratingKey:
|
||||
elem.attrib['ratingKey'] = self.item.ratingKey
|
||||
if self.item.key:
|
||||
elem.attrib['key'] = self.item.key
|
||||
if self.item.container.address:
|
||||
elem.attrib['containerKey'] = self.item.container.address
|
||||
|
||||
# Send the audio, video and subtitle choice if it's available
|
||||
if self.choice:
|
||||
for stream in ("audioStream", "videoStream", "subtitleStream"):
|
||||
if self.choice.get(stream) and self.choice[stream].id:
|
||||
elem.attrib[stream + "ID"] = self.choice[stream].id
|
||||
|
||||
server = self.item.getServer()
|
||||
if server:
|
||||
elem.attrib["machineIdentifier"] = server.uuid
|
||||
|
||||
if server.activeConnection:
|
||||
parts = urlparse.uslparse(server.activeConnection.address)
|
||||
elem.attrib["protocol"] = parts.scheme
|
||||
elem.attrib["address"] = parts.netloc.split(':', 1)[0]
|
||||
if ':' in parts.netloc:
|
||||
elem.attrib["port"] = parts.netloc.split(':', 1)[-1]
|
||||
elif parts.scheme == 'https':
|
||||
elem.attrib["port"] = '443'
|
||||
else:
|
||||
elem.attrib["port"] = '80'
|
||||
|
||||
if self.playQueue:
|
||||
elem.attrib["playQueueID"] = str(self.playQueue.id)
|
||||
elem.attrib["playQueueItemID"] = str(self.playQueue.selectedId)
|
||||
elem.attrib["playQueueVersion"] = str(self.playQueue.version)
|
||||
|
||||
for key, val in self.attrs.items():
|
||||
elem.attrib[key] = val
|
||||
|
||||
|
||||
class NowPlayingManager(object):
|
||||
def __init__(self):
|
||||
|
||||
# Constants
|
||||
self.NAVIGATION = "navigation"
|
||||
self.FULLSCREEN_VIDEO = "fullScreenVideo"
|
||||
self.FULLSCREEN_MUSIC = "fullScreenMusic"
|
||||
self.FULLSCREEN_PHOTO = "fullScreenPhoto"
|
||||
self.TIMELINE_TYPES = ["video", "music", "photo"]
|
||||
|
||||
# Members
|
||||
self.serverTimelines = util.AttributeDict()
|
||||
self.subscribers = util.AttributeDict()
|
||||
self.pollReplies = util.AttributeDict()
|
||||
self.timelines = util.AttributeDict()
|
||||
self.location = self.NAVIGATION
|
||||
|
||||
self.textFieldName = None
|
||||
self.textFieldContent = None
|
||||
self.textFieldSecure = None
|
||||
|
||||
# Initialization
|
||||
for timelineType in self.TIMELINE_TYPES:
|
||||
self.timelines[timelineType] = TimelineData(timelineType)
|
||||
|
||||
def updatePlaybackState(self, timelineType, playerObject, state, time, playQueue=None, duration=0):
|
||||
timeline = self.timelines[timelineType]
|
||||
timeline.state = state
|
||||
timeline.item = playerObject.item
|
||||
timeline.choice = playerObject.choice
|
||||
timeline.playQueue = playQueue
|
||||
timeline.attrs["time"] = str(time)
|
||||
timeline.duration = duration
|
||||
|
||||
# self.sendTimelineToAll()
|
||||
|
||||
self.sendTimelineToServer(timelineType, timeline, time)
|
||||
|
||||
def sendTimelineToServer(self, timelineType, timeline, time):
|
||||
if not hasattr(timeline.item, 'getServer') or not timeline.item.getServer():
|
||||
return
|
||||
|
||||
serverTimeline = self.getServerTimeline(timelineType)
|
||||
|
||||
# Only send timeline if it's the first, item changes, playstate changes or timer pops
|
||||
itemsEqual = timeline.item and serverTimeline.item and timeline.item.ratingKey == serverTimeline.item.ratingKey
|
||||
if itemsEqual and timeline.state == serverTimeline.state and not serverTimeline.isExpired():
|
||||
return
|
||||
|
||||
serverTimeline.reset()
|
||||
serverTimeline.item = timeline.item
|
||||
serverTimeline.state = timeline.state
|
||||
|
||||
# Ignore sending timelines for multi part media with no duration
|
||||
obj = timeline.choice
|
||||
if obj and obj.part and obj.part.duration.asInt() == 0 and obj.media.parts and len(obj.media.parts) > 1:
|
||||
util.WARN_LOG("Timeline not supported: the current part doesn't have a valid duration")
|
||||
return
|
||||
|
||||
# It's possible with timers and in player seeking for the time to be greater than the
|
||||
# duration, which causes a 400, so in that case we'll set the time to the duration.
|
||||
duration = timeline.item.duration.asInt() or timeline.duration
|
||||
if time > duration:
|
||||
time = duration
|
||||
|
||||
params = util.AttributeDict()
|
||||
params["time"] = time
|
||||
params["duration"] = duration
|
||||
params["state"] = timeline.state
|
||||
params["guid"] = timeline.item.guid
|
||||
params["ratingKey"] = timeline.item.ratingKey
|
||||
params["url"] = timeline.item.url
|
||||
params["key"] = timeline.item.key
|
||||
params["containerKey"] = timeline.item.container.address
|
||||
if timeline.playQueue:
|
||||
params["playQueueItemID"] = timeline.playQueue.selectedId
|
||||
|
||||
path = "/:/timeline"
|
||||
for paramKey in params:
|
||||
if params[paramKey]:
|
||||
path = http.addUrlParam(path, paramKey + "=" + urllib.quote(str(params[paramKey])))
|
||||
|
||||
request = plexrequest.PlexRequest(timeline.item.getServer(), path)
|
||||
context = request.createRequestContext("timelineUpdate", callback.Callable(self.onTimelineResponse))
|
||||
context.playQueue = timeline.playQueue
|
||||
plexapp.APP.startRequest(request, context)
|
||||
|
||||
def getServerTimeline(self, timelineType):
|
||||
if not self.serverTimelines.get(timelineType):
|
||||
serverTL = ServerTimeline()
|
||||
serverTL.reset()
|
||||
|
||||
self.serverTimelines[timelineType] = serverTL
|
||||
|
||||
return self.serverTimelines[timelineType]
|
||||
|
||||
def nowPlayingSetControllable(self, timelineType, name, isControllable):
|
||||
self.timelines[timelineType].setControllable(name, isControllable)
|
||||
|
||||
def onTimelineResponse(self, request, response, context):
|
||||
if not context.playQueue or not context.playQueue.refreshOnTimeline:
|
||||
return
|
||||
context.playQueue.refreshOnTimeline = False
|
||||
context.playQueue.refresh(False)
|
59
resources/lib/plexnet/photo.py
Normal file
59
resources/lib/plexnet/photo.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import media
|
||||
import plexobjects
|
||||
import plexmedia
|
||||
|
||||
|
||||
class Photo(media.MediaItem):
|
||||
TYPE = 'photo'
|
||||
|
||||
def _setData(self, data):
|
||||
media.MediaItem._setData(self, data)
|
||||
|
||||
if self.isFullObject():
|
||||
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||
|
||||
def analyze(self):
|
||||
""" The primary purpose of media analysis is to gather information about that media
|
||||
item. All of the media you add to a Library has properties that are useful to
|
||||
know–whether it's a video file, a music track, or one of your photos.
|
||||
"""
|
||||
self.server.query('/%s/analyze' % self.key)
|
||||
|
||||
def markWatched(self):
|
||||
path = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||
self.server.query(path)
|
||||
self.reload()
|
||||
|
||||
def markUnwatched(self):
|
||||
path = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||
self.server.query(path)
|
||||
self.reload()
|
||||
|
||||
def play(self, client):
|
||||
client.playMedia(self)
|
||||
|
||||
def refresh(self):
|
||||
self.server.query('%s/refresh' % self.key, method=self.server.session.put)
|
||||
|
||||
def isPhotoOrDirectoryItem(self):
|
||||
return True
|
||||
|
||||
|
||||
class PhotoDirectory(media.MediaItem):
|
||||
TYPE = 'photodirectory'
|
||||
|
||||
def all(self):
|
||||
path = self.key
|
||||
return plexobjects.listItems(self.server, path)
|
||||
|
||||
def isPhotoOrDirectoryItem(self):
|
||||
return True
|
||||
|
||||
|
||||
@plexobjects.registerLibFactory('photo')
|
||||
def PhotoFactory(data, initpath=None, server=None, container=None):
|
||||
if data.tag == 'Photo':
|
||||
return Photo(data, initpath=initpath, server=server, container=container)
|
||||
else:
|
||||
return PhotoDirectory(data, initpath=initpath, server=server, container=container)
|
179
resources/lib/plexnet/playlist.py
Normal file
179
resources/lib/plexnet/playlist.py
Normal file
|
@ -0,0 +1,179 @@
|
|||
import random
|
||||
|
||||
import plexobjects
|
||||
import signalsmixin
|
||||
|
||||
|
||||
class BasePlaylist(plexobjects.PlexObject, signalsmixin.SignalsMixin):
|
||||
TYPE = 'baseplaylist'
|
||||
|
||||
isRemote = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
plexobjects.PlexObject.__init__(self, *args, **kwargs)
|
||||
signalsmixin.SignalsMixin.__init__(self)
|
||||
self._items = []
|
||||
self._shuffle = None
|
||||
self.pos = 0
|
||||
self.startShuffled = False
|
||||
self.isRepeat = False
|
||||
self.isRepeatOne = False
|
||||
|
||||
def __getitem__(self, idx):
|
||||
if self._shuffle:
|
||||
return self._items[self._shuffle[idx]]
|
||||
else:
|
||||
return self._items[idx]
|
||||
|
||||
def __iter__(self):
|
||||
if self._shuffle:
|
||||
for i in self._shuffle:
|
||||
yield self._items[i]
|
||||
else:
|
||||
for i in self._items:
|
||||
yield i
|
||||
|
||||
def __len__(self):
|
||||
return len(self._items)
|
||||
|
||||
def items(self):
|
||||
if self._shuffle:
|
||||
return [i for i in self]
|
||||
else:
|
||||
return self._items
|
||||
|
||||
def setRepeat(self, repeat, one=False):
|
||||
if self.isRepeat == repeat and self.isRepeatOne == one:
|
||||
return
|
||||
|
||||
self.isRepeat = repeat
|
||||
self.isRepeatOne = one
|
||||
|
||||
def hasNext(self):
|
||||
if len(self._items) < 2:
|
||||
return False
|
||||
if self.isRepeatOne:
|
||||
return False
|
||||
if self.isRepeat:
|
||||
return True
|
||||
return self.pos < len(self._items) - 1
|
||||
|
||||
def hasPrev(self):
|
||||
if len(self._items) < 2:
|
||||
return False
|
||||
if self.isRepeatOne:
|
||||
return False
|
||||
if self.isRepeat:
|
||||
return True
|
||||
return self.pos > 0
|
||||
|
||||
def next(self):
|
||||
if not self.hasNext():
|
||||
return False
|
||||
|
||||
if self.isRepeatOne:
|
||||
return True
|
||||
|
||||
self.pos += 1
|
||||
if self.pos >= len(self._items):
|
||||
self.pos = 0
|
||||
|
||||
return True
|
||||
|
||||
def prev(self):
|
||||
if not self.hasPrev():
|
||||
return False
|
||||
|
||||
if self.isRepeatOne:
|
||||
return True
|
||||
|
||||
self.pos -= 1
|
||||
if self.pos < 0:
|
||||
self.pos = len(self._items) - 1
|
||||
|
||||
return True
|
||||
|
||||
def getPosFromItem(self, item):
|
||||
if item not in self._items:
|
||||
return -1
|
||||
return self._items.index(item)
|
||||
|
||||
def setCurrent(self, pos):
|
||||
if not isinstance(pos, int):
|
||||
item = pos
|
||||
pos = self.getPosFromItem(item)
|
||||
self._items[pos] = item
|
||||
|
||||
if pos < 0 or pos >= len(self._items):
|
||||
return False
|
||||
|
||||
self.pos = pos
|
||||
return True
|
||||
|
||||
def current(self):
|
||||
return self[self.pos]
|
||||
|
||||
def userCurrent(self):
|
||||
for item in self._items:
|
||||
if not item.isWatched or item.viewOffset.asInt():
|
||||
return item
|
||||
else:
|
||||
return self.current()
|
||||
|
||||
def prevItem(self):
|
||||
if self.pos < 1:
|
||||
return None
|
||||
return self[self.pos - 1]
|
||||
|
||||
def shuffle(self, on=True, first=False):
|
||||
if on and self._items:
|
||||
self._shuffle = range(len(self._items))
|
||||
random.shuffle(self._shuffle)
|
||||
if not first:
|
||||
self.pos = self._shuffle.index(self.pos)
|
||||
else:
|
||||
if self._shuffle:
|
||||
self.pos = self._shuffle[self.pos]
|
||||
if not first:
|
||||
self._shuffle = None
|
||||
self.trigger('items.changed')
|
||||
self.refresh()
|
||||
|
||||
def setShuffle(self, shuffle=None):
|
||||
if shuffle is None:
|
||||
shuffle = not self.isShuffled
|
||||
|
||||
self.shuffle(shuffle)
|
||||
|
||||
@property
|
||||
def isShuffled(self):
|
||||
return bool(self._shuffle)
|
||||
|
||||
def refresh(self, *args, **kwargs):
|
||||
self.trigger('change')
|
||||
|
||||
|
||||
class LocalPlaylist(BasePlaylist):
|
||||
TYPE = 'localplaylist'
|
||||
|
||||
def __init__(self, items, server, media_item=None):
|
||||
BasePlaylist.__init__(self, None, server=server)
|
||||
self._items = items
|
||||
self._mediaItem = media_item
|
||||
|
||||
def __getattr__(self, name):
|
||||
if not self._mediaItem:
|
||||
return BasePlaylist.__getattr__(self, name)
|
||||
return getattr(self._mediaItem, name)
|
||||
|
||||
def get(self, name, default=''):
|
||||
if not self._mediaItem:
|
||||
return BasePlaylist.get(self, name, default)
|
||||
|
||||
return self._mediaItem.get(name, default)
|
||||
|
||||
@property
|
||||
def defaultArt(self):
|
||||
if not self._mediaItem:
|
||||
return super(LocalPlaylist, self).defaultArt
|
||||
return self._mediaItem.defaultArt
|
767
resources/lib/plexnet/playqueue.py
Normal file
767
resources/lib/plexnet/playqueue.py
Normal file
|
@ -0,0 +1,767 @@
|
|||
import re
|
||||
import urllib
|
||||
import time
|
||||
|
||||
import plexapp
|
||||
import plexrequest
|
||||
import callback
|
||||
import plexobjects
|
||||
import util
|
||||
import signalsmixin
|
||||
|
||||
|
||||
class AudioUsage(object):
|
||||
def __init__(self, skipsPerHour, playQueueId):
|
||||
self.HOUR = 3600
|
||||
self.skipsPerHour = skipsPerHour
|
||||
self.playQueueId = playQueueId
|
||||
self.skips = []
|
||||
|
||||
def allowSkip(self):
|
||||
if self.skipsPerHour < 0:
|
||||
return True
|
||||
self.updateSkips()
|
||||
return len(self.skips) < self.skipsPerHour
|
||||
|
||||
def updateSkips(self, reset=False):
|
||||
if reset or len(self.skips) == 0:
|
||||
if reset:
|
||||
self.skips = []
|
||||
return
|
||||
|
||||
# Remove old skips if applicable
|
||||
epoch = util.now()
|
||||
if self.skips[0] + self.HOUR < epoch:
|
||||
newSkips = []
|
||||
for skip in self.skips:
|
||||
if skip + self.HOUR > epoch:
|
||||
newSkips.append(skip)
|
||||
self.skips = newSkips
|
||||
self.log("updated skips")
|
||||
|
||||
def registerSkip(self):
|
||||
self.skips.append(util.now())
|
||||
self.updateSkips()
|
||||
self.log("registered skip")
|
||||
|
||||
def allowSkipMessage(self):
|
||||
if self.skipsPerHour < 0 or self.allowSkip():
|
||||
return None
|
||||
return "You can skip {0} songs an hour per mix.".format(self.skipsPerHour)
|
||||
|
||||
def log(self, prefix):
|
||||
util.DEBUG_LOG("AudioUsage {0}: total skips={1}, allowed skips={2}".format(prefix, len(self.skips), self.skipsPerHour))
|
||||
|
||||
|
||||
class UsageFactory(object):
|
||||
def __init__(self, play_queue):
|
||||
self.playQueue = play_queue
|
||||
self.type = play_queue.type
|
||||
self.usage = play_queue.usage
|
||||
|
||||
@classmethod
|
||||
def createUsage(cls, playQueue):
|
||||
obj = cls(playQueue)
|
||||
|
||||
if obj.type:
|
||||
if obj.type == "audio":
|
||||
return obj.createAudioUsage()
|
||||
|
||||
util.DEBUG_LOG("Don't know how to usage for " + str(obj.type))
|
||||
return None
|
||||
|
||||
def createAudioUsage(self):
|
||||
skips = self.playQueue.container.stationSkipsPerHour.asInt(-1)
|
||||
if skips == -1:
|
||||
return None
|
||||
|
||||
# Create new usage if invalid, or if we start a new PQ, otherwise
|
||||
# we'll return the existing usage for the PQ.
|
||||
if not self.usage or self.usage.playQueueId != self.playQueue.id:
|
||||
self.usage = AudioUsage(skips, self.playQueue.id)
|
||||
|
||||
return self.usage
|
||||
|
||||
|
||||
class PlayOptions(util.AttributeDict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
util.AttributeDict.__init__(self, *args, **kwargs)
|
||||
# At the moment, this is really just a glorified struct. But the
|
||||
# expected fields include key, shuffle, extraPrefixCount,
|
||||
# and unwatched. We may give this more definition over time.
|
||||
|
||||
# These aren't widely used yet, but half inspired by a PMS discussion...
|
||||
self.CONTEXT_AUTO = 0
|
||||
self.CONTEXT_SELF = 1
|
||||
self.CONTEXT_PARENT = 2
|
||||
self.CONTEXT_CONTAINER = 3
|
||||
|
||||
self.context = self.CONTEXT_AUTO
|
||||
|
||||
|
||||
def createLocalPlayQueue(item, children, contentType, options):
|
||||
pass
|
||||
|
||||
|
||||
class PlayQueueFactory(object):
|
||||
def getContentType(self, item):
|
||||
if item.isMusicOrDirectoryItem():
|
||||
return "audio"
|
||||
elif item.isVideoOrDirectoryItem():
|
||||
return "video"
|
||||
elif item.isPhotoOrDirectoryItem():
|
||||
return "photo"
|
||||
|
||||
return None
|
||||
|
||||
def canCreateRemotePlayQueue(self):
|
||||
if self.item.getServer().isSecondary():
|
||||
reason = "Server is secondary"
|
||||
elif not (self.item.isLibraryItem() or self.item.isGracenoteCollection() or self.item.isLibraryPQ):
|
||||
reason = "Item is not a library item or gracenote collection"
|
||||
else:
|
||||
return True
|
||||
|
||||
util.DEBUG_LOG("Requires local play queue: " + reason)
|
||||
return False
|
||||
|
||||
def itemRequiresRemotePlayQueue(self):
|
||||
# TODO(rob): handle entire section? (if we create PQ's of sections)
|
||||
# return item instanceof PlexSection || item.type == PlexObject.Type.artist;
|
||||
return self.item.type == "artist"
|
||||
|
||||
|
||||
def createPlayQueueForItem(item, children=None, options=None, args=None):
|
||||
obj = PlayQueueFactory()
|
||||
|
||||
contentType = obj.getContentType(item)
|
||||
if not contentType:
|
||||
# TODO(schuyler): We may need to try harder, but I'm not sure yet. For
|
||||
# example, what if we're shuffling an entire library?
|
||||
#
|
||||
# No reason to crash here. We can safely return None and move on.
|
||||
# We'll stop if we're in dev mode to catch and debug.
|
||||
#
|
||||
util.DEBUG_LOG("Don't know how to create play queue for item " + repr(item))
|
||||
return None
|
||||
|
||||
obj.item = item
|
||||
|
||||
options = PlayOptions(options or {})
|
||||
|
||||
if obj.canCreateRemotePlayQueue():
|
||||
return createRemotePlayQueue(item, contentType, options, args)
|
||||
else:
|
||||
if obj.itemRequiresRemotePlayQueue():
|
||||
util.DEBUG_LOG("Can't create remote PQs and item does not support local PQs")
|
||||
return None
|
||||
else:
|
||||
return createLocalPlayQueue(item, children, contentType, options)
|
||||
|
||||
|
||||
class PlayQueue(signalsmixin.SignalsMixin):
|
||||
TYPE = 'playqueue'
|
||||
|
||||
isRemote = True
|
||||
|
||||
def __init__(self, server, contentType, options=None):
|
||||
signalsmixin.SignalsMixin.__init__(self)
|
||||
self.id = None
|
||||
self.selectedId = None
|
||||
self.version = -1
|
||||
self.isShuffled = False
|
||||
self.isRepeat = False
|
||||
self.isRepeatOne = False
|
||||
self.isLocalPlayQueue = False
|
||||
self.isMixed = None
|
||||
self.totalSize = 0
|
||||
self.windowSize = 0
|
||||
self.forcedWindow = False
|
||||
self.container = None
|
||||
|
||||
# Forced limitations
|
||||
self.allowShuffle = False
|
||||
self.allowSeek = True
|
||||
self.allowRepeat = False
|
||||
self.allowSkipPrev = False
|
||||
self.allowSkipNext = False
|
||||
self.allowAddToQueue = False
|
||||
|
||||
self.refreshOnTimeline = False
|
||||
|
||||
self.server = server
|
||||
self.type = contentType
|
||||
self._items = []
|
||||
self.options = options or util.AttributeDict()
|
||||
|
||||
self.usage = None
|
||||
|
||||
self.refreshTimer = None
|
||||
|
||||
self.canceled = False
|
||||
self.responded = False
|
||||
self.initialized = False
|
||||
|
||||
self.composite = plexobjects.PlexValue('', parent=self)
|
||||
|
||||
# Add a few default options for specific PQ types
|
||||
if self.type == "audio":
|
||||
self.options.includeRelated = True
|
||||
elif self.type == "photo":
|
||||
self.setRepeat(True)
|
||||
|
||||
def get(self, name):
|
||||
return getattr(self, name, plexobjects.PlexValue('', parent=self))
|
||||
|
||||
@property
|
||||
def defaultArt(self):
|
||||
return self.current().defaultArt
|
||||
|
||||
def waitForInitialization(self):
|
||||
start = time.time()
|
||||
timeout = util.TIMEOUT
|
||||
util.DEBUG_LOG('Waiting for playQueue to initialize...')
|
||||
while not self.canceled and not self.initialized:
|
||||
if not self.responded and time.time() - start > timeout:
|
||||
util.DEBUG_LOG('PlayQueue timed out wating for initialization')
|
||||
return self.initialized
|
||||
time.sleep(0.1)
|
||||
|
||||
if self.initialized:
|
||||
util.DEBUG_LOG('PlayQueue initialized in {0:.2f} secs: {1}'.format(time.time() - start, self))
|
||||
else:
|
||||
util.DEBUG_LOG('PlayQueue failed to initialize')
|
||||
|
||||
return self.initialized
|
||||
|
||||
def onRefreshTimer(self):
|
||||
self.refreshTimer = None
|
||||
self.refresh(True, False)
|
||||
|
||||
def refresh(self, force=True, delay=False, wait=False):
|
||||
# Ignore refreshing local PQs
|
||||
if self.isLocal():
|
||||
return
|
||||
|
||||
if wait:
|
||||
self.responded = False
|
||||
self.initialized = False
|
||||
# We refresh our play queue if the caller insists or if we only have a
|
||||
# portion of our play queue loaded. In particular, this means that we don't
|
||||
# refresh the play queue if we're asked to refresh because a new track is
|
||||
# being played but we have the entire album loaded already.
|
||||
|
||||
if force or self.isWindowed():
|
||||
if delay:
|
||||
# We occasionally want to refresh the PQ in response to moving to a
|
||||
# new item and starting playback, but if we refresh immediately:
|
||||
# we probably end up refreshing before PMS realizes we've moved on.
|
||||
# There's no great solution, but delaying our refresh by just a few
|
||||
# seconds makes us much more likely to get an accurate window (and
|
||||
# accurate selected IDs) from PMS.
|
||||
|
||||
if not self.refreshTimer:
|
||||
self.refreshTimer = plexapp.createTimer(5000, self.onRefreshTimer)
|
||||
plexapp.APP.addTimer(self.refreshTimer)
|
||||
else:
|
||||
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id))
|
||||
self.addRequestOptions(request)
|
||||
context = request.createRequestContext("refresh", callback.Callable(self.onResponse))
|
||||
plexapp.APP.startRequest(request, context)
|
||||
|
||||
if wait:
|
||||
return self.waitForInitialization()
|
||||
|
||||
def shuffle(self, shuffle=True):
|
||||
self.setShuffle(shuffle)
|
||||
|
||||
def setShuffle(self, shuffle=None):
|
||||
if shuffle is None:
|
||||
shuffle = not self.isShuffled
|
||||
|
||||
if self.isShuffled == shuffle:
|
||||
return
|
||||
|
||||
if shuffle:
|
||||
command = "/shuffle"
|
||||
else:
|
||||
command = "/unshuffle"
|
||||
|
||||
# Don't change self.isShuffled, it'll be set in OnResponse if all goes well
|
||||
|
||||
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id) + command, "PUT")
|
||||
self.addRequestOptions(request)
|
||||
context = request.createRequestContext("shuffle", callback.Callable(self.onResponse))
|
||||
plexapp.APP.startRequest(request, context)
|
||||
|
||||
def setRepeat(self, repeat, one=False):
|
||||
if self.isRepeat == repeat and self.isRepeatOne == one:
|
||||
return
|
||||
|
||||
self.options.repeat = repeat
|
||||
self.isRepeat = repeat
|
||||
self.isRepeatOne = one
|
||||
|
||||
def moveItemUp(self, item):
|
||||
for index in range(1, len(self._items)):
|
||||
if self._items[index].get("playQueueItemID") == item.get("playQueueItemID"):
|
||||
if index > 1:
|
||||
after = self._items[index - 2]
|
||||
else:
|
||||
after = None
|
||||
|
||||
self.swapItem(index, -1)
|
||||
self.moveItem(item, after)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def moveItemDown(self, item):
|
||||
for index in range(len(self._items) - 1):
|
||||
if self._items[index].get("playQueueItemID") == item.get("playQueueItemID"):
|
||||
after = self._items[index + 1]
|
||||
self.swapItem(index)
|
||||
self.moveItem(item, after)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def moveItem(self, item, after):
|
||||
if after:
|
||||
query = "?after=" + after.get("playQueueItemID", "-1")
|
||||
else:
|
||||
query = ""
|
||||
|
||||
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id) + "/items/" + item.get("playQueueItemID", "-1") + "/move" + query, "PUT")
|
||||
self.addRequestOptions(request)
|
||||
context = request.createRequestContext("move", callback.Callable(self.onResponse))
|
||||
plexapp.APP.startRequest(request, context)
|
||||
|
||||
def swapItem(self, index, delta=1):
|
||||
before = self._items[index]
|
||||
after = self._items[index + delta]
|
||||
|
||||
self._items[index] = after
|
||||
self._items[index + delta] = before
|
||||
|
||||
def removeItem(self, item):
|
||||
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id) + "/items/" + item.get("playQueueItemID", "-1"), "DELETE")
|
||||
self.addRequestOptions(request)
|
||||
context = request.createRequestContext("delete", callback.Callable(self.onResponse))
|
||||
plexapp.APP.startRequest(request, context)
|
||||
|
||||
def addItem(self, item, addNext=False, excludeSeedItem=False):
|
||||
request = plexrequest.PlexRequest(self.server, "/playQueues/" + str(self.id), "PUT")
|
||||
request.addParam("uri", item.getItemUri())
|
||||
request.addParam("next", addNext and "1" or "0")
|
||||
request.addParam("excludeSeedItem", excludeSeedItem and "1" or "0")
|
||||
self.addRequestOptions(request)
|
||||
context = request.createRequestContext("add", callback.Callable(self.onResponse))
|
||||
plexapp.APP.startRequest(request, context)
|
||||
|
||||
def onResponse(self, request, response, context):
|
||||
# Close any loading modal regardless of response status
|
||||
# Application().closeLoadingModal()
|
||||
util.DEBUG_LOG('playQueue: Received response')
|
||||
self.responded = True
|
||||
if response.parseResponse():
|
||||
util.DEBUG_LOG('playQueue: {0} items'.format(len(response.items)))
|
||||
self.container = response.container
|
||||
# Handle an empty PQ if we have specified an pqEmptyCallable
|
||||
if self.options and self.options.pqEmptyCallable:
|
||||
callable = self.options.pqEmptyCallable
|
||||
del self.options["pqEmptyCallable"]
|
||||
if len(response.items) == 0:
|
||||
callable.call()
|
||||
return
|
||||
|
||||
self.id = response.container.playQueueID.asInt()
|
||||
self.isShuffled = response.container.playQueueShuffled.asBool()
|
||||
self.totalSize = response.container.playQueueTotalCount.asInt()
|
||||
self.windowSize = len(response.items)
|
||||
self.version = response.container.playQueueVersion.asInt()
|
||||
|
||||
itemsChanged = False
|
||||
if len(response.items) == len(self._items):
|
||||
for i in range(len(self._items)):
|
||||
if self._items[i] != response.items[i]:
|
||||
itemsChanged = True
|
||||
break
|
||||
else:
|
||||
itemsChanged = True
|
||||
|
||||
if itemsChanged:
|
||||
self._items = response.items
|
||||
|
||||
# Process any forced limitations
|
||||
self.allowSeek = response.container.allowSeek.asBool()
|
||||
self.allowShuffle = (
|
||||
self.totalSize > 1 and response.container.allowShuffle.asBool() and not response.container.playQueueLastAddedItemID
|
||||
)
|
||||
self.allowRepeat = response.container.allowRepeat.asBool()
|
||||
self.allowSkipPrev = self.totalSize > 1 and response.container.allowSkipPrevious != "0"
|
||||
self.allowSkipNext = self.totalSize > 1 and response.container.allowSkipNext != "0"
|
||||
|
||||
# Figure out the selected track index and offset. PMS tries to make some
|
||||
# of this easy, but it might not realize that we've advanced to a new
|
||||
# track, so we can't blindly trust it. On the other hand, it's possible
|
||||
# that PMS completely changed the PQ item IDs (e.g. upon shuffling), so
|
||||
# we might need to use its values. We iterate through the items and try
|
||||
# to find the item that we believe is selected, only settling for what
|
||||
# PMS says if we fail.
|
||||
|
||||
playQueueOffset = None
|
||||
selectedId = None
|
||||
pmsSelectedId = response.container.playQueueSelectedItemID.asInt()
|
||||
self.deriveIsMixed()
|
||||
|
||||
# lastItem = None # Not used
|
||||
for index in range(len(self._items)):
|
||||
item = self._items[index]
|
||||
|
||||
if not playQueueOffset and item.playQueueItemID.asInt() == pmsSelectedId:
|
||||
playQueueOffset = response.container.playQueueSelectedItemOffset.asInt() - index + 1
|
||||
|
||||
# Update the index of everything we've already past, and handle
|
||||
# wrapping indexes (repeat).
|
||||
for i in range(index):
|
||||
pqIndex = playQueueOffset + i
|
||||
if pqIndex < 1:
|
||||
pqIndex = pqIndex + self.totalSize
|
||||
|
||||
self._items[i].playQueueIndex = plexobjects.PlexValue(str(pqIndex), parent=self._items[i])
|
||||
|
||||
if playQueueOffset:
|
||||
pqIndex = playQueueOffset + index
|
||||
if pqIndex > self.totalSize:
|
||||
pqIndex = pqIndex - self.totalSize
|
||||
|
||||
item.playQueueIndex = plexobjects.PlexValue(str(pqIndex), parent=item)
|
||||
|
||||
# If we found the item that we believe is selected: we should
|
||||
# continue to treat it as selected.
|
||||
# TODO(schuyler): Should we be checking the metadata ID (rating key)
|
||||
# instead? I don't think it matters in practice, but it may be
|
||||
# more correct.
|
||||
|
||||
if not selectedId and item.playQueueItemID.asInt() == self.selectedId:
|
||||
selectedId = self.selectedId
|
||||
|
||||
if not selectedId:
|
||||
self.selectedId = pmsSelectedId
|
||||
|
||||
# TODO(schuyler): Set repeat as soon as PMS starts returning it
|
||||
|
||||
# Fix up the container for all our items
|
||||
response.container.address = "/playQueues/" + str(self.id)
|
||||
|
||||
# Create usage limitations
|
||||
self.usage = UsageFactory.createUsage(self)
|
||||
|
||||
self.initialized = True
|
||||
self.trigger("change")
|
||||
|
||||
if itemsChanged:
|
||||
self.trigger("items.changed")
|
||||
|
||||
def isWindowed(self):
|
||||
return (not self.isLocal() and (self.totalSize > self.windowSize or self.forcedWindow))
|
||||
|
||||
def hasNext(self):
|
||||
if self.isRepeatOne:
|
||||
return True
|
||||
|
||||
if not self.allowSkipNext and -1 < self.items().index(self.current()) < (len(self.items()) - 1): # TODO: Was 'or' - did change cause issues?
|
||||
return self.isRepeat and not self.isWindowed()
|
||||
|
||||
return True
|
||||
|
||||
def hasPrev(self):
|
||||
# return self.allowSkipPrev or self.items().index(self.current()) > 0
|
||||
return self.items().index(self.current()) > 0
|
||||
|
||||
def next(self):
|
||||
if not self.hasNext():
|
||||
return None
|
||||
|
||||
if self.isRepeatOne:
|
||||
return self.current()
|
||||
|
||||
pos = self.items().index(self.current()) + 1
|
||||
if pos >= len(self.items()):
|
||||
if not self.isRepeat or self.isWindowed():
|
||||
return None
|
||||
pos = 0
|
||||
|
||||
item = self.items()[pos]
|
||||
self.selectedId = item.playQueueItemID.asInt()
|
||||
return item
|
||||
|
||||
def prev(self):
|
||||
if not self.hasPrev():
|
||||
return None
|
||||
if self.isRepeatOne:
|
||||
return self.current()
|
||||
pos = self.items().index(self.current()) - 1
|
||||
item = self.items()[pos]
|
||||
self.selectedId = item.playQueueItemID.asInt()
|
||||
return item
|
||||
|
||||
def setCurrent(self, pos):
|
||||
if pos < 0 or pos >= len(self.items()):
|
||||
return False
|
||||
|
||||
item = self.items()[pos]
|
||||
self.selectedId = item.playQueueItemID.asInt()
|
||||
return item
|
||||
|
||||
def setCurrentItem(self, item):
|
||||
self.selectedId = item.playQueueItemID.asInt()
|
||||
|
||||
def __eq__(self, other):
|
||||
if not other:
|
||||
return False
|
||||
if self.__class__ != other.__class__:
|
||||
return False
|
||||
return self.id == other.id and self.type == other.type
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def addRequestOptions(self, request):
|
||||
boolOpts = ["repeat", "includeRelated"]
|
||||
for opt in boolOpts:
|
||||
if self.options.get(opt):
|
||||
request.addParam(opt, "1")
|
||||
|
||||
intOpts = ["extrasPrefixCount"]
|
||||
for opt in intOpts:
|
||||
if self.options.get(opt):
|
||||
request.addParam(opt, str(self.options.get(opt)))
|
||||
|
||||
includeChapters = self.options.get('includeChapters') is not None and self.options.includeChapters or 1
|
||||
request.addParam("includeChapters", str(includeChapters))
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
str(self.__class__.__name__) + " " +
|
||||
str(self.type) + " windowSize=" +
|
||||
str(self.windowSize) + " totalSize=" +
|
||||
str(self.totalSize) + " selectedId=" +
|
||||
str(self.selectedId) + " shuffled=" +
|
||||
str(self.isShuffled) + " repeat=" +
|
||||
str(self.isRepeat) + " mixed=" +
|
||||
str(self.isMixed) + " allowShuffle=" +
|
||||
str(self.allowShuffle) + " version=" +
|
||||
str(self.version) + " id=" + str(self.id)
|
||||
)
|
||||
|
||||
def isLocal(self):
|
||||
return self.isLocalPlayQueue
|
||||
|
||||
def deriveIsMixed(self):
|
||||
if self.isMixed is None:
|
||||
self.isMixed = False
|
||||
|
||||
lastItem = None
|
||||
for item in self._items:
|
||||
if not self.isMixed:
|
||||
if not item.get("parentKey"):
|
||||
self.isMixed = True
|
||||
else:
|
||||
self.isMixed = lastItem and item.get("parentKey") != lastItem.get("parentKey")
|
||||
|
||||
lastItem = item
|
||||
|
||||
def items(self):
|
||||
return self._items
|
||||
|
||||
def current(self):
|
||||
for item in self.items():
|
||||
if item.playQueueItemID.asInt() == self.selectedId:
|
||||
return item
|
||||
|
||||
return None
|
||||
|
||||
def prevItem(self):
|
||||
last = None
|
||||
for item in self.items():
|
||||
if item.playQueueItemID.asInt() == self.selectedId:
|
||||
return last
|
||||
last = item
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def createRemotePlayQueue(item, contentType, options, args):
|
||||
util.DEBUG_LOG('Creating remote playQueue request...')
|
||||
obj = PlayQueue(item.getServer(), contentType, options)
|
||||
|
||||
# The item's URI is made up of the library section UUID, a descriptor of
|
||||
# the item type (item or directory), and the item's path, URL-encoded.
|
||||
|
||||
uri = "library://" + item.getLibrarySectionUuid() + "/"
|
||||
itemType = item.isDirectory() and "directory" or "item"
|
||||
path = None
|
||||
|
||||
if not options.key:
|
||||
# if item.onDeck and len(item.onDeck) > 0:
|
||||
# options.key = item.onDeck[0].getAbsolutePath("key")
|
||||
# el
|
||||
if not item.isDirectory():
|
||||
options.key = item.get("key")
|
||||
|
||||
# If we're asked to play unwatched, ignore the option unless we are unwatched.
|
||||
options.unwatched = options.unwatched and item.isUnwatched()
|
||||
|
||||
# TODO(schuyler): Until we build postplay, we're not allowed to queue containers for episodes.
|
||||
if item.type == "episode":
|
||||
options.context = options.CONTEXT_SELF
|
||||
elif item.type == "movie":
|
||||
if not options.extrasPrefixCount and not options.resume:
|
||||
options.extrasPrefixCount = plexapp.INTERFACE.getPreference("cinema_trailers", 0)
|
||||
|
||||
# How exactly to construct the item URI depends on the metadata type, though
|
||||
# whenever possible we simply use /library/metadata/:id.
|
||||
|
||||
if item.isLibraryItem() and not item.isLibraryPQ:
|
||||
path = "/library/metadata/" + item.ratingKey
|
||||
else:
|
||||
path = item.getAbsolutePath("key")
|
||||
|
||||
if options.context == options.CONTEXT_SELF:
|
||||
# If the context is specifically for just this item,: just use the
|
||||
# item's key and get out.
|
||||
pass
|
||||
elif item.type == "playlist":
|
||||
path = None
|
||||
uri = item.get("ratingKey")
|
||||
options.isPlaylist = True
|
||||
elif item.type == "track":
|
||||
# TODO(rob): Is there ever a time the container address is wrong? If we
|
||||
# expect to play a single track,: use options.CONTEXT_SELF.
|
||||
path = item.container.address or "/library/metadata/" + item.get("parentRatingKey", "")
|
||||
itemType = "directory"
|
||||
elif item.isPhotoOrDirectoryItem():
|
||||
if item.type == "photoalbum" or item.parentKey:
|
||||
path = item.getParentPath(item.type == "photoalbum" and "key" or "parentKey")
|
||||
itemType = "item"
|
||||
elif item.isDirectory():
|
||||
path = item.getAbsolutePath("key")
|
||||
else:
|
||||
path = item.container.address
|
||||
itemType = "directory"
|
||||
options.key = item.getAbsolutePath("key")
|
||||
|
||||
elif item.type == "episode":
|
||||
path = "/library/metadata/" + item.get("grandparentRatingKey", "")
|
||||
itemType = "directory"
|
||||
options.key = item.getAbsolutePath("key")
|
||||
# elif item.type == "show":
|
||||
# path = "/library/metadata/" + item.get("ratingKey", "")
|
||||
|
||||
if path:
|
||||
if args:
|
||||
path += util.joinArgs(args)
|
||||
|
||||
util.DEBUG_LOG("playQueue path: " + str(path))
|
||||
|
||||
if "/search" not in path:
|
||||
# Convert a few params to the PQ spec
|
||||
convert = {
|
||||
'type': "sourceType",
|
||||
'unwatchedLeaves': "unwatched"
|
||||
}
|
||||
|
||||
for key in convert:
|
||||
regex = re.compile("(?i)([?&])" + key + "=")
|
||||
path = regex.sub("\1" + convert[key] + "=", path)
|
||||
|
||||
util.DEBUG_LOG("playQueue path: " + str(path))
|
||||
uri = uri + itemType + "/" + urllib.quote_plus(path)
|
||||
|
||||
util.DEBUG_LOG("playQueue uri: " + str(uri))
|
||||
|
||||
# Create the PQ request
|
||||
request = plexrequest.PlexRequest(obj.server, "/playQueues")
|
||||
|
||||
request.addParam(not options.isPlaylist and "uri" or "playlistID", uri)
|
||||
request.addParam("type", contentType)
|
||||
# request.addParam('X-Plex-Client-Identifier', plexapp.INTERFACE.getGlobal('clientIdentifier'))
|
||||
|
||||
# Add options we pass once during PQ creation
|
||||
if options.shuffle:
|
||||
request.addParam("shuffle", "1")
|
||||
options.key = None
|
||||
else:
|
||||
request.addParam("shuffle", "0")
|
||||
|
||||
if options.key:
|
||||
request.addParam("key", options.key)
|
||||
|
||||
# Add options we pass every time querying PQs
|
||||
obj.addRequestOptions(request)
|
||||
|
||||
util.DEBUG_LOG('Initial playQueue request started...')
|
||||
context = request.createRequestContext("create", callback.Callable(obj.onResponse))
|
||||
plexapp.APP.startRequest(request, context, body='')
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def createPlayQueueForId(id, server=None, contentType=None):
|
||||
obj = PlayQueue(server, contentType)
|
||||
obj.id = id
|
||||
|
||||
request = plexrequest.PlexRequest(server, "/playQueues/" + str(id))
|
||||
request.addParam("own", "1")
|
||||
obj.addRequestOptions(request)
|
||||
context = request.createRequestContext("own", callback.Callable(obj.onResponse))
|
||||
plexapp.APP.startRequest(request, context)
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
class AudioPlayer():
|
||||
pass
|
||||
|
||||
|
||||
class VideoPlayer():
|
||||
pass
|
||||
|
||||
|
||||
class PhotoPlayer():
|
||||
pass
|
||||
|
||||
|
||||
def addItemToPlayQueue(item, addNext=False):
|
||||
# See if we have an active play queue for this self.dia type or if we need to
|
||||
# create one.
|
||||
|
||||
if item.isMusicOrDirectoryItem():
|
||||
player = AudioPlayer()
|
||||
elif item.isVideoOrDirectoryItem():
|
||||
player = VideoPlayer()
|
||||
elif item.isPhotoOrDirectoryItem():
|
||||
player = PhotoPlayer()
|
||||
else:
|
||||
player = None
|
||||
|
||||
if not player:
|
||||
util.ERROR_LOG("Don't know how to add item to play queue: " + str(item))
|
||||
return None
|
||||
elif not player.allowAddToQueue():
|
||||
util.DEBUG_LOG("Not allowed to add to this player")
|
||||
return None
|
||||
|
||||
if player.playQueue:
|
||||
playQueue = player.playQueue
|
||||
playQueue.addItem(item, addNext)
|
||||
else:
|
||||
options = PlayOptions()
|
||||
options.context = options.CONTEXT_SELF
|
||||
playQueue = createPlayQueueForItem(item, None, options)
|
||||
if playQueue:
|
||||
player.setPlayQueue(playQueue, False)
|
||||
|
||||
return playQueue
|
473
resources/lib/plexnet/plexapp.py
Normal file
473
resources/lib/plexnet/plexapp.py
Normal file
|
@ -0,0 +1,473 @@
|
|||
import threading
|
||||
import platform
|
||||
import uuid
|
||||
import sys
|
||||
import callback
|
||||
|
||||
import signalsmixin
|
||||
import simpleobjects
|
||||
import nowplayingmanager
|
||||
import util
|
||||
|
||||
Res = simpleobjects.Res
|
||||
|
||||
APP = None
|
||||
INTERFACE = None
|
||||
|
||||
MANAGER = None
|
||||
SERVERMANAGER = None
|
||||
ACCOUNT = None
|
||||
|
||||
PLATFORM = util.X_PLEX_DEVICE
|
||||
|
||||
def init():
|
||||
global MANAGER, SERVERMANAGER, ACCOUNT
|
||||
import myplexaccount
|
||||
ACCOUNT = myplexaccount.ACCOUNT
|
||||
import plexservermanager
|
||||
SERVERMANAGER = plexservermanager.MANAGER
|
||||
import myplexmanager
|
||||
MANAGER = myplexmanager.MANAGER
|
||||
ACCOUNT.init()
|
||||
|
||||
|
||||
class App(signalsmixin.SignalsMixin):
|
||||
def __init__(self):
|
||||
signalsmixin.SignalsMixin.__init__(self)
|
||||
self.pendingRequests = {}
|
||||
self.initializers = {}
|
||||
self.timers = []
|
||||
self.nowplayingmanager = nowplayingmanager.NowPlayingManager()
|
||||
|
||||
def addTimer(self, timer):
|
||||
self.timers.append(timer)
|
||||
|
||||
def startRequest(self, request, context, body=None, contentType=None):
|
||||
context.request = request
|
||||
|
||||
started = request.startAsync(body=body, contentType=contentType, context=context)
|
||||
|
||||
if started:
|
||||
requestID = context.request.getIdentity()
|
||||
self.pendingRequests[requestID] = context
|
||||
elif context.callback:
|
||||
context.callback(None, context)
|
||||
|
||||
return started
|
||||
|
||||
def onRequestTimeout(self, context):
|
||||
requestID = context.request.getIdentity()
|
||||
|
||||
if requestID not in self.pendingRequests:
|
||||
return
|
||||
|
||||
context.request.cancel()
|
||||
|
||||
util.WARN_LOG("Request to {0} timed out after {1} sec".format(util.cleanToken(context.request.url), context.timeout))
|
||||
|
||||
if context.callback:
|
||||
context.callback(None, context)
|
||||
|
||||
def delRequest(self, request):
|
||||
requestID = request.getIdentity()
|
||||
if requestID not in self.pendingRequests:
|
||||
return
|
||||
|
||||
del self.pendingRequests[requestID]
|
||||
|
||||
def addInitializer(self, name):
|
||||
self.initializers[name] = True
|
||||
|
||||
def clearInitializer(self, name):
|
||||
if name in self.initializers:
|
||||
del self.initializers[name]
|
||||
if self.isInitialized():
|
||||
self.onInitialized()
|
||||
|
||||
def isInitialized(self):
|
||||
return not self.initializers
|
||||
|
||||
def onInitialized(self):
|
||||
# Wire up a few of our own listeners
|
||||
# PlexServerManager()
|
||||
# self.on("change:user", callback.Callable(self.onAccountChange))
|
||||
|
||||
self.trigger('init')
|
||||
|
||||
def cancelAllTimers(self):
|
||||
for timer in self.timers:
|
||||
timer.cancel()
|
||||
|
||||
def preShutdown(self):
|
||||
import http
|
||||
http.HttpRequest._cancel = True
|
||||
if self.pendingRequests:
|
||||
util.DEBUG_LOG('Closing down {0} App() requests...'.format(len(self.pendingRequests)))
|
||||
for p in self.pendingRequests.values():
|
||||
if p:
|
||||
p.request.cancel()
|
||||
|
||||
if self.timers:
|
||||
util.DEBUG_LOG('Canceling App() timers...')
|
||||
self.cancelAllTimers()
|
||||
|
||||
if SERVERMANAGER.selectedServer:
|
||||
util.DEBUG_LOG('Closing server...')
|
||||
SERVERMANAGER.selectedServer.close()
|
||||
|
||||
def shutdown(self):
|
||||
if self.timers:
|
||||
util.DEBUG_LOG('Waiting for {0} App() timers: Started'.format(len(self.timers)))
|
||||
|
||||
self.cancelAllTimers()
|
||||
|
||||
for timer in self.timers:
|
||||
timer.join()
|
||||
|
||||
util.DEBUG_LOG('Waiting for App() timers: Finished')
|
||||
|
||||
|
||||
class DeviceInfo(object):
|
||||
def getCaptionsOption(self, key):
|
||||
return None
|
||||
|
||||
|
||||
class AppInterface(object):
|
||||
QUALITY_LOCAL = 0
|
||||
QUALITY_REMOTE = 1
|
||||
QUALITY_ONLINE = 2
|
||||
|
||||
_globals = {}
|
||||
|
||||
def __init__(self):
|
||||
self.setQualities()
|
||||
|
||||
def setQualities(self):
|
||||
# Calculate the max quality based on 4k support
|
||||
if self._globals.get("supports4k"):
|
||||
maxQuality = simpleobjects.AttributeDict({
|
||||
'height': 2160,
|
||||
'maxHeight': 2160,
|
||||
'origHeight': 1080
|
||||
})
|
||||
maxResolution = self._globals.get("Is4k") and "4k" or "1080p"
|
||||
else:
|
||||
maxQuality = simpleobjects.AttributeDict({
|
||||
'height': 1080,
|
||||
'maxHeight': 1088
|
||||
})
|
||||
maxResolution = "1080p"
|
||||
|
||||
self._globals['qualities'] = [
|
||||
simpleobjects.AttributeDict({'title': "Original", 'index': 13, 'maxBitrate': 200000}),
|
||||
simpleobjects.AttributeDict({'title': "20 Mbps " + maxResolution, 'index': 12, 'maxBitrate': 20000}),
|
||||
simpleobjects.AttributeDict({'title': "12 Mbps " + maxResolution, 'index': 11, 'maxBitrate': 12000}),
|
||||
simpleobjects.AttributeDict({'title': "10 Mbps " + maxResolution, 'index': 10, 'maxBitrate': 10000}),
|
||||
simpleobjects.AttributeDict({'title': "8 Mbps " + maxResolution, 'index': 9, 'maxBitrate': 8000}),
|
||||
simpleobjects.AttributeDict({'title': "4 Mbps 720p", 'index': 8, 'maxBitrate': 4000, 'maxHeight': 720}),
|
||||
simpleobjects.AttributeDict({'title': "3 Mbps 720p", 'index': 7, 'maxBitrate': 3000, 'maxHeight': 720}),
|
||||
simpleobjects.AttributeDict({'title': "2 Mbps 720p", 'index': 6, 'maxBitrate': 2000, 'maxHeight': 720}),
|
||||
simpleobjects.AttributeDict({'title': "1.5 Mbps 480p", 'index': 5, 'maxBitrate': 1500, 'maxHeight': 480}),
|
||||
simpleobjects.AttributeDict({'title': "720 Kbps", 'index': 4, 'maxBitrate': 720, 'maxHeight': 360}),
|
||||
simpleobjects.AttributeDict({'title': "320 Kbps", 'index': 3, 'maxBitrate': 320, 'maxHeight': 360}),
|
||||
maxQuality
|
||||
]
|
||||
|
||||
for quality in self._globals['qualities']:
|
||||
if quality.index >= 9:
|
||||
quality.update(maxQuality)
|
||||
|
||||
def getPreference(self, pref, default=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def setPreference(self, pref, value):
|
||||
raise NotImplementedError
|
||||
|
||||
def clearRegistry(self, reg, sec=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def getRegistry(self, reg, default=None, sec=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def setRegistry(self, reg, value, sec=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def getGlobal(self, glbl, default=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def getCapabilities(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def LOG(self, msg):
|
||||
raise NotImplementedError
|
||||
|
||||
def DEBUG_LOG(self, msg):
|
||||
self.LOG(msg)
|
||||
|
||||
def WARN_LOG(self, msg):
|
||||
self.LOG(msg)
|
||||
|
||||
def ERROR_LOG(self, msg):
|
||||
self.LOG(msg)
|
||||
|
||||
def ERROR(self, msg=None, err=None):
|
||||
self.LOG(msg)
|
||||
|
||||
def FATAL(self, msg=None):
|
||||
self.ERROR_LOG('FATAL: {0}'.format(msg))
|
||||
|
||||
def supportsAudioStream(self, codec, channels):
|
||||
return False
|
||||
|
||||
def supportsSurroundSound(self):
|
||||
return False
|
||||
|
||||
def getMaxResolution(self, quality_type, allow4k=False):
|
||||
return 480
|
||||
|
||||
def getQualityIndex(self, qualityType):
|
||||
if qualityType == self.QUALITY_LOCAL:
|
||||
return self.getPreference("local_quality", 13)
|
||||
elif qualityType == self.QUALITY_ONLINE:
|
||||
return self.getPreference("online_quality", 8)
|
||||
else:
|
||||
return self.getPreference("remote_quality", 13)
|
||||
|
||||
def settingsGetMaxResolution(self, qualityType, allow4k):
|
||||
qualityIndex = self.getQualityIndex(qualityType)
|
||||
|
||||
if qualityIndex >= 9:
|
||||
return allow4k and 2160 or 1088
|
||||
elif qualityIndex >= 6:
|
||||
return 720
|
||||
elif qualityIndex >= 5:
|
||||
return 480
|
||||
else:
|
||||
return 360
|
||||
|
||||
def getMaxBitrate(self, qualityType):
|
||||
qualityIndex = self.getQualityIndex(qualityType)
|
||||
|
||||
qualities = self.getGlobal("qualities", [])
|
||||
for quality in qualities:
|
||||
if quality.index == qualityIndex:
|
||||
return util.validInt(quality.maxBitrate)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
class PlayerSettingsInterface(object):
|
||||
def __init__(self):
|
||||
self.prefOverrides = {}
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(INTERFACE, name)
|
||||
|
||||
def setPrefOverride(self, key, val):
|
||||
self.prefOverrides[key] = val
|
||||
|
||||
def getPrefOverride(self, key, default=None):
|
||||
return self.prefOverrides.get(key, default)
|
||||
|
||||
def getQualityIndex(self, qualityType):
|
||||
if qualityType == INTERFACE.QUALITY_LOCAL:
|
||||
return self.getPreference("local_quality", 13)
|
||||
elif qualityType == INTERFACE.QUALITY_ONLINE:
|
||||
return self.getPreference("online_quality", 8)
|
||||
else:
|
||||
return self.getPreference("remote_quality", 13)
|
||||
|
||||
def getPreference(self, key, default=None):
|
||||
if key in self.prefOverrides:
|
||||
return self.prefOverrides[key]
|
||||
else:
|
||||
return INTERFACE.getPreference(key, default)
|
||||
|
||||
def getMaxResolution(self, quality_type, allow4k=False):
|
||||
qualityIndex = self.getQualityIndex(quality_type)
|
||||
|
||||
if qualityIndex >= 9:
|
||||
return allow4k and 2160 or 1088
|
||||
elif qualityIndex >= 6:
|
||||
return 720
|
||||
elif qualityIndex >= 5:
|
||||
return 480
|
||||
else:
|
||||
return 360
|
||||
|
||||
|
||||
class DumbInterface(AppInterface):
|
||||
_prefs = {}
|
||||
_regs = {
|
||||
None: {}
|
||||
}
|
||||
_globals = {
|
||||
'platform': platform.uname()[0],
|
||||
'appVersionStr': '0.0.0a1',
|
||||
'clientIdentifier': str(hex(uuid.getnode())),
|
||||
'platformVersion': platform.uname()[2],
|
||||
'product': 'PlexNet.API',
|
||||
'provides': 'player',
|
||||
'device': platform.uname()[0],
|
||||
'model': 'Unknown',
|
||||
'friendlyName': 'PlexNet.API',
|
||||
'deviceInfo': DeviceInfo()
|
||||
}
|
||||
|
||||
def getPreference(self, pref, default=None):
|
||||
return self._prefs.get(pref, default)
|
||||
|
||||
def setPreference(self, pref, value):
|
||||
self._prefs[pref] = value
|
||||
|
||||
def getRegistry(self, reg, default=None, sec=None):
|
||||
section = self._regs.get(sec)
|
||||
if section:
|
||||
return section.get(reg, default)
|
||||
|
||||
return default
|
||||
|
||||
def setRegistry(self, reg, value, sec=None):
|
||||
if sec and sec not in self._regs:
|
||||
self._regs[sec] = {}
|
||||
self._regs[sec][reg] = value
|
||||
|
||||
def clearRegistry(self, reg, sec=None):
|
||||
del self._regs[sec][reg]
|
||||
|
||||
def getGlobal(self, glbl, default=None):
|
||||
return self._globals.get(glbl, default)
|
||||
|
||||
def getCapabilities(self):
|
||||
return ''
|
||||
|
||||
def LOG(self, msg):
|
||||
print 'PlexNet.API: {0}'.format(msg)
|
||||
|
||||
def DEBUG_LOG(self, msg):
|
||||
self.LOG('DEBUG: {0}'.format(msg))
|
||||
|
||||
def WARN_LOG(self, msg):
|
||||
self.LOG('WARNING: {0}'.format(msg))
|
||||
|
||||
def ERROR_LOG(self, msg):
|
||||
self.LOG('ERROR: {0}'.format(msg))
|
||||
|
||||
def ERROR(self, msg=None, err=None):
|
||||
if err:
|
||||
self.LOG('ERROR: {0} - {1}'.format(msg, err.message))
|
||||
else:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
class CompatEvent(threading._Event):
|
||||
def wait(self, timeout):
|
||||
threading._Event.wait(self, timeout)
|
||||
return self.isSet()
|
||||
|
||||
|
||||
class Timer(object):
|
||||
def __init__(self, timeout, function, repeat=False, *args, **kwargs):
|
||||
self.function = function
|
||||
self.timeout = timeout
|
||||
self.repeat = repeat
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
self._reset = False
|
||||
self.event = CompatEvent()
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
self.event.clear()
|
||||
self.thread = threading.Thread(target=self.run, name='TIMER:{0}'.format(self.function), *self.args, **self.kwargs)
|
||||
self.thread.start()
|
||||
|
||||
def run(self):
|
||||
util.DEBUG_LOG('Timer {0}: {1}'.format(repr(self.function), self._reset and 'RESET'or 'STARTED'))
|
||||
try:
|
||||
while not self.event.isSet() and not self.shouldAbort():
|
||||
while not self.event.wait(self.timeout) and not self.shouldAbort():
|
||||
if self._reset:
|
||||
return
|
||||
|
||||
self.function(*self.args, **self.kwargs)
|
||||
if not self.repeat:
|
||||
return
|
||||
finally:
|
||||
if not self._reset:
|
||||
if self in APP.timers:
|
||||
APP.timers.remove(self)
|
||||
|
||||
util.DEBUG_LOG('Timer {0}: FINISHED'.format(repr(self.function)))
|
||||
|
||||
self._reset = False
|
||||
|
||||
def cancel(self):
|
||||
self.event.set()
|
||||
|
||||
def reset(self):
|
||||
self._reset = True
|
||||
self.cancel()
|
||||
if self.thread and self.thread.isAlive():
|
||||
self.thread.join()
|
||||
self.start()
|
||||
|
||||
def shouldAbort(self):
|
||||
return False
|
||||
|
||||
def join(self):
|
||||
if self.thread.isAlive():
|
||||
self.thread.join()
|
||||
|
||||
def isExpired(self):
|
||||
return self.event.isSet()
|
||||
|
||||
|
||||
TIMER = Timer
|
||||
|
||||
|
||||
def createTimer(timeout, function, repeat=False, *args, **kwargs):
|
||||
if isinstance(function, basestring):
|
||||
def dummy(*args, **kwargs):
|
||||
pass
|
||||
dummy.__name__ = function
|
||||
function = dummy
|
||||
timer = TIMER(timeout / 1000.0, function, repeat=repeat, *args, **kwargs)
|
||||
return timer
|
||||
|
||||
|
||||
def setTimer(timer):
|
||||
global TIMER
|
||||
TIMER = timer
|
||||
|
||||
|
||||
def setInterface(interface):
|
||||
global INTERFACE
|
||||
INTERFACE = interface
|
||||
|
||||
|
||||
def setApp(app):
|
||||
global APP
|
||||
APP = app
|
||||
|
||||
|
||||
def setUserAgent(agent):
|
||||
util.USER_AGENT = agent
|
||||
util.BASE_HEADERS = util.resetBaseHeaders()
|
||||
|
||||
|
||||
def setAbortFlagFunction(func):
|
||||
import asyncadapter
|
||||
asyncadapter.ABORT_FLAG_FUNCTION = func
|
||||
|
||||
|
||||
def refreshResources(force=False):
|
||||
import gdm
|
||||
gdm.DISCOVERY.discover()
|
||||
MANAGER.refreshResources(force)
|
||||
SERVERMANAGER.refreshManualConnections()
|
||||
|
||||
|
||||
setApp(App())
|
||||
setInterface(DumbInterface())
|
213
resources/lib/plexnet/plexconnection.py
Normal file
213
resources/lib/plexnet/plexconnection.py
Normal file
|
@ -0,0 +1,213 @@
|
|||
import random
|
||||
|
||||
import http
|
||||
import plexapp
|
||||
import callback
|
||||
import util
|
||||
|
||||
|
||||
class ConnectionSource(int):
|
||||
def init(self, name):
|
||||
self.name = name
|
||||
return self
|
||||
|
||||
def __repr__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class PlexConnection(object):
|
||||
# Constants
|
||||
STATE_UNKNOWN = "unknown"
|
||||
STATE_UNREACHABLE = "unreachable"
|
||||
STATE_REACHABLE = "reachable"
|
||||
STATE_UNAUTHORIZED = "unauthorized"
|
||||
STATE_INSECURE = "insecure_untested"
|
||||
|
||||
SOURCE_MANUAL = ConnectionSource(1).init('MANUAL')
|
||||
SOURCE_DISCOVERED = ConnectionSource(2).init('DISCOVERED')
|
||||
SOURCE_MANUAL_AND_DISCOVERED = ConnectionSource(3).init('MANUAL, DISCOVERED')
|
||||
SOURCE_MYPLEX = ConnectionSource(4).init('MYPLEX')
|
||||
SOURCE_MANUAL_AND_MYPLEX = ConnectionSource(5).init('MANUAL, MYPLEX')
|
||||
SOURCE_DISCOVERED_AND_MYPLEX = ConnectionSource(6).init('DISCOVERED, MYPLEX')
|
||||
SOURCE_ALL = ConnectionSource(7).init('ALL')
|
||||
|
||||
SCORE_REACHABLE = 4
|
||||
SCORE_LOCAL = 2
|
||||
SCORE_SECURE = 1
|
||||
|
||||
SOURCE_BY_VAL = {
|
||||
1: SOURCE_MANUAL,
|
||||
2: SOURCE_DISCOVERED,
|
||||
3: SOURCE_MANUAL_AND_DISCOVERED,
|
||||
4: SOURCE_MYPLEX,
|
||||
5: SOURCE_MANUAL_AND_MYPLEX,
|
||||
6: SOURCE_DISCOVERED_AND_MYPLEX,
|
||||
7: SOURCE_ALL
|
||||
}
|
||||
|
||||
def __init__(self, source, address, isLocal, token, isFallback=False):
|
||||
self.state = self.STATE_UNKNOWN
|
||||
self.sources = source
|
||||
self.address = address
|
||||
self.isLocal = isLocal
|
||||
self.isSecure = address[:5] == 'https'
|
||||
self.isFallback = isFallback
|
||||
self.token = token
|
||||
self.refreshed = True
|
||||
self.score = 0
|
||||
self.request = None
|
||||
|
||||
self.lastTestedAt = 0
|
||||
self.hasPendingRequest = False
|
||||
|
||||
self.getScore(True)
|
||||
|
||||
def __eq__(self, other):
|
||||
if not other:
|
||||
return False
|
||||
if self.__class__ != other.__class__:
|
||||
return False
|
||||
return self.address == other.address
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
return "Connection: {0} local: {1} token: {2} sources: {3} state: {4}".format(
|
||||
self.address,
|
||||
self.isLocal,
|
||||
util.hideToken(self.token),
|
||||
repr(self.sources),
|
||||
self.state
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def merge(self, other):
|
||||
# plex.tv trumps all, otherwise assume newer is better
|
||||
# ROKU: if (other.sources and self.SOURCE_MYPLEX) <> 0 then
|
||||
if other.sources == self.SOURCE_MYPLEX:
|
||||
self.token = other.token
|
||||
else:
|
||||
self.token = self.token or other.token
|
||||
|
||||
self.address = other.address
|
||||
self.sources = self.SOURCE_BY_VAL[self.sources | other.sources]
|
||||
self.isLocal = self.isLocal | other.isLocal
|
||||
self.isSecure = other.isSecure
|
||||
self.isFallback = self.isFallback or other.isFallback
|
||||
self.refreshed = True
|
||||
|
||||
self.getScore(True)
|
||||
|
||||
def testReachability(self, server, allowFallback=False):
|
||||
# Check if we will allow the connection test. If this is a fallback connection,
|
||||
# then we will defer it until we "allowFallback" (test insecure connections
|
||||
# after secure tests have completed and failed). Insecure connections will be
|
||||
# tested if the policy "always" allows them, or if set to "same_network" and
|
||||
# the current connection is local and server has (publicAddressMatches=1).
|
||||
|
||||
allowConnectionTest = not self.isFallback
|
||||
if not allowConnectionTest:
|
||||
insecurePolicy = plexapp.INTERFACE.getPreference("allow_insecure")
|
||||
if insecurePolicy == "always" or (insecurePolicy == "same_network" and server.sameNetwork and self.isLocal):
|
||||
allowConnectionTest = allowFallback
|
||||
server.hasFallback = not allowConnectionTest
|
||||
util.LOG(
|
||||
'{0} for {1}'.format(
|
||||
allowConnectionTest and "Continuing with insecure connection testing" or "Insecure connection testing is deferred", server
|
||||
)
|
||||
)
|
||||
else:
|
||||
util.LOG("Insecure connections not allowed. Ignore insecure connection test for {0}".format(server))
|
||||
self.state = self.STATE_INSECURE
|
||||
callable = callback.Callable(server.onReachabilityResult, [self], random.randint(0, 256))
|
||||
callable.deferCall()
|
||||
return True
|
||||
|
||||
if allowConnectionTest:
|
||||
if not self.isSecure and (
|
||||
not allowFallback and
|
||||
server.hasSecureConnections() or
|
||||
server.activeConnection and
|
||||
server.activeConnection.state != self.STATE_REACHABLE and
|
||||
server.activeConnection.isSecure
|
||||
):
|
||||
util.DEBUG_LOG("Invalid insecure connection test in progress")
|
||||
self.request = http.HttpRequest(self.buildUrl(server, "/"))
|
||||
context = self.request.createRequestContext("reachability", callback.Callable(self.onReachabilityResponse))
|
||||
context.server = server
|
||||
util.addPlexHeaders(self.request, server.getToken())
|
||||
self.hasPendingRequest = plexapp.APP.startRequest(self.request, context)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def cancelReachability(self):
|
||||
if self.request:
|
||||
self.request.ignoreResponse = True
|
||||
self.request.cancel()
|
||||
|
||||
def onReachabilityResponse(self, request, response, context):
|
||||
self.hasPendingRequest = False
|
||||
# It's possible we may have a result pending before we were able
|
||||
# to cancel it, so we'll just ignore it.
|
||||
|
||||
# if request.ignoreResponse:
|
||||
# return
|
||||
|
||||
if response.isSuccess():
|
||||
data = response.getBodyXml()
|
||||
if data is not None and context.server.collectDataFromRoot(data):
|
||||
self.state = self.STATE_REACHABLE
|
||||
else:
|
||||
# This is unexpected, but treat it as unreachable
|
||||
util.ERROR_LOG("Unable to parse root response from {0}".format(context.server))
|
||||
self.state = self.STATE_UNREACHABLE
|
||||
elif response.getStatus() == 401:
|
||||
self.state = self.STATE_UNAUTHORIZED
|
||||
else:
|
||||
self.state = self.STATE_UNREACHABLE
|
||||
|
||||
self.getScore(True)
|
||||
|
||||
context.server.onReachabilityResult(self)
|
||||
|
||||
def buildUrl(self, server, path, includeToken=False):
|
||||
if '://' in path:
|
||||
url = path
|
||||
else:
|
||||
url = self.address + path
|
||||
|
||||
if includeToken:
|
||||
# If we have a token, use it. Otherwise see if any other connections
|
||||
# for this server have one. That will let us use a plex.tv token for
|
||||
# something like a manually configured connection.
|
||||
|
||||
token = self.token or server.getToken()
|
||||
|
||||
if token:
|
||||
url = http.addUrlParam(url, "X-Plex-Token=" + token)
|
||||
|
||||
return url
|
||||
|
||||
def simpleBuildUrl(self, server, path):
|
||||
token = (self.token or server.getToken())
|
||||
param = ''
|
||||
if token:
|
||||
param = '&X-Plex-Token={0}'.format(token)
|
||||
|
||||
return '{0}{1}{2}'.format(self.address, path, param)
|
||||
|
||||
def getScore(self, recalc=False):
|
||||
if recalc:
|
||||
self.score = 0
|
||||
if self.state == self.STATE_REACHABLE:
|
||||
self.score += self.SCORE_REACHABLE
|
||||
if self.isSecure:
|
||||
self.score += self.SCORE_SECURE
|
||||
if self.isLocal:
|
||||
self.score += self.SCORE_LOCAL
|
||||
|
||||
return self.score
|
577
resources/lib/plexnet/plexlibrary.py
Normal file
577
resources/lib/plexnet/plexlibrary.py
Normal file
|
@ -0,0 +1,577 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PlexLibrary
|
||||
"""
|
||||
import plexobjects
|
||||
import playlist
|
||||
import media
|
||||
import exceptions
|
||||
import util
|
||||
import signalsmixin
|
||||
|
||||
|
||||
class Library(plexobjects.PlexObject):
|
||||
def __repr__(self):
|
||||
return '<Library:{0}>'.format(self.title1.encode('utf8'))
|
||||
|
||||
def sections(self):
|
||||
items = []
|
||||
|
||||
path = '/library/sections'
|
||||
for elem in self.server.query(path):
|
||||
stype = elem.attrib['type']
|
||||
if stype in SECTION_TYPES:
|
||||
cls = SECTION_TYPES[stype]
|
||||
items.append(cls(elem, initpath=path, server=self.server, container=self))
|
||||
return items
|
||||
|
||||
def section(self, title=None):
|
||||
for item in self.sections():
|
||||
if item.title == title:
|
||||
return item
|
||||
raise exceptions.NotFound('Invalid library section: %s' % title)
|
||||
|
||||
def all(self):
|
||||
return plexobjects.listItems(self.server, '/library/all')
|
||||
|
||||
def onDeck(self):
|
||||
return plexobjects.listItems(self.server, '/library/onDeck')
|
||||
|
||||
def recentlyAdded(self):
|
||||
return plexobjects.listItems(self.server, '/library/recentlyAdded')
|
||||
|
||||
def get(self, title):
|
||||
return plexobjects.findItem(self.server, '/library/all', title)
|
||||
|
||||
def getByKey(self, key):
|
||||
return plexobjects.findKey(self.server, key)
|
||||
|
||||
def search(self, title, libtype=None, **kwargs):
|
||||
""" Searching within a library section is much more powerful. It seems certain attributes on the media
|
||||
objects can be targeted to filter this search down a bit, but I havent found the documentation for
|
||||
it. For example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items
|
||||
such as actor=<id> seem to work, but require you already know the id of the actor.
|
||||
TLDR: This is untested but seems to work. Use library section search when you can.
|
||||
"""
|
||||
args = {}
|
||||
if title:
|
||||
args['title'] = title
|
||||
if libtype:
|
||||
args['type'] = plexobjects.searchType(libtype)
|
||||
for attr, value in kwargs.items():
|
||||
args[attr] = value
|
||||
query = '/library/all%s' % util.joinArgs(args)
|
||||
return plexobjects.listItems(self.server, query)
|
||||
|
||||
def cleanBundles(self):
|
||||
self.server.query('/library/clean/bundles')
|
||||
|
||||
def emptyTrash(self):
|
||||
for section in self.sections():
|
||||
section.emptyTrash()
|
||||
|
||||
def optimize(self):
|
||||
self.server.query('/library/optimize')
|
||||
|
||||
def refresh(self):
|
||||
self.server.query('/library/sections/all/refresh')
|
||||
|
||||
|
||||
class LibrarySection(plexobjects.PlexObject):
|
||||
ALLOWED_FILTERS = ()
|
||||
ALLOWED_SORT = ()
|
||||
BOOLEAN_FILTERS = ('unwatched', 'duplicate')
|
||||
|
||||
isLibraryPQ = True
|
||||
|
||||
def __repr__(self):
|
||||
title = self.title.replace(' ', '.')[0:20]
|
||||
return '<%s:%s>' % (self.__class__.__name__, title.encode('utf8'))
|
||||
|
||||
@staticmethod
|
||||
def fromFilter(filter_):
|
||||
cls = SECTION_IDS.get(filter_.getLibrarySectionType())
|
||||
if not cls:
|
||||
return
|
||||
section = cls(None, initpath=filter_.initpath, server=filter_.server, container=filter_.container)
|
||||
section.key = filter_.getLibrarySectionId()
|
||||
section.title = filter_.reasonTitle
|
||||
section.type = cls.TYPE
|
||||
return section
|
||||
|
||||
def reload(self, **kwargs):
|
||||
""" Reload the data for this object from PlexServer XML. """
|
||||
initpath = '/library/sections/{0}'.format(self.key)
|
||||
key = self.key
|
||||
try:
|
||||
data = self.server.query(initpath, params=kwargs)
|
||||
except Exception, e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
util.ERROR(err=e)
|
||||
self.initpath = self.key
|
||||
return
|
||||
|
||||
self._setData(data[0])
|
||||
self.initpath = self.key = key
|
||||
|
||||
def isDirectory(self):
|
||||
return True
|
||||
|
||||
def isLibraryItem(self):
|
||||
return True
|
||||
|
||||
def getAbsolutePath(self, key):
|
||||
if key == 'key':
|
||||
return '/library/sections/{0}/all'.format(self.key)
|
||||
|
||||
return plexobjects.PlexObject.getAbsolutePath(self, key)
|
||||
|
||||
def all(self, start=None, size=None, filter_=None, sort=None, unwatched=False, type_=None):
|
||||
if self.key.startswith('/'):
|
||||
path = '{0}/all'.format(self.key)
|
||||
else:
|
||||
path = '/library/sections/{0}/all'.format(self.key)
|
||||
|
||||
args = {}
|
||||
|
||||
if size is not None:
|
||||
args['X-Plex-Container-Start'] = start
|
||||
args['X-Plex-Container-Size'] = size
|
||||
|
||||
if filter_:
|
||||
args[filter_[0]] = filter_[1]
|
||||
|
||||
if sort:
|
||||
args['sort'] = '{0}:{1}'.format(*sort)
|
||||
|
||||
if type_:
|
||||
args['type'] = str(type_)
|
||||
|
||||
if unwatched:
|
||||
args[self.TYPE == 'movie' and 'unwatched' or 'unwatchedLeaves'] = 1
|
||||
|
||||
if args:
|
||||
path += util.joinArgs(args)
|
||||
|
||||
return plexobjects.listItems(self.server, path)
|
||||
|
||||
def jumpList(self, filter_=None, sort=None, unwatched=False, type_=None):
|
||||
if self.key.startswith('/'):
|
||||
path = '{0}/firstCharacter'.format(self.key)
|
||||
else:
|
||||
path = '/library/sections/{0}/firstCharacter'.format(self.key)
|
||||
|
||||
args = {}
|
||||
|
||||
if filter_:
|
||||
args[filter_[0]] = filter_[1]
|
||||
|
||||
if sort:
|
||||
args['sort'] = '{0}:{1}'.format(*sort)
|
||||
|
||||
if type_:
|
||||
args['type'] = str(type_)
|
||||
|
||||
if unwatched:
|
||||
args[self.TYPE == 'movie' and 'unwatched' or 'unwatchedLeaves'] = 1
|
||||
|
||||
if args:
|
||||
path += util.joinArgs(args)
|
||||
|
||||
try:
|
||||
return plexobjects.listItems(self.server, path, bytag=True)
|
||||
except exceptions.BadRequest:
|
||||
util.ERROR('jumpList() request error for path: {0}'.format(repr(path)))
|
||||
return None
|
||||
|
||||
@property
|
||||
def onDeck(self):
|
||||
return plexobjects.listItems(self.server, '/library/sections/%s/onDeck' % self.key)
|
||||
|
||||
def analyze(self):
|
||||
self.server.query('/library/sections/%s/analyze' % self.key)
|
||||
|
||||
def emptyTrash(self):
|
||||
self.server.query('/library/sections/%s/emptyTrash' % self.key)
|
||||
|
||||
def refresh(self):
|
||||
self.server.query('/library/sections/%s/refresh' % self.key)
|
||||
|
||||
def listChoices(self, category, libtype=None, **kwargs):
|
||||
""" List choices for the specified filter category. kwargs can be any of the same
|
||||
kwargs in self.search() to help narrow down the choices to only those that
|
||||
matter in your current context.
|
||||
"""
|
||||
if category in kwargs:
|
||||
raise exceptions.BadRequest('Cannot include kwarg equal to specified category: %s' % category)
|
||||
args = {}
|
||||
for subcategory, value in kwargs.items():
|
||||
args[category] = self._cleanSearchFilter(subcategory, value)
|
||||
if libtype is not None:
|
||||
args['type'] = plexobjects.searchType(libtype)
|
||||
query = '/library/sections/%s/%s%s' % (self.key, category, util.joinArgs(args))
|
||||
|
||||
return plexobjects.listItems(self.server, query, bytag=True)
|
||||
|
||||
def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwargs):
|
||||
""" Search the library. If there are many results, they will be fetched from the server
|
||||
in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first <num>
|
||||
results, it would be wise to set the maxresults option to that amount so this functions
|
||||
doesn't iterate over all results on the server.
|
||||
title: General string query to search for.
|
||||
sort: column:dir; column can be any of {addedAt, originallyAvailableAt, lastViewedAt,
|
||||
titleSort, rating, mediaHeight, duration}. dir can be asc or desc.
|
||||
maxresults: Only return the specified number of results
|
||||
libtype: Filter results to a spcifiec libtype {movie, show, episode, artist, album, track}
|
||||
kwargs: Any of the available filters for the current library section. Partial string
|
||||
matches allowed. Multiple matches OR together. All inputs will be compared with the
|
||||
available options and a warning logged if the option does not appear valid.
|
||||
'unwatched': Display or hide unwatched content (True, False). [all]
|
||||
'duplicate': Display or hide duplicate items (True, False). [movie]
|
||||
'actor': List of actors to search ([actor_or_id, ...]). [movie]
|
||||
'collection': List of collections to search within ([collection_or_id, ...]). [all]
|
||||
'contentRating': List of content ratings to search within ([rating_or_key, ...]). [movie, tv]
|
||||
'country': List of countries to search within ([country_or_key, ...]). [movie, music]
|
||||
'decade': List of decades to search within ([yyy0, ...]). [movie]
|
||||
'director': List of directors to search ([director_or_id, ...]). [movie]
|
||||
'genre': List Genres to search within ([genere_or_id, ...]). [all]
|
||||
'network': List of TV networks to search within ([resolution_or_key, ...]). [tv]
|
||||
'resolution': List of video resolutions to search within ([resolution_or_key, ...]). [movie]
|
||||
'studio': List of studios to search within ([studio_or_key, ...]). [music]
|
||||
'year': List of years to search within ([yyyy, ...]). [all]
|
||||
"""
|
||||
# Cleanup the core arguments
|
||||
args = {}
|
||||
for category, value in kwargs.items():
|
||||
args[category] = self._cleanSearchFilter(category, value, libtype)
|
||||
if title is not None:
|
||||
args['title'] = title
|
||||
if sort is not None:
|
||||
args['sort'] = self._cleanSearchSort(sort)
|
||||
if libtype is not None:
|
||||
args['type'] = plexobjects.searchType(libtype)
|
||||
# Iterate over the results
|
||||
results, subresults = [], '_init'
|
||||
args['X-Plex-Container-Start'] = 0
|
||||
args['X-Plex-Container-Size'] = min(util.X_PLEX_CONTAINER_SIZE, maxresults)
|
||||
while subresults and maxresults > len(results):
|
||||
query = '/library/sections/%s/all%s' % (self.key, util.joinArgs(args))
|
||||
subresults = plexobjects.listItems(self.server, query)
|
||||
results += subresults[:maxresults - len(results)]
|
||||
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
|
||||
return results
|
||||
|
||||
def _cleanSearchFilter(self, category, value, libtype=None):
|
||||
# check a few things before we begin
|
||||
if category not in self.ALLOWED_FILTERS:
|
||||
raise exceptions.BadRequest('Unknown filter category: %s' % category)
|
||||
if category in self.BOOLEAN_FILTERS:
|
||||
return '1' if value else '0'
|
||||
if not isinstance(value, (list, tuple)):
|
||||
value = [value]
|
||||
# convert list of values to list of keys or ids
|
||||
result = set()
|
||||
choices = self.listChoices(category, libtype)
|
||||
lookup = {}
|
||||
for c in choices:
|
||||
lookup[c.title.lower()] = c.key
|
||||
|
||||
allowed = set(c.key for c in choices)
|
||||
for item in value:
|
||||
item = str(item.id if isinstance(item, media.MediaTag) else item).lower()
|
||||
# find most logical choice(s) to use in url
|
||||
if item in allowed:
|
||||
result.add(item)
|
||||
continue
|
||||
if item in lookup:
|
||||
result.add(lookup[item])
|
||||
continue
|
||||
matches = [k for t, k in lookup.items() if item in t]
|
||||
if matches:
|
||||
map(result.add, matches)
|
||||
continue
|
||||
# nothing matched; use raw item value
|
||||
util.LOG('Filter value not listed, using raw item value: {0}'.format(item))
|
||||
result.add(item)
|
||||
return ','.join(result)
|
||||
|
||||
def _cleanSearchSort(self, sort):
|
||||
sort = '%s:asc' % sort if ':' not in sort else sort
|
||||
scol, sdir = sort.lower().split(':')
|
||||
lookup = {}
|
||||
for s in self.ALLOWED_SORT:
|
||||
lookup[s.lower()] = s
|
||||
if scol not in lookup:
|
||||
raise exceptions.BadRequest('Unknown sort column: %s' % scol)
|
||||
if sdir not in ('asc', 'desc'):
|
||||
raise exceptions.BadRequest('Unknown sort dir: %s' % sdir)
|
||||
return '%s:%s' % (lookup[scol], sdir)
|
||||
|
||||
|
||||
class MovieSection(LibrarySection):
|
||||
ALLOWED_FILTERS = (
|
||||
'unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating', 'collection',
|
||||
'director', 'actor', 'country', 'studio', 'resolution'
|
||||
)
|
||||
ALLOWED_SORT = (
|
||||
'addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
|
||||
'mediaHeight', 'duration'
|
||||
)
|
||||
TYPE = 'movie'
|
||||
ID = '1'
|
||||
|
||||
|
||||
class ShowSection(LibrarySection):
|
||||
ALLOWED_FILTERS = ('unwatched', 'year', 'genre', 'contentRating', 'network', 'collection')
|
||||
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort', 'rating', 'unwatched')
|
||||
TYPE = 'show'
|
||||
ID = '2'
|
||||
|
||||
def searchShows(self, **kwargs):
|
||||
return self.search(libtype='show', **kwargs)
|
||||
|
||||
def searchEpisodes(self, **kwargs):
|
||||
return self.search(libtype='episode', **kwargs)
|
||||
|
||||
|
||||
class MusicSection(LibrarySection):
|
||||
ALLOWED_FILTERS = ('genre', 'country', 'collection')
|
||||
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
|
||||
TYPE = 'artist'
|
||||
ID = '8'
|
||||
|
||||
def searchShows(self, **kwargs):
|
||||
return self.search(libtype='artist', **kwargs)
|
||||
|
||||
def searchEpisodes(self, **kwargs):
|
||||
return self.search(libtype='album', **kwargs)
|
||||
|
||||
def searchTracks(self, **kwargs):
|
||||
return self.search(libtype='track', **kwargs)
|
||||
|
||||
|
||||
class PhotoSection(LibrarySection):
|
||||
ALLOWED_FILTERS = ()
|
||||
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
|
||||
TYPE = 'photo'
|
||||
ID = 'None'
|
||||
|
||||
def isPhotoOrDirectoryItem(self):
|
||||
return True
|
||||
|
||||
|
||||
@plexobjects.registerLibType
|
||||
class Generic(plexobjects.PlexObject):
|
||||
TYPE = 'Directory'
|
||||
|
||||
def __repr__(self):
|
||||
title = self.title.replace(' ', '.')[0:20]
|
||||
return '<{0}:{1}:{2}>'.format(self.__class__.__name__, self.key, title)
|
||||
|
||||
|
||||
@plexobjects.registerLibType
|
||||
class Playlist(playlist.BasePlaylist, signalsmixin.SignalsMixin):
|
||||
TYPE = 'playlist'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
playlist.BasePlaylist.__init__(self, *args, **kwargs)
|
||||
self._itemsLoaded = False
|
||||
|
||||
def __repr__(self):
|
||||
title = self.title.replace(' ', '.')[0:20]
|
||||
return '<{0}:{1}:{2}>'.format(self.__class__.__name__, self.key, title)
|
||||
|
||||
def exists(self):
|
||||
try:
|
||||
self.server.query('/playlists/{0}'.format(self.ratingKey))
|
||||
return True
|
||||
except exceptions.BadRequest:
|
||||
return False
|
||||
|
||||
def isMusicOrDirectoryItem(self):
|
||||
return self.playlistType == 'audio'
|
||||
|
||||
def isVideoOrDirectoryItem(self):
|
||||
return self.playlistType == 'video'
|
||||
|
||||
def items(self):
|
||||
if not self._itemsLoaded:
|
||||
path = '/playlists/{0}/items'.format(self.ratingKey)
|
||||
self._items = plexobjects.listItems(self.server, path)
|
||||
self._itemsLoaded = True
|
||||
|
||||
return playlist.BasePlaylist.items(self)
|
||||
|
||||
def extend(self, start=0, size=0):
|
||||
if not self._items:
|
||||
self._items = [None] * self.leafCount.asInt()
|
||||
|
||||
args = {}
|
||||
|
||||
if size is not None:
|
||||
args['X-Plex-Container-Start'] = start
|
||||
args['X-Plex-Container-Size'] = size
|
||||
|
||||
path = '/playlists/{0}/items'.format(self.ratingKey)
|
||||
if args:
|
||||
path += util.joinArgs(args) if '?' not in path else '&' + util.joinArgs(args).lstrip('?')
|
||||
|
||||
items = plexobjects.listItems(self.server, path)
|
||||
self._items[start:start + len(items)] = items
|
||||
|
||||
self.trigger('items.added')
|
||||
|
||||
return items
|
||||
|
||||
def unshuffledItems(self):
|
||||
if not self._itemsLoaded:
|
||||
self.items()
|
||||
return self._items
|
||||
|
||||
@property
|
||||
def defaultThumb(self):
|
||||
return self.composite
|
||||
|
||||
def buildComposite(self, **kwargs):
|
||||
if kwargs:
|
||||
params = '?' + '&'.join('{0}={1}'.format(k, v) for k, v in kwargs.items())
|
||||
else:
|
||||
params = ''
|
||||
|
||||
path = self.composite + params
|
||||
return self.getServer().buildUrl(path, True)
|
||||
|
||||
|
||||
class BaseHub(plexobjects.PlexObject):
|
||||
def reset(self):
|
||||
self.set('offset', 0)
|
||||
self.set('size', len(self.items))
|
||||
totalSize = self.items[0].container.totalSize.asInt()
|
||||
if totalSize: # Hubs from a list of hubs don't have this, so it it's not here this is intital and we can leave as is
|
||||
self.set(
|
||||
'more',
|
||||
(self.items[0].container.offset.asInt() + self.items[0].container.size.asInt() < totalSize) and '1' or ''
|
||||
)
|
||||
|
||||
|
||||
class Hub(BaseHub):
|
||||
TYPE = "Hub"
|
||||
|
||||
def init(self, data):
|
||||
self.items = []
|
||||
|
||||
container = plexobjects.PlexContainer(data, self.key, self.server, self.key or '')
|
||||
|
||||
if self.type == 'genre':
|
||||
self.items = [media.Genre(elem, initpath='/hubs', server=self.server, container=container) for elem in data]
|
||||
elif self.type == 'director':
|
||||
self.items = [media.Director(elem, initpath='/hubs', server=self.server, container=container) for elem in data]
|
||||
elif self.type == 'actor':
|
||||
self.items = [media.Role(elem, initpath='/hubs', server=self.server, container=container) for elem in data]
|
||||
else:
|
||||
for elem in data:
|
||||
try:
|
||||
self.items.append(plexobjects.buildItem(self.server, elem, '/hubs', container=container, tag_fallback=True))
|
||||
except exceptions.UnknownType:
|
||||
util.DEBUG_LOG('Unkown hub item type({1}): {0}'.format(elem, elem.attrib.get('type')))
|
||||
|
||||
def __repr__(self):
|
||||
return '<{0}:{1}>'.format(self.__class__.__name__, self.hubIdentifier)
|
||||
|
||||
def reload(self, **kwargs):
|
||||
""" Reload the data for this object from PlexServer XML. """
|
||||
try:
|
||||
data = self.server.query(self.key, params=kwargs)
|
||||
except Exception, e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
util.ERROR(err=e)
|
||||
self.initpath = self.key
|
||||
return
|
||||
|
||||
self.initpath = self.key
|
||||
self._setData(data)
|
||||
self.init(data)
|
||||
|
||||
def extend(self, start=None, size=None):
|
||||
path = self.key
|
||||
|
||||
args = {}
|
||||
|
||||
if size is not None:
|
||||
args['X-Plex-Container-Start'] = start
|
||||
args['X-Plex-Container-Size'] = size
|
||||
|
||||
if args:
|
||||
path += util.joinArgs(args) if '?' not in path else '&' + util.joinArgs(args).lstrip('?')
|
||||
|
||||
items = plexobjects.listItems(self.server, path)
|
||||
self.offset = plexobjects.PlexValue(start)
|
||||
self.size = plexobjects.PlexValue(len(items))
|
||||
self.more = plexobjects.PlexValue(
|
||||
(items[0].container.offset.asInt() + items[0].container.size.asInt() < items[0].container.totalSize.asInt()) and '1' or ''
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
class PlaylistHub(BaseHub):
|
||||
TYPE = "Hub"
|
||||
type = None
|
||||
hubIdentifier = None
|
||||
|
||||
def init(self, data):
|
||||
try:
|
||||
self.items = self.extend(0, 10)
|
||||
except exceptions.BadRequest:
|
||||
util.DEBUG_LOG('AudioPlaylistHub: Bad request: {0}'.format(self))
|
||||
self.items = []
|
||||
|
||||
def extend(self, start=None, size=None):
|
||||
path = '/playlists/all?playlistType={0}'.format(self.type)
|
||||
|
||||
args = {}
|
||||
|
||||
if size is not None:
|
||||
args['X-Plex-Container-Start'] = start
|
||||
args['X-Plex-Container-Size'] = size
|
||||
else:
|
||||
start = 0
|
||||
|
||||
if args:
|
||||
path += '&' + util.joinArgs(args).lstrip('?')
|
||||
|
||||
items = plexobjects.listItems(self.server, path)
|
||||
|
||||
if not items:
|
||||
return
|
||||
|
||||
self.set('offset', start)
|
||||
self.set('size', len(items))
|
||||
self.set('more', (items[0].container.offset.asInt() + items[0].container.size.asInt() < items[0].container.totalSize.asInt()) and '1' or '')
|
||||
return items
|
||||
|
||||
|
||||
class AudioPlaylistHub(PlaylistHub):
|
||||
type = 'audio'
|
||||
hubIdentifier = 'playlists.audio'
|
||||
|
||||
|
||||
class VideoPlaylistHub(PlaylistHub):
|
||||
type = 'video'
|
||||
hubIdentifier = 'playlists.video'
|
||||
|
||||
|
||||
SECTION_TYPES = {
|
||||
MovieSection.TYPE: MovieSection,
|
||||
ShowSection.TYPE: ShowSection,
|
||||
MusicSection.TYPE: MusicSection,
|
||||
PhotoSection.TYPE: PhotoSection
|
||||
}
|
||||
|
||||
SECTION_IDS = {
|
||||
MovieSection.ID: MovieSection,
|
||||
ShowSection.ID: ShowSection,
|
||||
MusicSection.ID: MusicSection,
|
||||
PhotoSection.ID: PhotoSection
|
||||
}
|
159
resources/lib/plexnet/plexmedia.py
Normal file
159
resources/lib/plexnet/plexmedia.py
Normal file
|
@ -0,0 +1,159 @@
|
|||
import locks
|
||||
import http
|
||||
import plexobjects
|
||||
import plexpart
|
||||
import plexrequest
|
||||
import util
|
||||
|
||||
|
||||
class PlexMedia(plexobjects.PlexObject):
|
||||
def __init__(self, data, initpath=None, server=None, container=None):
|
||||
self._data = data.attrib
|
||||
plexobjects.PlexObject.__init__(self, data, initpath, server)
|
||||
self.container_ = self.get('container')
|
||||
self.container = container
|
||||
self.indirectHeaders = None
|
||||
self.parts = []
|
||||
# If we weren't given any data, this is a synthetic media
|
||||
if data is not None:
|
||||
self.parts = [plexpart.PlexPart(elem, initpath=self.initpath, server=self.server, media=self) for elem in data]
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
def hasStreams(self):
|
||||
return len(self.parts) > 0 and self.parts[0].hasStreams()
|
||||
|
||||
def isIndirect(self):
|
||||
return self.get('indirect') == '1'
|
||||
|
||||
def isAccessible(self):
|
||||
for part in self.parts:
|
||||
if not part.isAccessible():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def isAvailable(self):
|
||||
for part in self.parts:
|
||||
if not part.isAvailable():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def resolveIndirect(self):
|
||||
if not self.isIndirect() or locks.LOCKS.isLocked("resolve_indirect"):
|
||||
return self
|
||||
|
||||
part = self.parts[0]
|
||||
if part is None:
|
||||
util.DEBUG("Failed to resolve indirect media: missing valid part")
|
||||
return None
|
||||
|
||||
postBody = None
|
||||
postUrl = part.postURL
|
||||
request = plexrequest.PlexRequest(self.getServer(), part.key, postUrl is not None and "POST" or "GET")
|
||||
|
||||
if postUrl is not None:
|
||||
util.DEBUG("Fetching content for indirect media POST URL: {0}".format(postUrl))
|
||||
# Force setting the certificate to handle following https redirects
|
||||
postRequest = http.HttpRequest(postUrl, None, True)
|
||||
postResponse = postRequest.getToStringWithTimeout(30)
|
||||
if len(postResponse) > 0 and type(postRequest.event) == "roUrlEvent":
|
||||
util.DEBUG("Retrieved data from postURL, posting to resolve container")
|
||||
crlf = chr(13) + chr(10)
|
||||
postBody = ""
|
||||
for header in postRequest.event.getResponseHeadersArray():
|
||||
for name in header:
|
||||
postBody = postBody + name + ": " + header[name] + crlf
|
||||
postBody = postBody + crlf + postResponse
|
||||
else:
|
||||
util.DEBUG("Failed to resolve indirect media postUrl")
|
||||
self.Set("indirect", "-1")
|
||||
return self
|
||||
|
||||
request.addParam("postURL", postUrl)
|
||||
|
||||
response = request.doRequestWithTimeout(30, postBody)
|
||||
|
||||
item = response.items[0]
|
||||
if item is None or item.mediaItems[0] is None:
|
||||
util.DEBUG("Failed to resolve indirect media: no media items")
|
||||
self.indirect = -1
|
||||
return self
|
||||
|
||||
media = item.mediaItems[0]
|
||||
|
||||
# Add indirect headers to the media item
|
||||
media.indirectHeaders = util.AttributeDict()
|
||||
for header in (item.container.httpHeaders or '').split("&"):
|
||||
arr = header.split("=")
|
||||
if len(arr) == 2:
|
||||
media.indirectHeaders[arr[0]] = arr[1]
|
||||
|
||||
# Reset the fallback media id if applicable
|
||||
if self.id.asInt() < 0:
|
||||
media.id = self.id
|
||||
|
||||
return media.resolveIndirect()
|
||||
|
||||
def __str__(self):
|
||||
extra = []
|
||||
attrs = ("videoCodec", "audioCodec", "audioChannels", "protocol", "id")
|
||||
if self.get('container'):
|
||||
extra.append("container={0}".format(self.get('container')))
|
||||
|
||||
for astr in attrs:
|
||||
if hasattr(self, astr):
|
||||
attr = getattr(self, astr)
|
||||
if attr and not attr.NA:
|
||||
extra.append("{0}={1}".format(astr, attr))
|
||||
|
||||
return self.versionString(log_safe=True) + " " + ' '.join(extra)
|
||||
|
||||
def versionString(self, log_safe=False):
|
||||
details = []
|
||||
details.append(self.getVideoResolutionString())
|
||||
if self.bitrate.asInt() > 0:
|
||||
details.append(util.bitrateToString(self.bitrate.asInt() * 1000))
|
||||
|
||||
detailString = ', '.join(details)
|
||||
return (log_safe and ' * ' or u" \u2022 ").join(filter(None, [self.title, detailString]))
|
||||
|
||||
def __eq__(self, other):
|
||||
if not other:
|
||||
return False
|
||||
|
||||
if self.__class__ != other.__class__:
|
||||
return False
|
||||
|
||||
return self.id == other.id
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def getVideoResolution(self):
|
||||
if self.videoResolution:
|
||||
standardDefinitionHeight = 480
|
||||
if str(util.validInt(filter(unicode.isdigit, self.videoResolution))) != self.videoResolution:
|
||||
return self.height.asInt() > standardDefinitionHeight and self.height.asInt() or standardDefinitionHeight
|
||||
else:
|
||||
return self.videoResolution.asInt(standardDefinitionHeight)
|
||||
|
||||
return self.height.asInt()
|
||||
|
||||
def getVideoResolutionString(self):
|
||||
resNumber = util.validInt(filter(unicode.isdigit, self.videoResolution))
|
||||
if resNumber > 0 and str(resNumber) == self.videoResolution:
|
||||
return self.videoResolution + "p"
|
||||
|
||||
return self.videoResolution.upper()
|
||||
|
||||
def isSelected(self):
|
||||
import plexapp
|
||||
return self.selected.asBool() or self.id == plexapp.INTERFACE.getPreference("local_mediaId")
|
||||
|
||||
# TODO(schuyler): getParts
|
542
resources/lib/plexnet/plexobjects.py
Normal file
542
resources/lib/plexnet/plexobjects.py
Normal file
|
@ -0,0 +1,542 @@
|
|||
from datetime import datetime
|
||||
|
||||
import exceptions
|
||||
import util
|
||||
import plexapp
|
||||
import json
|
||||
|
||||
# Search Types - Plex uses these to filter specific media types when searching.
|
||||
SEARCHTYPES = {
|
||||
'movie': 1,
|
||||
'show': 2,
|
||||
'season': 3,
|
||||
'episode': 4,
|
||||
'artist': 8,
|
||||
'album': 9,
|
||||
'track': 10
|
||||
}
|
||||
|
||||
LIBRARY_TYPES = {}
|
||||
|
||||
|
||||
def registerLibType(cls):
|
||||
LIBRARY_TYPES[cls.TYPE] = cls
|
||||
return cls
|
||||
|
||||
|
||||
def registerLibFactory(ftype):
|
||||
def wrap(func):
|
||||
LIBRARY_TYPES[ftype] = func
|
||||
return func
|
||||
return wrap
|
||||
|
||||
|
||||
class PlexValue(unicode):
|
||||
def __new__(cls, value, parent=None):
|
||||
self = super(PlexValue, cls).__new__(cls, value)
|
||||
self.parent = parent
|
||||
self.NA = False
|
||||
return self
|
||||
|
||||
def __call__(self, default):
|
||||
return not self.NA and self or PlexValue(default, self.parent)
|
||||
|
||||
def asBool(self):
|
||||
return self == '1'
|
||||
|
||||
def asInt(self, default=0):
|
||||
return int(self or default)
|
||||
|
||||
def asFloat(self, default=0):
|
||||
return float(self or default)
|
||||
|
||||
def asDatetime(self, format_=None):
|
||||
if not self:
|
||||
return None
|
||||
|
||||
if self.isdigit():
|
||||
dt = datetime.fromtimestamp(int(self))
|
||||
else:
|
||||
dt = datetime.strptime(self, '%Y-%m-%d')
|
||||
|
||||
if not format_:
|
||||
return dt
|
||||
|
||||
return dt.strftime(format_)
|
||||
|
||||
def asURL(self):
|
||||
return self.parent.server.url(self)
|
||||
|
||||
def asTranscodedImageURL(self, w, h, **extras):
|
||||
return self.parent.server.getImageTranscodeURL(self, w, h, **extras)
|
||||
|
||||
|
||||
class JEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
try:
|
||||
return json.JSONEncoder.default(self, o)
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def asFullObject(func):
|
||||
def wrap(self, *args, **kwargs):
|
||||
if not self.isFullObject():
|
||||
self.reload()
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
return wrap
|
||||
|
||||
|
||||
class Checks:
|
||||
def isLibraryItem(self):
|
||||
return "/library/metadata" in self.get('key', '') or ("/playlists/" in self.get('key', '') and self.get("type", "") == "playlist")
|
||||
|
||||
def isVideoItem(self):
|
||||
return False
|
||||
|
||||
def isMusicItem(self):
|
||||
return False
|
||||
|
||||
def isOnlineItem(self):
|
||||
return self.isChannelItem() or self.isMyPlexItem() or self.isVevoItem() or self.isIvaItem()
|
||||
|
||||
def isMyPlexItem(self):
|
||||
return self.container.server.TYPE == 'MYPLEXSERVER' or self.container.identifier == 'com.plexapp.plugins.myplex'
|
||||
|
||||
def isChannelItem(self):
|
||||
identifier = self.getIdentifier() or "com.plexapp.plugins.library"
|
||||
return not self.isLibraryItem() and not self.isMyPlexItem() and identifier != "com.plexapp.plugins.library"
|
||||
|
||||
def isVevoItem(self):
|
||||
return 'vevo://' in self.get('guid')
|
||||
|
||||
def isIvaItem(self):
|
||||
return 'iva://' in self.get('guid')
|
||||
|
||||
def isGracenoteCollection(self):
|
||||
return False
|
||||
|
||||
def isIPhoto(self):
|
||||
return (self.title == "iPhoto" or self.container.title == "iPhoto" or (self.mediaType == "Image" or self.mediaType == "Movie"))
|
||||
|
||||
def isDirectory(self):
|
||||
return self.name == "Directory" or self.name == "Playlist"
|
||||
|
||||
def isPhotoOrDirectoryItem(self):
|
||||
return self.type == "photoalbum" # or self.isPhotoItem()
|
||||
|
||||
def isMusicOrDirectoryItem(self):
|
||||
return self.type in ('artist', 'album', 'track')
|
||||
|
||||
def isVideoOrDirectoryItem(self):
|
||||
return self.type in ('movie', 'show', 'episode')
|
||||
|
||||
def isSettings(self):
|
||||
return False
|
||||
|
||||
|
||||
class PlexObject(object, Checks):
|
||||
def __init__(self, data, initpath=None, server=None, container=None):
|
||||
self.initpath = initpath
|
||||
self.key = None
|
||||
self.server = server
|
||||
self.container = container
|
||||
self.mediaChoice = None
|
||||
self.titleSort = PlexValue('')
|
||||
self.deleted = False
|
||||
self._reloaded = False
|
||||
|
||||
if data is None:
|
||||
return
|
||||
|
||||
self._setData(data)
|
||||
|
||||
self.init(data)
|
||||
|
||||
def _setData(self, data):
|
||||
if data is False:
|
||||
return
|
||||
|
||||
self.name = data.tag
|
||||
for k, v in data.attrib.items():
|
||||
setattr(self, k, PlexValue(v, self))
|
||||
|
||||
def __getattr__(self, attr):
|
||||
a = PlexValue('', self)
|
||||
a.NA = True
|
||||
|
||||
try:
|
||||
setattr(self, attr, a)
|
||||
except AttributeError:
|
||||
util.LOG('Failed to set attribute: {0} ({1})'.format(attr, self.__class__))
|
||||
|
||||
return a
|
||||
|
||||
def exists(self):
|
||||
# Used for media items - for others we just return True
|
||||
return True
|
||||
|
||||
def get(self, attr, default=''):
|
||||
ret = self.__dict__.get(attr)
|
||||
return ret is not None and ret or PlexValue(default, self)
|
||||
|
||||
def set(self, attr, value):
|
||||
setattr(self, attr, PlexValue(unicode(value), self))
|
||||
|
||||
def init(self, data):
|
||||
pass
|
||||
|
||||
def isFullObject(self):
|
||||
return self.initpath is None or self.key is None or self.initpath == self.key
|
||||
|
||||
def getAddress(self):
|
||||
return self.server.activeConnection.address
|
||||
|
||||
@property
|
||||
def defaultTitle(self):
|
||||
return self.get('title')
|
||||
|
||||
@property
|
||||
def defaultThumb(self):
|
||||
return self.__dict__.get('thumb') and self.thumb or PlexValue('', self)
|
||||
|
||||
@property
|
||||
def defaultArt(self):
|
||||
return self.__dict__.get('art') and self.art or PlexValue('', self)
|
||||
|
||||
def refresh(self):
|
||||
import requests
|
||||
self.server.query('%s/refresh' % self.key, method=requests.put)
|
||||
|
||||
def reload(self, _soft=False, **kwargs):
|
||||
""" Reload the data for this object from PlexServer XML. """
|
||||
if _soft and self._reloaded:
|
||||
return self
|
||||
|
||||
try:
|
||||
if self.get('ratingKey'):
|
||||
data = self.server.query('/library/metadata/{0}'.format(self.ratingKey), params=kwargs)
|
||||
else:
|
||||
data = self.server.query(self.key, params=kwargs)
|
||||
self._reloaded = True
|
||||
except Exception, e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
util.ERROR(err=e)
|
||||
self.initpath = self.key
|
||||
return self
|
||||
|
||||
self.initpath = self.key
|
||||
|
||||
try:
|
||||
self._setData(data[0])
|
||||
except IndexError:
|
||||
util.DEBUG_LOG('No data on reload: {0}'.format(self))
|
||||
return self
|
||||
|
||||
return self
|
||||
|
||||
def softReload(self, **kwargs):
|
||||
return self.reload(_soft=True, **kwargs)
|
||||
|
||||
def getLibrarySectionId(self):
|
||||
ID = self.get('librarySectionID')
|
||||
|
||||
if not ID:
|
||||
ID = self.container.get("librarySectionID", '')
|
||||
|
||||
return ID
|
||||
|
||||
def getLibrarySectionTitle(self):
|
||||
title = self.get('librarySectionTitle')
|
||||
|
||||
if not title:
|
||||
title = self.container.get("librarySectionTitle", '')
|
||||
|
||||
if not title:
|
||||
lsid = self.getLibrarySectionId()
|
||||
if lsid:
|
||||
data = self.server.query('/library/sections/{0}'.format(lsid))
|
||||
title = data.attrib.get('title1')
|
||||
if title:
|
||||
self.librarySectionTitle = title
|
||||
return title
|
||||
|
||||
def getLibrarySectionType(self):
|
||||
type_ = self.get('librarySectionType')
|
||||
|
||||
if not type_:
|
||||
type_ = self.container.get("librarySectionType", '')
|
||||
|
||||
if not type_:
|
||||
lsid = self.getLibrarySectionId()
|
||||
if lsid:
|
||||
data = self.server.query('/library/sections/{0}'.format(lsid))
|
||||
type_ = data.attrib.get('type')
|
||||
if type_:
|
||||
self.librarySectionTitle = type_
|
||||
return type_
|
||||
|
||||
def getLibrarySectionUuid(self):
|
||||
uuid = self.get("uuid") or self.get("librarySectionUUID")
|
||||
|
||||
if not uuid:
|
||||
uuid = self.container.get("librarySectionUUID", "")
|
||||
|
||||
return uuid
|
||||
|
||||
def _findLocation(self, data):
|
||||
elem = data.find('Location')
|
||||
if elem is not None:
|
||||
return elem.attrib.get('path')
|
||||
return None
|
||||
|
||||
def _findPlayer(self, data):
|
||||
elem = data.find('Player')
|
||||
if elem is not None:
|
||||
from plexapi.client import Client
|
||||
return Client(self.server, elem)
|
||||
return None
|
||||
|
||||
def _findTranscodeSession(self, data):
|
||||
elem = data.find('TranscodeSession')
|
||||
if elem is not None:
|
||||
from plexapi import media
|
||||
return media.TranscodeSession(self.server, elem)
|
||||
return None
|
||||
|
||||
def _findUser(self, data):
|
||||
elem = data.find('User')
|
||||
if elem is not None:
|
||||
from plexapi.myplex import MyPlexUser
|
||||
return MyPlexUser(elem, self.initpath)
|
||||
return None
|
||||
|
||||
def getAbsolutePath(self, attr):
|
||||
path = getattr(self, attr, None)
|
||||
if path is None:
|
||||
return None
|
||||
else:
|
||||
return self.container._getAbsolutePath(path)
|
||||
|
||||
def _getAbsolutePath(self, path):
|
||||
if path.startswith('/'):
|
||||
return path
|
||||
elif "://" in path:
|
||||
return path
|
||||
else:
|
||||
return self.getAddress() + "/" + path
|
||||
|
||||
def getParentPath(self, key):
|
||||
# Some containers have /children on its key while others (such as playlists) use /items
|
||||
path = self.getAbsolutePath(key)
|
||||
if path is None:
|
||||
return ""
|
||||
|
||||
for suffix in ("/children", "/items"):
|
||||
path = path.replace(suffix, "")
|
||||
|
||||
return path
|
||||
|
||||
def getServer(self):
|
||||
return self.server
|
||||
|
||||
def getTranscodeServer(self, localServerRequired=False, transcodeType=None):
|
||||
server = self.server
|
||||
|
||||
# If the server is myPlex, try to use a different PMS for transcoding
|
||||
import myplexserver
|
||||
if server == myplexserver.MyPlexServer:
|
||||
fallbackServer = plexapp.SERVERMANAGER.getChannelServer()
|
||||
|
||||
if fallbackServer:
|
||||
server = fallbackServer
|
||||
elif localServerRequired:
|
||||
return None
|
||||
|
||||
return server
|
||||
|
||||
@classmethod
|
||||
def deSerialize(cls, jstring):
|
||||
import plexserver
|
||||
obj = json.loads(jstring)
|
||||
server = plexserver.PlexServer.deSerialize(obj['server'])
|
||||
server.identifier = None
|
||||
ad = util.AttributeDict()
|
||||
ad.attrib = obj['obj']
|
||||
ad.find = lambda x: None
|
||||
po = buildItem(server, ad, ad.initpath, container=server)
|
||||
|
||||
return po
|
||||
|
||||
def serialize(self, full=False):
|
||||
import json
|
||||
odict = {}
|
||||
if full:
|
||||
for k, v in self.__dict__.items():
|
||||
if k not in ('server', 'container', 'media', 'initpath', '_data') and v:
|
||||
odict[k] = v
|
||||
else:
|
||||
odict['key'] = self.key
|
||||
odict['type'] = self.type
|
||||
|
||||
odict['initpath'] = '/none'
|
||||
obj = {'obj': odict, 'server': self.server.serialize(full=full)}
|
||||
|
||||
return json.dumps(obj, cls=JEncoder)
|
||||
|
||||
|
||||
class PlexContainer(PlexObject):
|
||||
def __init__(self, data, initpath=None, server=None, address=None):
|
||||
PlexObject.__init__(self, data, initpath, server)
|
||||
self.setAddress(address)
|
||||
|
||||
def setAddress(self, address):
|
||||
if address != "/" and address.endswith("/"):
|
||||
self.address = address[:-1]
|
||||
else:
|
||||
self.address = address
|
||||
|
||||
# TODO(schuyler): Do we need to make sure that we only hang onto the path here and not a full URL?
|
||||
if not self.address.startswith("/") and "node.plexapp.com" not in self.address:
|
||||
util.FATAL("Container address is not an expected path: {0}".format(address))
|
||||
|
||||
def getAbsolutePath(self, path):
|
||||
if path.startswith('/'):
|
||||
return path
|
||||
elif "://" in path:
|
||||
return path
|
||||
else:
|
||||
return self.address + "/" + path
|
||||
|
||||
|
||||
class PlexServerContainer(PlexContainer):
|
||||
def __init__(self, data, initpath=None, server=None, address=None):
|
||||
PlexContainer.__init__(self, data, initpath, server, address)
|
||||
import plexserver
|
||||
self.resources = [plexserver.PlexServer(elem) for elem in data]
|
||||
|
||||
def __getitem__(self, idx):
|
||||
return self.resources[idx]
|
||||
|
||||
def __iter__(self):
|
||||
for i in self.resources:
|
||||
yield i
|
||||
|
||||
def __len__(self):
|
||||
return len(self.resources)
|
||||
|
||||
|
||||
class PlexItemList(object):
|
||||
def __init__(self, data, item_cls, tag, server=None, container=None):
|
||||
self._data = data
|
||||
self._itemClass = item_cls
|
||||
self._itemTag = tag
|
||||
self._server = server
|
||||
self._container = container
|
||||
self._items = None
|
||||
|
||||
def __iter__(self):
|
||||
for i in self.items:
|
||||
yield i
|
||||
|
||||
def __getitem__(self, idx):
|
||||
return self.items[idx]
|
||||
|
||||
@property
|
||||
def items(self):
|
||||
if self._items is None:
|
||||
if self._data is not None:
|
||||
if self._server:
|
||||
self._items = [self._itemClass(elem, server=self._server, container=self._container) for elem in self._data if elem.tag == self._itemTag]
|
||||
else:
|
||||
self._items = [self._itemClass(elem) for elem in self._data if elem.tag == self._itemTag]
|
||||
else:
|
||||
self._items = []
|
||||
|
||||
return self._items
|
||||
|
||||
def __call__(self, *args):
|
||||
return self.items
|
||||
|
||||
def __len__(self):
|
||||
return len(self.items)
|
||||
|
||||
def append(self, item):
|
||||
self.items.append(item)
|
||||
|
||||
|
||||
class PlexMediaItemList(PlexItemList):
|
||||
def __init__(self, data, item_cls, tag, initpath=None, server=None, media=None):
|
||||
PlexItemList.__init__(self, data, item_cls, tag, server)
|
||||
self._initpath = initpath
|
||||
self._media = media
|
||||
self._items = None
|
||||
|
||||
@property
|
||||
def items(self):
|
||||
if self._items is None:
|
||||
if self._data is not None:
|
||||
self._items = [self._itemClass(elem, self._initpath, self._server, self._media) for elem in self._data if elem.tag == self._itemTag]
|
||||
else:
|
||||
self._items = []
|
||||
|
||||
return self._items
|
||||
|
||||
|
||||
def findItem(server, path, title):
|
||||
for elem in server.query(path):
|
||||
if elem.attrib.get('title').lower() == title.lower():
|
||||
return buildItem(server, elem, path)
|
||||
raise exceptions.NotFound('Unable to find item: {0}'.format(title))
|
||||
|
||||
|
||||
def buildItem(server, elem, initpath, bytag=False, container=None, tag_fallback=False):
|
||||
libtype = elem.tag if bytag else elem.attrib.get('type')
|
||||
if not libtype and tag_fallback:
|
||||
libtype = elem.tag
|
||||
|
||||
if libtype in LIBRARY_TYPES:
|
||||
cls = LIBRARY_TYPES[libtype]
|
||||
return cls(elem, initpath=initpath, server=server, container=container)
|
||||
raise exceptions.UnknownType('Unknown library type: {0}'.format(libtype))
|
||||
|
||||
|
||||
class ItemContainer(list):
|
||||
def __getattr__(self, attr):
|
||||
return getattr(self.container, attr)
|
||||
|
||||
def init(self, container):
|
||||
self.container = container
|
||||
return self
|
||||
|
||||
|
||||
def listItems(server, path, libtype=None, watched=None, bytag=False, data=None, container=None):
|
||||
data = data if data is not None else server.query(path)
|
||||
container = container or PlexContainer(data, path, server, path)
|
||||
items = ItemContainer().init(container)
|
||||
|
||||
for elem in data:
|
||||
if libtype and elem.attrib.get('type') != libtype:
|
||||
continue
|
||||
if watched is True and elem.attrib.get('viewCount', 0) == 0:
|
||||
continue
|
||||
if watched is False and elem.attrib.get('viewCount', 0) >= 1:
|
||||
continue
|
||||
try:
|
||||
items.append(buildItem(server, elem, path, bytag, container))
|
||||
except exceptions.UnknownType:
|
||||
pass
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def searchType(libtype):
|
||||
searchtypesstrs = [str(k) for k in SEARCHTYPES.keys()]
|
||||
if libtype in SEARCHTYPES + searchtypesstrs:
|
||||
return libtype
|
||||
stype = SEARCHTYPES.get(libtype.lower())
|
||||
if not stype:
|
||||
raise exceptions.NotFound('Unknown libtype: %s' % libtype)
|
||||
return stype
|
177
resources/lib/plexnet/plexpart.py
Normal file
177
resources/lib/plexnet/plexpart.py
Normal file
|
@ -0,0 +1,177 @@
|
|||
import plexobjects
|
||||
import plexstream
|
||||
import plexrequest
|
||||
import util
|
||||
|
||||
|
||||
class PlexPart(plexobjects.PlexObject):
|
||||
def reload(self):
|
||||
self.initpath = self.key
|
||||
|
||||
def __init__(self, data, initpath=None, server=None, media=None):
|
||||
plexobjects.PlexObject.__init__(self, data, initpath, server)
|
||||
self.container_ = self.container
|
||||
self.container = media
|
||||
self.streams = []
|
||||
|
||||
# If we weren't given any data, this is a synthetic part
|
||||
if data is not None:
|
||||
self.streams = [plexstream.PlexStream(e, initpath=self.initpath, server=self.server) for e in data if e.tag == 'Stream']
|
||||
if self.indexes:
|
||||
indexKeys = self.indexes('').split(",")
|
||||
self.indexes = util.AttributeDict()
|
||||
for indexKey in indexKeys:
|
||||
self.indexes[indexKey] = True
|
||||
|
||||
def getAddress(self):
|
||||
address = self.key
|
||||
|
||||
if address != "":
|
||||
# TODO(schuyler): Do we need to add a token? Or will it be taken care of via header else:where?
|
||||
address = self.container.getAbsolutePath(address)
|
||||
|
||||
return address
|
||||
|
||||
def isAccessible(self):
|
||||
# If we haven't fetched accessibility info, assume it's accessible.
|
||||
return self.accessible.asBool() if self.accessible else True
|
||||
|
||||
def isAvailable(self):
|
||||
# If we haven't fetched availability info, assume it's available
|
||||
return not self.exists or self.exists.asBool()
|
||||
|
||||
def getStreamsOfType(self, streamType):
|
||||
streams = []
|
||||
|
||||
foundSelected = False
|
||||
|
||||
for stream in self.streams:
|
||||
if stream.streamType.asInt() == streamType:
|
||||
streams.append(stream)
|
||||
|
||||
if stream.isSelected():
|
||||
foundSelected = True
|
||||
|
||||
# If this is subtitles, add the none option
|
||||
if streamType == plexstream.PlexStream.TYPE_SUBTITLE:
|
||||
none = plexstream.NoneStream()
|
||||
streams.insert(0, none)
|
||||
none.setSelected(not foundSelected)
|
||||
|
||||
return streams
|
||||
|
||||
# def getSelectedStreamStringOfType(self, streamType):
|
||||
# default = None
|
||||
# availableStreams = 0
|
||||
# for stream in self.streams:
|
||||
# if stream.streamType.asInt() == streamType:
|
||||
# availableStreams = availableStreams + 1
|
||||
# if stream.isSelected() or (default is None and streamType != stream.TYPE_SUBTITLE):
|
||||
# default = stream
|
||||
|
||||
# if default is not None:
|
||||
# availableStreams = availableStreams - 1
|
||||
# title = default.getTitle()
|
||||
# suffix = "More"
|
||||
# else:
|
||||
# title = "None"
|
||||
# suffix = "Available"
|
||||
|
||||
# if availableStreams > 0 and streamType != stream.TYPE_VIDEO:
|
||||
# # Indicate available streams to choose from, excluding video
|
||||
# # streams until the server supports multiple videos streams.
|
||||
|
||||
# return u"{0} : {1} {2}".format(title, availableStreams, suffix)
|
||||
# else:
|
||||
# return title
|
||||
|
||||
def getSelectedStreamOfType(self, streamType):
|
||||
# Video streams, in particular, may not be selected. Pretend like the
|
||||
# first one was selected.
|
||||
|
||||
default = None
|
||||
|
||||
for stream in self.streams:
|
||||
if stream.streamType.asInt() == streamType:
|
||||
if stream.isSelected():
|
||||
return stream
|
||||
elif default is None and streamType != stream.TYPE_SUBTITLE:
|
||||
default = stream
|
||||
|
||||
return default
|
||||
|
||||
def setSelectedStream(self, streamType, streamId, async):
|
||||
if streamType == plexstream.PlexStream.TYPE_AUDIO:
|
||||
typeString = "audio"
|
||||
elif streamType == plexstream.PlexStream.TYPE_SUBTITLE:
|
||||
typeString = "subtitle"
|
||||
elif streamType == plexstream.PlexStream.TYPE_VIDEO:
|
||||
typeString = "video"
|
||||
else:
|
||||
return None
|
||||
|
||||
path = "/library/parts/{0}?{1}StreamID={2}".format(self.id(''), typeString, streamId)
|
||||
|
||||
if self.getServer().supportsFeature("allPartsStreamSelection"):
|
||||
path = path + "&allParts=1"
|
||||
|
||||
request = plexrequest.PlexRequest(self.getServer(), path, "PUT")
|
||||
|
||||
if async:
|
||||
context = request.createRequestContext("ignored")
|
||||
import plexapp
|
||||
plexapp.APP.startRequest(request, context, "")
|
||||
else:
|
||||
request.postToStringWithTimeout()
|
||||
|
||||
matching = plexstream.NoneStream()
|
||||
|
||||
# Update any affected streams
|
||||
for stream in self.streams:
|
||||
if stream.streamType.asInt() == streamType:
|
||||
if stream.id == streamId:
|
||||
stream.setSelected(True)
|
||||
matching = stream
|
||||
elif stream.isSelected():
|
||||
stream.setSelected(False)
|
||||
|
||||
return matching
|
||||
|
||||
def isIndexed(self):
|
||||
return bool(self.indexes)
|
||||
|
||||
def getIndexUrl(self, indexKey):
|
||||
path = self.getIndexPath(indexKey)
|
||||
if path is not None:
|
||||
return self.container.server.buildUrl(path + "?interval=10000", True)
|
||||
else:
|
||||
return None
|
||||
|
||||
def getIndexPath(self, indexKey, interval=None):
|
||||
if self.indexes is not None and indexKey in self.indexes:
|
||||
return "/library/parts/{0}/indexes/{1}".format(self.id, indexKey)
|
||||
else:
|
||||
return None
|
||||
|
||||
def hasStreams(self):
|
||||
return bool(self.streams)
|
||||
|
||||
def __str__(self):
|
||||
return "Part {0} {1}".format(self.id("NaN"), self.key)
|
||||
|
||||
def __eq__(self, other):
|
||||
if other is None:
|
||||
return False
|
||||
|
||||
if self.__class__ != other.__class__:
|
||||
return False
|
||||
|
||||
return self.id == other.id
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
# TODO(schuyler): getStreams, getIndexThumbUrl
|
619
resources/lib/plexnet/plexplayer.py
Normal file
619
resources/lib/plexnet/plexplayer.py
Normal file
|
@ -0,0 +1,619 @@
|
|||
import re
|
||||
import util
|
||||
import captions
|
||||
import http
|
||||
import plexrequest
|
||||
import mediadecisionengine
|
||||
import serverdecision
|
||||
|
||||
DecisionFailure = serverdecision.DecisionFailure
|
||||
|
||||
|
||||
class PlexPlayer(object):
|
||||
DECISION_ENDPOINT = "/video/:/transcode/universal/decision"
|
||||
|
||||
def __init__(self, item, seekValue=0, forceUpdate=False):
|
||||
self.decision = None
|
||||
self.seekValue = seekValue
|
||||
self.metadata = None
|
||||
self.init(item, forceUpdate)
|
||||
|
||||
def init(self, item, forceUpdate=False):
|
||||
self.item = item
|
||||
self.choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item, forceUpdate=forceUpdate)
|
||||
if self.choice:
|
||||
self.media = self.choice.media
|
||||
|
||||
def terminate(self, code, reason):
|
||||
util.LOG('TERMINATE PLAYER: ({0}, {1})'.format(code, reason))
|
||||
# TODO: Handle this? ---------------------------------------------------------------------------------------------------------- TODO
|
||||
|
||||
def rebuild(self, item, decision=None):
|
||||
# item.settings = self.item.settings
|
||||
oldChoice = self.choice
|
||||
self.init(item, True)
|
||||
util.LOG("Replacing '{0}' with '{1}' and rebuilding.".format(oldChoice, self.choice))
|
||||
self.build()
|
||||
self.decision = decision
|
||||
|
||||
def build(self, forceTranscode=False):
|
||||
if self.item.settings.getPreference("playback_directplay", False):
|
||||
directPlayPref = self.item.settings.getPreference("playback_directplay_force", False) and 'forced' or 'allow'
|
||||
else:
|
||||
directPlayPref = 'disabled'
|
||||
|
||||
if forceTranscode or directPlayPref == "disabled" or self.choice.hasBurnedInSubtitles is True:
|
||||
directPlay = False
|
||||
else:
|
||||
directPlay = directPlayPref == "forced" and True or None
|
||||
|
||||
return self._build(directPlay, self.item.settings.getPreference("playback_remux", False))
|
||||
|
||||
def _build(self, directPlay=None, directStream=True, currentPartIndex=None):
|
||||
isForced = directPlay is not None
|
||||
if isForced:
|
||||
util.LOG(directPlay and "Forced Direct Play" or "Forced Transcode; allowDirectStream={0}".format(directStream))
|
||||
|
||||
directPlay = directPlay or self.choice.isDirectPlayable
|
||||
server = self.item.getServer()
|
||||
|
||||
# A lot of our content metadata is independent of the direct play decision.
|
||||
# Add that first.
|
||||
|
||||
obj = util.AttributeDict()
|
||||
obj.duration = self.media.duration.asInt()
|
||||
|
||||
videoRes = self.media.getVideoResolution()
|
||||
obj.fullHD = videoRes >= 1080
|
||||
obj.streamQualities = (videoRes >= 480 and self.item.settings.getGlobal("IsHD")) and ["HD"] or ["SD"]
|
||||
|
||||
frameRate = self.media.videoFrameRate or "24p"
|
||||
if frameRate == "24p":
|
||||
obj.frameRate = 24
|
||||
elif frameRate == "NTSC":
|
||||
obj.frameRate = 30
|
||||
|
||||
# Add soft subtitle info
|
||||
if self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_ANY:
|
||||
obj.subtitleUrl = server.buildUrl(self.choice.subtitleStream.getSubtitlePath(), True)
|
||||
elif self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_DP:
|
||||
obj.subtitleConfig = {'TrackName': "mkv/" + str(self.choice.subtitleStream.index.asInt() + 1)}
|
||||
|
||||
# Create one content metadata object for each part and store them as a
|
||||
# linked list. We probably want a doubly linked list, except that it
|
||||
# becomes a circular reference nuisance, so we make the current item the
|
||||
# base object and singly link in each direction from there.
|
||||
|
||||
baseObj = obj
|
||||
prevObj = None
|
||||
startOffset = 0
|
||||
|
||||
startPartIndex = currentPartIndex or 0
|
||||
for partIndex in range(startPartIndex, len(self.media.parts)):
|
||||
isCurrentPart = (currentPartIndex is not None and partIndex == currentPartIndex)
|
||||
partObj = util.AttributeDict()
|
||||
partObj.update(baseObj)
|
||||
|
||||
partObj.live = False
|
||||
partObj.partIndex = partIndex
|
||||
partObj.startOffset = startOffset
|
||||
|
||||
part = self.media.parts[partIndex]
|
||||
|
||||
partObj.partDuration = part.duration.asInt()
|
||||
|
||||
if part.isIndexed():
|
||||
partObj.sdBifPath = part.getIndexPath("sd")
|
||||
partObj.hdBifPath = part.getIndexPath("hd")
|
||||
|
||||
# We have to evaluate every part before playback. Normally we'd expect
|
||||
# all parts to be identical, but in reality they can be different.
|
||||
|
||||
if partIndex > 0 and (not isForced and directPlay or not isCurrentPart):
|
||||
choice = mediadecisionengine.MediaDecisionEngine().evaluateMediaVideo(self.item, self.media, partIndex)
|
||||
canDirectPlay = (choice.isDirectPlayable is True)
|
||||
else:
|
||||
canDirectPlay = directPlay
|
||||
|
||||
if canDirectPlay:
|
||||
partObj = self.buildDirectPlay(partObj, partIndex)
|
||||
else:
|
||||
transcodeServer = self.item.getTranscodeServer(True, "video")
|
||||
if transcodeServer is None:
|
||||
return None
|
||||
partObj = self.buildTranscode(transcodeServer, partObj, partIndex, directStream, isCurrentPart)
|
||||
|
||||
# Set up our linked list references. If we couldn't build an actual
|
||||
# object: fail fast. Otherwise, see if we're at our start offset
|
||||
# yet in order to decide if we need to link forwards or backwards.
|
||||
# We also need to account for parts missing a duration, by verifying
|
||||
# the prevObj is None or if the startOffset has incremented.
|
||||
|
||||
if partObj is None:
|
||||
obj = None
|
||||
break
|
||||
elif prevObj is None or (startOffset > 0 and int(self.seekValue / 1000) >= startOffset):
|
||||
obj = partObj
|
||||
partObj.prevObj = prevObj
|
||||
elif prevObj is not None:
|
||||
prevObj.nextPart = partObj
|
||||
|
||||
startOffset = startOffset + int(part.duration.asInt() / 1000)
|
||||
|
||||
prevObj = partObj
|
||||
|
||||
# Only set PlayStart for the initial part, and adjust for the part's offset
|
||||
if obj is not None:
|
||||
if obj.live:
|
||||
# Start the stream at the end. Per Roku, this can be achieved using
|
||||
# a number higher than the duration. Using the current time should
|
||||
# ensure it's definitely high enough.
|
||||
|
||||
obj.playStart = util.now() + 1800
|
||||
else:
|
||||
obj.playStart = int(self.seekValue / 1000) - obj.startOffset
|
||||
|
||||
self.metadata = obj
|
||||
|
||||
util.LOG("Constructed video item for playback: {0}".format(dict(obj)))
|
||||
|
||||
return self.metadata
|
||||
|
||||
@property
|
||||
def startOffset(self):
|
||||
return self.metadata and self.metadata.startOffset or 0
|
||||
|
||||
def offsetIsValid(self, offset_seconds):
|
||||
return self.metadata.startOffset <= offset_seconds < self.metadata.startOffset + (self.metadata.partDuration / 1000)
|
||||
|
||||
def isLiveHls(url=None, headers=None):
|
||||
# Check to see if this is a live HLS playlist to fix two issues. One is a
|
||||
# Roku workaround since it doesn't obey the absence of EXT-X-ENDLIST to
|
||||
# start playback at the END of the playlist. The second is for us to know
|
||||
# if it's live to modify the functionality and player UI.
|
||||
|
||||
# if IsString(url):
|
||||
# request = createHttpRequest(url, "GET", true)
|
||||
# AddRequestHeaders(request.request, headers)
|
||||
# response = request.GetToStringWithTimeout(10)
|
||||
|
||||
# ' Inspect one of the media playlist streams if this is a master playlist.
|
||||
# if response.instr("EXT-X-STREAM-INF") > -1 then
|
||||
# Info("Identify live HLS: inspecting the master playlist")
|
||||
# mediaUrl = CreateObject("roRegex", "(^https?://.*$)", "m").Match(response)[1]
|
||||
# if mediaUrl <> invalid then
|
||||
# request = createHttpRequest(mediaUrl, "GET", true)
|
||||
# AddRequestHeaders(request.request, headers)
|
||||
# response = request.GetToStringWithTimeout(10)
|
||||
# end if
|
||||
# end if
|
||||
|
||||
# isLiveHls = (response.Trim().Len() > 0 and response.instr("EXT-X-ENDLIST") = -1 and response.instr("EXT-X-STREAM-INF") = -1)
|
||||
# Info("Identify live HLS: live=" + isLiveHls.toStr())
|
||||
# return isLiveHls
|
||||
|
||||
return False
|
||||
|
||||
def getServerDecision(self):
|
||||
directPlay = not (self.metadata and self.metadata.isTranscoded)
|
||||
decisionPath = self.getDecisionPath(directPlay)
|
||||
newDecision = None
|
||||
|
||||
if decisionPath:
|
||||
server = self.metadata.transcodeServer or self.item.getServer()
|
||||
request = plexrequest.PlexRequest(server, decisionPath)
|
||||
response = request.getWithTimeout(10)
|
||||
|
||||
if response.isSuccess() and response.container:
|
||||
decision = serverdecision.ServerDecision(self, response, self)
|
||||
|
||||
if decision.isSuccess():
|
||||
util.LOG("MDE: Server was happy with client's original decision. {0}".format(decision))
|
||||
elif decision.isDecision(True):
|
||||
util.WARN_LOG("MDE: Server was unhappy with client's original decision. {0}".format(decision))
|
||||
return decision.getDecision()
|
||||
else:
|
||||
util.LOG("MDE: Server was unbiased about the decision. {0}".format(decision))
|
||||
|
||||
# Check if the server has provided a new media item to use it. If
|
||||
# there is no item, then we'll continue along as if there was no
|
||||
# decision made.
|
||||
newDecision = decision.getDecision(False)
|
||||
else:
|
||||
util.WARN_LOG("MDE: Server failed to provide a decision")
|
||||
else:
|
||||
util.WARN_LOG("MDE: Server or item does not support decisions")
|
||||
|
||||
return newDecision or self
|
||||
|
||||
def getDecisionPath(self, directPlay=False):
|
||||
if not self.item or not self.metadata:
|
||||
return None
|
||||
|
||||
decisionPath = self.metadata.decisionPath
|
||||
if not decisionPath:
|
||||
server = self.metadata.transcodeServer or self.item.getServer()
|
||||
decisionPath = self.buildTranscode(server, util.AttributeDict(), self.metadata.partIndex, True, False).decisionPath
|
||||
|
||||
util.TEST(decisionPath)
|
||||
|
||||
# Modify the decision params based on the transcode url
|
||||
if decisionPath:
|
||||
if directPlay:
|
||||
decisionPath = decisionPath.replace("directPlay=0", "directPlay=1")
|
||||
|
||||
# Clear all subtitle parameters and add the a valid subtitle type based
|
||||
# on the video player. This will let the server decide if it can supply
|
||||
# sidecar subs, burn or embed w/ an optional transcode.
|
||||
for key in ("subtitles", "advancedSubtitles"):
|
||||
decisionPath = re.sub('([?&]{0}=)\w+'.format(key), '', decisionPath)
|
||||
subType = 'sidecar' # AppSettings().getBoolPreference("custom_video_player"), "embedded", "sidecar")
|
||||
decisionPath = http.addUrlParam(decisionPath, "subtitles=" + subType)
|
||||
|
||||
# Global variables for all decisions
|
||||
decisionPath = http.addUrlParam(decisionPath, "mediaBufferSize=20971") # Kodi default is 20971520 (20MB)
|
||||
decisionPath = http.addUrlParam(decisionPath, "hasMDE=1")
|
||||
decisionPath = http.addUrlParam(decisionPath, 'X-Plex-Platform=Chrome')
|
||||
|
||||
return decisionPath
|
||||
|
||||
def getTranscodeReason(self):
|
||||
# Combine the server and local MDE decisions
|
||||
obj = []
|
||||
if self.decision:
|
||||
obj.append(self.decision.getDecisionText())
|
||||
if self.item:
|
||||
obj.append(self.item.transcodeReason)
|
||||
reason = ' '.join(obj)
|
||||
if not reason:
|
||||
return None
|
||||
|
||||
return reason
|
||||
|
||||
def buildTranscodeHls(self, obj):
|
||||
util.DEBUG_LOG('buildTranscodeHls()')
|
||||
obj.streamFormat = "hls"
|
||||
obj.streamBitrates = [0]
|
||||
obj.switchingStrategy = "no-adaptation"
|
||||
obj.transcodeEndpoint = "/video/:/transcode/universal/start.m3u8"
|
||||
|
||||
builder = http.HttpRequest(obj.transcodeServer.buildUrl(obj.transcodeEndpoint, True))
|
||||
builder.extras = []
|
||||
builder.addParam("protocol", "hls")
|
||||
|
||||
if self.choice.subtitleDecision == self.choice.SUBTITLES_SOFT_ANY:
|
||||
builder.addParam("skipSubtitles", "1")
|
||||
else: # elif self.choice.hasBurnedInSubtitles is True: # Must burn transcoded because we can't set offset
|
||||
captionSize = captions.CAPTIONS.getBurnedSize()
|
||||
if captionSize is not None:
|
||||
builder.addParam("subtitleSize", captionSize)
|
||||
|
||||
# Augment the server's profile for things that depend on the Roku's configuration.
|
||||
if self.item.settings.supportsAudioStream("ac3", 6):
|
||||
builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=ac3)")
|
||||
builder.extras.append("add-direct-play-profile(type=videoProfile&container=matroska&videoCodec=*&audioCodec=ac3)")
|
||||
|
||||
return builder
|
||||
|
||||
def buildTranscodeMkv(self, obj):
|
||||
util.DEBUG_LOG('buildTranscodeMkv()')
|
||||
obj.streamFormat = "mkv"
|
||||
obj.streamBitrates = [0]
|
||||
obj.transcodeEndpoint = "/video/:/transcode/universal/start.mkv"
|
||||
|
||||
builder = http.HttpRequest(obj.transcodeServer.buildUrl(obj.transcodeEndpoint, True))
|
||||
builder.extras = []
|
||||
# builder.addParam("protocol", "http")
|
||||
builder.addParam("copyts", "1")
|
||||
|
||||
obj.subtitleUrl = None
|
||||
if True: # if self.choice.subtitleDecision == self.choice.SUBTITLES_BURN: # Must burn transcoded because we can't set offset
|
||||
builder.addParam("subtitles", "burn")
|
||||
captionSize = captions.CAPTIONS.getBurnedSize()
|
||||
if captionSize is not None:
|
||||
builder.addParam("subtitleSize", captionSize)
|
||||
|
||||
else:
|
||||
# TODO(rob): can we safely assume the id will also be 3 (one based index).
|
||||
# If not, we will have to get tricky and select the subtitle stream after
|
||||
# video playback starts via roCaptionRenderer: GetSubtitleTracks() and
|
||||
# ChangeSubtitleTrack()
|
||||
|
||||
obj.subtitleConfig = {'TrackName': "mkv/3"}
|
||||
|
||||
# Allow text conversion of subtitles if we only burn image formats
|
||||
if self.item.settings.getPreference("burn_subtitles") == "image":
|
||||
builder.addParam("advancedSubtitles", "text")
|
||||
|
||||
builder.addParam("subtitles", "auto")
|
||||
|
||||
# Augment the server's profile for things that depend on the Roku's configuration.
|
||||
if self.item.settings.supportsSurroundSound():
|
||||
if self.choice.audioStream is not None:
|
||||
numChannels = self.choice.audioStream.channels.asInt(6)
|
||||
else:
|
||||
numChannels = 6
|
||||
|
||||
for codec in ("ac3", "eac3", "dca"):
|
||||
if self.item.settings.supportsAudioStream(codec, numChannels):
|
||||
builder.extras.append("append-transcode-target-audio-codec(type=videoProfile&context=streaming&audioCodec=" + codec + ")")
|
||||
builder.extras.append("add-direct-play-profile(type=videoProfile&container=matroska&videoCodec=*&audioCodec=" + codec + ")")
|
||||
if codec == "dca":
|
||||
builder.extras.append(
|
||||
"add-limitation(scope=videoAudioCodec&scopeName=dca&type=upperBound&name=audio.channels&value=6&isRequired=false)"
|
||||
)
|
||||
|
||||
# AAC sample rate cannot be less than 22050hz (HLS is capable).
|
||||
if self.choice.audioStream is not None and self.choice.audioStream.samplingRate.asInt(22050) < 22050:
|
||||
builder.extras.append("add-limitation(scope=videoAudioCodec&scopeName=aac&type=lowerBound&name=audio.samplingRate&value=22050&isRequired=false)")
|
||||
|
||||
# HEVC and VP9 support!
|
||||
if self.item.settings.getGlobal("hevcSupport"):
|
||||
builder.extras.append("append-transcode-target-codec(type=videoProfile&context=streaming&videoCodec=hevc)")
|
||||
|
||||
if self.item.settings.getGlobal("vp9Support"):
|
||||
builder.extras.append("append-transcode-target-codec(type=videoProfile&context=streaming&videoCodec=vp9)")
|
||||
|
||||
return builder
|
||||
|
||||
def buildDirectPlay(self, obj, partIndex):
|
||||
util.DEBUG_LOG('buildDirectPlay()')
|
||||
part = self.media.parts[partIndex]
|
||||
|
||||
server = self.item.getServer()
|
||||
|
||||
# Check if we should include our token or not for this request
|
||||
obj.isRequestToServer = server.isRequestToServer(server.buildUrl(part.getAbsolutePath("key")))
|
||||
obj.streamUrls = [server.buildUrl(part.getAbsolutePath("key"), obj.isRequestToServer)]
|
||||
obj.token = obj.isRequestToServer and server.getToken() or None
|
||||
if self.media.protocol == "hls":
|
||||
obj.streamFormat = "hls"
|
||||
obj.switchingStrategy = "full-adaptation"
|
||||
obj.live = self.isLiveHLS(obj.streamUrls[0], self.media.indirectHeaders)
|
||||
else:
|
||||
obj.streamFormat = self.media.get('container', 'mp4')
|
||||
if obj.streamFormat == "mov" or obj.streamFormat == "m4v":
|
||||
obj.streamFormat = "mp4"
|
||||
|
||||
obj.streamBitrates = [self.media.bitrate.asInt()]
|
||||
obj.isTranscoded = False
|
||||
|
||||
if self.choice.audioStream is not None:
|
||||
obj.audioLanguageSelected = self.choice.audioStream.languageCode
|
||||
|
||||
return obj
|
||||
|
||||
def hasMoreParts(self):
|
||||
return (self.metadata is not None and self.metadata.nextPart is not None)
|
||||
|
||||
def getNextPartOffset(self):
|
||||
return self.metadata.nextPart.startOffset * 1000
|
||||
|
||||
def goToNextPart(self):
|
||||
oldPart = self.metadata
|
||||
if oldPart is None:
|
||||
return
|
||||
|
||||
newPart = oldPart.nextPart
|
||||
if newPart is None:
|
||||
return
|
||||
|
||||
newPart.prevPart = oldPart
|
||||
oldPart.nextPart = None
|
||||
self.metadata = newPart
|
||||
|
||||
util.LOG("Next part set for playback: {0}".format(self.metadata))
|
||||
|
||||
def getBifUrl(self, offset=0):
|
||||
server = self.item.getServer()
|
||||
startOffset = 0
|
||||
for part in self.media.parts:
|
||||
duration = part.duration.asInt()
|
||||
if startOffset <= offset < startOffset + duration:
|
||||
bifUrl = part.getIndexPath("hd") or part.getIndexPath("sd")
|
||||
if bifUrl is not None:
|
||||
url = server.buildUrl('{0}/{1}'.format(bifUrl, offset - startOffset), True)
|
||||
return url
|
||||
|
||||
startOffset += duration
|
||||
|
||||
return None
|
||||
|
||||
def buildTranscode(self, server, obj, partIndex, directStream, isCurrentPart):
|
||||
util.DEBUG_LOG('buildTranscode()')
|
||||
obj.transcodeServer = server
|
||||
obj.isTranscoded = True
|
||||
|
||||
# if server.supportsFeature("mkvTranscode") and self.item.settings.getPreference("transcode_format", 'mkv') != "hls":
|
||||
if server.supportsFeature("mkvTranscode"):
|
||||
builder = self.buildTranscodeMkv(obj)
|
||||
else:
|
||||
builder = self.buildTranscodeHls(obj)
|
||||
|
||||
if self.item.getServer().TYPE == 'MYPLEXSERVER':
|
||||
path = server.swizzleUrl(self.item.getAbsolutePath("key"))
|
||||
else:
|
||||
path = self.item.getAbsolutePath("key")
|
||||
|
||||
builder.addParam("path", path)
|
||||
|
||||
part = self.media.parts[partIndex]
|
||||
seekOffset = int(self.seekValue / 1000)
|
||||
|
||||
# Disabled for HLS due to a Roku bug plexinc/roku-client-issues#776
|
||||
if True: # obj.streamFormat == "mkv":
|
||||
# Trust our seekOffset for this part if it's the current part (now playing) or
|
||||
# the seekOffset is within the time frame. We have to trust the current part
|
||||
# as we may have to rebuild the transcode when seeking, and not all parts
|
||||
# have a valid duration.
|
||||
|
||||
if isCurrentPart or len(self.media.parts) <= 1 or (
|
||||
seekOffset >= obj.startOffset and seekOffset <= obj.get('startOffset', 0) + int(part.duration.asInt() / 1000)
|
||||
):
|
||||
startOffset = seekOffset - (obj.startOffset or 0)
|
||||
|
||||
# Avoid a perfect storm of PMS and Roku quirks. If we pass an offset to
|
||||
# the transcoder,: it'll start transcoding from that point. But if
|
||||
# we try to start a few seconds into the video, the Roku seems to want
|
||||
# to grab the first segment. The first segment doesn't exist, so PMS
|
||||
# returns a 404 (but only if the offset is <= 12s, otherwise it returns
|
||||
# a blank segment). If the Roku gets a 404 for the first segment,:
|
||||
# it'll fail. So, if we're going to start playing from less than 12
|
||||
# seconds, don't bother telling the transcoder. It's not worth the
|
||||
# potential failure, let it transcode from the start so that the first
|
||||
# segment will always exist.
|
||||
|
||||
# TODO: Probably can remove this (Rick)
|
||||
if startOffset <= 12:
|
||||
startOffset = 0
|
||||
else:
|
||||
startOffset = 0
|
||||
|
||||
builder.addParam("offset", str(startOffset))
|
||||
|
||||
builder.addParam("session", self.item.settings.getGlobal("clientIdentifier"))
|
||||
builder.addParam("directStream", directStream and "1" or "0")
|
||||
builder.addParam("directPlay", "0")
|
||||
|
||||
qualityIndex = self.item.settings.getQualityIndex(self.item.getQualityType(server))
|
||||
builder.addParam("videoQuality", self.item.settings.getGlobal("transcodeVideoQualities")[qualityIndex])
|
||||
builder.addParam("videoResolution", str(self.item.settings.getGlobal("transcodeVideoResolutions")[qualityIndex]))
|
||||
builder.addParam("maxVideoBitrate", self.item.settings.getGlobal("transcodeVideoBitrates")[qualityIndex])
|
||||
|
||||
if self.media.mediaIndex is not None:
|
||||
builder.addParam("mediaIndex", str(self.media.mediaIndex))
|
||||
|
||||
builder.addParam("partIndex", str(partIndex))
|
||||
|
||||
# Augment the server's profile for things that depend on the Roku's configuration.
|
||||
if self.item.settings.getPreference("h264_level", "auto") != "auto":
|
||||
builder.extras.append(
|
||||
"add-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.level&value={0}&isRequired=true)".format(
|
||||
self.item.settings.getPreference("h264_level")
|
||||
)
|
||||
)
|
||||
|
||||
if not self.item.settings.getGlobal("supports1080p60") and self.item.settings.getGlobal("transcodeVideoResolutions")[qualityIndex][0] >= 1920:
|
||||
builder.extras.append("add-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.frameRate&value=30&isRequired=false)")
|
||||
|
||||
if builder.extras:
|
||||
builder.addParam("X-Plex-Client-Profile-Extra", '+'.join(builder.extras))
|
||||
|
||||
if server.isLocalConnection():
|
||||
builder.addParam("location", "lan")
|
||||
|
||||
obj.streamUrls = [builder.getUrl()]
|
||||
|
||||
# Build the decision path now that we have build our stream url, and only if the server supports it.
|
||||
if server.supportsFeature("streamingBrain"):
|
||||
util.TEST("TEST==========================")
|
||||
decisionPath = builder.getRelativeUrl().replace(obj.transcodeEndpoint, self.DECISION_ENDPOINT)
|
||||
if decisionPath.startswith(self.DECISION_ENDPOINT):
|
||||
obj.decisionPath = decisionPath
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
class PlexAudioPlayer(object):
|
||||
def __init__(self, item):
|
||||
self.containerFormats = {
|
||||
'aac': "es.aac-adts"
|
||||
}
|
||||
|
||||
self.item = item
|
||||
self.choice = mediadecisionengine.MediaDecisionEngine().chooseMedia(item)
|
||||
if self.choice:
|
||||
self.media = self.choice.media
|
||||
self.lyrics = None # createLyrics(item, self.media)
|
||||
|
||||
def build(self, directPlay=None):
|
||||
directPlay = directPlay or self.choice.isDirectPlayable
|
||||
|
||||
obj = util.AttributeDict()
|
||||
|
||||
# TODO(schuyler): Do we want/need to add anything generic here? Title? Duration?
|
||||
|
||||
if directPlay:
|
||||
obj = self.buildDirectPlay(obj)
|
||||
else:
|
||||
obj = self.buildTranscode(obj)
|
||||
|
||||
self.metadata = obj
|
||||
|
||||
util.LOG("Constructed audio item for playback: {0}".format(dict(obj)))
|
||||
|
||||
return self.metadata
|
||||
|
||||
def buildTranscode(self, obj):
|
||||
transcodeServer = self.item.getTranscodeServer(True, "audio")
|
||||
if not transcodeServer:
|
||||
return None
|
||||
|
||||
obj.streamFormat = "mp3"
|
||||
obj.isTranscoded = True
|
||||
obj.transcodeServer = transcodeServer
|
||||
obj.transcodeEndpoint = "/music/:/transcode/universal/start.m3u8"
|
||||
|
||||
builder = http.HttpRequest(transcodeServer.buildUrl(obj.transcodeEndpoint, True))
|
||||
# builder.addParam("protocol", "http")
|
||||
builder.addParam("path", self.item.getAbsolutePath("key"))
|
||||
builder.addParam("session", self.item.getGlobal("clientIdentifier"))
|
||||
builder.addParam("directPlay", "0")
|
||||
builder.addParam("directStream", "0")
|
||||
|
||||
obj.url = builder.getUrl()
|
||||
|
||||
return obj
|
||||
|
||||
def buildDirectPlay(self, obj):
|
||||
if self.choice.part:
|
||||
obj.url = self.item.getServer().buildUrl(self.choice.part.getAbsolutePath("key"), True)
|
||||
|
||||
# Set and override the stream format if applicable
|
||||
obj.streamFormat = self.choice.media.get('container', 'mp3')
|
||||
if self.containerFormats.get(obj.streamFormat):
|
||||
obj.streamFormat = self.containerFormats[obj.streamFormat]
|
||||
|
||||
# If we're direct playing a FLAC, bitrate can be required, and supposedly
|
||||
# this is the only way to do it. plexinc/roku-client#48
|
||||
#
|
||||
bitrate = self.choice.media.bitrate.asInt()
|
||||
if bitrate > 0:
|
||||
obj.streams = [{'url': obj.url, 'bitrate': bitrate}]
|
||||
|
||||
return obj
|
||||
|
||||
# We may as well fallback to transcoding if we could not direct play
|
||||
return self.buildTranscode(obj)
|
||||
|
||||
def getLyrics(self):
|
||||
return self.lyrics
|
||||
|
||||
def hasLyrics(self):
|
||||
return False
|
||||
return self.lyrics.isAvailable()
|
||||
|
||||
|
||||
class PlexPhotoPlayer(object):
|
||||
def __init__(self, item):
|
||||
self.item = item
|
||||
self.choice = item
|
||||
self.media = item.media()[0]
|
||||
self.metadata = None
|
||||
|
||||
def build(self):
|
||||
if self.media.parts and self.media.parts[0]:
|
||||
obj = util.AttributeDict()
|
||||
|
||||
part = self.media.parts[0]
|
||||
path = part.key or part.thumb
|
||||
server = self.item.getServer()
|
||||
|
||||
obj.url = server.buildUrl(path, True)
|
||||
obj.enableBlur = server.supportsPhotoTranscoding
|
||||
|
||||
util.DEBUG_LOG("Constructed photo item for playback: {0}".format(dict(obj)))
|
||||
|
||||
self.metadata = obj
|
||||
|
||||
return self.metadata
|
45
resources/lib/plexnet/plexrequest.py
Normal file
45
resources/lib/plexnet/plexrequest.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
from xml.etree import ElementTree
|
||||
|
||||
import plexserver
|
||||
import plexresult
|
||||
import http
|
||||
import util
|
||||
|
||||
|
||||
class PlexRequest(http.HttpRequest):
|
||||
def __init__(self, server, path, method=None):
|
||||
server = server or plexserver.dummyPlexServer()
|
||||
|
||||
http.HttpRequest.__init__(self, server.buildUrl(path, includeToken=True), method)
|
||||
|
||||
self.server = server
|
||||
self.path = path
|
||||
|
||||
util.addPlexHeaders(self, server.getToken())
|
||||
|
||||
def onResponse(self, event, context):
|
||||
if context.get('completionCallback'):
|
||||
result = plexresult.PlexResult(self.server, self.path)
|
||||
result.setResponse(event)
|
||||
context['completionCallback'](self, result, context)
|
||||
|
||||
def doRequestWithTimeout(self, timeout=10, postBody=None):
|
||||
# non async request/response
|
||||
if postBody:
|
||||
data = ElementTree.fromstring(self.postToStringWithTimeout(postBody, timeout))
|
||||
else:
|
||||
data = ElementTree.fromstring(self.getToStringWithTimeout(timeout))
|
||||
|
||||
response = plexresult.PlexResult(self.server, self.path)
|
||||
response.setResponse(self.event)
|
||||
response.parseFakeXMLResponse(data)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class PlexServerRequest(PlexRequest):
|
||||
def onResponse(self, event, context):
|
||||
if context.get('completionCallback'):
|
||||
result = plexresult.PlexServerResult(self.server, self.path)
|
||||
result.setResponse(event)
|
||||
context['completionCallback'](self, result, context)
|
201
resources/lib/plexnet/plexresource.py
Normal file
201
resources/lib/plexnet/plexresource.py
Normal file
|
@ -0,0 +1,201 @@
|
|||
from xml.etree import ElementTree
|
||||
|
||||
import http
|
||||
import exceptions
|
||||
import plexobjects
|
||||
import plexconnection
|
||||
import util
|
||||
|
||||
RESOURCES = 'https://plex.tv/api/resources?includeHttps=1'
|
||||
|
||||
|
||||
class PlexResource(object):
|
||||
def __init__(self, data):
|
||||
self.connection = None
|
||||
self.connections = []
|
||||
self.accessToken = None
|
||||
self.sourceType = None
|
||||
|
||||
if data is None:
|
||||
return
|
||||
|
||||
self.accessToken = data.attrib.get('accessToken')
|
||||
self.httpsRequired = data.attrib.get('httpsRequired') == '1'
|
||||
self.type = data.attrib.get('type')
|
||||
self.clientIdentifier = data.attrib.get('clientIdentifier')
|
||||
self.product = data.attrib.get('product')
|
||||
self.provides = data.attrib.get('provides')
|
||||
self.serverClass = data.attrib.get('serverClass')
|
||||
self.sourceType = data.attrib.get('sourceType')
|
||||
self.uuid = self.clientIdentifier
|
||||
|
||||
hasSecureConn = False
|
||||
|
||||
for conn in data.findall('Connection'):
|
||||
if conn.attrib.get('protocol') == "https":
|
||||
hasSecureConn = True
|
||||
break
|
||||
|
||||
for conn in data.findall('Connection'):
|
||||
connection = plexconnection.PlexConnection(
|
||||
plexconnection.PlexConnection.SOURCE_MYPLEX,
|
||||
conn.attrib.get('uri'),
|
||||
conn.attrib.get('local') == '1',
|
||||
self.accessToken,
|
||||
hasSecureConn and conn.attrib.get('protocol') != "https"
|
||||
)
|
||||
|
||||
# Keep the secure connection on top
|
||||
if connection.isSecure:
|
||||
self.connections.insert(0, connection)
|
||||
else:
|
||||
self.connections.append(connection)
|
||||
|
||||
# If the connection is one of our plex.direct secure connections, add
|
||||
# the nonsecure variant as well, unless https is required.
|
||||
#
|
||||
if self.httpsRequired and conn.attrib.get('protocol') == "https" and conn.attrib.get('address') not in conn.attrib.get('uri'):
|
||||
self.connections.append(
|
||||
plexconnection.PlexConnection(
|
||||
plexconnection.PlexConnection.SOURCE_MYPLEX,
|
||||
"http://{0}:{1}".format(conn.attrib.get('address'), conn.attrib.get('port')),
|
||||
conn.attrib.get('local') == '1',
|
||||
self.accessToken,
|
||||
True
|
||||
)
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return '<{0}:{1}>'.format(self.__class__.__name__, self.name.encode('utf8'))
|
||||
|
||||
|
||||
class ResourceConnection(plexobjects.PlexObject):
|
||||
# Constants
|
||||
STATE_UNKNOWN = "unknown"
|
||||
STATE_UNREACHABLE = "unreachable"
|
||||
STATE_REACHABLE = "reachable"
|
||||
STATE_UNAUTHORIZED = "unauthorized"
|
||||
STATE_INSECURE = "insecure_untested"
|
||||
|
||||
SOURCE_MANUAL = 1
|
||||
SOURCE_DISCOVERED = 2
|
||||
SOURCE_MYPLEX = 4
|
||||
|
||||
SCORE_REACHABLE = 4
|
||||
SCORE_LOCAL = 2
|
||||
SCORE_SECURE = 1
|
||||
|
||||
def init(self, data):
|
||||
self.secure = True
|
||||
self.reachable = False
|
||||
self.data = None
|
||||
|
||||
def __repr__(self):
|
||||
return '<{0}:{1}>'.format(self.__class__.__name__, self.uri.encode('utf8'))
|
||||
|
||||
@property
|
||||
def http_uri(self):
|
||||
return 'http://{0}:{1}'.format(self.address, self.port)
|
||||
|
||||
@property
|
||||
def URL(self):
|
||||
if self.secure:
|
||||
return self.uri
|
||||
else:
|
||||
return self.http_url
|
||||
|
||||
def connect(self):
|
||||
util.LOG('Connecting: {0}'.format(util.cleanToken(self.URL)))
|
||||
try:
|
||||
self.data = self.query('/')
|
||||
self.reachable = True
|
||||
return True
|
||||
except Exception as err:
|
||||
util.ERROR(util.cleanToken(self.URL), err)
|
||||
|
||||
util.LOG('Connecting: Secure failed, trying insecure...')
|
||||
self.secure = False
|
||||
|
||||
try:
|
||||
self.data = self.query('/')
|
||||
self.reachable = True
|
||||
return True
|
||||
except Exception as err:
|
||||
util.ERROR(util.cleanToken(self.URL), err)
|
||||
|
||||
return False
|
||||
|
||||
def headers(self, token=None):
|
||||
headers = util.BASE_HEADERS.copy()
|
||||
if token:
|
||||
headers['X-Plex-Token'] = token
|
||||
return headers
|
||||
|
||||
def query(self, path, method=None, token=None, **kwargs):
|
||||
method = method or http.requests.get
|
||||
url = self.getURL(path)
|
||||
util.LOG('{0} {1}'.format(method.__name__.upper(), url))
|
||||
response = method(url, headers=self.headers(token), timeout=util.TIMEOUT, **kwargs)
|
||||
if response.status_code not in (200, 201):
|
||||
codename = http.status_codes.get(response.status_code)[0]
|
||||
raise exceptions.BadRequest('({0}) {1}'.format(response.status_code, codename))
|
||||
data = response.text.encode('utf8')
|
||||
|
||||
return ElementTree.fromstring(data) if data else None
|
||||
|
||||
def getURL(self, path, token=None):
|
||||
if token:
|
||||
delim = '&' if '?' in path else '?'
|
||||
return '{base}{path}{delim}X-Plex-Token={token}'.format(base=self.URL, path=path, delim=delim, token=util.hideToken(token))
|
||||
return '{0}{1}'.format(self.URL, path)
|
||||
|
||||
|
||||
class PlexResourceList(plexobjects.PlexItemList):
|
||||
def __init__(self, data, initpath=None, server=None):
|
||||
self._data = data
|
||||
self.initpath = initpath
|
||||
self._server = server
|
||||
self._items = None
|
||||
|
||||
@property
|
||||
def items(self):
|
||||
if self._items is None:
|
||||
if self._data is not None:
|
||||
self._items = [PlexResource(elem, initpath=self.initpath, server=self._server) for elem in self._data]
|
||||
else:
|
||||
self._items = []
|
||||
|
||||
return self._items
|
||||
|
||||
|
||||
def fetchResources(token):
|
||||
headers = util.BASE_HEADERS.copy()
|
||||
headers['X-Plex-Token'] = token
|
||||
util.LOG('GET {0}?X-Plex-Token={1}'.format(RESOURCES, util.hideToken(token)))
|
||||
response = http.GET(RESOURCES)
|
||||
data = ElementTree.fromstring(response.text.encode('utf8'))
|
||||
import plexserver
|
||||
return [plexserver.PlexServer(elem) for elem in data]
|
||||
|
||||
|
||||
def findResource(resources, search, port=32400):
|
||||
""" Searches server.name """
|
||||
search = search.lower()
|
||||
util.LOG('Looking for server: {0}'.format(search))
|
||||
for server in resources:
|
||||
if search == server.name.lower():
|
||||
util.LOG('Server found: {0}'.format(server))
|
||||
return server
|
||||
util.LOG('Unable to find server: {0}'.format(search))
|
||||
raise exceptions.NotFound('Unable to find server: {0}'.format(search))
|
||||
|
||||
|
||||
def findResourceByID(resources, ID):
|
||||
""" Searches server.clientIdentifier """
|
||||
util.LOG('Looking for server by ID: {0}'.format(ID))
|
||||
for server in resources:
|
||||
if ID == server.clientIdentifier:
|
||||
util.LOG('Server found by ID: {0}'.format(server))
|
||||
return server
|
||||
util.LOG('Unable to find server by ID: {0}'.format(ID))
|
||||
raise exceptions.NotFound('Unable to find server by ID: {0}'.format(ID))
|
101
resources/lib/plexnet/plexresult.py
Normal file
101
resources/lib/plexnet/plexresult.py
Normal file
|
@ -0,0 +1,101 @@
|
|||
import http
|
||||
import plexobjects
|
||||
|
||||
|
||||
class PlexResult(http.HttpResponse):
|
||||
def __init__(self, server, address):
|
||||
self.server = server
|
||||
self.address = address
|
||||
self.container = None
|
||||
self.parsed = None
|
||||
self.items = []
|
||||
|
||||
def setResponse(self, event):
|
||||
self.event = event
|
||||
|
||||
def parseResponse(self):
|
||||
if self.parsed:
|
||||
return self.parsed
|
||||
|
||||
self.parsed = False
|
||||
|
||||
if self.isSuccess():
|
||||
data = self.getBodyXml()
|
||||
if data is not None:
|
||||
self.container = plexobjects.PlexContainer(data, initpath=self.address, server=self.server, address=self.address)
|
||||
|
||||
for node in data:
|
||||
self.addItem(self.container, node)
|
||||
|
||||
self.parsed = True
|
||||
|
||||
return self.parsed
|
||||
|
||||
def parseFakeXMLResponse(self, data):
|
||||
if self.parsed:
|
||||
return self.parsed
|
||||
|
||||
self.parsed = False
|
||||
|
||||
if data is not None:
|
||||
self.container = plexobjects.PlexContainer(data, initpath=self.address, server=self.server, address=self.address)
|
||||
|
||||
for node in data:
|
||||
self.addItem(self.container, node)
|
||||
|
||||
self.parsed = True
|
||||
|
||||
return self.parsed
|
||||
|
||||
def addItem(self, container, node):
|
||||
if node.attrib.get('type') in ('track', 'movie', 'episode', 'photo') and node.tag != 'PlayQueue':
|
||||
item = plexobjects.buildItem(self.server, node, self.address, container=self.container)
|
||||
else:
|
||||
item = plexobjects.PlexObject(node, server=self.container.server, container=self.container)
|
||||
|
||||
# TODO(rob): handle channel settings. We should be able to utilize
|
||||
# the settings component with some modifications.
|
||||
if not item.isSettings():
|
||||
self.items.append(item)
|
||||
else:
|
||||
# Decrement the size and total size if applicable
|
||||
if self.container.get("size"):
|
||||
self.container.size = plexobjects.PlexValue(str(self.container.size.asInt() - 1))
|
||||
if self.container.get("totalSize"):
|
||||
self.container.totalSize = plexobjects.PlexValue(str(self.container.totalSize.asInt() - 1))
|
||||
|
||||
|
||||
class PlexServerResult(PlexResult):
|
||||
def parseResponse(self):
|
||||
if self.parsed:
|
||||
return self.parsed
|
||||
|
||||
self.parsed = False
|
||||
|
||||
if self.isSuccess():
|
||||
data = self.getBodyXml()
|
||||
if data is not None:
|
||||
self.container = plexobjects.PlexServerContainer(data, initpath=self.address, server=self.server, address=self.address)
|
||||
|
||||
for node in data:
|
||||
self.addItem(self.container, node)
|
||||
|
||||
self.parsed = True
|
||||
|
||||
return self.parsed
|
||||
|
||||
def parseFakeXMLResponse(self, data):
|
||||
if self.parsed:
|
||||
return self.parsed
|
||||
|
||||
self.parsed = False
|
||||
|
||||
if data is not None:
|
||||
self.container = plexobjects.PlexServerContainer(data, initpath=self.address, server=self.server, address=self.address)
|
||||
|
||||
for node in data:
|
||||
self.addItem(self.container, node)
|
||||
|
||||
self.parsed = True
|
||||
|
||||
return self.parsed
|
623
resources/lib/plexnet/plexserver.py
Normal file
623
resources/lib/plexnet/plexserver.py
Normal file
|
@ -0,0 +1,623 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import http
|
||||
import time
|
||||
import util
|
||||
import exceptions
|
||||
import compat
|
||||
import verlib
|
||||
import re
|
||||
import json
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import signalsmixin
|
||||
import plexobjects
|
||||
import plexresource
|
||||
import plexlibrary
|
||||
import plexapp
|
||||
# from plexapi.client import Client
|
||||
# from plexapi.playqueue import PlayQueue
|
||||
|
||||
|
||||
TOTAL_QUERIES = 0
|
||||
DEFAULT_BASEURI = 'http://localhost:32400'
|
||||
|
||||
|
||||
class PlexServer(plexresource.PlexResource, signalsmixin.SignalsMixin):
|
||||
TYPE = 'PLEXSERVER'
|
||||
|
||||
def __init__(self, data=None):
|
||||
signalsmixin.SignalsMixin.__init__(self)
|
||||
plexresource.PlexResource.__init__(self, data)
|
||||
self.accessToken = None
|
||||
self.multiuser = False
|
||||
self.isSupported = None
|
||||
self.hasFallback = False
|
||||
self.supportsAudioTranscoding = False
|
||||
self.supportsVideoTranscoding = False
|
||||
self.supportsPhotoTranscoding = False
|
||||
self.supportsVideoRemuxOnly = False
|
||||
self.supportsScrobble = True
|
||||
self.allowsMediaDeletion = False
|
||||
self.allowChannelAccess = False
|
||||
self.activeConnection = None
|
||||
self.serverClass = None
|
||||
|
||||
self.pendingReachabilityRequests = 0
|
||||
self.pendingSecureRequests = 0
|
||||
|
||||
self.features = {}
|
||||
self.librariesByUuid = {}
|
||||
|
||||
self.server = self
|
||||
self.session = http.Session()
|
||||
|
||||
self.owner = None
|
||||
self.owned = False
|
||||
self.synced = False
|
||||
self.sameNetwork = False
|
||||
self.uuid = None
|
||||
self.name = None
|
||||
self.platform = None
|
||||
self.versionNorm = None
|
||||
self.rawVersion = None
|
||||
self.transcodeSupport = False
|
||||
|
||||
if data is None:
|
||||
return
|
||||
|
||||
self.owner = data.attrib.get('sourceTitle')
|
||||
self.owned = data.attrib.get('owned') == '1'
|
||||
self.synced = data.attrib.get('synced') == '1'
|
||||
self.sameNetwork = data.attrib.get('publicAddressMatches') == '1'
|
||||
self.uuid = data.attrib.get('clientIdentifier')
|
||||
self.name = data.attrib.get('name')
|
||||
self.platform = data.attrib.get('platform')
|
||||
self.rawVersion = data.attrib.get('productVersion')
|
||||
self.versionNorm = util.normalizedVersion(self.rawVersion)
|
||||
self.transcodeSupport = data.attrib.get('transcodeSupport') == '1'
|
||||
|
||||
def __eq__(self, other):
|
||||
if not other:
|
||||
return False
|
||||
if self.__class__ != other.__class__:
|
||||
|
||||
return False
|
||||
return self.uuid == other.uuid and self.owner == other.owner
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __str__(self):
|
||||
return "<PlexServer {0} owned: {1} uuid: {2} version: {3}>".format(repr(self.name), self.owned, self.uuid, self.versionNorm)
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def close(self):
|
||||
self.session.cancel()
|
||||
|
||||
def get(self, attr, default=None):
|
||||
return default
|
||||
|
||||
@property
|
||||
def isSecure(self):
|
||||
if self.activeConnection:
|
||||
return self.activeConnection.isSecure
|
||||
|
||||
def getObject(self, key):
|
||||
data = self.query(key)
|
||||
return plexobjects.buildItem(self, data[0], key, container=self)
|
||||
|
||||
def hubs(self, section=None, count=None, search_query=None):
|
||||
hubs = []
|
||||
|
||||
params = {}
|
||||
if search_query:
|
||||
q = '/hubs/search'
|
||||
params['query'] = search_query.lower()
|
||||
if section:
|
||||
params['sectionId'] = section
|
||||
|
||||
if count is not None:
|
||||
params['limit'] = count
|
||||
else:
|
||||
q = '/hubs'
|
||||
if section:
|
||||
if section == 'playlists':
|
||||
audio = plexlibrary.AudioPlaylistHub(False, server=self.server)
|
||||
video = plexlibrary.VideoPlaylistHub(False, server=self.server)
|
||||
if audio.items:
|
||||
hubs.append(audio)
|
||||
if video.items:
|
||||
hubs.append(video)
|
||||
return hubs
|
||||
else:
|
||||
q = '/hubs/sections/%s' % section
|
||||
|
||||
if count is not None:
|
||||
params['count'] = count
|
||||
|
||||
data = self.query(q, params=params)
|
||||
container = plexobjects.PlexContainer(data, initpath=q, server=self, address=q)
|
||||
|
||||
for elem in data:
|
||||
hubs.append(plexlibrary.Hub(elem, server=self, container=container))
|
||||
return hubs
|
||||
|
||||
def playlists(self, start=0, size=10, hub=None):
|
||||
try:
|
||||
return plexobjects.listItems(self, '/playlists/all')
|
||||
except exceptions.BadRequest:
|
||||
return None
|
||||
|
||||
@property
|
||||
def library(self):
|
||||
if self.platform == 'cloudsync':
|
||||
return plexlibrary.Library(None, server=self)
|
||||
else:
|
||||
return plexlibrary.Library(self.query('/library/'), server=self)
|
||||
|
||||
def buildUrl(self, path, includeToken=False):
|
||||
if self.activeConnection:
|
||||
return self.activeConnection.buildUrl(self, path, includeToken)
|
||||
else:
|
||||
util.WARN_LOG("Server connection is None, returning an empty url")
|
||||
return ""
|
||||
|
||||
def query(self, path, method=None, **kwargs):
|
||||
method = method or self.session.get
|
||||
url = self.buildUrl(path, includeToken=True)
|
||||
util.LOG('{0} {1}'.format(method.__name__.upper(), re.sub('X-Plex-Token=[^&]+', 'X-Plex-Token=****', url)))
|
||||
try:
|
||||
response = method(url, **kwargs)
|
||||
if response.status_code not in (200, 201):
|
||||
codename = http.status_codes.get(response.status_code, ['Unknown'])[0]
|
||||
raise exceptions.BadRequest('({0}) {1}'.format(response.status_code, codename))
|
||||
data = response.text.encode('utf8')
|
||||
except http.requests.ConnectionError:
|
||||
util.ERROR()
|
||||
return None
|
||||
|
||||
return ElementTree.fromstring(data) if data else None
|
||||
|
||||
def getImageTranscodeURL(self, path, width, height, **extraOpts):
|
||||
if not path:
|
||||
return ''
|
||||
|
||||
params = ("&width=%s&height=%s" % (width, height)) + ''.join(["&%s=%s" % (key, extraOpts[key]) for key in extraOpts])
|
||||
|
||||
if "://" in path:
|
||||
imageUrl = self.convertUrlToLoopBack(path)
|
||||
else:
|
||||
imageUrl = "http://127.0.0.1:" + self.getLocalServerPort() + path
|
||||
|
||||
path = "/photo/:/transcode?url=" + compat.quote_plus(imageUrl) + params
|
||||
|
||||
# Try to use a better server to transcode for synced servers
|
||||
if self.synced:
|
||||
import plexservermanager
|
||||
selectedServer = plexservermanager.MANAGER.getTranscodeServer("photo")
|
||||
if selectedServer:
|
||||
return selectedServer.buildUrl(path, True)
|
||||
|
||||
if self.activeConnection:
|
||||
return self.activeConnection.simpleBuildUrl(self, path)
|
||||
else:
|
||||
util.WARN_LOG("Server connection is None, returning an empty url")
|
||||
return ""
|
||||
|
||||
def isReachable(self, onlySupported=True):
|
||||
if onlySupported and not self.isSupported:
|
||||
return False
|
||||
|
||||
return self.activeConnection and self.activeConnection.state == plexresource.ResourceConnection.STATE_REACHABLE
|
||||
|
||||
def isLocalConnection(self):
|
||||
return self.activeConnection and (self.sameNetwork or self.activeConnection.isLocal)
|
||||
|
||||
def isRequestToServer(self, url):
|
||||
if not self.activeConnection:
|
||||
return False
|
||||
|
||||
if ':' in self.activeConnection.address[8:]:
|
||||
schemeAndHost = self.activeConnection.address.rsplit(':', 1)[0]
|
||||
else:
|
||||
schemeAndHost = self.activeConnection.address
|
||||
|
||||
return url.startswith(schemeAndHost)
|
||||
|
||||
def getToken(self):
|
||||
# It's dangerous to use for each here, because it may reset the index
|
||||
# on self.connections when something else was in the middle of an iteration.
|
||||
|
||||
for i in range(len(self.connections)):
|
||||
conn = self.connections[i]
|
||||
if conn.token:
|
||||
return conn.token
|
||||
|
||||
return None
|
||||
|
||||
def getLocalServerPort(self):
|
||||
# TODO(schuyler): The correct thing to do here is to iterate over local
|
||||
# connections and pull out the port. For now, we're always returning 32400.
|
||||
|
||||
return '32400'
|
||||
|
||||
def collectDataFromRoot(self, data):
|
||||
# Make sure we're processing data for our server, and not some other
|
||||
# server that happened to be at the same IP.
|
||||
if self.uuid != data.attrib.get('machineIdentifier'):
|
||||
util.LOG("Got a reachability response, but from a different server")
|
||||
return False
|
||||
|
||||
self.serverClass = data.attrib.get('serverClass')
|
||||
self.supportsAudioTranscoding = data.attrib.get('transcoderAudio') == '1'
|
||||
self.supportsVideoTranscoding = data.attrib.get('transcoderVideo') == '1' or data.attrib.get('transcoderVideoQualities')
|
||||
self.supportsVideoRemuxOnly = data.attrib.get('transcoderVideoRemuxOnly') == '1'
|
||||
self.supportsPhotoTranscoding = data.attrib.get('transcoderPhoto') == '1' or (
|
||||
not data.attrib.get('transcoderPhoto') and not self.synced and not self.isSecondary()
|
||||
)
|
||||
self.allowChannelAccess = data.attrib.get('allowChannelAccess') == '1' or (
|
||||
not data.attrib.get('allowChannelAccess') and self.owned and not self.synced and not self.isSecondary()
|
||||
)
|
||||
self.supportsScrobble = not self.isSecondary() or self.synced
|
||||
self.allowsMediaDeletion = not self.synced and self.owned and data.attrib.get('allowMediaDeletion') == '1'
|
||||
self.multiuser = data.attrib.get('multiuser') == '1'
|
||||
self.name = data.attrib.get('friendlyName') or self.name
|
||||
self.platform = data.attrib.get('platform')
|
||||
|
||||
# TODO(schuyler): Process transcoder qualities
|
||||
|
||||
self.rawVersion = data.attrib.get('version')
|
||||
if self.rawVersion:
|
||||
self.versionNorm = util.normalizedVersion(self.rawVersion)
|
||||
|
||||
features = {
|
||||
'mkvTranscode': '0.9.11.11',
|
||||
'themeTranscode': '0.9.14.0',
|
||||
'allPartsStreamSelection': '0.9.12.5',
|
||||
'claimServer': '0.9.14.2',
|
||||
'streamingBrain': '1.2.0'
|
||||
}
|
||||
|
||||
for f, v in features.items():
|
||||
if util.normalizedVersion(v) <= self.versionNorm:
|
||||
self.features[f] = True
|
||||
|
||||
appMinVer = plexapp.INTERFACE.getGlobal('minServerVersionArr', '0.0.0.0')
|
||||
self.isSupported = self.isSecondary() or util.normalizedVersion(appMinVer) <= self.versionNorm
|
||||
|
||||
util.DEBUG_LOG("Server information updated from reachability check: {0}".format(self))
|
||||
|
||||
return True
|
||||
|
||||
def updateReachability(self, force=True, allowFallback=False):
|
||||
if not force and self.activeConnection and self.activeConnection.state != plexresource.ResourceConnection.STATE_UNKNOWN:
|
||||
return
|
||||
|
||||
util.LOG('Updating reachability for {0}: conns={1}, allowFallback={2}'.format(repr(self.name), len(self.connections), allowFallback))
|
||||
|
||||
epoch = time.time()
|
||||
retrySeconds = 60
|
||||
minSeconds = 10
|
||||
for i in range(len(self.connections)):
|
||||
conn = self.connections[i]
|
||||
diff = epoch - (conn.lastTestedAt or 0)
|
||||
if conn.hasPendingRequest:
|
||||
util.DEBUG_LOG("Skip reachability test for {0} (has pending request)".format(conn))
|
||||
elif diff < minSeconds or (not self.isSecondary() and self.isReachable() and diff < retrySeconds):
|
||||
util.DEBUG_LOG("Skip reachability test for {0} (checked {1} secs ago)".format(conn, diff))
|
||||
elif conn.testReachability(self, allowFallback):
|
||||
self.pendingReachabilityRequests += 1
|
||||
if conn.isSecure:
|
||||
self.pendingSecureRequests += 1
|
||||
|
||||
if self.pendingReachabilityRequests == 1:
|
||||
self.trigger("started:reachability")
|
||||
|
||||
if self.pendingReachabilityRequests <= 0:
|
||||
self.trigger("completed:reachability")
|
||||
|
||||
def cancelReachability(self):
|
||||
for i in range(len(self.connections)):
|
||||
conn = self.connections[i]
|
||||
conn.cancelReachability()
|
||||
|
||||
def onReachabilityResult(self, connection):
|
||||
connection.lastTestedAt = time.time()
|
||||
connection.hasPendingRequest = None
|
||||
self.pendingReachabilityRequests -= 1
|
||||
if connection.isSecure:
|
||||
self.pendingSecureRequests -= 1
|
||||
|
||||
util.DEBUG_LOG("Reachability result for {0}: {1} is {2}".format(repr(self.name), connection.address, connection.state))
|
||||
|
||||
# Noneate active connection if the state is unreachable
|
||||
if self.activeConnection and self.activeConnection.state != plexresource.ResourceConnection.STATE_REACHABLE:
|
||||
self.activeConnection = None
|
||||
|
||||
# Pick a best connection. If we already had an active connection and
|
||||
# it's still reachable, stick with it. (replace with local if
|
||||
# available)
|
||||
best = self.activeConnection
|
||||
for i in range(len(self.connections) - 1, -1, -1):
|
||||
conn = self.connections[i]
|
||||
|
||||
if not best or conn.getScore() > best.getScore():
|
||||
best = conn
|
||||
|
||||
if best and best.state == best.STATE_REACHABLE:
|
||||
if best.isSecure or self.pendingSecureRequests <= 0:
|
||||
self.activeConnection = best
|
||||
else:
|
||||
util.DEBUG_LOG("Found a good connection for {0}, but holding out for better".format(repr(self.name)))
|
||||
|
||||
if self.pendingReachabilityRequests <= 0:
|
||||
# Retest the server with fallback enabled. hasFallback will only
|
||||
# be True if there are available insecure connections and fallback
|
||||
# is allowed.
|
||||
|
||||
if self.hasFallback:
|
||||
self.updateReachability(False, True)
|
||||
else:
|
||||
self.trigger("completed:reachability")
|
||||
|
||||
util.LOG("Active connection for {0} is {1}".format(repr(self.name), self.activeConnection))
|
||||
|
||||
import plexservermanager
|
||||
plexservermanager.MANAGER.updateReachabilityResult(self, bool(self.activeConnection))
|
||||
|
||||
def markAsRefreshing(self):
|
||||
for i in range(len(self.connections)):
|
||||
conn = self.connections[i]
|
||||
conn.refreshed = False
|
||||
|
||||
def markUpdateFinished(self, source):
|
||||
# Any connections for the given source which haven't been refreshed should
|
||||
# be removed. Since removing from a list is hard, we'll make a new list.
|
||||
toKeep = []
|
||||
hasSecureConn = False
|
||||
|
||||
for i in range(len(self.connections)):
|
||||
conn = self.connections[i]
|
||||
if not conn.refreshed:
|
||||
conn.sources = conn.sources & (~source)
|
||||
|
||||
# If we lost our plex.tv connection, don't remember the token.
|
||||
if source == conn.SOURCE_MYPLEX:
|
||||
conn.token = None
|
||||
|
||||
if conn.sources:
|
||||
if conn.address[:5] == "https":
|
||||
hasSecureConn = True
|
||||
toKeep.append(conn)
|
||||
else:
|
||||
util.DEBUG_LOG("Removed connection for {0} after updating connections for {1}".format(repr(self.name), source))
|
||||
if conn == self.activeConnection:
|
||||
util.DEBUG_LOG("Active connection lost")
|
||||
self.activeConnection = None
|
||||
|
||||
# Update fallback flag if our connections have changed
|
||||
if len(toKeep) != len(self.connections):
|
||||
for conn in toKeep:
|
||||
conn.isFallback = hasSecureConn and conn.address[:5] != "https"
|
||||
|
||||
self.connections = toKeep
|
||||
|
||||
return len(self.connections) > 0
|
||||
|
||||
def merge(self, other):
|
||||
# Wherever this other server came from, assume its information is better
|
||||
# except for manual connections.
|
||||
|
||||
if other.sourceType != plexresource.ResourceConnection.SOURCE_MANUAL:
|
||||
self.name = other.name
|
||||
self.versionNorm = other.versionNorm
|
||||
self.sameNetwork = other.sameNetwork
|
||||
|
||||
# Merge connections
|
||||
for otherConn in other.connections:
|
||||
merged = False
|
||||
for i in range(len(self.connections)):
|
||||
myConn = self.connections[i]
|
||||
if myConn == otherConn:
|
||||
myConn.merge(otherConn)
|
||||
merged = True
|
||||
break
|
||||
|
||||
if not merged:
|
||||
self.connections.append(otherConn)
|
||||
|
||||
# If the other server has a token, then it came from plex.tv, which
|
||||
# means that its ownership information is better than ours. But if
|
||||
# it was discovered, then it may incorrectly claim to be owned, so
|
||||
# we stick with whatever we already had.
|
||||
|
||||
if other.getToken():
|
||||
self.owned = other.owned
|
||||
self.owner = other.owner
|
||||
|
||||
def supportsFeature(self, feature):
|
||||
return feature in self.features
|
||||
|
||||
def getVersion(self):
|
||||
if not self.versionNorm:
|
||||
return ''
|
||||
|
||||
return str(self.versionNorm)
|
||||
|
||||
def convertUrlToLoopBack(self, url):
|
||||
# If the URL starts with our server URL, replace it with 127.0.0.1:32400.
|
||||
if self.isRequestToServer(url):
|
||||
url = 'http://127.0.0.1:32400/' + url.split('://', 1)[-1].split('/', 1)[-1]
|
||||
return url
|
||||
|
||||
def resetLastTest(self):
|
||||
for i in range(len(self.connections)):
|
||||
conn = self.connections[i]
|
||||
conn.lastTestedAt = None
|
||||
|
||||
def isSecondary(self):
|
||||
return self.serverClass == "secondary"
|
||||
|
||||
def getLibrarySectionByUuid(self, uuid=None):
|
||||
if not uuid:
|
||||
return None
|
||||
return self.librariesByUuid[uuid]
|
||||
|
||||
def setLibrarySectionByUuid(self, uuid, library):
|
||||
self.librariesByUuid[uuid] = library
|
||||
|
||||
def hasInsecureConnections(self):
|
||||
if plexapp.INTERFACE.getPreference('allow_insecure') == 'always':
|
||||
return False
|
||||
|
||||
# True if we have any insecure connections we have disallowed
|
||||
for i in range(len(self.connections)):
|
||||
conn = self.connections[i]
|
||||
if not conn.isSecure and conn.state == conn.STATE_INSECURE:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def hasSecureConnections(self):
|
||||
for i in range(len(self.connections)):
|
||||
conn = self.connections[i]
|
||||
if conn.isSecure:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def getLibrarySectionPrefs(self, uuid):
|
||||
# TODO: Make sure I did this right - ruuk
|
||||
librarySection = self.getLibrarySectionByUuid(uuid)
|
||||
|
||||
if librarySection and librarySection.key:
|
||||
# Query and store the prefs only when asked for. We could just return the
|
||||
# items, but it'll be more useful to store the pref ids in an associative
|
||||
# array for ease of selecting the pref we need.
|
||||
|
||||
if not librarySection.sectionPrefs:
|
||||
path = "/library/sections/{0}/prefs".format(librarySection.key)
|
||||
data = self.query(path)
|
||||
if data:
|
||||
librarySection.sectionPrefs = {}
|
||||
for elem in data:
|
||||
item = plexobjects.buildItem(self, elem, path)
|
||||
if item.id:
|
||||
librarySection.sectionPrefs[item.id] = item
|
||||
|
||||
return librarySection.sectionPrefs
|
||||
|
||||
return None
|
||||
|
||||
def swizzleUrl(self, url, includeToken=False):
|
||||
m = re.Search("^\w+:\/\/.+?(\/.+)", url)
|
||||
newUrl = m and m.group(1) or None
|
||||
return self.buildUrl(newUrl or url, includeToken)
|
||||
|
||||
def hasHubs(self):
|
||||
return self.platform != 'cloudsync'
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
return self.activeConnection.address
|
||||
|
||||
@classmethod
|
||||
def deSerialize(cls, jstring):
|
||||
try:
|
||||
serverObj = json.loads(jstring)
|
||||
except:
|
||||
util.ERROR()
|
||||
util.ERROR_LOG("Failed to deserialize PlexServer JSON")
|
||||
return
|
||||
|
||||
import plexconnection
|
||||
|
||||
server = createPlexServerForName(serverObj['uuid'], serverObj['name'])
|
||||
server.owned = bool(serverObj.get('owned'))
|
||||
server.sameNetwork = serverObj.get('sameNetwork')
|
||||
|
||||
hasSecureConn = False
|
||||
for i in range(len(serverObj.get('connections', []))):
|
||||
conn = serverObj['connections'][i]
|
||||
if conn['address'][:5] == "https":
|
||||
hasSecureConn = True
|
||||
break
|
||||
|
||||
for i in range(len(serverObj.get('connections', []))):
|
||||
conn = serverObj['connections'][i]
|
||||
isFallback = hasSecureConn and conn['address'][:5] != "https"
|
||||
sources = plexconnection.PlexConnection.SOURCE_BY_VAL[conn['sources']]
|
||||
connection = plexconnection.PlexConnection(sources, conn['address'], conn['isLocal'], conn['token'], isFallback)
|
||||
|
||||
# Keep the secure connection on top
|
||||
if connection.isSecure:
|
||||
server.connections.insert(0, connection)
|
||||
else:
|
||||
server.connections.append(connection)
|
||||
|
||||
if conn.get('active'):
|
||||
server.activeConnection = connection
|
||||
|
||||
return server
|
||||
|
||||
def serialize(self, full=False):
|
||||
serverObj = {
|
||||
'name': self.name,
|
||||
'uuid': self.uuid,
|
||||
'owned': self.owned,
|
||||
'connections': []
|
||||
}
|
||||
|
||||
if full:
|
||||
for conn in self.connections:
|
||||
serverObj['connections'].append({
|
||||
'sources': conn.sources,
|
||||
'address': conn.address,
|
||||
'isLocal': conn.isLocal,
|
||||
'isSecure': conn.isSecure,
|
||||
'token': conn.token
|
||||
})
|
||||
if conn == self.activeConnection:
|
||||
serverObj['connections'][-1]['active'] = True
|
||||
else:
|
||||
serverObj['connections'] = [{
|
||||
'sources': self.activeConnection.sources,
|
||||
'address': self.activeConnection.address,
|
||||
'isLocal': self.activeConnection.isLocal,
|
||||
'isSecure': self.activeConnection.isSecure,
|
||||
'token': self.activeConnection.token or self.getToken(),
|
||||
'active': True
|
||||
}]
|
||||
|
||||
return json.dumps(serverObj)
|
||||
|
||||
|
||||
def dummyPlexServer():
|
||||
return createPlexServer()
|
||||
|
||||
|
||||
def createPlexServer():
|
||||
return PlexServer()
|
||||
|
||||
|
||||
def createPlexServerForConnection(conn):
|
||||
obj = createPlexServer()
|
||||
obj.connections.append(conn)
|
||||
obj.activeConnection = conn
|
||||
return obj
|
||||
|
||||
|
||||
def createPlexServerForName(uuid, name):
|
||||
obj = createPlexServer()
|
||||
obj.uuid = uuid
|
||||
obj.name = name
|
||||
return obj
|
||||
|
||||
|
||||
def createPlexServerForResource(resource):
|
||||
# resource.__class__ = PlexServer
|
||||
# resource.server = resource
|
||||
# resource.session = http.Session()
|
||||
return resource
|
619
resources/lib/plexnet/plexservermanager.py
Normal file
619
resources/lib/plexnet/plexservermanager.py
Normal file
|
@ -0,0 +1,619 @@
|
|||
import json
|
||||
|
||||
import http
|
||||
import plexconnection
|
||||
import plexresource
|
||||
import plexserver
|
||||
import myplexserver
|
||||
import signalsmixin
|
||||
import callback
|
||||
import plexapp
|
||||
import gdm
|
||||
import util
|
||||
|
||||
|
||||
class SearchContext(dict):
|
||||
def __getattr__(self, attr):
|
||||
return self.get(attr)
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
self[attr] = value
|
||||
|
||||
|
||||
class PlexServerManager(signalsmixin.SignalsMixin):
|
||||
def __init__(self):
|
||||
signalsmixin.SignalsMixin.__init__(self)
|
||||
# obj.Append(ListenersMixin())
|
||||
self.serversByUuid = {}
|
||||
self.selectedServer = None
|
||||
self.transcodeServer = None
|
||||
self.channelServer = None
|
||||
self.deferReachabilityTimer = None
|
||||
|
||||
self.startSelectedServerSearch()
|
||||
self.loadState()
|
||||
|
||||
plexapp.APP.on("change:user", callback.Callable(self.onAccountChange))
|
||||
plexapp.APP.on("change:allow_insecure", callback.Callable(self.onSecurityChange))
|
||||
plexapp.APP.on("change:manual_connections", callback.Callable(self.onManualConnectionChange))
|
||||
|
||||
def getSelectedServer(self):
|
||||
return self.selectedServer
|
||||
|
||||
def setSelectedServer(self, server, force=False):
|
||||
# Don't do anything if the server is already selected.
|
||||
if self.selectedServer and self.selectedServer == server:
|
||||
return False
|
||||
|
||||
if server:
|
||||
# Don't select servers that don't have connections.
|
||||
if not server.activeConnection:
|
||||
return False
|
||||
|
||||
# Don't select servers that are not supported
|
||||
if not server.isSupported:
|
||||
return False
|
||||
|
||||
if not self.selectedServer or force:
|
||||
util.LOG("Setting selected server to {0}".format(server))
|
||||
self.selectedServer = server
|
||||
|
||||
# Update our saved state.
|
||||
self.saveState()
|
||||
|
||||
# Notify anyone who might care.
|
||||
plexapp.APP.trigger("change:selectedServer", server=server)
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def getServer(self, uuid=None):
|
||||
if uuid is None:
|
||||
return None
|
||||
elif uuid == "myplex":
|
||||
return myplexserver.MyPlexServer()
|
||||
else:
|
||||
return self.serversByUuid[uuid]
|
||||
|
||||
def getServers(self):
|
||||
servers = []
|
||||
for uuid in self.serversByUuid:
|
||||
if uuid != "myplex":
|
||||
servers.append(self.serversByUuid[uuid])
|
||||
|
||||
return servers
|
||||
|
||||
def hasPendingRequests(self):
|
||||
for server in self.getServers():
|
||||
if server.pendingReachabilityRequests:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def removeServer(self, server):
|
||||
del self.serversByUuid[server.uuid]
|
||||
|
||||
self.trigger('remove:server')
|
||||
|
||||
if server == self.selectedServer:
|
||||
util.LOG("The selected server went away")
|
||||
self.setSelectedServer(None, force=True)
|
||||
|
||||
if server == self.transcodeServer:
|
||||
util.LOG("The selected transcode server went away")
|
||||
self.transcodeServer = None
|
||||
|
||||
if server == self.channelServer:
|
||||
util.LOG("The selected channel server went away")
|
||||
self.channelServer = None
|
||||
|
||||
def updateFromConnectionType(self, servers, source):
|
||||
self.markDevicesAsRefreshing()
|
||||
|
||||
for server in servers:
|
||||
self.mergeServer(server)
|
||||
|
||||
if self.searchContext and source == plexresource.ResourceConnection.SOURCE_MYPLEX:
|
||||
self.searchContext.waitingForResources = False
|
||||
|
||||
self.deviceRefreshComplete(source)
|
||||
self.updateReachability(True, True)
|
||||
self.saveState()
|
||||
|
||||
def updateFromDiscovery(self, server):
|
||||
merged = self.mergeServer(server)
|
||||
|
||||
if not merged.activeConnection:
|
||||
merged.updateReachability(False, True)
|
||||
else:
|
||||
# self.notifyAboutDevice(merged, True)
|
||||
pass
|
||||
|
||||
def markDevicesAsRefreshing(self):
|
||||
for uuid in self.serversByUuid.keys():
|
||||
self.serversByUuid[uuid].markAsRefreshing()
|
||||
|
||||
def mergeServer(self, server):
|
||||
if server.uuid in self.serversByUuid:
|
||||
existing = self.serversByUuid[server.uuid]
|
||||
existing.merge(server)
|
||||
util.DEBUG_LOG("Merged {0}".format(repr(server.name)))
|
||||
return existing
|
||||
else:
|
||||
self.serversByUuid[server.uuid] = server
|
||||
util.DEBUG_LOG("Added new server {0}".format(repr(server.name)))
|
||||
self.trigger("new:server", server=server)
|
||||
return server
|
||||
|
||||
def deviceRefreshComplete(self, source):
|
||||
toRemove = []
|
||||
for uuid in self.serversByUuid:
|
||||
if not self.serversByUuid[uuid].markUpdateFinished(source):
|
||||
toRemove.append(uuid)
|
||||
|
||||
for uuid in toRemove:
|
||||
server = self.serversByUuid[uuid]
|
||||
|
||||
util.DEBUG_LOG("Server {0} has no more connections - removing".format(repr(server.name)))
|
||||
# self.notifyAboutDevice(server, False)
|
||||
self.removeServer(server)
|
||||
|
||||
def updateReachability(self, force=False, preferSearch=False, defer=False):
|
||||
# We don't need to test any servers unless we are signed in and authenticated.
|
||||
if not plexapp.ACCOUNT.isAuthenticated and plexapp.ACCOUNT.isActive():
|
||||
util.LOG("Ignore testing server reachability until we're authenticated")
|
||||
return
|
||||
|
||||
# To improve reachability performance and app startup, we'll try to test the
|
||||
# preferred server first, and defer the connection tests for a few seconds.
|
||||
|
||||
hasPreferredServer = bool(self.searchContext.preferredServer)
|
||||
preferredServerExists = hasPreferredServer and self.searchContext.preferredServer in self.serversByUuid
|
||||
|
||||
if preferSearch and hasPreferredServer and preferredServerExists:
|
||||
# Update the preferred server immediately if requested and exits
|
||||
util.LOG("Updating reachability for preferred server: force={0}".format(force))
|
||||
self.serversByUuid[self.searchContext.preferredServer].updateReachability(force)
|
||||
self.deferUpdateReachability()
|
||||
elif defer:
|
||||
self.deferUpdateReachability()
|
||||
elif hasPreferredServer and not preferredServerExists and gdm.DISCOVERY.isActive():
|
||||
# Defer the update if requested or if GDM discovery is enabled and
|
||||
# active while the preferred server doesn't exist.
|
||||
|
||||
util.LOG("Defer update reachability until GDM has finished to help locate the preferred server")
|
||||
self.deferUpdateReachability(True, False)
|
||||
else:
|
||||
if self.deferReachabilityTimer:
|
||||
self.deferReachabilityTimer.cancel()
|
||||
self.deferReachabilityTimer = None
|
||||
|
||||
util.LOG("Updating reachability for all devices: force={0}".format(force))
|
||||
for uuid in self.serversByUuid:
|
||||
self.serversByUuid[uuid].updateReachability(force)
|
||||
|
||||
def cancelReachability(self):
|
||||
if self.deferReachabilityTimer:
|
||||
self.deferReachabilityTimer.cancel()
|
||||
self.deferReachabilityTimer = None
|
||||
|
||||
for uuid in self.serversByUuid:
|
||||
self.serversByUuid[uuid].cancelReachability()
|
||||
|
||||
def updateReachabilityResult(self, server, reachable=False):
|
||||
searching = not self.selectedServer and self.searchContext
|
||||
|
||||
if reachable:
|
||||
# If we're in the middle of a search for our selected server, see if
|
||||
# this is a candidate.
|
||||
self.trigger('reachable:server', server=server)
|
||||
if searching:
|
||||
# If this is what we were hoping for, select it
|
||||
if server.uuid == self.searchContext.preferredServer:
|
||||
self.setSelectedServer(server, True)
|
||||
elif server.synced:
|
||||
self.searchContext.fallbackServer = server
|
||||
elif self.compareServers(self.searchContext.bestServer, server) < 0:
|
||||
self.searchContext.bestServer = server
|
||||
else:
|
||||
# If this is what we were hoping for, see if there are any more pending
|
||||
# requests to hope for.
|
||||
|
||||
if searching and server.uuid == self.searchContext.preferredServer and server.pendingReachabilityRequests <= 0:
|
||||
self.searchContext.preferredServer = None
|
||||
|
||||
if server == self.selectedServer:
|
||||
util.LOG("Selected server is not reachable")
|
||||
self.setSelectedServer(None, True)
|
||||
|
||||
if server == self.transcodeServer:
|
||||
util.LOG("The selected transcode server is not reachable")
|
||||
self.transcodeServer = None
|
||||
|
||||
if server == self.channelServer:
|
||||
util.LOG("The selected channel server is not reachable")
|
||||
self.channelServer = None
|
||||
|
||||
# See if we should settle for the best we've found so far.
|
||||
self.checkSelectedServerSearch()
|
||||
|
||||
def checkSelectedServerSearch(self, skip_preferred=False, skip_owned=False):
|
||||
if self.selectedServer:
|
||||
return self.selectedServer
|
||||
elif self.searchContext:
|
||||
# If we're still waiting on the resources response then there's no
|
||||
# reason to settle, so don't even iterate over our servers.
|
||||
|
||||
if self.searchContext.waitingForResources:
|
||||
util.DEBUG_LOG("Still waiting for plex.tv resources")
|
||||
return
|
||||
|
||||
waitingForPreferred = False
|
||||
waitingForOwned = False
|
||||
waitingForAnything = False
|
||||
waitingToTestAll = bool(self.deferReachabilityTimer)
|
||||
|
||||
if skip_preferred:
|
||||
self.searchContext.preferredServer = None
|
||||
if self.deferReachabilityTimer:
|
||||
self.deferReachabilityTimer.cancel()
|
||||
self.deferReachabilityTimer = None
|
||||
|
||||
if not skip_owned:
|
||||
# Iterate over all our servers and see if we're waiting on any results
|
||||
servers = self.getServers()
|
||||
pendingCount = 0
|
||||
for server in servers:
|
||||
if server.pendingReachabilityRequests > 0:
|
||||
pendingCount += server.pendingReachabilityRequests
|
||||
if server.uuid == self.searchContext.preferredServer:
|
||||
waitingForPreferred = True
|
||||
elif server.owned:
|
||||
waitingForOwned = True
|
||||
else:
|
||||
waitingForAnything = True
|
||||
|
||||
pendingString = "{0} pending reachability tests".format(pendingCount)
|
||||
|
||||
if waitingForPreferred:
|
||||
util.LOG("Still waiting for preferred server: " + pendingString)
|
||||
elif waitingToTestAll:
|
||||
util.LOG("Preferred server not reachable, testing all servers now")
|
||||
self.updateReachability(True, False, False)
|
||||
elif waitingForOwned and (not self.searchContext.bestServer or not self.searchContext.bestServer.owned):
|
||||
util.LOG("Still waiting for an owned server: " + pendingString)
|
||||
elif waitingForAnything and not self.searchContext.bestServer:
|
||||
util.LOG("Still waiting for any server: {0}".format(pendingString))
|
||||
else:
|
||||
# No hope for anything better, let's select what we found
|
||||
util.LOG("Settling for the best server we found")
|
||||
self.setSelectedServer(self.searchContext.bestServer or self.searchContext.fallbackServer, True)
|
||||
return self.selectedServer
|
||||
|
||||
def compareServers(self, first, second):
|
||||
if not first or not first.isSupported:
|
||||
return second and -1 or 0
|
||||
elif not second:
|
||||
return 1
|
||||
elif first.owned != second.owned:
|
||||
return first.owned and 1 or -1
|
||||
elif first.isLocalConnection() != second.isLocalConnection():
|
||||
return first.isLocalConnection() and 1 or -1
|
||||
else:
|
||||
return 0
|
||||
|
||||
def loadState(self):
|
||||
jstring = plexapp.INTERFACE.getRegistry("PlexServerManager")
|
||||
if not jstring:
|
||||
return
|
||||
|
||||
try:
|
||||
obj = json.loads(jstring)
|
||||
except:
|
||||
util.ERROR()
|
||||
obj = None
|
||||
|
||||
if not obj:
|
||||
util.ERROR_LOG("Failed to parse PlexServerManager JSON")
|
||||
return
|
||||
|
||||
for serverObj in obj['servers']:
|
||||
server = plexserver.createPlexServerForName(serverObj['uuid'], serverObj['name'])
|
||||
server.owned = bool(serverObj.get('owned'))
|
||||
server.sameNetwork = serverObj.get('sameNetwork')
|
||||
|
||||
hasSecureConn = False
|
||||
for i in range(len(serverObj.get('connections', []))):
|
||||
conn = serverObj['connections'][i]
|
||||
if conn['address'][:5] == "https":
|
||||
hasSecureConn = True
|
||||
break
|
||||
|
||||
for i in range(len(serverObj.get('connections', []))):
|
||||
conn = serverObj['connections'][i]
|
||||
isFallback = hasSecureConn and conn['address'][:5] != "https"
|
||||
sources = plexconnection.PlexConnection.SOURCE_BY_VAL[conn['sources']]
|
||||
connection = plexconnection.PlexConnection(sources, conn['address'], conn['isLocal'], conn['token'], isFallback)
|
||||
|
||||
# Keep the secure connection on top
|
||||
if connection.isSecure:
|
||||
server.connections.insert(0, connection)
|
||||
else:
|
||||
server.connections.append(connection)
|
||||
|
||||
self.serversByUuid[server.uuid] = server
|
||||
|
||||
util.LOG("Loaded {0} servers from registry".format(len(obj['servers'])))
|
||||
self.updateReachability(False, True)
|
||||
|
||||
def saveState(self):
|
||||
# Serialize our important information to JSON and save it to the registry.
|
||||
# We'll always update server info upon connecting, so we don't need much
|
||||
# info here. We do have to use roArray instead of roList, because Brightscript.
|
||||
|
||||
obj = {}
|
||||
|
||||
servers = self.getServers()
|
||||
obj['servers'] = []
|
||||
|
||||
for server in servers:
|
||||
# Don't save secondary servers. They should be discovered through GDM or myPlex.
|
||||
if not server.isSecondary():
|
||||
serverObj = {
|
||||
'name': server.name,
|
||||
'uuid': server.uuid,
|
||||
'owned': server.owned,
|
||||
'sameNetwork': server.sameNetwork,
|
||||
'connections': []
|
||||
}
|
||||
|
||||
for i in range(len(server.connections)):
|
||||
conn = server.connections[i]
|
||||
serverObj['connections'].append({
|
||||
'sources': conn.sources,
|
||||
'address': conn.address,
|
||||
'isLocal': conn.isLocal,
|
||||
'isSecure': conn.isSecure,
|
||||
'token': conn.token
|
||||
})
|
||||
|
||||
obj['servers'].append(serverObj)
|
||||
|
||||
if self.selectedServer and not self.selectedServer.synced and not self.selectedServer.isSecondary():
|
||||
plexapp.INTERFACE.setPreference("lastServerId", self.selectedServer.uuid)
|
||||
|
||||
plexapp.INTERFACE.setRegistry("PlexServerManager", json.dumps(obj))
|
||||
|
||||
def clearState(self):
|
||||
plexapp.INTERFACE.setRegistry("PlexServerManager", '')
|
||||
|
||||
def isValidForTranscoding(self, server):
|
||||
return server and server.activeConnection and server.owned and not server.synced and not server.isSecondary()
|
||||
|
||||
def getChannelServer(self):
|
||||
if not self.channelServer or not self.channelServer.isReachable():
|
||||
self.channelServer = None
|
||||
|
||||
# Attempt to find a server that supports channels and transcoding
|
||||
for s in self.getServers():
|
||||
if s.supportsVideoTranscoding and s.allowChannelAccess and s.isReachable() and self.compareServers(self.channelServer, s) < 0:
|
||||
self.channelServer = s
|
||||
|
||||
# Fallback to any server that supports channels
|
||||
if not self.channelServer:
|
||||
for s in self.getServers():
|
||||
if s.allowChannelAccess and s.isReachable() and self.compareServers(self.channelServer, s) < 0:
|
||||
self.channelServer = s
|
||||
|
||||
if self.channelServer:
|
||||
util.LOG("Setting channel server to {0}".format(self.channelServer))
|
||||
|
||||
return self.channelServer
|
||||
|
||||
def getTranscodeServer(self, transcodeType=None):
|
||||
if not self.selectedServer:
|
||||
return None
|
||||
|
||||
transcodeMap = {
|
||||
'audio': "supportsAudioTranscoding",
|
||||
'video': "supportsVideoTranscoding",
|
||||
'photo': "supportsPhotoTranscoding"
|
||||
}
|
||||
transcodeSupport = transcodeMap[transcodeType]
|
||||
|
||||
# Try to use a better transcoding server for synced or secondary servers
|
||||
if self.selectedServer.synced or self.selectedServer.isSecondary():
|
||||
if self.transcodeServer and self.transcodeServer.isReachable():
|
||||
return self.transcodeServer
|
||||
|
||||
self.transcodeServer = None
|
||||
for server in self.getServers():
|
||||
if not server.synced and server.isReachable() and self.compareServers(self.transcodeServer, server) < 0:
|
||||
if not transcodeSupport or server.transcodeSupport:
|
||||
self.transcodeServer = server
|
||||
|
||||
if self.transcodeServer:
|
||||
transcodeTypeString = transcodeType or ''
|
||||
util.LOG("Found a better {0} transcode server than {1}, using: {2}".format(transcodeTypeString, self.selectedserver, self.transcodeServer))
|
||||
return self.transcodeServer
|
||||
|
||||
return self.selectedServer
|
||||
|
||||
def startSelectedServerSearch(self, reset=False):
|
||||
if reset:
|
||||
self.selectedServer = None
|
||||
self.transcodeServer = None
|
||||
self.channelServer = None
|
||||
|
||||
# Keep track of some information during our search
|
||||
self.searchContext = SearchContext({
|
||||
'bestServer': None,
|
||||
'preferredServer': plexapp.INTERFACE.getPreference('lastServerId', ''),
|
||||
'waitingForResources': plexapp.ACCOUNT.isSignedIn
|
||||
})
|
||||
|
||||
util.LOG("Starting selected server search, hoping for {0}".format(self.searchContext.preferredServer))
|
||||
|
||||
def onAccountChange(self, account, reallyChanged=False):
|
||||
# Clear any AudioPlayer data before invalidating the active server
|
||||
if reallyChanged:
|
||||
# AudioPlayer().Cleanup()
|
||||
# PhotoPlayer().Cleanup()
|
||||
|
||||
# Clear selected and transcode servers on user change
|
||||
self.selectedServer = None
|
||||
self.transcodeServer = None
|
||||
self.channelServer = None
|
||||
self.cancelReachability()
|
||||
|
||||
if account.isSignedIn:
|
||||
# If the user didn't really change, such as selecting the previous user
|
||||
# on the lock screen, then we don't need to clear anything. We can
|
||||
# avoid a costly round of reachability checks.
|
||||
|
||||
if not reallyChanged:
|
||||
return
|
||||
|
||||
# A request to refresh resources has already been kicked off. We need
|
||||
# to clear out any connections for the previous user and then start
|
||||
# our selected server search.
|
||||
|
||||
self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MYPLEX)
|
||||
self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_DISCOVERED)
|
||||
self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MANUAL)
|
||||
|
||||
self.startSelectedServerSearch(True)
|
||||
else:
|
||||
# Clear servers/connections from plex.tv
|
||||
self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MYPLEX)
|
||||
|
||||
def deferUpdateReachability(self, addTimer=True, logInfo=True):
|
||||
if addTimer and not self.deferReachabilityTimer:
|
||||
self.deferReachabilityTimer = plexapp.createTimer(1000, callback.Callable(self.onDeferUpdateReachabilityTimer), repeat=True)
|
||||
plexapp.APP.addTimer(self.deferReachabilityTimer)
|
||||
else:
|
||||
if self.deferReachabilityTimer:
|
||||
self.deferReachabilityTimer.reset()
|
||||
|
||||
if self.deferReachabilityTimer and logInfo:
|
||||
util.LOG('Defer update reachability for all devices a few seconds: GDMactive={0}'.format(gdm.DISCOVERY.isActive()))
|
||||
|
||||
def onDeferUpdateReachabilityTimer(self):
|
||||
if not self.selectedServer and self.searchContext:
|
||||
for server in self.getServers():
|
||||
if server.pendingReachabilityRequests > 0 and server.uuid == self.searchContext.preferredServer:
|
||||
util.DEBUG_LOG(
|
||||
'Still waiting on {0} responses from preferred server: {1}'.format(
|
||||
server.pendingReachabilityRequests, self.searchContext.preferredServer
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
self.deferReachabilityTimer.cancel()
|
||||
self.deferReachabilityTimer = None
|
||||
self.updateReachability(True, False, False)
|
||||
|
||||
def resetLastTest(self):
|
||||
for uuid in self.serversByUuid:
|
||||
self.serversByUuid[uuid].resetLastTest()
|
||||
|
||||
def clearServers(self):
|
||||
self.cancelReachability()
|
||||
self.serversByUuid = {}
|
||||
self.saveState()
|
||||
|
||||
def onSecurityChange(self, value=None):
|
||||
# If the security policy changes, then we will need to allow all
|
||||
# connections to be retested by resetting the last test. We can
|
||||
# simply call `self.resetLastTest()` to allow all connection to be
|
||||
# tested when the server dropdown is enable, but we may as well
|
||||
# test all the connections immediately.
|
||||
|
||||
plexapp.refreshResources(True)
|
||||
|
||||
def onManualConnectionChange(self, value=None):
|
||||
# Clear all manual connections on change. We will keep the selected
|
||||
# server around temporarily if it's a manual connection regardless
|
||||
# if it's been removed.
|
||||
|
||||
# Remember the current server in case it's removed
|
||||
server = self.getSelectedServer()
|
||||
activeConn = []
|
||||
if server and server.activeConnection:
|
||||
activeConn.append(server.activeConnection)
|
||||
|
||||
# Clear all manual connections
|
||||
self.updateFromConnectionType([], plexresource.ResourceConnection.SOURCE_MANUAL)
|
||||
|
||||
# Reused the previous selected server if our manual connection has gone away
|
||||
if not self.getSelectedServer() and activeConn.sources == plexresource.ResourceConnection.SOURCE_MANUAL:
|
||||
server.activeConnection = activeConn
|
||||
server.connections.append(activeConn)
|
||||
self.setSelectedServer(server, True)
|
||||
|
||||
def refreshManualConnections(self):
|
||||
manualConnections = self.getManualConnections()
|
||||
if not manualConnections:
|
||||
return
|
||||
|
||||
util.LOG("Refreshing {0} manual connections".format(len(manualConnections)))
|
||||
|
||||
for conn in manualConnections:
|
||||
# Default to http, as the server will need to be signed in for https to work,
|
||||
# so the client should too. We'd also have to allow hostname entry, instead of
|
||||
# IP address for the cert to validate.
|
||||
|
||||
proto = "http"
|
||||
port = conn.port or "32400"
|
||||
serverAddress = "{0}://{1}:{2}".format(proto, conn.connection, port)
|
||||
|
||||
request = http.HttpRequest(serverAddress + "/identity")
|
||||
context = request.createRequestContext("manual_connections", callback.Callable(self.onManualConnectionsResponse))
|
||||
context.serverAddress = serverAddress
|
||||
context.address = conn.connection
|
||||
context.proto = proto
|
||||
context.port = port
|
||||
plexapp.APP.startRequest(request, context)
|
||||
|
||||
def onManualConnectionsResponse(self, request, response, context):
|
||||
if not response.isSuccess():
|
||||
return
|
||||
|
||||
data = response.getBodyXml()
|
||||
if data is not None:
|
||||
serverAddress = context.serverAddress
|
||||
util.DEBUG_LOG("Received manual connection response for {0}".format(serverAddress))
|
||||
|
||||
machineID = data.attrib.get('machineIdentifier')
|
||||
name = context.address
|
||||
if not name or not machineID:
|
||||
return
|
||||
|
||||
# TODO(rob): Do we NOT want to consider manual connections local?
|
||||
conn = plexconnection.PlexConnection(plexresource.ResourceConnection.SOURCE_MANUAL, serverAddress, True, None)
|
||||
server = plexserver.createPlexServerForConnection(conn)
|
||||
server.uuid = machineID
|
||||
server.name = name
|
||||
server.sourceType = plexresource.ResourceConnection.SOURCE_MANUAL
|
||||
self.updateFromConnectionType([server], plexresource.ResourceConnection.SOURCE_MANUAL)
|
||||
|
||||
def getManualConnections(self):
|
||||
manualConnections = []
|
||||
|
||||
jstring = plexapp.INTERFACE.getPreference('manual_connections')
|
||||
if jstring:
|
||||
connections = json.loads(jstring)
|
||||
if isinstance(connections, list):
|
||||
for conn in connections:
|
||||
conn = util.AttributeDict(conn)
|
||||
if conn.connection:
|
||||
manualConnections.append(conn)
|
||||
|
||||
return manualConnections
|
||||
|
||||
# TODO(schuyler): Notifications
|
||||
# TODO(schuyler): Transcode (and primary) server selection
|
||||
|
||||
|
||||
MANAGER = PlexServerManager()
|
149
resources/lib/plexnet/plexstream.py
Normal file
149
resources/lib/plexnet/plexstream.py
Normal file
|
@ -0,0 +1,149 @@
|
|||
import plexobjects
|
||||
import util
|
||||
|
||||
|
||||
class PlexStream(plexobjects.PlexObject):
|
||||
# Constants
|
||||
TYPE_UNKNOWN = 0
|
||||
TYPE_VIDEO = 1
|
||||
TYPE_AUDIO = 2
|
||||
TYPE_SUBTITLE = 3
|
||||
TYPE_LYRICS = 4
|
||||
|
||||
# We have limited font support, so make a very modest effort at using
|
||||
# English names for common unsupported languages.
|
||||
|
||||
SAFE_LANGUAGE_NAMES = {
|
||||
'ara': "Arabic",
|
||||
'arm': "Armenian",
|
||||
'bel': "Belarusian",
|
||||
'ben': "Bengali",
|
||||
'bul': "Bulgarian",
|
||||
'chi': "Chinese",
|
||||
'cze': "Czech",
|
||||
'gre': "Greek",
|
||||
'heb': "Hebrew",
|
||||
'hin': "Hindi",
|
||||
'jpn': "Japanese",
|
||||
'kor': "Korean",
|
||||
'rus': "Russian",
|
||||
'srp': "Serbian",
|
||||
'tha': "Thai",
|
||||
'ukr': "Ukrainian",
|
||||
'yid': "Yiddish"
|
||||
}
|
||||
|
||||
def reload(self):
|
||||
pass
|
||||
|
||||
def getTitle(self, translate_func=util.dummyTranslate):
|
||||
title = self.getLanguageName(translate_func)
|
||||
streamType = self.streamType.asInt()
|
||||
|
||||
if streamType == self.TYPE_VIDEO:
|
||||
title = self.getCodec() or translate_func("Unknown")
|
||||
elif streamType == self.TYPE_AUDIO:
|
||||
codec = self.getCodec()
|
||||
channels = self.getChannels(translate_func)
|
||||
|
||||
if codec != "" and channels != "":
|
||||
title += u" ({0} {1})".format(codec, channels)
|
||||
elif codec != "" or channels != "":
|
||||
title += u" ({0}{1})".format(codec, channels)
|
||||
elif streamType == self.TYPE_SUBTITLE:
|
||||
extras = []
|
||||
|
||||
codec = self.getCodec()
|
||||
if codec:
|
||||
extras.append(codec)
|
||||
|
||||
if not self.key:
|
||||
extras.append(translate_func("Embedded"))
|
||||
|
||||
if self.forced.asBool():
|
||||
extras.append(translate_func("Forced"))
|
||||
|
||||
if len(extras) > 0:
|
||||
title += u" ({0})".format('/'.join(extras))
|
||||
elif streamType == self.TYPE_LYRICS:
|
||||
title = translate_func("Lyrics")
|
||||
if self.format:
|
||||
title += u" ({0})".format(self.format)
|
||||
|
||||
return title
|
||||
|
||||
def getCodec(self):
|
||||
codec = (self.codec or '').lower()
|
||||
|
||||
if codec in ('dca', 'dca-ma', 'dts-hd', 'dts-es', 'dts-hra'):
|
||||
codec = "DTS"
|
||||
else:
|
||||
codec = codec.upper()
|
||||
|
||||
return codec
|
||||
|
||||
def getChannels(self, translate_func=util.dummyTranslate):
|
||||
channels = self.channels.asInt()
|
||||
|
||||
if channels == 1:
|
||||
return translate_func("Mono")
|
||||
elif channels == 2:
|
||||
return translate_func("Stereo")
|
||||
elif channels > 0:
|
||||
return "{0}.1".format(channels - 1)
|
||||
else:
|
||||
return ""
|
||||
|
||||
def getLanguageName(self, translate_func=util.dummyTranslate):
|
||||
code = self.languageCode
|
||||
|
||||
if not code:
|
||||
return translate_func("Unknown")
|
||||
|
||||
return self.SAFE_LANGUAGE_NAMES.get(code) or self.language or "Unknown"
|
||||
|
||||
def getSubtitlePath(self):
|
||||
query = "?encoding=utf-8"
|
||||
|
||||
if self.codec == "smi":
|
||||
query += "&format=srt"
|
||||
|
||||
return self.key + query
|
||||
|
||||
def getSubtitleServerPath(self):
|
||||
if not self.key:
|
||||
return None
|
||||
|
||||
return self.getServer().buildUrl(self.getSubtitlePath(), True)
|
||||
|
||||
def isSelected(self):
|
||||
return self.selected.asBool()
|
||||
|
||||
def setSelected(self, selected):
|
||||
self.selected = plexobjects.PlexValue(selected and '1' or '0')
|
||||
|
||||
def __str__(self):
|
||||
return self.getTitle()
|
||||
|
||||
def __eq__(self, other):
|
||||
if not other:
|
||||
return False
|
||||
|
||||
if self.__class__ != other.__class__:
|
||||
return False
|
||||
|
||||
for attr in ("streamType", "language", "codec", "channels", "index"):
|
||||
if getattr(self, attr) != getattr(other, attr):
|
||||
return False
|
||||
|
||||
|
||||
# Synthetic subtitle stream for 'none'
|
||||
|
||||
class NoneStream(PlexStream):
|
||||
def __init__(self, *args, **kwargs):
|
||||
PlexStream.__init__(self, None, *args, **kwargs)
|
||||
self.id = plexobjects.PlexValue("0")
|
||||
self.streamType = plexobjects.PlexValue(str(self.TYPE_SUBTITLE))
|
||||
|
||||
def getTitle(self, translate_func=util.dummyTranslate):
|
||||
return translate_func("None")
|
101
resources/lib/plexnet/serverdecision.py
Normal file
101
resources/lib/plexnet/serverdecision.py
Normal file
|
@ -0,0 +1,101 @@
|
|||
import mediachoice
|
||||
import util
|
||||
|
||||
|
||||
class DecisionFailure(Exception):
|
||||
def __init__(self, code, reason):
|
||||
self.code = code
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class ServerDecision(object):
|
||||
DECISION_DIRECT_PLAY = "directplay"
|
||||
DECISION_COPY = "copy"
|
||||
DECISION_TRANSCODE = "transcode"
|
||||
DIRECT_PLAY_OK = 1000
|
||||
TRANSCODE_OK = 1001
|
||||
|
||||
def __init__(self, original, response, player):
|
||||
self.original = original
|
||||
self.response = response
|
||||
self.player = player
|
||||
self.item = None
|
||||
|
||||
self.init()
|
||||
|
||||
def init(self):
|
||||
self.isSupported = self.response.server.supportsFeature("streamingBrain")
|
||||
for item in self.response.items:
|
||||
if item and item.media:
|
||||
self.item = item
|
||||
self.original.transcodeDecision = mediachoice.MediaChoice(self.item.media[0])
|
||||
|
||||
# Decision codes and text
|
||||
self.decisionsCodes = {}
|
||||
self.decisionsTexts = {}
|
||||
for key in ["directPlayDecision", "generalDecision", "mdeDecision", "transcodeDecision", "termination"]:
|
||||
self.decisionsCodes[key] = self.response.container.get(key + "Code", "-1").asInt()
|
||||
self.decisionsTexts[key] = self.response.container.get(key + "Text")
|
||||
|
||||
util.DEBUG_LOG("Decision codes: {0}".format(self.decisionsCodes))
|
||||
|
||||
def __str__(self):
|
||||
if self.isSupported:
|
||||
obj = []
|
||||
for v in self.decisionsTexts.values():
|
||||
if v:
|
||||
obj.append(v)
|
||||
return ' '.join(obj)
|
||||
else:
|
||||
return "Server version does not support decisions."
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
def getDecision(self, requireDecision=True):
|
||||
if not self.item:
|
||||
# Return no decision. The player will either continue with the original
|
||||
# or terminate if a valid decision was required.
|
||||
|
||||
if requireDecision:
|
||||
# Terminate the player by default if there was no decision returned.
|
||||
code = self.decisionsCodes["generalDecision"]
|
||||
reason = ' '.join([self.decisionsTexts["transcodeDecision"], self.decisionsTexts["generalDecision"]])
|
||||
raise DecisionFailure(code, reason)
|
||||
|
||||
return None
|
||||
|
||||
# Rebuild the original item with the new item.
|
||||
util.WARN_LOG("Server requested new playback decision: {0}".format(self))
|
||||
self.original.rebuild(self.item, self)
|
||||
return self.original
|
||||
|
||||
def isSuccess(self):
|
||||
code = self.decisionsCodes["mdeDecision"]
|
||||
return not self.isSupported or 1000 <= code < 2000
|
||||
|
||||
def isDecision(self, requireItem=False):
|
||||
# Server has provided a valid decision if there was a valid decision code
|
||||
# or if the response returned zero items (could not play).
|
||||
return self.isSupported and (self.decisionsCodes["mdeDecision"] > -1 or requireItem and not self.item)
|
||||
|
||||
def isTimelineDecision(self):
|
||||
return self.isSupported and self.item
|
||||
|
||||
def isTermination(self):
|
||||
return self.isSupported and self.decisionsCodes["termination"] > -1
|
||||
|
||||
def directPlayOK(self):
|
||||
return self.decisionsCodes["mdeDecision"] == 1000
|
||||
|
||||
def getTermination(self):
|
||||
return {
|
||||
'code': str(self.decisionsCodes["termination"]),
|
||||
'text': self.decisionsTexts["termination"] or "Unknown" # TODO: Translate Unknown
|
||||
}
|
||||
|
||||
def getDecisionText(self):
|
||||
for key in ["mdeDecision", "directPlayDecision", "generalDecision", "transcodeDecision"]:
|
||||
if self.decisionsTexts.get(key):
|
||||
return self.decisionsTexts[key]
|
||||
return None
|
10
resources/lib/plexnet/signalslot/__init__.py
Normal file
10
resources/lib/plexnet/signalslot/__init__.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
try:
|
||||
from .signal import Signal
|
||||
from .slot import Slot
|
||||
from .exceptions import *
|
||||
except ImportError: # pragma: no cover
|
||||
# Possible we are running from setup.py, in which case we're after
|
||||
# the __version__ string only.
|
||||
pass
|
||||
|
||||
__version__ = '0.1.1'
|
0
resources/lib/plexnet/signalslot/contrib/__init__.py
Normal file
0
resources/lib/plexnet/signalslot/contrib/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
from .task import Task
|
75
resources/lib/plexnet/signalslot/contrib/task/task.py
Normal file
75
resources/lib/plexnet/signalslot/contrib/task/task.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
import sys
|
||||
import eventlet
|
||||
import contexter
|
||||
import six
|
||||
|
||||
|
||||
class Task(object):
|
||||
@classmethod
|
||||
def get_or_create(cls, signal, kwargs=None, logger=None):
|
||||
if not hasattr(cls, '_registry'):
|
||||
cls._registry = []
|
||||
|
||||
task = cls(signal, kwargs, logger=logger)
|
||||
|
||||
if task not in cls._registry:
|
||||
cls._registry.append(task)
|
||||
|
||||
return cls._registry[cls._registry.index(task)]
|
||||
|
||||
def __init__(self, signal, kwargs=None, logger=None):
|
||||
self.signal = signal
|
||||
self.kwargs = kwargs or {}
|
||||
self.logger = logger
|
||||
self.failures = 0
|
||||
self.task_semaphore = eventlet.semaphore.BoundedSemaphore(1)
|
||||
|
||||
def __call__(self, semaphores=None):
|
||||
semaphores = semaphores or []
|
||||
|
||||
with contexter.Contexter(self.task_semaphore, *semaphores):
|
||||
result = self._do()
|
||||
|
||||
if result:
|
||||
self.failures = 0
|
||||
else:
|
||||
self.failures += 1
|
||||
|
||||
return result
|
||||
|
||||
def _do(self):
|
||||
try:
|
||||
self._emit()
|
||||
except Exception:
|
||||
self._exception(*sys.exc_info())
|
||||
return False
|
||||
else:
|
||||
self._completed()
|
||||
return True
|
||||
finally:
|
||||
self._clean()
|
||||
|
||||
def _clean(self):
|
||||
pass
|
||||
|
||||
def _completed(self):
|
||||
if self.logger:
|
||||
self.logger.info('[%s] Completed' % self)
|
||||
|
||||
def _exception(self, e_type, e_value, e_traceback):
|
||||
if self.logger:
|
||||
self.logger.exception('[%s] Raised exception: %s' % (
|
||||
self, e_value))
|
||||
else:
|
||||
six.reraise(e_type, e_value, e_traceback)
|
||||
|
||||
def _emit(self):
|
||||
if self.logger:
|
||||
self.logger.info('[%s] Running' % self)
|
||||
self.signal.emit(**self.kwargs)
|
||||
|
||||
def __eq__(self, other):
|
||||
return (self.signal == other.signal and self.kwargs == other.kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return '%s: %s' % (self.signal.__class__.__name__, self.kwargs)
|
184
resources/lib/plexnet/signalslot/contrib/task/test.py
Normal file
184
resources/lib/plexnet/signalslot/contrib/task/test.py
Normal file
|
@ -0,0 +1,184 @@
|
|||
import pytest
|
||||
import mock
|
||||
import logging
|
||||
import eventlet
|
||||
import time
|
||||
from signalslot import Signal
|
||||
from signalslot.contrib.task import Task
|
||||
|
||||
eventlet.monkey_patch(time=True)
|
||||
|
||||
|
||||
class TestTask(object):
|
||||
def setup_method(self, method):
|
||||
self.signal = mock.Mock()
|
||||
|
||||
def get_task_mock(self, *methods, **kwargs):
|
||||
if kwargs.get('logger'):
|
||||
log = logging.getLogger('TestTask')
|
||||
else:
|
||||
log = None
|
||||
task_mock = Task(self.signal, logger=log)
|
||||
|
||||
for method in methods:
|
||||
setattr(task_mock, method, mock.Mock())
|
||||
|
||||
return task_mock
|
||||
|
||||
def test_eq(self):
|
||||
x = Task(self.signal, dict(some_kwarg='foo'),
|
||||
logger=logging.getLogger('TaskX'))
|
||||
y = Task(self.signal, dict(some_kwarg='foo'),
|
||||
logger=logging.getLogger('TaskY'))
|
||||
|
||||
assert x == y
|
||||
|
||||
def test_not_eq(self):
|
||||
x = Task(self.signal, dict(some_kwarg='foo',
|
||||
logger=logging.getLogger('TaskX')))
|
||||
y = Task(self.signal, dict(some_kwarg='bar',
|
||||
logger=logging.getLogger('TaskY')))
|
||||
|
||||
assert x != y
|
||||
|
||||
def test_unicode(self):
|
||||
t = Task(self.signal, dict(some_kwarg='foo'),
|
||||
logger=logging.getLogger('TaskT'))
|
||||
|
||||
assert str(t) == "Mock: {'some_kwarg': 'foo'}"
|
||||
|
||||
def test_get_or_create_gets(self):
|
||||
x = Task.get_or_create(self.signal, dict(some_kwarg='foo'),
|
||||
logger=logging.getLogger('TaskX'))
|
||||
y = Task.get_or_create(self.signal, dict(some_kwarg='foo'),
|
||||
logger=logging.getLogger('TaskY'))
|
||||
|
||||
assert x is y
|
||||
|
||||
def test_get_or_create_creates(self):
|
||||
x = Task.get_or_create(self.signal, dict(some_kwarg='foo'),
|
||||
logger=logging.getLogger('TaskX'))
|
||||
y = Task.get_or_create(self.signal, dict(some_kwarg='bar'),
|
||||
logger=logging.getLogger('TaskY'))
|
||||
|
||||
assert x is not y
|
||||
|
||||
def test_get_or_create_without_kwargs(self):
|
||||
t = Task.get_or_create(self.signal)
|
||||
|
||||
assert t.kwargs == {}
|
||||
|
||||
def test_get_or_create_uses_cls(self):
|
||||
class Foo(Task):
|
||||
pass
|
||||
|
||||
assert isinstance(Foo.get_or_create(self.signal), Foo)
|
||||
|
||||
def test_do_emit(self):
|
||||
task_mock = self.get_task_mock('_clean', '_exception', '_completed')
|
||||
|
||||
task_mock._do()
|
||||
|
||||
self.signal.emit.assert_called_once_with()
|
||||
|
||||
def test_do_emit_nolog(self):
|
||||
task_mock = self.get_task_mock(
|
||||
'_clean', '_exception', '_completed', logging=True)
|
||||
|
||||
task_mock._do()
|
||||
|
||||
self.signal.emit.assert_called_once_with()
|
||||
|
||||
def test_do_emit_no_log(self):
|
||||
task_mock = self.get_task_mock('_clean', '_exception', '_completed')
|
||||
|
||||
task_mock._do()
|
||||
|
||||
self.signal.emit.assert_called_once_with()
|
||||
|
||||
def test_do_complete(self):
|
||||
task_mock = self.get_task_mock('_clean', '_exception', '_completed')
|
||||
|
||||
task_mock._do()
|
||||
|
||||
task_mock._exception.assert_not_called()
|
||||
task_mock._completed.assert_called_once_with()
|
||||
task_mock._clean.assert_called_once_with()
|
||||
|
||||
def test_do_success(self):
|
||||
task_mock = self.get_task_mock()
|
||||
assert task_mock._do() is True
|
||||
|
||||
def test_do_failure_nolog(self):
|
||||
# Our dummy exception
|
||||
class DummyError(Exception):
|
||||
pass
|
||||
|
||||
task_mock = self.get_task_mock('_emit')
|
||||
task_mock._emit.side_effect = DummyError()
|
||||
|
||||
# This will throw an exception at us, be ready to catch it.
|
||||
try:
|
||||
task_mock._do()
|
||||
assert False
|
||||
except DummyError:
|
||||
pass
|
||||
|
||||
def test_do_failure_withlog(self):
|
||||
task_mock = self.get_task_mock('_emit', logger=True)
|
||||
task_mock._emit.side_effect = Exception()
|
||||
assert task_mock._do() is False
|
||||
|
||||
def test_do_exception(self):
|
||||
task_mock = self.get_task_mock(
|
||||
'_clean', '_exception', '_completed', '_emit')
|
||||
|
||||
task_mock._emit.side_effect = Exception()
|
||||
|
||||
task_mock._do()
|
||||
|
||||
task_mock._exception.assert_called_once_with(
|
||||
Exception, task_mock._emit.side_effect, mock.ANY)
|
||||
|
||||
task_mock._completed.assert_not_called()
|
||||
task_mock._clean.assert_called_once_with()
|
||||
|
||||
@mock.patch('signalslot.signal.inspect')
|
||||
def test_semaphore(self, inspect):
|
||||
slot = mock.Mock()
|
||||
slot.side_effect = lambda **k: time.sleep(.3)
|
||||
|
||||
signal = Signal('tost')
|
||||
signal.connect(slot)
|
||||
|
||||
x = Task.get_or_create(signal, dict(some_kwarg='foo'),
|
||||
logger=logging.getLogger('TaskX'))
|
||||
y = Task.get_or_create(signal, dict(some_kwarg='foo'),
|
||||
logger=logging.getLogger('TaskY'))
|
||||
|
||||
eventlet.spawn(x)
|
||||
time.sleep(.1)
|
||||
eventlet.spawn(y)
|
||||
time.sleep(.1)
|
||||
|
||||
assert slot.call_count == 1
|
||||
time.sleep(.4)
|
||||
assert slot.call_count == 2
|
||||
|
||||
def test_call_context(self):
|
||||
task_mock = self.get_task_mock('_clean', '_exception', '_completed',
|
||||
'_emit')
|
||||
|
||||
task_mock._emit.side_effect = Exception()
|
||||
|
||||
assert task_mock.failures == 0
|
||||
task_mock()
|
||||
assert task_mock.failures == 1
|
||||
|
||||
def test_call_success(self):
|
||||
task_mock = self.get_task_mock('_clean', '_exception', '_completed',
|
||||
'_emit')
|
||||
|
||||
assert task_mock.failures == 0
|
||||
task_mock()
|
||||
assert task_mock.failures == 0
|
28
resources/lib/plexnet/signalslot/exceptions.py
Normal file
28
resources/lib/plexnet/signalslot/exceptions.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
class SignalSlotException(Exception):
|
||||
"""Base class for all exceptions of this module."""
|
||||
pass
|
||||
|
||||
|
||||
class SlotMustAcceptKeywords(SignalSlotException):
|
||||
"""
|
||||
Raised when connecting a slot that does not accept ``**kwargs`` in its
|
||||
signature.
|
||||
"""
|
||||
def __init__(self, signal, slot):
|
||||
m = 'Cannot connect %s to %s because it does not accept **kwargs' % (
|
||||
slot, signal)
|
||||
|
||||
super(SlotMustAcceptKeywords, self).__init__(m)
|
||||
|
||||
|
||||
# Not yet being used.
|
||||
class QueueCantQueueNonSignalInstance(SignalSlotException): # pragma: no cover
|
||||
"""
|
||||
Raised when trying to queue something else than a
|
||||
:py:class:`~signalslot.signal.Signal` instance.
|
||||
"""
|
||||
def __init__(self, queue, arg):
|
||||
m = 'Cannot queue %s to %s because it is not a Signal instance' % (
|
||||
arg, queue)
|
||||
|
||||
super(QueueCantQueueNonSignalInstance, self).__init__(m)
|
167
resources/lib/plexnet/signalslot/signal.py
Normal file
167
resources/lib/plexnet/signalslot/signal.py
Normal file
|
@ -0,0 +1,167 @@
|
|||
"""
|
||||
Module defining the Signal class.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import threading
|
||||
|
||||
from . import exceptions
|
||||
|
||||
|
||||
class DummyLock(object):
|
||||
"""
|
||||
Class that implements a no-op instead of a re-entrant lock.
|
||||
"""
|
||||
|
||||
def __enter__(self):
|
||||
pass
|
||||
|
||||
def __exit__(self, exc_type=None, exc_value=None, traceback=None):
|
||||
pass
|
||||
|
||||
|
||||
class BaseSlot(object):
|
||||
"""
|
||||
Slot abstract class for type resolution purposes.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Signal(object):
|
||||
"""
|
||||
Define a signal by instanciating a :py:class:`Signal` object, ie.:
|
||||
|
||||
>>> conf_pre_load = Signal()
|
||||
|
||||
Optionaly, you can declare a list of argument names for this signal, ie.:
|
||||
|
||||
>>> conf_pre_load = Signal(args=['conf'])
|
||||
|
||||
Any callable can be connected to a Signal, it **must** accept keywords
|
||||
(``**kwargs``), ie.:
|
||||
|
||||
>>> def yourmodule_conf(conf, **kwargs):
|
||||
... conf['yourmodule_option'] = 'foo'
|
||||
...
|
||||
|
||||
Connect your function to the signal using :py:meth:`connect`:
|
||||
|
||||
>>> conf_pre_load.connect(yourmodule_conf)
|
||||
|
||||
Emit the signal to call all connected callbacks using
|
||||
:py:meth:`emit`:
|
||||
|
||||
>>> conf = {}
|
||||
>>> conf_pre_load.emit(conf=conf)
|
||||
>>> conf
|
||||
{'yourmodule_option': 'foo'}
|
||||
|
||||
Note that you may disconnect a callback from a signal if it is already
|
||||
connected:
|
||||
|
||||
>>> conf_pre_load.is_connected(yourmodule_conf)
|
||||
True
|
||||
>>> conf_pre_load.disconnect(yourmodule_conf)
|
||||
>>> conf_pre_load.is_connected(yourmodule_conf)
|
||||
False
|
||||
"""
|
||||
def __init__(self, args=None, name=None, threadsafe=False):
|
||||
self._slots = []
|
||||
self._slots_lk = threading.RLock() if threadsafe else DummyLock()
|
||||
self.args = args or []
|
||||
self.name = name
|
||||
|
||||
@property
|
||||
def slots(self):
|
||||
"""
|
||||
Return a list of slots for this signal.
|
||||
"""
|
||||
with self._slots_lk:
|
||||
# Do a slot clean-up
|
||||
slots = []
|
||||
for s in self._slots:
|
||||
if isinstance(s, BaseSlot) and (not s.is_alive):
|
||||
continue
|
||||
slots.append(s)
|
||||
self._slots = slots
|
||||
return list(slots)
|
||||
|
||||
def connect(self, slot):
|
||||
"""
|
||||
Connect a callback ``slot`` to this signal.
|
||||
"""
|
||||
if not isinstance(slot, BaseSlot):
|
||||
try:
|
||||
if inspect.getargspec(slot).keywords is None:
|
||||
raise exceptions.SlotMustAcceptKeywords(self, slot)
|
||||
except TypeError:
|
||||
if inspect.getargspec(slot.__call__).keywords is None:
|
||||
raise exceptions.SlotMustAcceptKeywords(self, slot)
|
||||
|
||||
with self._slots_lk:
|
||||
if not self.is_connected(slot):
|
||||
self._slots.append(slot)
|
||||
|
||||
def is_connected(self, slot):
|
||||
"""
|
||||
Check if a callback ``slot`` is connected to this signal.
|
||||
"""
|
||||
with self._slots_lk:
|
||||
return slot in self._slots
|
||||
|
||||
def disconnect(self, slot):
|
||||
"""
|
||||
Disconnect a slot from a signal if it is connected else do nothing.
|
||||
"""
|
||||
with self._slots_lk:
|
||||
if self.is_connected(slot):
|
||||
self._slots.pop(self._slots.index(slot))
|
||||
|
||||
def emit(self, **kwargs):
|
||||
"""
|
||||
Emit this signal which will execute every connected callback ``slot``,
|
||||
passing keyword arguments.
|
||||
|
||||
If a slot returns anything other than None, then :py:meth:`emit` will
|
||||
return that value preventing any other slot from being called.
|
||||
|
||||
>>> need_something = Signal()
|
||||
>>> def get_something(**kwargs):
|
||||
... return 'got something'
|
||||
...
|
||||
>>> def make_something(**kwargs):
|
||||
... print('I will not be called')
|
||||
...
|
||||
>>> need_something.connect(get_something)
|
||||
>>> need_something.connect(make_something)
|
||||
>>> need_something.emit()
|
||||
'got something'
|
||||
"""
|
||||
for slot in reversed(self.slots):
|
||||
result = slot(**kwargs)
|
||||
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
def __eq__(self, other):
|
||||
"""
|
||||
Return True if other has the same slots connected.
|
||||
|
||||
>>> a = Signal()
|
||||
>>> b = Signal()
|
||||
>>> a == b
|
||||
True
|
||||
>>> def slot(**kwargs):
|
||||
... pass
|
||||
...
|
||||
>>> a.connect(slot)
|
||||
>>> a == b
|
||||
False
|
||||
>>> b.connect(slot)
|
||||
>>> a == b
|
||||
True
|
||||
"""
|
||||
return self.slots == other.slots
|
||||
|
||||
def __repr__(self):
|
||||
return '<signalslot.Signal: %s>' % (self.name or 'NO_NAME')
|
73
resources/lib/plexnet/signalslot/slot.py
Normal file
73
resources/lib/plexnet/signalslot/slot.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
"""
|
||||
Module defining the Slot class.
|
||||
"""
|
||||
|
||||
import types
|
||||
import weakref
|
||||
import sys
|
||||
|
||||
from .signal import BaseSlot
|
||||
|
||||
# We cannot test a branch for Python >= 3.4 in Python < 3.4.
|
||||
if sys.version_info < (3, 4): # pragma: no cover
|
||||
from weakrefmethod import WeakMethod
|
||||
else: # pragma: no cover
|
||||
from weakref import WeakMethod
|
||||
|
||||
|
||||
class Slot(BaseSlot):
|
||||
"""
|
||||
A slot is a callable object that manages a connection to a signal.
|
||||
If weak is true or the slot is a subclass of weakref.ref, the slot
|
||||
is automatically de-referenced to the called function.
|
||||
"""
|
||||
def __init__(self, slot, weak=False):
|
||||
self._weak = weak or isinstance(slot, weakref.ref)
|
||||
if weak and not isinstance(slot, weakref.ref):
|
||||
if isinstance(slot, types.MethodType):
|
||||
slot = WeakMethod(slot)
|
||||
else:
|
||||
slot = weakref.ref(slot)
|
||||
self._slot = slot
|
||||
|
||||
@property
|
||||
def is_alive(self):
|
||||
"""
|
||||
Return True if this slot is "alive".
|
||||
"""
|
||||
return (not self._weak) or (self._slot() is not None)
|
||||
|
||||
@property
|
||||
def func(self):
|
||||
"""
|
||||
Return the function that is called by this slot.
|
||||
"""
|
||||
if self._weak:
|
||||
return self._slot()
|
||||
else:
|
||||
return self._slot
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
"""
|
||||
Execute this slot.
|
||||
"""
|
||||
func = self.func
|
||||
if func is not None:
|
||||
return func(**kwargs)
|
||||
|
||||
def __eq__(self, other):
|
||||
"""
|
||||
Compare this slot to another.
|
||||
"""
|
||||
if isinstance(other, BaseSlot):
|
||||
return self.func == other.func
|
||||
else:
|
||||
return self.func == other
|
||||
|
||||
def __repr__(self):
|
||||
fn = self.func
|
||||
if fn is None:
|
||||
fn = 'dead'
|
||||
else:
|
||||
fn = repr(fn)
|
||||
return '<signalslot.Slot: %s>' % fn
|
205
resources/lib/plexnet/signalslot/tests.py
Normal file
205
resources/lib/plexnet/signalslot/tests.py
Normal file
|
@ -0,0 +1,205 @@
|
|||
import pytest
|
||||
import mock
|
||||
|
||||
from signalslot import Signal, SlotMustAcceptKeywords, Slot
|
||||
|
||||
|
||||
@mock.patch('signalslot.signal.inspect')
|
||||
class TestSignal(object):
|
||||
def setup_method(self, method):
|
||||
self.signal_a = Signal(threadsafe=True)
|
||||
self.signal_b = Signal(args=['foo'])
|
||||
|
||||
self.slot_a = mock.Mock(spec=lambda **kwargs: None)
|
||||
self.slot_a.return_value = None
|
||||
self.slot_b = mock.Mock(spec=lambda **kwargs: None)
|
||||
self.slot_b.return_value = None
|
||||
|
||||
def test_is_connected(self, inspect):
|
||||
self.signal_a.connect(self.slot_a)
|
||||
|
||||
assert self.signal_a.is_connected(self.slot_a)
|
||||
assert not self.signal_a.is_connected(self.slot_b)
|
||||
assert not self.signal_b.is_connected(self.slot_a)
|
||||
assert not self.signal_b.is_connected(self.slot_b)
|
||||
|
||||
def test_emit_one_slot(self, inspect):
|
||||
self.signal_a.connect(self.slot_a)
|
||||
|
||||
self.signal_a.emit()
|
||||
|
||||
self.slot_a.assert_called_once_with()
|
||||
assert self.slot_b.call_count == 0
|
||||
|
||||
def test_emit_two_slots(self, inspect):
|
||||
self.signal_a.connect(self.slot_a)
|
||||
self.signal_a.connect(self.slot_b)
|
||||
|
||||
self.signal_a.emit()
|
||||
|
||||
self.slot_a.assert_called_once_with()
|
||||
self.slot_b.assert_called_once_with()
|
||||
|
||||
def test_emit_one_slot_with_arguments(self, inspect):
|
||||
self.signal_b.connect(self.slot_a)
|
||||
|
||||
self.signal_b.emit(foo='bar')
|
||||
|
||||
self.slot_a.assert_called_once_with(foo='bar')
|
||||
assert self.slot_b.call_count == 0
|
||||
|
||||
def test_emit_two_slots_with_arguments(self, inspect):
|
||||
self.signal_b.connect(self.slot_a)
|
||||
self.signal_b.connect(self.slot_b)
|
||||
|
||||
self.signal_b.emit(foo='bar')
|
||||
|
||||
self.slot_a.assert_called_once_with(foo='bar')
|
||||
self.slot_b.assert_called_once_with(foo='bar')
|
||||
|
||||
def test_reconnect_does_not_duplicate(self, inspect):
|
||||
self.signal_a.connect(self.slot_a)
|
||||
self.signal_a.connect(self.slot_a)
|
||||
self.signal_a.emit()
|
||||
|
||||
self.slot_a.assert_called_once_with()
|
||||
|
||||
def test_disconnect_does_not_fail_on_not_connected_slot(self, inspect):
|
||||
self.signal_a.disconnect(self.slot_b)
|
||||
|
||||
|
||||
def test_anonymous_signal_has_nice_repr():
|
||||
signal = Signal()
|
||||
assert repr(signal) == '<signalslot.Signal: NO_NAME>'
|
||||
|
||||
|
||||
def test_named_signal_has_a_nice_repr():
|
||||
signal = Signal(name='update_stuff')
|
||||
assert repr(signal) == '<signalslot.Signal: update_stuff>'
|
||||
|
||||
|
||||
class TestSignalConnect(object):
|
||||
def setup_method(self, method):
|
||||
self.signal = Signal()
|
||||
|
||||
def test_connect_with_kwargs(self):
|
||||
def cb(**kwargs):
|
||||
pass
|
||||
|
||||
self.signal.connect(cb)
|
||||
|
||||
def test_connect_without_kwargs(self):
|
||||
def cb():
|
||||
pass
|
||||
|
||||
with pytest.raises(SlotMustAcceptKeywords):
|
||||
self.signal.connect(cb)
|
||||
|
||||
|
||||
class MyTestError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TestException(object):
|
||||
def setup_method(self, method):
|
||||
self.signal = Signal(threadsafe=False)
|
||||
self.seen_exception = False
|
||||
|
||||
def failing_slot(**args):
|
||||
raise MyTestError('die!')
|
||||
|
||||
self.signal.connect(failing_slot)
|
||||
|
||||
def test_emit_exception(self):
|
||||
try:
|
||||
self.signal.emit()
|
||||
except MyTestError:
|
||||
self.seen_exception = True
|
||||
|
||||
assert self.seen_exception
|
||||
|
||||
|
||||
class TestStrongSlot(object):
|
||||
def setup_method(self, method):
|
||||
self.called = False
|
||||
|
||||
def slot(**kwargs):
|
||||
self.called = True
|
||||
|
||||
self.slot = Slot(slot)
|
||||
|
||||
def test_alive(self):
|
||||
assert self.slot.is_alive
|
||||
|
||||
def test_call(self):
|
||||
self.slot(testing=1234)
|
||||
assert self.called
|
||||
|
||||
|
||||
class TestWeakFuncSlot(object):
|
||||
def setup_method(self, method):
|
||||
self.called = False
|
||||
|
||||
def slot(**kwargs):
|
||||
self.called = True
|
||||
|
||||
self.slot = Slot(slot, weak=True)
|
||||
self.slot_ref = slot
|
||||
|
||||
def test_alive(self):
|
||||
assert self.slot.is_alive
|
||||
assert repr(self.slot) == '<signalslot.Slot: %s>' % repr(self.slot_ref)
|
||||
|
||||
def test_call(self):
|
||||
self.slot(testing=1234)
|
||||
assert self.called
|
||||
|
||||
def test_gc(self):
|
||||
self.slot_ref = None
|
||||
assert not self.slot.is_alive
|
||||
assert repr(self.slot) == '<signalslot.Slot: dead>'
|
||||
self.slot(testing=1234)
|
||||
|
||||
|
||||
class TestWeakMethodSlot(object):
|
||||
def setup_method(self, method):
|
||||
|
||||
class MyObject(object):
|
||||
|
||||
def __init__(self):
|
||||
self.called = False
|
||||
|
||||
def slot(self, **kwargs):
|
||||
self.called = True
|
||||
|
||||
self.obj_ref = MyObject()
|
||||
self.slot = Slot(self.obj_ref.slot, weak=True)
|
||||
self.signal = Signal()
|
||||
self.signal.connect(self.slot)
|
||||
|
||||
def test_alive(self):
|
||||
assert self.slot.is_alive
|
||||
|
||||
def test_call(self):
|
||||
self.signal.emit(testing=1234)
|
||||
assert self.obj_ref.called
|
||||
|
||||
def test_gc(self):
|
||||
self.obj_ref = None
|
||||
assert not self.slot.is_alive
|
||||
self.signal.emit(testing=1234)
|
||||
|
||||
|
||||
class TestSlotEq(object):
|
||||
def setup_method(self, method):
|
||||
self.slot_a = Slot(self.slot, weak=False)
|
||||
self.slot_b = Slot(self.slot, weak=True)
|
||||
|
||||
def slot(self, **kwargs):
|
||||
pass
|
||||
|
||||
def test_eq_other(self):
|
||||
assert self.slot_a == self.slot_b
|
||||
|
||||
def test_eq_func(self):
|
||||
assert self.slot_a == self.slot
|
40
resources/lib/plexnet/signalsmixin.py
Normal file
40
resources/lib/plexnet/signalsmixin.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
import signalslot
|
||||
|
||||
|
||||
class SignalsMixin(object):
|
||||
def __init__(self):
|
||||
self._signals = {}
|
||||
|
||||
def on(self, signalName, callback):
|
||||
if signalName not in self._signals:
|
||||
self._signals[signalName] = signalslot.Signal(threadsafe=True)
|
||||
|
||||
signal = self._signals[signalName]
|
||||
|
||||
signal.connect(callback)
|
||||
|
||||
def off(self, signalName, callback):
|
||||
if not self._signals:
|
||||
return
|
||||
|
||||
if not signalName:
|
||||
if not callback:
|
||||
self._signals = {}
|
||||
else:
|
||||
for name in self._signals:
|
||||
self.off(name, callback)
|
||||
else:
|
||||
if not callback:
|
||||
if signalName in self._signals:
|
||||
del self._signals[signalName]
|
||||
else:
|
||||
self._signals[signalName].disconnect(callback)
|
||||
|
||||
def trigger(self, signalName, **kwargs):
|
||||
if not self._signals:
|
||||
return
|
||||
|
||||
if signalName not in self._signals:
|
||||
return
|
||||
|
||||
self._signals[signalName].emit(**kwargs)
|
21
resources/lib/plexnet/simpleobjects.py
Normal file
21
resources/lib/plexnet/simpleobjects.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
class Res(tuple):
|
||||
def __str__(self):
|
||||
return '{0}x{1}'.format(*self[:2])
|
||||
|
||||
@classmethod
|
||||
def fromString(cls, res_string):
|
||||
try:
|
||||
return cls(map(lambda n: int(n), res_string.split('x')))
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
class AttributeDict(dict):
|
||||
def __getattr__(self, attr):
|
||||
return self.get(attr)
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
self[attr] = value
|
||||
|
||||
def __repr__(self):
|
||||
return '<{0}:{1}:{2}>'.format(self.__class__.__name__, self.id, self.get('title', 'None').encode('utf8'))
|
93
resources/lib/plexnet/threadutils.py
Normal file
93
resources/lib/plexnet/threadutils.py
Normal file
|
@ -0,0 +1,93 @@
|
|||
# import inspect
|
||||
# import ctypes
|
||||
import threading
|
||||
# import time
|
||||
|
||||
|
||||
# def _async_raise(tid, exctype):
|
||||
# '''Raises an exception in the threads with id tid'''
|
||||
# if not inspect.isclass(exctype):
|
||||
# raise TypeError("Only types can be raised (not instances)")
|
||||
|
||||
# try:
|
||||
# res = ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), ctypes.py_object(exctype))
|
||||
# except AttributeError:
|
||||
# # To catch: undefined symbol: PyThreadState_SetAsyncExc
|
||||
# return
|
||||
|
||||
# if res == 0:
|
||||
# raise ValueError("invalid thread id")
|
||||
# elif res != 1:
|
||||
# # "if it returns a number greater than one, you're in trouble,
|
||||
# # and you should call it again with exc=NULL to revert the effect"
|
||||
# ctypes.pythonapi.PyThreadState_SetAsyncExc(ctypes.c_long(tid), 0)
|
||||
# raise SystemError("PyThreadState_SetAsyncExc failed")
|
||||
|
||||
|
||||
# class KillThreadException(Exception):
|
||||
# pass
|
||||
|
||||
|
||||
class KillableThread(threading.Thread):
|
||||
pass
|
||||
'''A thread class that supports raising exception in the thread from
|
||||
another thread.
|
||||
'''
|
||||
# def _get_my_tid(self):
|
||||
# """determines this (self's) thread id
|
||||
|
||||
# CAREFUL : this function is executed in the context of the caller
|
||||
# thread, to get the identity of the thread represented by this
|
||||
# instance.
|
||||
# """
|
||||
# if not self.isAlive():
|
||||
# raise threading.ThreadError("the thread is not active")
|
||||
|
||||
# return self.ident
|
||||
|
||||
# def _raiseExc(self, exctype):
|
||||
# """Raises the given exception type in the context of this thread.
|
||||
|
||||
# If the thread is busy in a system call (time.sleep(),
|
||||
# socket.accept(), ...), the exception is simply ignored.
|
||||
|
||||
# If you are sure that your exception should terminate the thread,
|
||||
# one way to ensure that it works is:
|
||||
|
||||
# t = ThreadWithExc( ... )
|
||||
# ...
|
||||
# t.raiseExc( SomeException )
|
||||
# while t.isAlive():
|
||||
# time.sleep( 0.1 )
|
||||
# t.raiseExc( SomeException )
|
||||
|
||||
# If the exception is to be caught by the thread, you need a way to
|
||||
# check that your thread has caught it.
|
||||
|
||||
# CAREFUL : this function is executed in the context of the
|
||||
# caller thread, to raise an excpetion in the context of the
|
||||
# thread represented by this instance.
|
||||
# """
|
||||
# _async_raise(self._get_my_tid(), exctype)
|
||||
|
||||
def kill(self, force_and_wait=False):
|
||||
pass
|
||||
# try:
|
||||
# self._raiseExc(KillThreadException)
|
||||
|
||||
# if force_and_wait:
|
||||
# time.sleep(0.1)
|
||||
# while self.isAlive():
|
||||
# self._raiseExc(KillThreadException)
|
||||
# time.sleep(0.1)
|
||||
# except threading.ThreadError:
|
||||
# pass
|
||||
|
||||
# def onKilled(self):
|
||||
# pass
|
||||
|
||||
# def run(self):
|
||||
# try:
|
||||
# self._Thread__target(*self._Thread__args, **self._Thread__kwargs)
|
||||
# except KillThreadException:
|
||||
# self.onKilled()
|
181
resources/lib/plexnet/util.py
Normal file
181
resources/lib/plexnet/util.py
Normal file
|
@ -0,0 +1,181 @@
|
|||
import simpleobjects
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import platform
|
||||
import uuid
|
||||
|
||||
import verlib
|
||||
import compat
|
||||
import plexapp
|
||||
|
||||
BASE_HEADERS = ''
|
||||
|
||||
|
||||
def resetBaseHeaders():
|
||||
return {
|
||||
'X-Plex-Platform': X_PLEX_PLATFORM,
|
||||
'X-Plex-Platform-Version': X_PLEX_PLATFORM_VERSION,
|
||||
'X-Plex-Provides': X_PLEX_PROVIDES,
|
||||
'X-Plex-Product': X_PLEX_PRODUCT,
|
||||
'X-Plex-Version': X_PLEX_VERSION,
|
||||
'X-Plex-Device': X_PLEX_DEVICE,
|
||||
'X-Plex-Client-Identifier': X_PLEX_IDENTIFIER,
|
||||
'Accept-Encoding': 'gzip,deflate',
|
||||
'User-Agent': USER_AGENT
|
||||
}
|
||||
|
||||
|
||||
# Core Settings
|
||||
PROJECT = 'PlexNet' # name provided to plex server
|
||||
VERSION = '0.0.0a1' # version of this api
|
||||
TIMEOUT = 10 # request timeout
|
||||
X_PLEX_CONTAINER_SIZE = 50 # max results to return in a single search page
|
||||
|
||||
# Plex Header Configuation
|
||||
X_PLEX_PROVIDES = 'player,controller' # one or more of [player, controller, server]
|
||||
X_PLEX_PLATFORM = platform.uname()[0] # Platform name, eg iOS, MacOSX, Android, LG, etc
|
||||
X_PLEX_PLATFORM_VERSION = platform.uname()[2] # Operating system version, eg 4.3.1, 10.6.7, 3.2
|
||||
X_PLEX_PRODUCT = PROJECT # Plex application name, eg Laika, Plex Media Server, Media Link
|
||||
X_PLEX_VERSION = VERSION # Plex application version number
|
||||
USER_AGENT = '{0}/{1}'.format(PROJECT, VERSION)
|
||||
|
||||
try:
|
||||
_platform = platform.system()
|
||||
except:
|
||||
try:
|
||||
_platform = platform.platform(terse=True)
|
||||
except:
|
||||
_platform = sys.platform
|
||||
|
||||
X_PLEX_DEVICE = _platform # Device name and model number, eg iPhone3,2, Motorola XOOM, LG5200TV
|
||||
X_PLEX_IDENTIFIER = str(hex(uuid.getnode())) # UUID, serial number, or other number unique per device
|
||||
|
||||
BASE_HEADERS = resetBaseHeaders()
|
||||
|
||||
QUALITY_LOCAL = 0
|
||||
QUALITY_REMOTE = 1
|
||||
QUALITY_ONLINE = 2
|
||||
|
||||
Res = simpleobjects.Res
|
||||
AttributeDict = simpleobjects.AttributeDict
|
||||
|
||||
|
||||
def LOG(msg):
|
||||
plexapp.INTERFACE.LOG(msg)
|
||||
|
||||
|
||||
def DEBUG_LOG(msg):
|
||||
plexapp.INTERFACE.DEBUG_LOG(msg)
|
||||
|
||||
|
||||
def ERROR_LOG(msg):
|
||||
plexapp.INTERFACE.ERROR_LOG(msg)
|
||||
|
||||
|
||||
def WARN_LOG(msg):
|
||||
plexapp.INTERFACE.WARN_LOG(msg)
|
||||
|
||||
|
||||
def ERROR(msg=None, err=None):
|
||||
plexapp.INTERFACE.ERROR(msg, err)
|
||||
|
||||
|
||||
def FATAL(msg=None):
|
||||
plexapp.INTERFACE.FATAL(msg)
|
||||
|
||||
|
||||
def TEST(msg):
|
||||
plexapp.INTERFACE.LOG(' ---TEST: {0}'.format(msg))
|
||||
|
||||
|
||||
def userAgent():
|
||||
return plexapp.INTERFACE.getGlobal("userAgent")
|
||||
|
||||
|
||||
def dummyTranslate(string):
|
||||
return string
|
||||
|
||||
|
||||
def hideToken(token):
|
||||
# return 'X' * len(token)
|
||||
if not token:
|
||||
return token
|
||||
return '****' + token[-4:]
|
||||
|
||||
|
||||
def cleanToken(url):
|
||||
return re.sub('X-Plex-Token=[^&]+', 'X-Plex-Token=****', url)
|
||||
|
||||
|
||||
def now(local=False):
|
||||
if local:
|
||||
return time.time()
|
||||
else:
|
||||
return time.mktime(time.gmtime())
|
||||
|
||||
|
||||
def joinArgs(args):
|
||||
if not args:
|
||||
return ''
|
||||
|
||||
arglist = []
|
||||
for key in sorted(args, key=lambda x: x.lower()):
|
||||
value = str(args[key])
|
||||
arglist.append('{0}={1}'.format(key, compat.quote(value)))
|
||||
|
||||
return '?{0}'.format('&'.join(arglist))
|
||||
|
||||
|
||||
def addPlexHeaders(transferObj, token=None):
|
||||
transferObj.addHeader("X-Plex-Platform", plexapp.INTERFACE.getGlobal("platform"))
|
||||
transferObj.addHeader("X-Plex-Version", plexapp.INTERFACE.getGlobal("appVersionStr"))
|
||||
transferObj.addHeader("X-Plex-Client-Identifier", plexapp.INTERFACE.getGlobal("clientIdentifier"))
|
||||
transferObj.addHeader("X-Plex-Platform-Version", plexapp.INTERFACE.getGlobal("platformVersion", "unknown"))
|
||||
transferObj.addHeader("X-Plex-Product", plexapp.INTERFACE.getGlobal("product"))
|
||||
transferObj.addHeader("X-Plex-Provides", not plexapp.INTERFACE.getPreference("remotecontrol", False) and 'player' or '')
|
||||
transferObj.addHeader("X-Plex-Device", plexapp.INTERFACE.getGlobal("device"))
|
||||
transferObj.addHeader("X-Plex-Model", plexapp.INTERFACE.getGlobal("model"))
|
||||
transferObj.addHeader("X-Plex-Device-Name", plexapp.INTERFACE.getGlobal("friendlyName"))
|
||||
|
||||
# Adding the X-Plex-Client-Capabilities header causes node.plexapp.com to 500
|
||||
if not type(transferObj) == "roUrlTransfer" or 'node.plexapp.com' not in transferObj.getUrl():
|
||||
transferObj.addHeader("X-Plex-Client-Capabilities", plexapp.INTERFACE.getCapabilities())
|
||||
|
||||
addAccountHeaders(transferObj, token)
|
||||
|
||||
|
||||
def addAccountHeaders(transferObj, token=None):
|
||||
if token:
|
||||
transferObj.addHeader("X-Plex-Token", token)
|
||||
|
||||
# TODO(schuyler): Add username?
|
||||
|
||||
|
||||
def validInt(int_str):
|
||||
try:
|
||||
return int(int_str)
|
||||
except:
|
||||
return 0
|
||||
|
||||
|
||||
def bitrateToString(bits):
|
||||
if not bits:
|
||||
return ''
|
||||
|
||||
speed = bits / 1000000.0
|
||||
if speed < 1:
|
||||
speed = int(round(bits / 1000.0))
|
||||
return '{0} Kbps'.format(speed)
|
||||
else:
|
||||
return '{0:.1f} Mbps'.format(speed)
|
||||
|
||||
|
||||
def normalizedVersion(ver):
|
||||
try:
|
||||
modv = '.'.join(ver.split('.')[:4]).split('-', 1)[0] # Clean the version i.e. Turn 1.2.3.4-asdf8-ads7f into 1.2.3.4
|
||||
return verlib.NormalizedVersion(verlib.suggest_normalized_version(modv))
|
||||
except:
|
||||
if ver:
|
||||
ERROR()
|
||||
return verlib.NormalizedVersion(verlib.suggest_normalized_version('0.0.0'))
|
328
resources/lib/plexnet/verlib.py
Normal file
328
resources/lib/plexnet/verlib.py
Normal file
|
@ -0,0 +1,328 @@
|
|||
"""
|
||||
"Rational" version definition and parsing for DistutilsVersionFight
|
||||
discussion at PyCon 2009.
|
||||
"""
|
||||
import re
|
||||
|
||||
|
||||
class IrrationalVersionError(Exception):
|
||||
"""This is an irrational version."""
|
||||
pass
|
||||
|
||||
|
||||
class HugeMajorVersionNumError(IrrationalVersionError):
|
||||
"""An irrational version because the major version number is huge
|
||||
(often because a year or date was used).
|
||||
|
||||
See `error_on_huge_major_num` option in `NormalizedVersion` for details.
|
||||
This guard can be disabled by setting that option False.
|
||||
"""
|
||||
pass
|
||||
|
||||
# A marker used in the second and third parts of the `parts` tuple, for
|
||||
# versions that don't have those segments, to sort properly. An example
|
||||
# of versions in sort order ('highest' last):
|
||||
# 1.0b1 ((1,0), ('b',1), ('f',))
|
||||
# 1.0.dev345 ((1,0), ('f',), ('dev', 345))
|
||||
# 1.0 ((1,0), ('f',), ('f',))
|
||||
# 1.0.post256.dev345 ((1,0), ('f',), ('f', 'post', 256, 'dev', 345))
|
||||
# 1.0.post345 ((1,0), ('f',), ('f', 'post', 345, 'f'))
|
||||
# ^ ^ ^
|
||||
# 'b' < 'f' ---------------------/ | |
|
||||
# | |
|
||||
# 'dev' < 'f' < 'post' -------------------/ |
|
||||
# |
|
||||
# 'dev' < 'f' ----------------------------------------------/
|
||||
# Other letters would do, but 'f' for 'final' is kind of nice.
|
||||
FINAL_MARKER = ('f',)
|
||||
|
||||
VERSION_RE = re.compile(r'''
|
||||
^
|
||||
(?P<version>\d+\.\d+) # minimum 'N.N'
|
||||
(?P<extraversion>(?:\.\d+)*) # any number of extra '.N' segments
|
||||
(?:
|
||||
(?P<prerel>[abc]|rc) # 'a'=alpha, 'b'=beta, 'c'=release candidate
|
||||
# 'rc'= alias for release candidate
|
||||
(?P<prerelversion>\d+(?:\.\d+)*)
|
||||
)?
|
||||
(?P<postdev>(\.post(?P<post>\d+))?(\.dev(?P<dev>\d+))?)?
|
||||
$''', re.VERBOSE)
|
||||
|
||||
|
||||
class NormalizedVersion(object):
|
||||
"""A rational version.
|
||||
|
||||
Good:
|
||||
1.2 # equivalent to "1.2.0"
|
||||
1.2.0
|
||||
1.2a1
|
||||
1.2.3a2
|
||||
1.2.3b1
|
||||
1.2.3c1
|
||||
1.2.3.4
|
||||
TODO: fill this out
|
||||
|
||||
Bad:
|
||||
1 # mininum two numbers
|
||||
1.2a # release level must have a release serial
|
||||
1.2.3b
|
||||
"""
|
||||
|
||||
def __init__(self, s, error_on_huge_major_num=True):
|
||||
"""Create a NormalizedVersion instance from a version string.
|
||||
|
||||
@param s {str} The version string.
|
||||
@param error_on_huge_major_num {bool} Whether to consider an
|
||||
apparent use of a year or full date as the major version number
|
||||
an error. Default True. One of the observed patterns on PyPI before
|
||||
the introduction of `NormalizedVersion` was version numbers like this:
|
||||
2009.01.03
|
||||
20040603
|
||||
2005.01
|
||||
This guard is here to strongly encourage the package author to
|
||||
use an alternate version, because a release deployed into PyPI
|
||||
and, e.g. downstream Linux package managers, will forever remove
|
||||
the possibility of using a version number like "1.0" (i.e.
|
||||
where the major number is less than that huge major number).
|
||||
"""
|
||||
self._parse(s, error_on_huge_major_num)
|
||||
|
||||
@classmethod
|
||||
def from_parts(cls, version, prerelease=FINAL_MARKER,
|
||||
devpost=FINAL_MARKER):
|
||||
return cls(cls.parts_to_str((version, prerelease, devpost)))
|
||||
|
||||
def _parse(self, s, error_on_huge_major_num=True):
|
||||
"""Parses a string version into parts."""
|
||||
match = VERSION_RE.search(s)
|
||||
if not match:
|
||||
raise IrrationalVersionError(s)
|
||||
|
||||
groups = match.groupdict()
|
||||
parts = []
|
||||
|
||||
# main version
|
||||
block = self._parse_numdots(groups['version'], s, False, 2)
|
||||
extraversion = groups.get('extraversion')
|
||||
if extraversion not in ('', None):
|
||||
block += self._parse_numdots(extraversion[1:], s)
|
||||
parts.append(tuple(block))
|
||||
|
||||
# prerelease
|
||||
prerel = groups.get('prerel')
|
||||
if prerel is not None:
|
||||
block = [prerel]
|
||||
block += self._parse_numdots(groups.get('prerelversion'), s,
|
||||
pad_zeros_length=1)
|
||||
parts.append(tuple(block))
|
||||
else:
|
||||
parts.append(FINAL_MARKER)
|
||||
|
||||
# postdev
|
||||
if groups.get('postdev'):
|
||||
post = groups.get('post')
|
||||
dev = groups.get('dev')
|
||||
postdev = []
|
||||
if post is not None:
|
||||
postdev.extend([FINAL_MARKER[0], 'post', int(post)])
|
||||
if dev is None:
|
||||
postdev.append(FINAL_MARKER[0])
|
||||
if dev is not None:
|
||||
postdev.extend(['dev', int(dev)])
|
||||
parts.append(tuple(postdev))
|
||||
else:
|
||||
parts.append(FINAL_MARKER)
|
||||
self.parts = tuple(parts)
|
||||
if error_on_huge_major_num and self.parts[0][0] > 1980:
|
||||
raise HugeMajorVersionNumError("huge major version number, %r, "
|
||||
"which might cause future problems: %r" % (self.parts[0][0], s))
|
||||
|
||||
def _parse_numdots(self, s, full_ver_str, drop_trailing_zeros=True,
|
||||
pad_zeros_length=0):
|
||||
"""Parse 'N.N.N' sequences, return a list of ints.
|
||||
|
||||
@param s {str} 'N.N.N..." sequence to be parsed
|
||||
@param full_ver_str {str} The full version string from which this
|
||||
comes. Used for error strings.
|
||||
@param drop_trailing_zeros {bool} Whether to drop trailing zeros
|
||||
from the returned list. Default True.
|
||||
@param pad_zeros_length {int} The length to which to pad the
|
||||
returned list with zeros, if necessary. Default 0.
|
||||
"""
|
||||
nums = []
|
||||
for n in s.split("."):
|
||||
if len(n) > 1 and n[0] == '0':
|
||||
raise IrrationalVersionError("cannot have leading zero in "
|
||||
"version number segment: '%s' in %r" % (n, full_ver_str))
|
||||
nums.append(int(n))
|
||||
if drop_trailing_zeros:
|
||||
while nums and nums[-1] == 0:
|
||||
nums.pop()
|
||||
while len(nums) < pad_zeros_length:
|
||||
nums.append(0)
|
||||
return nums
|
||||
|
||||
def __str__(self):
|
||||
return self.parts_to_str(self.parts)
|
||||
|
||||
@classmethod
|
||||
def parts_to_str(cls, parts):
|
||||
"""Transforms a version expressed in tuple into its string
|
||||
representation."""
|
||||
# XXX This doesn't check for invalid tuples
|
||||
main, prerel, postdev = parts
|
||||
s = '.'.join(str(v) for v in main)
|
||||
if prerel is not FINAL_MARKER:
|
||||
s += prerel[0]
|
||||
s += '.'.join(str(v) for v in prerel[1:])
|
||||
if postdev and postdev is not FINAL_MARKER:
|
||||
if postdev[0] == 'f':
|
||||
postdev = postdev[1:]
|
||||
i = 0
|
||||
while i < len(postdev):
|
||||
if i % 2 == 0:
|
||||
s += '.'
|
||||
s += str(postdev[i])
|
||||
i += 1
|
||||
return s
|
||||
|
||||
def __repr__(self):
|
||||
return "%s('%s')" % (self.__class__.__name__, self)
|
||||
|
||||
def _cannot_compare(self, other):
|
||||
raise TypeError("cannot compare %s and %s"
|
||||
% (type(self).__name__, type(other).__name__))
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, NormalizedVersion):
|
||||
self._cannot_compare(other)
|
||||
return self.parts == other.parts
|
||||
|
||||
def __lt__(self, other):
|
||||
if not isinstance(other, NormalizedVersion):
|
||||
self._cannot_compare(other)
|
||||
return self.parts < other.parts
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def __gt__(self, other):
|
||||
return not (self.__lt__(other) or self.__eq__(other))
|
||||
|
||||
def __le__(self, other):
|
||||
return self.__eq__(other) or self.__lt__(other)
|
||||
|
||||
def __ge__(self, other):
|
||||
return self.__eq__(other) or self.__gt__(other)
|
||||
|
||||
|
||||
def suggest_normalized_version(s):
|
||||
"""Suggest a normalized version close to the given version string.
|
||||
|
||||
If you have a version string that isn't rational (i.e. NormalizedVersion
|
||||
doesn't like it) then you might be able to get an equivalent (or close)
|
||||
rational version from this function.
|
||||
|
||||
This does a number of simple normalizations to the given string, based
|
||||
on observation of versions currently in use on PyPI. Given a dump of
|
||||
those version during PyCon 2009, 4287 of them:
|
||||
- 2312 (53.93%) match NormalizedVersion without change
|
||||
- with the automatic suggestion
|
||||
- 3474 (81.04%) match when using this suggestion method
|
||||
|
||||
@param s {str} An irrational version string.
|
||||
@returns A rational version string, or None, if couldn't determine one.
|
||||
"""
|
||||
try:
|
||||
NormalizedVersion(s)
|
||||
return s # already rational
|
||||
except IrrationalVersionError:
|
||||
pass
|
||||
|
||||
rs = s.lower()
|
||||
|
||||
# part of this could use maketrans
|
||||
for orig, repl in (('-alpha', 'a'), ('-beta', 'b'), ('alpha', 'a'),
|
||||
('beta', 'b'), ('rc', 'c'), ('-final', ''),
|
||||
('-pre', 'c'),
|
||||
('-release', ''), ('.release', ''), ('-stable', ''),
|
||||
('+', '.'), ('_', '.'), (' ', ''), ('.final', ''),
|
||||
('final', '')):
|
||||
rs = rs.replace(orig, repl)
|
||||
|
||||
# if something ends with dev or pre, we add a 0
|
||||
rs = re.sub(r"pre$", r"pre0", rs)
|
||||
rs = re.sub(r"dev$", r"dev0", rs)
|
||||
|
||||
# if we have something like "b-2" or "a.2" at the end of the
|
||||
# version, that is pobably beta, alpha, etc
|
||||
# let's remove the dash or dot
|
||||
rs = re.sub(r"([abc|rc])[\-\.](\d+)$", r"\1\2", rs)
|
||||
|
||||
# 1.0-dev-r371 -> 1.0.dev371
|
||||
# 0.1-dev-r79 -> 0.1.dev79
|
||||
rs = re.sub(r"[\-\.](dev)[\-\.]?r?(\d+)$", r".\1\2", rs)
|
||||
|
||||
# Clean: 2.0.a.3, 2.0.b1, 0.9.0~c1
|
||||
rs = re.sub(r"[.~]?([abc])\.?", r"\1", rs)
|
||||
|
||||
# Clean: v0.3, v1.0
|
||||
if rs.startswith('v'):
|
||||
rs = rs[1:]
|
||||
|
||||
# Clean leading '0's on numbers.
|
||||
# TODO: unintended side-effect on, e.g., "2003.05.09"
|
||||
# PyPI stats: 77 (~2%) better
|
||||
rs = re.sub(r"\b0+(\d+)(?!\d)", r"\1", rs)
|
||||
|
||||
# Clean a/b/c with no version. E.g. "1.0a" -> "1.0a0". Setuptools infers
|
||||
# zero.
|
||||
# PyPI stats: 245 (7.56%) better
|
||||
rs = re.sub(r"(\d+[abc])$", r"\g<1>0", rs)
|
||||
|
||||
# the 'dev-rNNN' tag is a dev tag
|
||||
rs = re.sub(r"\.?(dev-r|dev\.r)\.?(\d+)$", r".dev\2", rs)
|
||||
|
||||
# clean the - when used as a pre delimiter
|
||||
rs = re.sub(r"-(a|b|c)(\d+)$", r"\1\2", rs)
|
||||
|
||||
# a terminal "dev" or "devel" can be changed into ".dev0"
|
||||
rs = re.sub(r"[\.\-](dev|devel)$", r".dev0", rs)
|
||||
|
||||
# a terminal "dev" can be changed into ".dev0"
|
||||
rs = re.sub(r"(?![\.\-])dev$", r".dev0", rs)
|
||||
|
||||
# a terminal "final" or "stable" can be removed
|
||||
rs = re.sub(r"(final|stable)$", "", rs)
|
||||
|
||||
# The 'r' and the '-' tags are post release tags
|
||||
# 0.4a1.r10 -> 0.4a1.post10
|
||||
# 0.9.33-17222 -> 0.9.3.post17222
|
||||
# 0.9.33-r17222 -> 0.9.3.post17222
|
||||
rs = re.sub(r"\.?(r|-|-r)\.?(\d+)$", r".post\2", rs)
|
||||
|
||||
# Clean 'r' instead of 'dev' usage:
|
||||
# 0.9.33+r17222 -> 0.9.3.dev17222
|
||||
# 1.0dev123 -> 1.0.dev123
|
||||
# 1.0.git123 -> 1.0.dev123
|
||||
# 1.0.bzr123 -> 1.0.dev123
|
||||
# 0.1a0dev.123 -> 0.1a0.dev123
|
||||
# PyPI stats: ~150 (~4%) better
|
||||
rs = re.sub(r"\.?(dev|git|bzr)\.?(\d+)$", r".dev\2", rs)
|
||||
|
||||
# Clean '.pre' (normalized from '-pre' above) instead of 'c' usage:
|
||||
# 0.2.pre1 -> 0.2c1
|
||||
# 0.2-c1 -> 0.2c1
|
||||
# 1.0preview123 -> 1.0c123
|
||||
# PyPI stats: ~21 (0.62%) better
|
||||
rs = re.sub(r"\.?(pre|preview|-c)(\d+)$", r"c\g<2>", rs)
|
||||
|
||||
# Tcl/Tk uses "px" for their post release markers
|
||||
rs = re.sub(r"p(\d+)$", r".post\1", rs)
|
||||
|
||||
try:
|
||||
NormalizedVersion(rs)
|
||||
return rs # already rational
|
||||
except IrrationalVersionError:
|
||||
pass
|
||||
return None
|
474
resources/lib/plexnet/video.py
Normal file
474
resources/lib/plexnet/video.py
Normal file
|
@ -0,0 +1,474 @@
|
|||
import plexobjects
|
||||
import media
|
||||
import plexmedia
|
||||
import plexstream
|
||||
import exceptions
|
||||
import compat
|
||||
import plexlibrary
|
||||
import util
|
||||
|
||||
|
||||
class PlexVideoItemList(plexobjects.PlexItemList):
|
||||
def __init__(self, data, initpath=None, server=None, container=None):
|
||||
self._data = data
|
||||
self._initpath = initpath
|
||||
self._server = server
|
||||
self._container = container
|
||||
self._items = None
|
||||
|
||||
@property
|
||||
def items(self):
|
||||
if self._items is None:
|
||||
if self._data is not None:
|
||||
self._items = [plexobjects.buildItem(self._server, elem, self._initpath, container=self._container) for elem in self._data]
|
||||
else:
|
||||
self._items = []
|
||||
|
||||
return self._items
|
||||
|
||||
|
||||
class Video(media.MediaItem):
|
||||
TYPE = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._settings = None
|
||||
media.MediaItem.__init__(self, *args, **kwargs)
|
||||
|
||||
def __eq__(self, other):
|
||||
return other and self.ratingKey == other.ratingKey
|
||||
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
@property
|
||||
def settings(self):
|
||||
if not self._settings:
|
||||
import plexapp
|
||||
self._settings = plexapp.PlayerSettingsInterface()
|
||||
|
||||
return self._settings
|
||||
|
||||
@settings.setter
|
||||
def settings(self, value):
|
||||
self._settings = value
|
||||
|
||||
def selectedAudioStream(self):
|
||||
if self.audioStreams:
|
||||
for stream in self.audioStreams:
|
||||
if stream.isSelected():
|
||||
return stream
|
||||
return None
|
||||
|
||||
def selectedSubtitleStream(self):
|
||||
if self.subtitleStreams:
|
||||
for stream in self.subtitleStreams:
|
||||
if stream.isSelected():
|
||||
return stream
|
||||
return None
|
||||
|
||||
def selectStream(self, stream, async=True):
|
||||
self.mediaChoice.part.setSelectedStream(stream.streamType.asInt(), stream.id, async)
|
||||
|
||||
def isVideoItem(self):
|
||||
return True
|
||||
|
||||
def _findStreams(self, streamtype):
|
||||
idx = 0
|
||||
streams = []
|
||||
for media_ in self.media():
|
||||
for part in media_.parts:
|
||||
for stream in part.streams:
|
||||
if stream.streamType.asInt() == streamtype:
|
||||
stream.typeIndex = idx
|
||||
streams.append(stream)
|
||||
idx += 1
|
||||
return streams
|
||||
|
||||
def analyze(self):
|
||||
""" The primary purpose of media analysis is to gather information about that media
|
||||
item. All of the media you add to a Library has properties that are useful to
|
||||
know - whether it's a video file, a music track, or one of your photos.
|
||||
"""
|
||||
self.server.query('/%s/analyze' % self.key)
|
||||
|
||||
def markWatched(self):
|
||||
path = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||
self.server.query(path)
|
||||
self.reload()
|
||||
|
||||
def markUnwatched(self):
|
||||
path = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
|
||||
self.server.query(path)
|
||||
self.reload()
|
||||
|
||||
# def play(self, client):
|
||||
# client.playMedia(self)
|
||||
|
||||
def refresh(self):
|
||||
self.server.query('%s/refresh' % self.key, method=self.server.session.put)
|
||||
|
||||
def _getStreamURL(self, **params):
|
||||
if self.TYPE not in ('movie', 'episode', 'track'):
|
||||
raise exceptions.Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
|
||||
mvb = params.get('maxVideoBitrate')
|
||||
vr = params.get('videoResolution')
|
||||
|
||||
# import plexapp
|
||||
|
||||
params = {
|
||||
'path': self.key,
|
||||
'offset': params.get('offset', 0),
|
||||
'copyts': params.get('copyts', 1),
|
||||
'protocol': params.get('protocol', 'hls'),
|
||||
'mediaIndex': params.get('mediaIndex', 0),
|
||||
'directStream': '1',
|
||||
'directPlay': '0',
|
||||
'X-Plex-Platform': params.get('platform', 'Chrome'),
|
||||
# 'X-Plex-Platform': params.get('platform', plexapp.INTERFACE.getGlobal('platform')),
|
||||
'maxVideoBitrate': max(mvb, 64) if mvb else None,
|
||||
'videoResolution': '{0}x{1}'.format(*vr) if vr else None
|
||||
}
|
||||
|
||||
final = {}
|
||||
|
||||
for k, v in params.items():
|
||||
if v is not None: # remove None values
|
||||
final[k] = v
|
||||
|
||||
streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video'
|
||||
server = self.getTranscodeServer(True, self.TYPE)
|
||||
|
||||
return server.buildUrl('/{0}/:/transcode/universal/start.m3u8?{1}'.format(streamtype, compat.urlencode(final)), includeToken=True)
|
||||
# path = "/video/:/transcode/universal/" + command + "?session=" + AppSettings().GetGlobal("clientIdentifier")
|
||||
|
||||
def resolutionString(self):
|
||||
res = self.media[0].videoResolution
|
||||
if not res:
|
||||
return ''
|
||||
|
||||
if res.isdigit():
|
||||
return '{0}p'.format(self.media[0].videoResolution)
|
||||
else:
|
||||
return res.upper()
|
||||
|
||||
def audioCodecString(self):
|
||||
codec = (self.media[0].audioCodec or '').lower()
|
||||
|
||||
if codec in ('dca', 'dca-ma', 'dts-hd', 'dts-es', 'dts-hra'):
|
||||
codec = "DTS"
|
||||
else:
|
||||
codec = codec.upper()
|
||||
|
||||
return codec
|
||||
|
||||
def audioChannelsString(self, translate_func=util.dummyTranslate):
|
||||
channels = self.media[0].audioChannels.asInt()
|
||||
|
||||
if channels == 1:
|
||||
return translate_func("Mono")
|
||||
elif channels == 2:
|
||||
return translate_func("Stereo")
|
||||
elif channels > 0:
|
||||
return "{0}.1".format(channels - 1)
|
||||
else:
|
||||
return ""
|
||||
|
||||
def available(self):
|
||||
return self.media()[0].isAccessible()
|
||||
|
||||
|
||||
class PlayableVideo(Video):
|
||||
TYPE = None
|
||||
|
||||
def _setData(self, data):
|
||||
Video._setData(self, data)
|
||||
if self.isFullObject():
|
||||
self.extras = PlexVideoItemList(data.find('Extras'), initpath=self.initpath, server=self.server, container=self)
|
||||
|
||||
def reload(self, *args, **kwargs):
|
||||
if not kwargs.get('_soft'):
|
||||
if self.get('viewCount'):
|
||||
del self.viewCount
|
||||
if self.get('viewOffset'):
|
||||
del self.viewOffset
|
||||
Video.reload(self, *args, **kwargs)
|
||||
return self
|
||||
|
||||
def postPlay(self, **params):
|
||||
query = '/hubs/metadata/{0}/postplay'.format(self.ratingKey)
|
||||
data = self.server.query(query, params=params)
|
||||
container = plexobjects.PlexContainer(data, initpath=query, server=self.server, address=query)
|
||||
|
||||
hubs = {}
|
||||
for elem in data:
|
||||
hub = plexlibrary.Hub(elem, server=self.server, container=container)
|
||||
hubs[hub.hubIdentifier] = hub
|
||||
return hubs
|
||||
|
||||
|
||||
@plexobjects.registerLibType
|
||||
class Movie(PlayableVideo):
|
||||
TYPE = 'movie'
|
||||
|
||||
def _setData(self, data):
|
||||
PlayableVideo._setData(self, data)
|
||||
if self.isFullObject():
|
||||
self.collections = plexobjects.PlexItemList(data, media.Collection, media.Collection.TYPE, server=self.server)
|
||||
self.countries = plexobjects.PlexItemList(data, media.Country, media.Country.TYPE, server=self.server)
|
||||
self.directors = plexobjects.PlexItemList(data, media.Director, media.Director.TYPE, server=self.server)
|
||||
self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server)
|
||||
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||
self.producers = plexobjects.PlexItemList(data, media.Producer, media.Producer.TYPE, server=self.server)
|
||||
self.roles = plexobjects.PlexItemList(data, media.Role, media.Role.TYPE, server=self.server, container=self.container)
|
||||
self.writers = plexobjects.PlexItemList(data, media.Writer, media.Writer.TYPE, server=self.server)
|
||||
self.related = plexobjects.PlexItemList(data.find('Related'), plexlibrary.Hub, plexlibrary.Hub.TYPE, server=self.server, container=self)
|
||||
else:
|
||||
if data.find(media.Media.TYPE) is not None:
|
||||
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||
|
||||
self._videoStreams = None
|
||||
self._audioStreams = None
|
||||
self._subtitleStreams = None
|
||||
|
||||
# data for active sessions
|
||||
self.sessionKey = plexobjects.PlexValue(data.attrib.get('sessionKey', ''), self)
|
||||
self.user = self._findUser(data)
|
||||
self.player = self._findPlayer(data)
|
||||
self.transcodeSession = self._findTranscodeSession(data)
|
||||
|
||||
@property
|
||||
def maxHeight(self):
|
||||
height = 0
|
||||
for m in self.media:
|
||||
if m.height.asInt() > height:
|
||||
height = m.height.asInt()
|
||||
return height
|
||||
|
||||
@property
|
||||
def videoStreams(self):
|
||||
if self._videoStreams is None:
|
||||
self._videoStreams = self._findStreams(plexstream.PlexStream.TYPE_VIDEO)
|
||||
return self._videoStreams
|
||||
|
||||
@property
|
||||
def audioStreams(self):
|
||||
if self._audioStreams is None:
|
||||
self._audioStreams = self._findStreams(plexstream.PlexStream.TYPE_AUDIO)
|
||||
return self._audioStreams
|
||||
|
||||
@property
|
||||
def subtitleStreams(self):
|
||||
if self._subtitleStreams is None:
|
||||
self._subtitleStreams = self._findStreams(plexstream.PlexStream.TYPE_SUBTITLE)
|
||||
return self._subtitleStreams
|
||||
|
||||
@property
|
||||
def actors(self):
|
||||
return self.roles
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
return self.get('viewCount').asInt() > 0
|
||||
|
||||
def getStreamURL(self, **params):
|
||||
return self._getStreamURL(**params)
|
||||
|
||||
|
||||
@plexobjects.registerLibType
|
||||
class Show(Video):
|
||||
TYPE = 'show'
|
||||
|
||||
def _setData(self, data):
|
||||
Video._setData(self, data)
|
||||
if self.isFullObject():
|
||||
self.genres = plexobjects.PlexItemList(data, media.Genre, media.Genre.TYPE, server=self.server)
|
||||
self.roles = plexobjects.PlexItemList(data, media.Role, media.Role.TYPE, server=self.server, container=self.container)
|
||||
self.related = plexobjects.PlexItemList(data.find('Related'), plexlibrary.Hub, plexlibrary.Hub.TYPE, server=self.server, container=self)
|
||||
self.extras = PlexVideoItemList(data.find('Extras'), initpath=self.initpath, server=self.server, container=self)
|
||||
|
||||
@property
|
||||
def unViewedLeafCount(self):
|
||||
return self.leafCount.asInt() - self.viewedLeafCount.asInt()
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
return self.viewedLeafCount == self.leafCount
|
||||
|
||||
def seasons(self):
|
||||
path = self.key
|
||||
return plexobjects.listItems(self.server, path, Season.TYPE)
|
||||
|
||||
def season(self, title):
|
||||
path = self.key
|
||||
return plexobjects.findItem(self.server, path, title)
|
||||
|
||||
def episodes(self, watched=None):
|
||||
leavesKey = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return plexobjects.listItems(self.server, leavesKey, watched=watched)
|
||||
|
||||
def episode(self, title):
|
||||
path = '/library/metadata/%s/allLeaves' % self.ratingKey
|
||||
return plexobjects.findItem(self.server, path, title)
|
||||
|
||||
def all(self):
|
||||
return self.episodes()
|
||||
|
||||
def watched(self):
|
||||
return self.episodes(watched=True)
|
||||
|
||||
def unwatched(self):
|
||||
return self.episodes(watched=False)
|
||||
|
||||
def refresh(self):
|
||||
self.server.query('/library/metadata/%s/refresh' % self.ratingKey)
|
||||
|
||||
def sectionOnDeck(self):
|
||||
query = '/library/sections/{0}/onDeck'.format(self.getLibrarySectionId())
|
||||
return plexobjects.listItems(self.server, query)
|
||||
|
||||
|
||||
@plexobjects.registerLibType
|
||||
class Season(Video):
|
||||
TYPE = 'season'
|
||||
|
||||
def _setData(self, data):
|
||||
Video._setData(self, data)
|
||||
if self.isFullObject():
|
||||
self.extras = PlexVideoItemList(data.find('Extras'), initpath=self.initpath, server=self.server, container=self)
|
||||
|
||||
@property
|
||||
def defaultTitle(self):
|
||||
return self.parentTitle or self.title
|
||||
|
||||
@property
|
||||
def unViewedLeafCount(self):
|
||||
return self.leafCount.asInt() - self.viewedLeafCount.asInt()
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
return self.viewedLeafCount == self.leafCount
|
||||
|
||||
def episodes(self, watched=None):
|
||||
path = self.key
|
||||
return plexobjects.listItems(self.server, path, watched=watched)
|
||||
|
||||
def episode(self, title):
|
||||
path = self.key
|
||||
return plexobjects.findItem(self.server, path, title)
|
||||
|
||||
def all(self):
|
||||
return self.episodes()
|
||||
|
||||
def show(self):
|
||||
return plexobjects.listItems(self.server, self.parentKey)[0]
|
||||
|
||||
def watched(self):
|
||||
return self.episodes(watched=True)
|
||||
|
||||
def unwatched(self):
|
||||
return self.episodes(watched=False)
|
||||
|
||||
|
||||
@plexobjects.registerLibType
|
||||
class Episode(PlayableVideo):
|
||||
TYPE = 'episode'
|
||||
|
||||
def init(self, data):
|
||||
self._show = None
|
||||
self._season = None
|
||||
|
||||
def _setData(self, data):
|
||||
PlayableVideo._setData(self, data)
|
||||
if self.isFullObject():
|
||||
self.directors = plexobjects.PlexItemList(data, media.Director, media.Director.TYPE, server=self.server)
|
||||
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||
self.writers = plexobjects.PlexItemList(data, media.Writer, media.Writer.TYPE, server=self.server)
|
||||
else:
|
||||
if data.find(media.Media.TYPE) is not None:
|
||||
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||
|
||||
self._videoStreams = None
|
||||
self._audioStreams = None
|
||||
self._subtitleStreams = None
|
||||
|
||||
# data for active sessions
|
||||
self.sessionKey = plexobjects.PlexValue(data.attrib.get('sessionKey', ''), self)
|
||||
self.user = self._findUser(data)
|
||||
self.player = self._findPlayer(data)
|
||||
self.transcodeSession = self._findTranscodeSession(data)
|
||||
|
||||
@property
|
||||
def defaultTitle(self):
|
||||
return self.grandparentTitle or self.parentTitle or self.title
|
||||
|
||||
@property
|
||||
def defaultThumb(self):
|
||||
return self.grandparentThumb or self.parentThumb or self.thumb
|
||||
|
||||
@property
|
||||
def videoStreams(self):
|
||||
if self._videoStreams is None:
|
||||
self._videoStreams = self._findStreams(plexstream.PlexStream.TYPE_VIDEO)
|
||||
return self._videoStreams
|
||||
|
||||
@property
|
||||
def audioStreams(self):
|
||||
if self._audioStreams is None:
|
||||
self._audioStreams = self._findStreams(plexstream.PlexStream.TYPE_AUDIO)
|
||||
return self._audioStreams
|
||||
|
||||
@property
|
||||
def subtitleStreams(self):
|
||||
if self._subtitleStreams is None:
|
||||
self._subtitleStreams = self._findStreams(plexstream.PlexStream.TYPE_SUBTITLE)
|
||||
return self._subtitleStreams
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
return self.get('viewCount').asInt() > 0
|
||||
|
||||
def getStreamURL(self, **params):
|
||||
return self._getStreamURL(**params)
|
||||
|
||||
def season(self):
|
||||
if not self._season:
|
||||
self._season = plexobjects.listItems(self.server, self.parentKey)[0]
|
||||
return self._season
|
||||
|
||||
def show(self):
|
||||
if not self._show:
|
||||
self._show = plexobjects.listItems(self.server, self.grandparentKey)[0]
|
||||
return self._show
|
||||
|
||||
@property
|
||||
def genres(self):
|
||||
return self.show().genres
|
||||
|
||||
@property
|
||||
def roles(self):
|
||||
return self.show().roles
|
||||
|
||||
@property
|
||||
def related(self):
|
||||
self.show().reload(_soft=True, includeRelated=1, includeRelatedCount=10)
|
||||
return self.show().related
|
||||
|
||||
|
||||
@plexobjects.registerLibType
|
||||
class Clip(PlayableVideo):
|
||||
TYPE = 'clip'
|
||||
|
||||
def _setData(self, data):
|
||||
PlayableVideo._setData(self, data)
|
||||
if self.isFullObject():
|
||||
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||
else:
|
||||
if data.find(media.Media.TYPE) is not None:
|
||||
self.media = plexobjects.PlexMediaItemList(data, plexmedia.PlexMedia, media.Media.TYPE, initpath=self.initpath, server=self.server, media=self)
|
||||
|
||||
@property
|
||||
def isWatched(self):
|
||||
return self.get('viewCount').asInt() > 0
|
||||
|
||||
def getStreamURL(self, **params):
|
||||
return self._getStreamURL(**params)
|
Loading…
Reference in a new issue