From 59040f3b3e7b926b1073e6ba836159a3f8330fcb Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 30 Sep 2018 13:16:48 +0200 Subject: [PATCH] First attempt at server select dialog --- resources/lib/plex_functions.py | 124 ++++------ resources/lib/plexapi/__init__.py | 1 + resources/lib/plexapi/base.py | 126 ++++++++++ resources/lib/utils.py | 40 ++++ resources/lib/windows/server_select.py | 224 ++++++++++++++++++ .../Main/1080i/script-plex-server_select.xml | 156 ++++++++++++ 6 files changed, 599 insertions(+), 72 deletions(-) create mode 100644 resources/lib/plexapi/__init__.py create mode 100644 resources/lib/plexapi/base.py create mode 100644 resources/lib/windows/server_select.py create mode 100644 resources/skins/Main/1080i/script-plex-server_select.xml diff --git a/resources/lib/plex_functions.py b/resources/lib/plex_functions.py index f9a5d9f9..80b672fc 100644 --- a/resources/lib/plex_functions.py +++ b/resources/lib/plex_functions.py @@ -11,9 +11,8 @@ from threading import Thread from xbmc import sleep from .downloadutils import DownloadUtils as DU -from . import utils -from . import plex_tv -from . import variables as v +from .plexapi.base import PlexServer, Connection +from . import utils, plex_tv, variables as v ############################################################################### LOG = getLogger('PLEX.plex_functions') @@ -193,7 +192,7 @@ def discover_pms(token=None): """ LOG.info('Start discovery of Plex Media Servers') # Look first for local PMS in the LAN - local_pms_list = _plex_gdm() + local_pms_list = plex_gdm() LOG.debug('PMS found in the local LAN using Plex GDM: %s', local_pms_list) # Get PMS from plex.tv if token: @@ -236,7 +235,7 @@ def _log_pms(pms_list): LOG.debug('Found the following PMS: %s', log_list) -def _plex_gdm(): +def plex_gdm(): """ PlexGDM - looks for PMS in the local LAN and returns a list of the PMS found """ @@ -278,44 +277,54 @@ def _plex_gdm(): # Check if we had a positive HTTP response if '200 OK' not in response['data']: continue - pms = { - 'ip': response['from'][0], - 'scheme': None, - 'local': True, # Since we found it using GDM - 'product': None, - 'baseURL': None, - 'name': None, - 'version': None, - 'token': None, - 'ownername': None, - 'device': None, - 'platform': None, - 'owned': None, - 'relay': None, - 'presence': True, # Since we're talking to the PMS - 'httpsRequired': None, - } + connection = Connection(local=True) + # Local LAN IP from GDM + connection.address = response['from'][0] + pms = PlexServer() + pms.presence = True + pms.connections.append(connection) for line in response['data'].split('\n'): - if 'Content-Type:' in line: - pms['product'] = utils.try_decode(line.split(':')[1].strip()) - elif 'Host:' in line: - pms['baseURL'] = line.split(':')[1].strip() - elif 'Name:' in line: - pms['name'] = utils.try_decode(line.split(':')[1].strip()) - elif 'Port:' in line: - pms['port'] = line.split(':')[1].strip() - elif 'Resource-Identifier:' in line: - pms['machineIdentifier'] = line.split(':')[1].strip() - elif 'Version:' in line: - pms['version'] = line.split(':')[1].strip() + try: + kind, info = line.split(':', 1) + except ValueError: + continue + else: + kind, info = kind.strip(), info.strip() + if kind == 'Name': + pms.name = info + elif kind == 'Resource-Identifier': + pms.clientIdentifier = info + elif kind == 'Content-Type': + pms.product = info + elif kind == 'Version': + pms.productVersion = info + elif kind == 'Updated-At': + pms.lastSeenAt = int(info) + elif kind == 'Host': + # Example: "Host: <....sfe...>.plex.direct" + connection.uri = info + elif kind == 'Port': + connection.port = int(info) + # Assume https + connection.protocol = 'https' + if connection.uri != connection.address: + # The PMS might return both local IP and plex.direct address + alt_connection = deepcopy(connection) + alt_connection.uri = 'https://%s:%s' % (connection.address, + connection.port) + pms.connections.append(alt_connection) + connection.uri = 'https://%s:%s' % (connection.uri, + connection.port) pms_list.append(pms) + LOG.debug('Found PMS in the LAN: %s: %s', pms, pms.connections) return pms_list -def _pms_list_from_plex_tv(token): +def pms_from_plex_tv(token): """ get Plex media Server List from plex.tv/pms/resources """ + pms_list = [] xml = DU().downloadUrl('https://plex.tv/api/resources', authenticate=False, parameters={'includeHttps': 1}, @@ -324,49 +333,20 @@ def _pms_list_from_plex_tv(token): xml.attrib except AttributeError: LOG.error('Could not get list of PMS from plex.tv') - return [] - - from Queue import Queue - queue = Queue() - thread_queue = [] - - max_age_in_seconds = 2 * 60 * 60 * 24 + return pms_list for device in xml.findall('Device'): - if 'server' not in device.get('provides'): + if 'server' not in device.get('provides', ''): # No PMS - skip continue if device.find('Connection') is None: # no valid connection - skip continue - # check MyPlex data age - skip if >2 days - info_age = time() - int(device.get('lastSeenAt')) - if info_age > max_age_in_seconds: - LOG.debug("Skip server %s not seen for 2 days", device.get('name')) - continue - pms = { - 'machineIdentifier': device.get('clientIdentifier'), - 'name': device.get('name'), - 'token': device.get('accessToken'), - 'ownername': device.get('sourceTitle'), - 'product': device.get('product'), # e.g. 'Plex Media Server' - 'version': device.get('productVersion'), # e.g. '1.11.2.4772-3e..' - 'device': device.get('device'), # e.g. 'PC' or 'Windows' - 'platform': device.get('platform'), # e.g. 'Windows', 'Android' - 'local': device.get('publicAddressMatches') == '1', - 'owned': device.get('owned') == '1', - 'relay': device.get('relay') == '1', - 'presence': device.get('presence') == '1', - 'httpsRequired': device.get('httpsRequired') == '1', - 'connections': [] - } - # Try a local connection first, no matter what plex.tv tells us - for connection in device.findall('Connection'): - if connection.get('local') == '1': - pms['connections'].append(connection) - # Then try non-local - for connection in device.findall('Connection'): - if connection.get('local') != '1': - pms['connections'].append(connection) + pms = PlexServer(xml=device) + pms_list.append(pms) + return pms_list + + + # Spawn threads to ping each PMS simultaneously thread = Thread(target=_poke_pms, args=(pms, queue)) thread_queue.append(thread) diff --git a/resources/lib/plexapi/__init__.py b/resources/lib/plexapi/__init__.py new file mode 100644 index 00000000..b93054b3 --- /dev/null +++ b/resources/lib/plexapi/__init__.py @@ -0,0 +1 @@ +# Dummy file to make this directory a package. diff --git a/resources/lib/plexapi/base.py b/resources/lib/plexapi/base.py new file mode 100644 index 00000000..101d8686 --- /dev/null +++ b/resources/lib/plexapi/base.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +""" +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger + +from ..utils import cast, to_list + +LOG = getLogger('PLEX.' + __name__) + + +class Connection(object): + def __init__(self, xml=None, **kwargs): + self.protocol = None + self.address = None + self.port = None + self.uri = None + self.local = None + if xml: + self.load_from_xml(xml) + # Set all remaining attributes set on Class instantiation + for key, value in kwargs.items(): + setattr(self, key, value) + + def __unicode__(self): + return ''.format(self=self) + + def __repr__(self): + return self.__unicode__().encode('utf-8') + + def __eq__(self, other): + return self.uri == other.uri + + def __ne__(self, other): + return self.uri != other.uri + + def load_from_xml(self, xml): + """ + Throw in an etree xml-element to load PMS settings from it + """ + if xml.tag != 'Connection': + raise RuntimeError('Did not receive Connection xml but %s' + % xml.tag) + self.protocol = cast(unicode, xml.get('protocol')) + self.address = cast(unicode, xml.get('address')) + self.port = cast(int, xml.get('port')) + self.uri = cast(unicode, xml.get('uri')) + self.local = cast(bool, xml.get('local')) + + +class PlexServer(object): + def __init__(self, xml=None, **kwargs): + # Information from plex.tv + self.name = None + self.clientIdentifier = None + self.provides = set() + self.owned = None + self.home = None + self.httpsRequired = None + self.synced = None + self.relay = None + self.publicAddressMatches = None + self.presence = None + self.accessToken = None + + self.product = None # Usually "Plex Media Server" + self.ownerId = None # User id of the owner of this PMS + self.owner = None # User name of the (foreign!) owner + self.productVersion = None + self.platform = None + self.platformVersion = None + self.device = None + self.createdAt = None + self.lastSeenAt = None + + # Connection info + self.connections = [] + if xml: + self.load_from_xml(xml) + # Set all remaining attributes set on Class instantiation + for key, value in kwargs.items(): + setattr(self, key, value) + + def load_from_xml(self, xml): + """ + Throw in an etree xml-element to load PMS settings from it + """ + if xml.tag != 'Device': + raise RuntimeError('Did not receive Device xml but %s' % xml.tag) + self.name = cast(unicode, xml.get('name')) + self.clientIdentifier = cast(unicode, xml.get('clientIdentifier')) + self.provides = set(to_list(cast(unicode, xml.get('provides')))) + self.owned = cast(bool, xml.get('owned')) + self.home = cast(bool, xml.get('home')) + self.httpsRequired = cast(bool, xml.get('httpsRequired')) + self.synced = cast(bool, xml.get('synced')) + self.relay = cast(bool, xml.get('relay')) + self.publicAddressMatches = cast(bool, + xml.get('publicAddressMatches')) + self.presence = cast(bool, xml.get('presence')) + self.accessToken = cast(unicode, xml.get('accessToken')) + self.product = cast(unicode, xml.get('product')) + self.ownerId = cast(int, xml.get('ownerId')) + self.owner = cast(unicode, xml.get('sourceTitle')) + self.productVersion = cast(unicode, xml.get('productVersion')) + self.platform = cast(unicode, xml.get('platform')) + self.platformVersion = cast(unicode, xml.get('platformVersion')) + self.device = cast(unicode, xml.get('device')) + self.createdAt = cast(int, xml.get('createdAt')) + self.lastSeenAt = cast(int, xml.get('lastSeenAt')) + + for connection in xml.findall('Connection'): + self.connections.append(Connection(xml=connection)) + + def __unicode__(self): + return ''.format(self=self) + + def __repr__(self): + return self.__unicode__().encode('utf-8') + + def __eq__(self, other): + return self.clientIdentifier == other.clientIdentifier + + def __ne__(self, other): + return self.clientIdentifier != other.clientIdentifier diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 62bd4f4a..e466cce0 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -249,6 +249,46 @@ def ERROR(txt='', hide_tb=False, notify=False): return short +def to_list(value, itemcast=None, delim=','): + """ + Returns a list of unicodes from the specified value [unicode]. + + Parameters: + value [unicode]: (comma) delimited string to convert to list. + itemcast (func): Function to cast each list item to (default unicode). + delim (str): string delimiter (optional; default ','). + """ + value = value or '' + itemcast = itemcast or unicode + return [itemcast(item) for item in value.split(delim) if item != ''] + + +def cast(func, value): + """ + Cast the specified value to the specified type (returned by func). + Currently supports int, float, bool, unicode (if str supplied), str + Will return None if value=None + + Parameters: + func (func): Calback function to used cast to type + value (any): value to be cast and returned. + """ + if value is None: + return value + if func == bool: + return bool(int(value)) + elif func in (int, float): + try: + return func(value) + except ValueError: + return float('nan') + elif func == unicode: + return value.decode('utf-8') + elif func == str: + return value.encode('utf-8') + return func(value) + + class AttributeDict(dict): """ Turns an etree xml response's xml.attrib into an object with attributes diff --git a/resources/lib/windows/server_select.py b/resources/lib/windows/server_select.py new file mode 100644 index 00000000..9a36f174 --- /dev/null +++ b/resources/lib/windows/server_select.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +:module: plexkodiconnect.userselect +:synopsis: This module shows a dialog to let one choose between different Plex + (home) users +""" +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger +import xbmc +import xbmcgui + +from . import kodigui +from .connection import plexapp +from .. import backgroundthread, utils, plex_functions as PF, variables as v + +LOG = getLogger('PLEX.' + __name__) + + +class UserThumbTask(backgroundthread.Task): + def setup(self, users, callback): + self.users = users + self.callback = callback + return self + + def run(self): + for user in self.users: + if self.isCanceled(): + return + thumb, back = user.thumb, '' + self.callback(user, thumb, back) + + +class ServerListItem(kodigui.ManagedListItem): + def init(self): + self.dataSource.on('completed:reachability', self.onUpdate) + self.dataSource.on('started:reachability', self.onUpdate) + return self + + def safeSetProperty(self, key, value): + # For if we catch the item in the middle of being removed + try: + self.setProperty(key, value) + return True + except AttributeError: + return False + + def safeSetLabel(self, value): + # For if we catch the item in the middle of being removed + try: + self.setLabel(value) + return True + except AttributeError: + return False + + def onUpdate(self, **kwargs): + if not self.listItem: # ex. can happen on Kodi shutdown + return + + if not self.dataSource.isSupported or not self.dataSource.isReachable(): + if self.dataSource.pendingReachabilityRequests > 0: + self.safeSetProperty('status', 'refreshing.gif') + else: + self.safeSetProperty('status', 'unreachable.png') + else: + self.safeSetProperty('status', self.dataSource.isSecure and 'secure.png' or '') + + self.safeSetProperty('current', plexapp.SERVERMANAGER.selectedServer == self.dataSource and '1' or '') + self.safeSetLabel(self.dataSource.name) + + def onDestroy(self): + self.dataSource.off('completed:reachability', self.onUpdate) + self.dataSource.off('started:reachability', self.onUpdate) + + +class ServerSelectWindow(kodigui.BaseWindow): + xmlFile = 'script-plex-server_select.xml' + path = v.ADDON_PATH + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + USER_LIST_ID = 101 + PIN_ENTRY_GROUP_ID = 400 + HOME_BUTTON_ID = 500 + SERVER_LIST_ID = 260 + + def __init__(self, *args, **kwargs): + self.tasks = None + self.server = None + self.aborted = False + self.serverList = None + kodigui.BaseWindow.__init__(self, *args, **kwargs) + + def onFirstInit(self): + self.serverList = kodigui.ManagedControlList(self, + self.SERVER_LIST_ID, + 10) + self.start() + + def onAction(self, action): + try: + ID = action.getId() + if 57 < ID < 68: + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.PIN_ENTRY_GROUP_ID)): + item = self.userList.getSelectedItem() + if not item.dataSource.isProtected: + return + self.setFocusId(self.PIN_ENTRY_GROUP_ID) + self.pinEntryClicked(ID + 142) + return + elif 142 <= ID <= 149: # JumpSMS action + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.PIN_ENTRY_GROUP_ID)): + item = self.userList.getSelectedItem() + if not item.dataSource.isProtected: + return + self.setFocusId(self.PIN_ENTRY_GROUP_ID) + self.pinEntryClicked(ID + 60) + return + elif ID in (xbmcgui.ACTION_NAV_BACK, xbmcgui.ACTION_BACKSPACE): + if xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.PIN_ENTRY_GROUP_ID)): + self.pinEntryClicked(211) + return + except: + utils.ERROR() + + kodigui.BaseWindow.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.USER_LIST_ID: + item = self.userList.getSelectedItem() + if item.dataSource.isProtected: + self.setFocusId(self.PIN_ENTRY_GROUP_ID) + else: + self.userSelected(item) + elif 200 < controlID < 212: + self.pinEntryClicked(controlID) + elif controlID == self.HOME_BUTTON_ID: + self.home_button_clicked() + + def onFocus(self, controlID): + if controlID == self.USER_LIST_ID: + item = self.userList.getSelectedItem() + item.setProperty('editing.pin', '') + + def showServers(self, from_refresh=False, mouse=False): + selection = None + if from_refresh: + mli = self.serverList.getSelectedItem() + if mli: + selection = mli.dataSource + else: + plexapp.refreshResources() + + servers = sorted( + plexapp.SERVERMANAGER.getServers(), + key=lambda x: (x.owned and '0' or '1') + x.name.lower() + ) + servers = PF.plex_gdm() + + items = [] + for s in servers: + item = ServerListItem(s.name, + not s.owned and s.owner or '', + data_source=s).init() + item.onUpdate() + item.setProperty( + 'current', + plexapp.SERVERMANAGER.selectedServer == s and '1' or '') + items.append(item) + + if len(items) > 1: + items[0].setProperty('first', '1') + items[-1].setProperty('last', '1') + elif items: + items[0].setProperty('only', '1') + + self.serverList.replaceItems(items) + + self.getControl(800).setHeight((min(len(items), 9) * 100) + 80) + + if selection: + for mli in self.serverList: + if mli.dataSource == selection: + self.serverList.selectItem(mli.pos()) + if not from_refresh and items and not mouse: + self.setFocusId(self.SERVER_LIST_ID) + + def start(self): + self.setProperty('busy', '1') + self.showServers() + self.setProperty('initialized', '1') + self.setProperty('busy', '') + + def home_button_clicked(self): + """ + Action taken if user clicked the home button + """ + self.server = None + self.aborted = True + self.doClose() + + def finished(self): + for task in self.tasks: + task.cancel() + + +def start(): + """ + Hit this function to open a dialog to choose the Plex user + + Returns + ======= + tuple (server, aborted) + server : PlexServer + Or None if server switch failed or aborted by the server + aborted : bool + True if the server cancelled the dialog + """ + w = ServerSelectWindow.open() + server, aborted = w.server, w.aborted + del w + return server, aborted diff --git a/resources/skins/Main/1080i/script-plex-server_select.xml b/resources/skins/Main/1080i/script-plex-server_select.xml new file mode 100644 index 00000000..bd800471 --- /dev/null +++ b/resources/skins/Main/1080i/script-plex-server_select.xml @@ -0,0 +1,156 @@ + + + 100 + + 1 + 0 + 0 + + 0xff111111 + + + + + 0 + 0 + 1920 + 1080 + script.plex/home/background-fallback.png + + + 0 + 0 + 1920 + 1080 + 1000 + $INFO[Window.Property(background)] + + + + + + 201 + 0 + 0 + 1920 + 135 + + 0 + 0 + 1920 + 135 + plugin.video.plexkodiconnect/white-square.png + 19000000 + + + + 20 + -5.5 + 1040 + 170 + left + 60 + horizontal + 101 + true + + 40 + 34.5 + 124 + 66 + + 0 + 0 + 124 + 66 + 101 + 101 + right + center + - + - + + + + !String.IsEmpty(Window.Property(dropdown)) + 0 + 0 + 124 + 66 + plugin.video.plexkodiconnect/white-square-rounded.png + + + 27 + 13 + + !Control.HasFocus(500) + String.IsEmpty(Window.Property(dropdown)) + 0 + 0 + 90 + 90 + plugin.video.plexkodiconnect/home/type/home.png + + + Control.HasFocus(500) | !String.IsEmpty(Window.Property(dropdown)) + -40 + -40 + 170 + 170 + plugin.video.plexkodiconnect/home/type/home-selected.png + + + + + 13 + 50 + auto + 66 + font12 + left + center + FFFFFFFF + + + + + + 213 + 35 + 200 + 65 + font12 + right + center + FFFFFFFF + + + + 153r + 54 + 93 + 30 + plugin.video.plexkodiconnect/home/plex.png + + + + + + !String.IsEmpty(Window.Property(busy)) + + 840 + 465 + 240 + 150 + script.plex/busy-back.png + A0FFFFFF + + + 915 + 521 + 90 + 38 + script.plex/busy.gif + + + +