diff --git a/resources/lib/plexnet/__init__.py b/resources/lib/plexnet/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/resources/lib/plexnet/asyncadapter.py b/resources/lib/plexnet/asyncadapter.py new file mode 100644 index 00000000..b3b6b0ff --- /dev/null +++ b/resources/lib/plexnet/asyncadapter.py @@ -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 `. + + :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 `. + + :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() diff --git a/resources/lib/plexnet/audio.py b/resources/lib/plexnet/audio.py new file mode 100644 index 00000000..0a1c8cca --- /dev/null +++ b/resources/lib/plexnet/audio.py @@ -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') diff --git a/resources/lib/plexnet/audioobject.py b/resources/lib/plexnet/audioobject.py new file mode 100644 index 00000000..a032f430 --- /dev/null +++ b/resources/lib/plexnet/audioobject.py @@ -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() diff --git a/resources/lib/plexnet/callback.py b/resources/lib/plexnet/callback.py new file mode 100644 index 00000000..276f969d --- /dev/null +++ b/resources/lib/plexnet/callback.py @@ -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 ''.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() diff --git a/resources/lib/plexnet/captions.py b/resources/lib/plexnet/captions.py new file mode 100644 index 00000000..b0b44643 --- /dev/null +++ b/resources/lib/plexnet/captions.py @@ -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() diff --git a/resources/lib/plexnet/compat.py b/resources/lib/plexnet/compat.py new file mode 100644 index 00000000..134d6a73 --- /dev/null +++ b/resources/lib/plexnet/compat.py @@ -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 diff --git a/resources/lib/plexnet/exceptions.py b/resources/lib/plexnet/exceptions.py new file mode 100644 index 00000000..fc7dda9b --- /dev/null +++ b/resources/lib/plexnet/exceptions.py @@ -0,0 +1,18 @@ +class BadRequest(Exception): + pass + + +class NotFound(Exception): + pass + + +class UnknownType(Exception): + pass + + +class Unsupported(Exception): + pass + + +class Unauthorized(Exception): + pass diff --git a/resources/lib/plexnet/gdm.py b/resources/lib/plexnet/gdm.py new file mode 100644 index 00000000..006ebe4d --- /dev/null +++ b/resources/lib/plexnet/gdm.py @@ -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)) + +''' diff --git a/resources/lib/plexnet/http.py b/resources/lib/plexnet/http.py new file mode 100644 index 00000000..8fc4fac9 --- /dev/null +++ b/resources/lib/plexnet/http.py @@ -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 diff --git a/resources/lib/plexnet/locks.py b/resources/lib/plexnet/locks.py new file mode 100644 index 00000000..678fee73 --- /dev/null +++ b/resources/lib/plexnet/locks.py @@ -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() diff --git a/resources/lib/plexnet/media.py b/resources/lib/plexnet/media.py new file mode 100644 index 00000000..9db7b72a --- /dev/null +++ b/resources/lib/plexnet/media.py @@ -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' diff --git a/resources/lib/plexnet/mediachoice.py b/resources/lib/plexnet/mediachoice.py new file mode 100644 index 00000000..ae6c71c5 --- /dev/null +++ b/resources/lib/plexnet/mediachoice.py @@ -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__() diff --git a/resources/lib/plexnet/mediadecisionengine.py b/resources/lib/plexnet/mediadecisionengine.py new file mode 100644 index 00000000..9cd64fa0 --- /dev/null +++ b/resources/lib/plexnet/mediadecisionengine.py @@ -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 diff --git a/resources/lib/plexnet/myplex.py b/resources/lib/plexnet/myplex.py new file mode 100644 index 00000000..43b42d84 --- /dev/null +++ b/resources/lib/plexnet/myplex.py @@ -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 diff --git a/resources/lib/plexnet/myplexaccount.py b/resources/lib/plexnet/myplexaccount.py new file mode 100644 index 00000000..30e81933 --- /dev/null +++ b/resources/lib/plexnet/myplexaccount.py @@ -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() diff --git a/resources/lib/plexnet/myplexmanager.py b/resources/lib/plexnet/myplexmanager.py new file mode 100644 index 00000000..c589e5e4 --- /dev/null +++ b/resources/lib/plexnet/myplexmanager.py @@ -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() diff --git a/resources/lib/plexnet/myplexrequest.py b/resources/lib/plexnet/myplexrequest.py new file mode 100644 index 00000000..7478bf9c --- /dev/null +++ b/resources/lib/plexnet/myplexrequest.py @@ -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") diff --git a/resources/lib/plexnet/myplexserver.py b/resources/lib/plexnet/myplexserver.py new file mode 100644 index 00000000..2e5f0f53 --- /dev/null +++ b/resources/lib/plexnet/myplexserver.py @@ -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) diff --git a/resources/lib/plexnet/netif/__init__.py b/resources/lib/plexnet/netif/__init__.py new file mode 100644 index 00000000..917888d8 --- /dev/null +++ b/resources/lib/plexnet/netif/__init__.py @@ -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 '' + 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 \ No newline at end of file diff --git a/resources/lib/plexnet/netif/getifaddrs.py b/resources/lib/plexnet/netif/getifaddrs.py new file mode 100644 index 00000000..0cfa21e6 --- /dev/null +++ b/resources/lib/plexnet/netif/getifaddrs.py @@ -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_')] \ No newline at end of file diff --git a/resources/lib/plexnet/netif/ipconfig.py b/resources/lib/plexnet/netif/ipconfig.py new file mode 100644 index 00000000..bec632fb --- /dev/null +++ b/resources/lib/plexnet/netif/ipconfig.py @@ -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 \ No newline at end of file diff --git a/resources/lib/plexnet/nowplayingmanager.py b/resources/lib/plexnet/nowplayingmanager.py new file mode 100644 index 00000000..1560c5a6 --- /dev/null +++ b/resources/lib/plexnet/nowplayingmanager.py @@ -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) diff --git a/resources/lib/plexnet/photo.py b/resources/lib/plexnet/photo.py new file mode 100644 index 00000000..e2036a8c --- /dev/null +++ b/resources/lib/plexnet/photo.py @@ -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) diff --git a/resources/lib/plexnet/playlist.py b/resources/lib/plexnet/playlist.py new file mode 100644 index 00000000..2130e6e7 --- /dev/null +++ b/resources/lib/plexnet/playlist.py @@ -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 diff --git a/resources/lib/plexnet/playqueue.py b/resources/lib/plexnet/playqueue.py new file mode 100644 index 00000000..ccf2630c --- /dev/null +++ b/resources/lib/plexnet/playqueue.py @@ -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 diff --git a/resources/lib/plexnet/plexapp.py b/resources/lib/plexnet/plexapp.py new file mode 100644 index 00000000..089c05c0 --- /dev/null +++ b/resources/lib/plexnet/plexapp.py @@ -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()) diff --git a/resources/lib/plexnet/plexconnection.py b/resources/lib/plexnet/plexconnection.py new file mode 100644 index 00000000..6fe75095 --- /dev/null +++ b/resources/lib/plexnet/plexconnection.py @@ -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 diff --git a/resources/lib/plexnet/plexlibrary.py b/resources/lib/plexnet/plexlibrary.py new file mode 100644 index 00000000..b8a1f8a6 --- /dev/null +++ b/resources/lib/plexnet/plexlibrary.py @@ -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 ''.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= 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 + 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 +} diff --git a/resources/lib/plexnet/plexmedia.py b/resources/lib/plexnet/plexmedia.py new file mode 100644 index 00000000..841da542 --- /dev/null +++ b/resources/lib/plexnet/plexmedia.py @@ -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 diff --git a/resources/lib/plexnet/plexobjects.py b/resources/lib/plexnet/plexobjects.py new file mode 100644 index 00000000..80e8e6aa --- /dev/null +++ b/resources/lib/plexnet/plexobjects.py @@ -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 diff --git a/resources/lib/plexnet/plexpart.py b/resources/lib/plexnet/plexpart.py new file mode 100644 index 00000000..4cd4add2 --- /dev/null +++ b/resources/lib/plexnet/plexpart.py @@ -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 diff --git a/resources/lib/plexnet/plexplayer.py b/resources/lib/plexnet/plexplayer.py new file mode 100644 index 00000000..a4cc0abb --- /dev/null +++ b/resources/lib/plexnet/plexplayer.py @@ -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 diff --git a/resources/lib/plexnet/plexrequest.py b/resources/lib/plexnet/plexrequest.py new file mode 100644 index 00000000..41df8a87 --- /dev/null +++ b/resources/lib/plexnet/plexrequest.py @@ -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) diff --git a/resources/lib/plexnet/plexresource.py b/resources/lib/plexnet/plexresource.py new file mode 100644 index 00000000..de8fefdb --- /dev/null +++ b/resources/lib/plexnet/plexresource.py @@ -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)) diff --git a/resources/lib/plexnet/plexresult.py b/resources/lib/plexnet/plexresult.py new file mode 100644 index 00000000..66847526 --- /dev/null +++ b/resources/lib/plexnet/plexresult.py @@ -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 diff --git a/resources/lib/plexnet/plexserver.py b/resources/lib/plexnet/plexserver.py new file mode 100644 index 00000000..fe6a9b1f --- /dev/null +++ b/resources/lib/plexnet/plexserver.py @@ -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 "".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 diff --git a/resources/lib/plexnet/plexservermanager.py b/resources/lib/plexnet/plexservermanager.py new file mode 100644 index 00000000..fba42cd6 --- /dev/null +++ b/resources/lib/plexnet/plexservermanager.py @@ -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() diff --git a/resources/lib/plexnet/plexstream.py b/resources/lib/plexnet/plexstream.py new file mode 100644 index 00000000..1e67add4 --- /dev/null +++ b/resources/lib/plexnet/plexstream.py @@ -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") diff --git a/resources/lib/plexnet/serverdecision.py b/resources/lib/plexnet/serverdecision.py new file mode 100644 index 00000000..8682106a --- /dev/null +++ b/resources/lib/plexnet/serverdecision.py @@ -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 diff --git a/resources/lib/plexnet/signalslot/__init__.py b/resources/lib/plexnet/signalslot/__init__.py new file mode 100644 index 00000000..6adc7832 --- /dev/null +++ b/resources/lib/plexnet/signalslot/__init__.py @@ -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' diff --git a/resources/lib/plexnet/signalslot/contrib/__init__.py b/resources/lib/plexnet/signalslot/contrib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/resources/lib/plexnet/signalslot/contrib/task/__init__.py b/resources/lib/plexnet/signalslot/contrib/task/__init__.py new file mode 100644 index 00000000..280e6a2a --- /dev/null +++ b/resources/lib/plexnet/signalslot/contrib/task/__init__.py @@ -0,0 +1 @@ +from .task import Task diff --git a/resources/lib/plexnet/signalslot/contrib/task/task.py b/resources/lib/plexnet/signalslot/contrib/task/task.py new file mode 100644 index 00000000..43148e5e --- /dev/null +++ b/resources/lib/plexnet/signalslot/contrib/task/task.py @@ -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) diff --git a/resources/lib/plexnet/signalslot/contrib/task/test.py b/resources/lib/plexnet/signalslot/contrib/task/test.py new file mode 100644 index 00000000..3d816944 --- /dev/null +++ b/resources/lib/plexnet/signalslot/contrib/task/test.py @@ -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 diff --git a/resources/lib/plexnet/signalslot/exceptions.py b/resources/lib/plexnet/signalslot/exceptions.py new file mode 100644 index 00000000..272b1b34 --- /dev/null +++ b/resources/lib/plexnet/signalslot/exceptions.py @@ -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) diff --git a/resources/lib/plexnet/signalslot/signal.py b/resources/lib/plexnet/signalslot/signal.py new file mode 100644 index 00000000..ea828ddf --- /dev/null +++ b/resources/lib/plexnet/signalslot/signal.py @@ -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 '' % (self.name or 'NO_NAME') diff --git a/resources/lib/plexnet/signalslot/slot.py b/resources/lib/plexnet/signalslot/slot.py new file mode 100644 index 00000000..e7aab17b --- /dev/null +++ b/resources/lib/plexnet/signalslot/slot.py @@ -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 '' % fn diff --git a/resources/lib/plexnet/signalslot/tests.py b/resources/lib/plexnet/signalslot/tests.py new file mode 100644 index 00000000..b2b91864 --- /dev/null +++ b/resources/lib/plexnet/signalslot/tests.py @@ -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) == '' + + +def test_named_signal_has_a_nice_repr(): + signal = Signal(name='update_stuff') + assert repr(signal) == '' + + +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) == '' % 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) == '' + 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 diff --git a/resources/lib/plexnet/signalsmixin.py b/resources/lib/plexnet/signalsmixin.py new file mode 100644 index 00000000..072341ac --- /dev/null +++ b/resources/lib/plexnet/signalsmixin.py @@ -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) diff --git a/resources/lib/plexnet/simpleobjects.py b/resources/lib/plexnet/simpleobjects.py new file mode 100644 index 00000000..97797b84 --- /dev/null +++ b/resources/lib/plexnet/simpleobjects.py @@ -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')) diff --git a/resources/lib/plexnet/threadutils.py b/resources/lib/plexnet/threadutils.py new file mode 100644 index 00000000..38718744 --- /dev/null +++ b/resources/lib/plexnet/threadutils.py @@ -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() diff --git a/resources/lib/plexnet/util.py b/resources/lib/plexnet/util.py new file mode 100644 index 00000000..143d5760 --- /dev/null +++ b/resources/lib/plexnet/util.py @@ -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')) diff --git a/resources/lib/plexnet/verlib.py b/resources/lib/plexnet/verlib.py new file mode 100644 index 00000000..769d8f7e --- /dev/null +++ b/resources/lib/plexnet/verlib.py @@ -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\d+\.\d+) # minimum 'N.N' + (?P(?:\.\d+)*) # any number of extra '.N' segments + (?: + (?P[abc]|rc) # 'a'=alpha, 'b'=beta, 'c'=release candidate + # 'rc'= alias for release candidate + (?P\d+(?:\.\d+)*) + )? + (?P(\.post(?P\d+))?(\.dev(?P\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 diff --git a/resources/lib/plexnet/video.py b/resources/lib/plexnet/video.py new file mode 100644 index 00000000..a7a17f62 --- /dev/null +++ b/resources/lib/plexnet/video.py @@ -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)