From 98e38ae9a8aee4dda081e32388139b7ce835296d Mon Sep 17 00:00:00 2001 From: croneter Date: Mon, 10 Sep 2018 20:53:46 +0200 Subject: [PATCH] Add Plex dialog to switch users --- .../resource.language.en_gb/strings.po | 5 + resources/lib/backgroundthread.py | 302 +++++ resources/lib/entrypoint.py | 1 - resources/lib/initialsetup.py | 16 +- resources/lib/plex_functions.py | 15 +- resources/lib/plex_tv.py | 417 ++++--- resources/lib/userclient.py | 16 +- resources/lib/utils.py | 61 +- resources/lib/windows/__init__.py | 1 + resources/lib/windows/dropdown.py | 174 +++ resources/lib/windows/kodigui.py | 1004 +++++++++++++++++ resources/lib/windows/optionsdialog.py | 58 + resources/lib/windows/userselect.py | 191 ++++ resources/settings.xml | 2 - 14 files changed, 2007 insertions(+), 256 deletions(-) create mode 100644 resources/lib/backgroundthread.py create mode 100644 resources/lib/windows/__init__.py create mode 100644 resources/lib/windows/dropdown.py create mode 100644 resources/lib/windows/kodigui.py create mode 100644 resources/lib/windows/optionsdialog.py create mode 100644 resources/lib/windows/userselect.py diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index ab88a1ee..39128c1f 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -1119,6 +1119,11 @@ msgctxt "#39228" msgid "Plex user:" msgstr "" +# Error message if user could not log in; the actual user name will be appended at the end of the string +msgctxt "#39229" +msgid "Login failed with plex.tv for user" +msgstr "" + msgctxt "#39250" msgid "Running the image cache process can take some time. It will happen in the background. Are you sure you want continue?" msgstr "" diff --git a/resources/lib/backgroundthread.py b/resources/lib/backgroundthread.py new file mode 100644 index 00000000..60784d8f --- /dev/null +++ b/resources/lib/backgroundthread.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger +import threading +import Queue +import heapq +import xbmc + +from . import utils + +LOG = getLogger('PLEX.' + __name__) + + +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() + + +class Tasks(list): + def add(self, task): + for t in self: + if not t.isValid(): + self.remove(t) + + if isinstance(task, list): + self += task + else: + self.append(task) + + def cancel(self): + while self: + self.pop().cancel() + + +class Task: + def __init__(self, priority=None): + self._priority = priority + self._canceled = False + self.finished = False + + def __cmp__(self, other): + return self._priority - other._priority + + def start(self): + BGThreader.addTask(self) + + def _run(self): + self.run() + self.finished = True + + def run(self): + pass + + def cancel(self): + self._canceled = True + + def isCanceled(self): + return self._canceled or xbmc.abortRequested + + def isValid(self): + return not self.finished and not self._canceled + + +class MutablePriorityQueue(Queue.PriorityQueue): + def _get(self, heappop=heapq.heappop): + self.queue.sort() + return heappop(self.queue) + + def lowest(self): + """Return the lowest priority item in the queue (not reliable!).""" + self.mutex.acquire() + try: + lowest = self.queue and min(self.queue) or None + except: + lowest = None + utils.ERROR() + finally: + self.mutex.release() + return lowest + + +class BackgroundWorker: + def __init__(self, queue, name=None): + self._queue = queue + self.name = name + self._thread = None + self._abort = False + self._task = None + + def _runTask(self, task): + if task._canceled: + return + try: + task._run() + except: + utils.ERROR() + + def abort(self): + self._abort = True + return self + + def aborted(self): + return self._abort or xbmc.abortRequested + + def start(self): + if self._thread and self._thread.isAlive(): + return + + self._thread = KillableThread(target=self._queueLoop, name='BACKGROUND-WORKER({0})'.format(self.name)) + self._thread.start() + + def _queueLoop(self): + if self._queue.empty(): + return + + LOG.debug('(%s): Active', self.name) + try: + while not self.aborted(): + self._task = self._queue.get_nowait() + self._runTask(self._task) + self._queue.task_done() + self._task = None + except Queue.Empty: + LOG.debug('(%s): Idle', self.name) + + def shutdown(self): + self.abort() + + if self._task: + self._task.cancel() + + if self._thread and self._thread.isAlive(): + LOG.debug('thread (%s): Waiting...', self.name) + self._thread.join() + LOG.debug('thread (%s): Done', self.name) + + def working(self): + return self._thread and self._thread.isAlive() + + +class BackgroundThreader: + def __init__(self, name=None, worker_count=8): + self.name = name + self._queue = MutablePriorityQueue() + self._abort = False + self._priority = -1 + self.workers = [BackgroundWorker(self._queue, 'queue.{0}:worker.{1}'.format(self.name, x)) for x in range(worker_count)] + + def _nextPriority(self): + self._priority += 1 + return self._priority + + def abort(self): + self._abort = True + for w in self.workers: + w.abort() + return self + + def aborted(self): + return self._abort or xbmc.abortRequested + + def shutdown(self): + self.abort() + + for w in self.workers: + w.shutdown() + + def addTask(self, task): + task._priority = self._nextPriority() + self._queue.put(task) + self.startWorkers() + + def addTasks(self, tasks): + for t in tasks: + t._priority = self._nextPriority() + self._queue.put(t) + + self.startWorkers() + + def addTasksToFront(self, tasks): + lowest = self.getLowestPrority() + if lowest is None: + return self.addTasks(tasks) + + p = lowest - len(tasks) + for t in tasks: + t._priority = p + self._queue.put(t) + p += 1 + + self.startWorkers() + + def startWorkers(self): + for w in self.workers: + w.start() + + def working(self): + return not self._queue.empty() or self.hasTask() + + def hasTask(self): + return any([w.working() for w in self.workers]) + + def getLowestPrority(self): + lowest = self._queue.lowest() + if not lowest: + return None + + return lowest._priority + + def moveToFront(self, qitem): + lowest = self.getLowestPrority() + if lowest is None: + return + + qitem._priority = lowest - 1 + + +class ThreaderManager: + def __init__(self): + self.index = 0 + self.abandoned = [] + self.threader = BackgroundThreader(str(self.index)) + + def __getattr__(self, name): + return getattr(self.threader, name) + + def reset(self): + if self.threader._queue.empty() and not self.threader.hasTask(): + return + + self.index += 1 + self.abandoned.append(self.threader.abort()) + self.threader = BackgroundThreader(str(self.index)) + + def shutdown(self): + self.threader.shutdown() + for a in self.abandoned: + a.shutdown() + + +BGThreader = ThreaderManager() diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index a3d67371..af5bf2b7 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -74,7 +74,6 @@ def toggle_plex_tv_sign_in(): utils.settings('plexLogin', value="") utils.settings('plexToken', value="") utils.settings('plexid', value="") - utils.settings('plexHomeSize', value="1") utils.settings('plexAvatar', value="") utils.settings('plex_status', value=utils.lang(39226)) diff --git a/resources/lib/initialsetup.py b/resources/lib/initialsetup.py index 02df3d75..93b4bdda 100644 --- a/resources/lib/initialsetup.py +++ b/resources/lib/initialsetup.py @@ -164,11 +164,14 @@ class InitialSetup(object): Returns True if successful, or False if not """ - result = plex_tv.sign_in_with_pin() - if result: - self.plex_login = result['username'] - self.plex_token = result['token'] - self.plexid = result['plexid'] + try: + user = plex_tv.sign_in_with_pin() + except: + utils.ERROR() + if user: + self.plex_login = user.username + self.plex_token = user.authToken + self.plexid = user.id return True return False @@ -209,10 +212,7 @@ class InitialSetup(object): else: utils.settings('plexLogin', value=self.plex_login) home = 'true' if xml.attrib.get('home') == '1' else 'false' - utils.settings('plexhome', value=home) utils.settings('plexAvatar', value=xml.attrib.get('thumb')) - utils.settings('plexHomeSize', - value=xml.attrib.get('homeSize', '1')) LOG.info('Updated Plex info from plex.tv') return answer diff --git a/resources/lib/plex_functions.py b/resources/lib/plex_functions.py index 153637e3..555cf0ba 100644 --- a/resources/lib/plex_functions.py +++ b/resources/lib/plex_functions.py @@ -90,27 +90,22 @@ def GetPlexLoginFromSettings(): Returns a dict: 'plexLogin': utils.settings('plexLogin'), 'plexToken': utils.settings('plexToken'), - 'plexhome': utils.settings('plexhome'), 'plexid': utils.settings('plexid'), 'myplexlogin': utils.settings('myplexlogin'), 'plexAvatar': utils.settings('plexAvatar'), - 'plexHomeSize': utils.settings('plexHomeSize') Returns strings or unicode Returns empty strings '' for a setting if not found. myplexlogin is 'true' if user opted to log into plex.tv (the default) - plexhome is 'true' if plex home is used (the default) """ return { 'plexLogin': utils.settings('plexLogin'), 'plexToken': utils.settings('plexToken'), - 'plexhome': utils.settings('plexhome'), 'plexid': utils.settings('plexid'), 'myplexlogin': utils.settings('myplexlogin'), 'plexAvatar': utils.settings('plexAvatar'), - 'plexHomeSize': utils.settings('plexHomeSize') } @@ -812,15 +807,11 @@ def GetUserArtworkURL(username): Returns the URL for the user's Avatar. Or False if something went wrong. """ - users = plex_tv.list_home_users(utils.settings('plexToken')) + users = plex_tv.plex_home_users(utils.settings('plexToken')) url = '' - # If an error is encountered, set to False - if not users: - LOG.info("Couldnt get user from plex.tv. No URL for user avatar") - return False for user in users: - if username in user['title']: - url = user['thumb'] + if user.title == username: + url = user.thumb LOG.debug("Avatar url for user %s is: %s", username, url) return url diff --git a/resources/lib/plex_tv.py b/resources/lib/plex_tv.py index a353b84a..b7e300db 100644 --- a/resources/lib/plex_tv.py +++ b/resources/lib/plex_tv.py @@ -2,118 +2,41 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, unicode_literals from logging import getLogger -from xbmc import sleep, executebuiltin +import time +import threading +import xbmc +import xbmcgui from .downloadutils import DownloadUtils as DU -from . import utils -from . import variables as v -from . import state +from . import utils, variables as v, state ############################################################################### -LOG = getLogger('PLEX.plex_tx') +LOG = getLogger('PLEX.plex_tv') ############################################################################### -def choose_home_user(token): +class HomeUser(utils.AttributeDict): """ - Let's user choose from a list of Plex home users. Will switch to that - user accordingly. - - Returns a dict: - { - 'username': Unicode - 'userid': '' Plex ID of the user - 'token': '' User's token - 'protected': True if PIN is needed, else False - } - - Will return False if something went wrong (wrong PIN, no connection) + Turns an etree xml answer into an object with attributes """ - # Get list of Plex home users - users = list_home_users(token) - if not users: - LOG.error("User download failed.") - return False - userlist = [] - userlist_coded = [] - for user in users: - username = user['title'] - userlist.append(username) - # To take care of non-ASCII usernames - userlist_coded.append(utils.try_encode(username)) - usernumber = len(userlist) - username = '' - usertoken = '' - trials = 0 - while trials < 3: - if usernumber > 1: - # Select user - user_select = utils.dialog( - 'select', - '%s%s' % (utils.lang(29999), utils.lang(39306)), - userlist_coded) - if user_select == -1: - LOG.info("No user selected.") - utils.settings('username', value='') - executebuiltin('Addon.Openutils.settings(%s)' % v.ADDON_ID) - return False - # Only 1 user received, choose that one - else: - user_select = 0 - selected_user = userlist[user_select] - LOG.info("Selected user: %s", selected_user) - user = users[user_select] - # Ask for PIN, if protected: - pin = None - if user['protected'] == '1': - LOG.debug('Asking for users PIN') - pin = utils.dialog('input', - '%s%s' % (utils.lang(39307), selected_user), - '', - type='{numeric}', - option='{hide}') - # User chose to cancel - # Plex bug: don't call url for protected user with empty PIN - if not pin: - trials += 1 - continue - # Switch to this Plex Home user, if applicable - result = switch_home_user(user['id'], - pin, - token, - utils.settings('plex_machineIdentifier')) - if result: - # Successfully retrieved username: break out of while loop - username = result['username'] - usertoken = result['usertoken'] - break - # Couldn't get user auth - else: - trials += 1 - # Could not login user, please try again - if not utils.dialog('yesno', - heading='{plex}', - line1='%s%s' % (utils.lang(39308), - selected_user), - line2=utils.lang(39309)): - # User chose to cancel - break - if not username: - LOG.error('Failed signing in a user to plex.tv') - executebuiltin('Addon.Openutils.settings(%s)' % v.ADDON_ID) - return False - return { - 'username': username, - 'userid': user['id'], - 'protected': True if user['protected'] == '1' else False, - 'token': usertoken - } + pass -def switch_home_user(userid, pin, token, machineIdentifier): +def homeuser_to_settings(user): """ - Retrieves Plex home token for a Plex home user. - Returns False if unsuccessful + Writes one HomeUser to the Kodi settings file + """ + utils.settings('myplexlogin', 'true') + utils.settings('plexLogin', user.title) + utils.settings('plexToken', user.authToken) + utils.settings('plexid', user.id) + utils.settings('plexAvatar', user.thumb) + utils.settings('plex_status', value=utils.lang(39227)) + + +def switch_home_user(userid, pin, token, machine_identifier): + """ + Retrieves Plex home token for a Plex home user. Returns None if this fails Input: userid id of the Plex home user @@ -121,40 +44,37 @@ def switch_home_user(userid, pin, token, machineIdentifier): token token for plex.tv Output: - { - 'username' - 'usertoken' Might be empty strings if no token found - for the machineIdentifier that was chosen - } + usertoken Might be empty strings if no token found + for the machine_identifier that was chosen utils.settings('userid') and utils.settings('username') with new plex token """ LOG.info('Switching to user %s', userid) - url = 'https://plex.tv/api/home/users/' + userid + '/switch' + url = 'https://plex.tv/api/home/users/%s/switch' % userid if pin: - url += '?pin=' + pin - answer = DU().downloadUrl(url, - authenticate=False, - action_type="POST", - headerOptions={'X-Plex-Token': token}) + url += '?pin=%s' % pin + xml = DU().downloadUrl(url, + authenticate=False, + action_type="POST", + headerOptions={'X-Plex-Token': token}) try: - answer.attrib + xml.attrib except AttributeError: - LOG.error('Error: plex.tv switch HomeUser change failed') - return False + LOG.error('Switch HomeUser change failed') + return - username = answer.attrib.get('title', '') - token = answer.attrib.get('authenticationToken', '') + username = xml.get('title', '') + token = xml.get('authenticationToken', '') # Write to settings file utils.settings('username', username) utils.settings('accessToken', token) - utils.settings('userid', answer.attrib.get('id', '')) + utils.settings('userid', xml.get('id', '')) utils.settings('plex_restricteduser', - 'true' if answer.attrib.get('restricted', '0') == '1' + 'true' if xml.get('restricted', '0') == '1' else 'false') state.RESTRICTED_USER = True if \ - answer.attrib.get('restricted', '0') == '1' else False + xml.get('restricted', '0') == '1' else False # Get final token to the PMS we've chosen url = 'https://plex.tv/api/resources?includeHttps=1' @@ -169,133 +89,186 @@ def switch_home_user(userid, pin, token, machineIdentifier): xml = [] found = 0 - LOG.debug('Our machineIdentifier is %s', machineIdentifier) + LOG.debug('Our machine_identifier is %s', machine_identifier) for device in xml: identifier = device.attrib.get('clientIdentifier') - LOG.debug('Found a Plex machineIdentifier: %s', identifier) - if identifier == machineIdentifier: + LOG.debug('Found the Plex clientIdentifier: %s', identifier) + if identifier == machine_identifier: found += 1 token = device.attrib.get('accessToken') - - result = { - 'username': username, - } if found == 0: LOG.info('No tokens found for your server! Using empty string') - result['usertoken'] = '' - else: - result['usertoken'] = token + token = '' LOG.info('Plex.tv switch HomeUser change successfull for user %s', username) - return result + return token -def list_home_users(token): +def plex_home_users(token): """ - Returns a list for myPlex home users for the current plex.tv account. - - Input: - token for plex.tv - Output: - List of users, where one entry is of the form: - "id": userId, - "admin": '1'/'0', - "guest": '1'/'0', - "restricted": '1'/'0', - "protected": '1'/'0', - "email": email, - "title": title, - "username": username, - "thumb": thumb_url - } - If any value is missing, None is returned instead (or "" from plex.tv) - If an error is encountered, False is returned + Returns a list of HomeUser elements from plex.tv """ xml = DU().downloadUrl('https://plex.tv/api/home/users/', authenticate=False, headerOptions={'X-Plex-Token': token}) + users = [] try: xml.attrib except AttributeError: LOG.error('Download of Plex home users failed.') - return False - users = [] - for user in xml: - users.append(user.attrib) + else: + for user in xml: + users.append(HomeUser(user.attrib)) return users +class PinLogin(object): + """ + Signs user in to plex.tv + """ + INIT = 'https://plex.tv/pins.xml' + POLL = 'https://plex.tv/pins/{0}.xml' + ACCOUNT = 'https://plex.tv/users/account' + POLL_INTERVAL = 1 + + def __init__(self, callback=None): + self._callback = callback + self.id = None + self.pin = None + self.token = None + self.finished = False + self._abort = False + self.expired = False + self.xml = None + self._init() + + def _init(self): + xml = DU().downloadUrl(self.INIT, + authenticate=False, + action_type="POST") + try: + xml.attrib + except AttributeError: + LOG.error("Error, no PIN from plex.tv provided") + raise RuntimeError + self.pin = xml.find('code').text + self.id = xml.find('id').text + LOG.debug('Successfully retrieved code and id from plex.tv') + + def _poll(self): + LOG.debug('Start polling plex.tv for token') + start = time.time() + while (not self._abort and + time.time() - start < 300 and + not state.STOP_PKC): + xml = DU().downloadUrl(self.POLL.format(self.id), + authenticate=False) + try: + token = xml.find('auth_token').text + except AttributeError: + time.sleep(self.POLL_INTERVAL) + continue + if token: + self.token = token + break + time.sleep(self.POLL_INTERVAL) + if self._callback: + self._callback(self.token, self.xml) + if self.token: + # Use temp token to get the final plex credentials + self.xml = DU().downloadUrl(self.ACCOUNT, + authenticate=False, + parameters={'X-Plex-Token': self.token}) + self.finished = True + LOG.debug('Polling done') + + def start_token_poll(self): + t = threading.Thread(target=self._poll, name='PIN-LOGIN:Token-Poll') + t.start() + return t + + def wait_for_token(self): + t = self.start_token_poll() + t.join() + return self.token + + def abort(self): + self._abort = True + + def sign_in_with_pin(): """ Prompts user to sign in by visiting https://plex.tv/pin - Writes to Kodi settings file. Also returns: - { - 'plexhome': 'true' if Plex Home, 'false' otherwise - 'username': - 'avatar': URL to user avator - 'token': - 'plexid': Plex user ID - 'homesize': Number of Plex home users (defaults to '1') - } - Returns False if authentication did not work. + Writes to Kodi settings file and returns the HomeUser or None """ - code, identifier = get_pin() - if not code: - # Problems trying to contact plex.tv. Try again later - utils.dialog('ok', heading='{plex}', line1=utils.lang(39303)) - return False - # Go to https://plex.tv/pin and enter the code: - # Or press No to cancel the sign in. - answer = utils.dialog('yesno', - heading='{plex}', - line1='%s%s' % (utils.lang(39304), "\n\n"), - line2='%s%s' % (code, "\n\n"), - line3=utils.lang(39311)) - if not answer: - return False - count = 0 - # Wait for approx 30 seconds (since the PIN is not visible anymore :-)) - while count < 30: - xml = check_pin(identifier) - if xml is not False: - break - # Wait for 1 seconds - sleep(1000) - count += 1 - if xml is False: - # Could not sign in to plex.tv Try again later - utils.dialog('ok', heading='{plex}', line1=utils.lang(39305)) - return False - # Parse xml - userid = xml.attrib.get('id') - home = xml.get('home', '0') - if home == '1': - home = 'true' - else: - home = 'false' - username = xml.get('username', '') - avatar = xml.get('thumb', '') - token = xml.findtext('authentication-token') - home_size = xml.get('homeSize', '1') - result = { - 'plexhome': home, - 'username': username, - 'avatar': avatar, - 'token': token, - 'plexid': userid, - 'homesize': home_size - } - utils.settings('plexLogin', username) - utils.settings('plexToken', token) - utils.settings('plexhome', home) - utils.settings('plexid', userid) - utils.settings('plexAvatar', avatar) - utils.settings('plexHomeSize', home_size) - # Let Kodi log into plex.tv on startup from now on - utils.settings('myplexlogin', 'true') - utils.settings('plex_status', value=utils.lang(39227)) - return result + xml = _sign_in_with_pin() + if not xml: + return + user = HomeUser(xml.attrib) + homeuser_to_settings(user) + return user + + +class TestWindow(xbmcgui.Window): + def onAction(self, action): + LOG.debug('onAction: %s', action) + +def _sign_in_with_pin(): + """ + Returns the user xml answer from plex.tv or None if unsuccessful + """ + from .dialogs import signin + return + + back = signin.Background.create() + try: + pre = signin.PreSignInWindow.open() + try: + if not pre.doSignin: + return + finally: + del pre + + while True: + pin_login_window = signin.PinLoginWindow.create() + try: + try: + pinlogin = PinLogin() + except RuntimeError: + # Could not sign in to plex.tv Try again later + utils.dialog('ok', + heading='{plex}', + line1=utils.lang(39305)) + return + pin_login_window.setPin(pinlogin.pin) + pinlogin.start_token_poll() + while not pinlogin.finished: + if pin_login_window.abort: + LOG.debug('Pin login aborted') + pinlogin.abort() + return + time.sleep(0.1) + if not pinlogin.expired: + if pinlogin.xml: + pin_login_window.setLinking() + return pinlogin.xml + return + finally: + pin_login_window.doClose() + del pin_login_window + if pinlogin.expired: + LOG.debug('Pin expired') + expired_window = signin.ExpiredWindow.open() + try: + if not expired_window.refresh: + LOG.debug('Pin refresh aborted') + return + finally: + del expired_window + finally: + back.doClose() + del back def get_pin(): @@ -323,7 +296,7 @@ def check_pin(identifier): """ Checks with plex.tv whether user entered the correct PIN on plex.tv/pin - Returns False if not yet done so, or the XML response file as etree + Returns None if not yet done so, or the XML response file as etree """ # Try to get a temporary token xml = DU().downloadUrl('https://plex.tv/pins/%s.xml' % identifier, @@ -332,9 +305,9 @@ def check_pin(identifier): temp_token = xml.find('auth_token').text except AttributeError: LOG.error("Could not find token in plex.tv answer") - return False + return if not temp_token: - return False + return # Use temp token to get the final plex credentials xml = DU().downloadUrl('https://plex.tv/users/account', authenticate=False, diff --git a/resources/lib/userclient.py b/resources/lib/userclient.py index 11dbf014..bcce8137 100644 --- a/resources/lib/userclient.py +++ b/resources/lib/userclient.py @@ -6,10 +6,10 @@ from threading import Thread from xbmc import sleep, executebuiltin +from .windows import userselect from .downloadutils import DownloadUtils as DU from . import utils from . import path_ops -from . import plex_tv from . import plex_functions as PF from . import variables as v from . import state @@ -237,22 +237,22 @@ class UserClient(Thread): plextoken = utils.settings('plexToken') if plextoken: LOG.info("Trying to connect to plex.tv to get a user list") - userInfo = plex_tv.choose_home_user(plextoken) - if userInfo is False: + user = userselect.start() + if not user: # FAILURE: Something went wrong, try again self.auth = True self.retry += 1 return False - username = userInfo['username'] - userId = userInfo['userid'] - usertoken = userInfo['token'] + username = user.title + user_id = user.id + usertoken = user.authToken else: LOG.info("Trying to authenticate without a token") username = '' - userId = '' + user_id = '' usertoken = '' - if self.load_user(username, userId, usertoken, authenticated=False): + if self.load_user(username, user_id, usertoken, authenticated=False): # SUCCESS: loaded a user from the settings return True # Something went wrong, try again diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 9f3ee06a..0445ff2c 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -18,13 +18,12 @@ from functools import wraps, partial from urllib import quote_plus import hashlib import re +import gc import xbmc import xbmcaddon import xbmcgui -from . import path_ops -from . import variables as v -from . import state +from . import path_ops, variables as v, state ############################################################################### @@ -52,6 +51,14 @@ REGEX_PLEX_ID_FROM_URL = re.compile(r'''metadata%2F(\d+)''') # Main methods +def garbageCollect(): + gc.collect(2) + + +def setGlobalProperty(key, val): + xbmcgui.Window(10000).setProperty('script.plex.{0}'.format(key), val) + + def reboot_kodi(message=None): """ Displays an OK prompt with 'Kodi will now restart to apply the changes' @@ -124,6 +131,14 @@ def lang(stringid): xbmc.getLocalizedString(stringid)) +def messageDialog(heading, msg): + """ + Shows a dialog using the Plex layout + """ + from .windows import optionsdialog + optionsdialog.show(heading, msg, 'OK') + + def dialog(typus, *args, **kwargs): """ Displays xbmcgui Dialog. Pass a string as typus: @@ -194,6 +209,46 @@ def dialog(typus, *args, **kwargs): return types[typus](*args, **kwargs) +def ERROR(txt='', hide_tb=False, notify=False): + import sys + short = str(sys.exc_info()[1]) + LOG.error('Error encountered: %s - %s', txt, short) + if hide_tb: + return short + + import traceback + trace = traceback.format_exc() + LOG.error("_____________________________________________________________") + for line in trace.splitlines(): + LOG.error(' ' + line) + LOG.error("_____________________________________________________________") + if notify: + dialog('notification', + heading='{plex}', + message=short, + icon='{error}') + return short + + +class AttributeDict(dict): + """ + Turns an etree xml response's xml.attrib into an object with attributes + """ + def __getattr__(self, attr): + return self.get(attr) + + def __setattr__(self, attr, value): + self[attr] = value + + def __unicode__(self): + return '<{0}:{1}:{2}>'.format(self.__class__.__name__, + self.id, + self.get('title', 'None')) + + def __repr__(self): + return self.__unicode__().encode('utf8') + + def millis_to_kodi_time(milliseconds): """ Converts time in milliseconds to the time dict used by the Kodi JSON RPC: diff --git a/resources/lib/windows/__init__.py b/resources/lib/windows/__init__.py new file mode 100644 index 00000000..b93054b3 --- /dev/null +++ b/resources/lib/windows/__init__.py @@ -0,0 +1 @@ +# Dummy file to make this directory a package. diff --git a/resources/lib/windows/dropdown.py b/resources/lib/windows/dropdown.py new file mode 100644 index 00000000..48c3f834 --- /dev/null +++ b/resources/lib/windows/dropdown.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +from . import kodigui +from .. import utils, variables as v + +SEPARATOR = None + + +class DropdownDialog(kodigui.BaseDialog): + xmlFile = 'script-plex-dropdown.xml' + path = v.ADDON_PATH + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + GROUP_ID = 100 + OPTIONS_LIST_ID = 250 + + def __init__(self, *args, **kwargs): + kodigui.BaseDialog.__init__(self, *args, **kwargs) + self.options = kwargs.get('options') + self.pos = kwargs.get('pos') + self.posIsBottom = kwargs.get('pos_is_bottom') + self.closeDirection = kwargs.get('close_direction') + self.setDropdownProp = kwargs.get('set_dropdown_prop', False) + self.withIndicator = kwargs.get('with_indicator', False) + self.suboptionCallback = kwargs.get('suboption_callback') + self.closeOnPlaybackEnded = kwargs.get('close_on_playback_ended', False) + self.header = kwargs.get('header') + self.choice = None + + @property + def x(self): + return self.pos[0] + + @property + def y(self): + y = self.pos[1] + if self.posIsBottom: + y -= (len(self.options) * 66) + 80 + return y + + def onFirstInit(self): + self.setProperty('dropdown', self.setDropdownProp and '1' or '') + self.setProperty('header', self.header) + self.optionsList = kodigui.ManagedControlList(self, self.OPTIONS_LIST_ID, 8) + self.showOptions() + height = min(66 * 14, (len(self.options) * 66)) + 80 + self.getControl(100).setPosition(self.x, self.y) + + shadowControl = self.getControl(110) + if self.header: + shadowControl.setHeight(height + 86) + self.getControl(111).setHeight(height + 6) + else: + shadowControl.setHeight(height) + + self.setProperty('show', '1') + self.setProperty('close.direction', self.closeDirection) + if self.closeOnPlaybackEnded: + from lib import player + player.PLAYER.on('session.ended', self.playbackSessionEnded) + + def onAction(self, action): + try: + pass + except: + utils.ERROR() + + kodigui.BaseDialog.onAction(self, action) + + def onClick(self, controlID): + if controlID == self.OPTIONS_LIST_ID: + self.setChoice() + else: + self.doClose() + + def playbackSessionEnded(self, **kwargs): + self.doClose() + + def setChoice(self): + mli = self.optionsList.getSelectedItem() + if not mli: + return + + choice = self.options[self.optionsList.getSelectedPosition()] + + if choice.get('ignore'): + return + + if self.suboptionCallback: + options = self.suboptionCallback(choice) + if options: + sub = showDropdown(options, (self.x + 290, self.y + 10), close_direction='left', with_indicator=True) + if not sub: + return + + choice['sub'] = sub + + self.choice = choice + self.doClose() + + def showOptions(self): + items = [] + options = [] + for o in self.options: + if o: + item = kodigui.ManagedListItem(o['display'], thumbnailImage=o.get('indicator', ''), data_source=o) + item.setProperty('with.indicator', self.withIndicator and '1' or '') + items.append(item) + options.append(o) + else: + if items: + items[-1].setProperty('separator', '1') + + self.options = options + + if len(items) > 1: + items[0].setProperty('first', '1') + items[-1].setProperty('last', '1') + elif items: + items[0].setProperty('only', '1') + + self.optionsList.reset() + self.optionsList.addItems(items) + + self.setFocusId(self.OPTIONS_LIST_ID) + + +class DropdownHeaderDialog(DropdownDialog): + xmlFile = 'script-plex-dropdown_header.xml' + + +def showDropdown( + options, pos=None, + pos_is_bottom=False, + close_direction='top', + set_dropdown_prop=True, + with_indicator=False, + suboption_callback=None, + close_on_playback_ended=False, + header=None +): + + if header: + pos = pos or (660, 400) + w = DropdownHeaderDialog.open( + options=options, pos=pos, + pos_is_bottom=pos_is_bottom, + close_direction=close_direction, + set_dropdown_prop=set_dropdown_prop, + with_indicator=with_indicator, + suboption_callback=suboption_callback, + close_on_playback_ended=close_on_playback_ended, + header=header + ) + else: + pos = pos or (810, 400) + w = DropdownDialog.open( + options=options, pos=pos, + pos_is_bottom=pos_is_bottom, + close_direction=close_direction, + set_dropdown_prop=set_dropdown_prop, + with_indicator=with_indicator, + suboption_callback=suboption_callback, + close_on_playback_ended=close_on_playback_ended, + header=header + ) + choice = w.choice + del w + utils.garbageCollect() + return choice diff --git a/resources/lib/windows/kodigui.py b/resources/lib/windows/kodigui.py new file mode 100644 index 00000000..f801e0c6 --- /dev/null +++ b/resources/lib/windows/kodigui.py @@ -0,0 +1,1004 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +import xbmc +import xbmcgui +import time +import threading +import traceback + +MONITOR = None + + +class BaseFunctions: + xmlFile = '' + path = '' + theme = '' + res = '720p' + width = 1280 + height = 720 + + usesGenerate = False + lastWinID = None + + def __init__(self): + self.isOpen = True + + def onWindowFocus(self): + # Not automatically called. Can be used by an external window manager + pass + + def onClosed(self): + pass + + @classmethod + def open(cls, **kwargs): + window = cls(cls.xmlFile, cls.path, cls.theme, cls.res, **kwargs) + window.modal() + return window + + @classmethod + def create(cls, show=True, **kwargs): + window = cls(cls.xmlFile, cls.path, cls.theme, cls.res, **kwargs) + if show: + window.show() + window.isOpen = True + return window + + def modal(self): + self.isOpen = True + self.doModal() + self.onClosed() + self.isOpen = False + + def activate(self): + if not self._winID: + self._winID = xbmcgui.getCurrentWindowId() + xbmc.executebuiltin('ReplaceWindow({0})'.format(self._winID)) + + def mouseXTrans(self, val): + return int((val / self.getWidth()) * self.width) + + def mouseYTrans(self, val): + return int((val / self.getHeight()) * self.height) + + def closing(self): + return self._closing + + @classmethod + def generate(self): + return None + + def setProperties(self, prop_list, val_list_or_val): + if isinstance(val_list_or_val, list) or isinstance(val_list_or_val, tuple): + val_list = val_list_or_val + else: + val_list = [val_list_or_val] * len(prop_list) + + for prop, val in zip(prop_list, val_list): + self.setProperty(prop, val) + + def propertyContext(self, prop, val='1'): + return WindowProperty(self, prop, val) + + def setBoolProperty(self, key, boolean): + self.setProperty(key, boolean and '1' or '') + + +class BaseWindow(xbmcgui.WindowXML, BaseFunctions): + def __init__(self, *args, **kwargs): + BaseFunctions.__init__(self) + self._closing = False + self._winID = None + self.started = False + self.finishedInit = False + + def onInit(self): + self._winID = xbmcgui.getCurrentWindowId() + BaseFunctions.lastWinID = self._winID + if self.started: + self.onReInit() + else: + self.started = True + self.onFirstInit() + self.finishedInit = True + + def onFirstInit(self): + pass + + def onReInit(self): + pass + + def setProperty(self, key, value): + if self._closing: + return + + if not self._winID: + self._winID = xbmcgui.getCurrentWindowId() + + try: + xbmcgui.Window(self._winID).setProperty(key, value) + xbmcgui.WindowXML.setProperty(self, key, value) + except RuntimeError: + xbmc.log('kodigui.BaseWindow.setProperty: Missing window', xbmc.LOGDEBUG) + + def doClose(self): + if not self.isOpen: + return + self._closing = True + self.isOpen = False + self.close() + + def show(self): + self._closing = False + self.isOpen = True + xbmcgui.WindowXML.show(self) + + def onClosed(self): + pass + + +class BaseDialog(xbmcgui.WindowXMLDialog, BaseFunctions): + def __init__(self, *args, **kwargs): + BaseFunctions.__init__(self) + self._closing = False + self._winID = '' + self.started = False + + def onInit(self): + self._winID = xbmcgui.getCurrentWindowDialogId() + BaseFunctions.lastWinID = self._winID + if self.started: + self.onReInit() + else: + self.started = True + self.onFirstInit() + + def onFirstInit(self): + pass + + def onReInit(self): + pass + + def setProperty(self, key, value): + if self._closing: + return + + if not self._winID: + self._winID = xbmcgui.getCurrentWindowId() + + try: + xbmcgui.Window(self._winID).setProperty(key, value) + xbmcgui.WindowXMLDialog.setProperty(self, key, value) + except RuntimeError: + xbmc.log('kodigui.BaseDialog.setProperty: Missing window', xbmc.LOGDEBUG) + + def doClose(self): + self._closing = True + self.close() + + def show(self): + self._closing = False + xbmcgui.WindowXMLDialog.show(self) + + def onClosed(self): + pass + + +class ControlledBase: + def doModal(self): + self.show() + self.wait() + + def wait(self): + while not self._closing and not MONITOR.waitForAbort(0.1): + pass + + def close(self): + self._closing = True + + +class ControlledWindow(ControlledBase, BaseWindow): + def onAction(self, action): + try: + if action in (xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_NAV_BACK): + self.doClose() + return + except: + traceback.print_exc() + + BaseWindow.onAction(self, action) + + +class ControlledDialog(ControlledBase, BaseDialog): + def onAction(self, action): + try: + if action in (xbmcgui.ACTION_PREVIOUS_MENU, xbmcgui.ACTION_NAV_BACK): + self.doClose() + return + except: + traceback.print_exc() + + BaseDialog.onAction(self, action) + + +DUMMY_LIST_ITEM = xbmcgui.ListItem() + + +class ManagedListItem(object): + def __init__(self, label='', label2='', iconImage='', thumbnailImage='', path='', data_source=None, properties=None): + self._listItem = xbmcgui.ListItem(label, label2, iconImage, thumbnailImage, path) + self.dataSource = data_source + self.properties = {} + self.label = label + self.label2 = label2 + self.iconImage = iconImage + self.thumbnailImage = thumbnailImage + self.path = path + self._ID = None + self._manager = None + self._valid = True + if properties: + for k, v in properties.items(): + self.setProperty(k, v) + + def __nonzero__(self): + return self._valid + + @property + def listItem(self): + if not self._listItem: + if not self._manager: + return None + + try: + self._listItem = self._manager.getListItemFromManagedItem(self) + except RuntimeError: + return None + + return self._listItem + + def invalidate(self): + self._valid = False + self._listItem = DUMMY_LIST_ITEM + + def _takeListItem(self, manager, lid): + self._manager = manager + self._ID = lid + self._listItem.setProperty('__ID__', lid) + li = self._listItem + self._listItem = None + self._manager._properties.update(self.properties) + return li + + def _updateListItem(self): + self.listItem.setProperty('__ID__', self._ID) + self.listItem.setLabel(self.label) + self.listItem.setLabel2(self.label2) + self.listItem.setIconImage(self.iconImage) + self.listItem.setThumbnailImage(self.thumbnailImage) + self.listItem.setPath(self.path) + for k in self._manager._properties.keys(): + self.listItem.setProperty(k, self.properties.get(k) or '') + + def clear(self): + self.label = '' + self.label2 = '' + self.iconImage = '' + self.thumbnailImage = '' + self.path = '' + for k in self.properties: + self.properties[k] = '' + self._updateListItem() + + def pos(self): + if not self._manager: + return None + return self._manager.getManagedItemPosition(self) + + def addContextMenuItems(self, items, replaceItems=False): + self.listItem.addContextMenuItems(items, replaceItems) + + def addStreamInfo(self, stype, values): + self.listItem.addStreamInfo(stype, values) + + def getLabel(self): + return self.label + + def getLabel2(self): + return self.label2 + + def getProperty(self, key): + return self.properties.get(key, '') + + def getdescription(self): + return self.listItem.getdescription() + + def getduration(self): + return self.listItem.getduration() + + def getfilename(self): + return self.listItem.getfilename() + + def isSelected(self): + return self.listItem.isSelected() + + def select(self, selected): + return self.listItem.select(selected) + + def setArt(self, values): + return self.listItem.setArt(values) + + def setIconImage(self, icon): + self.iconImage = icon + return self.listItem.setIconImage(icon) + + def setInfo(self, itype, infoLabels): + return self.listItem.setInfo(itype, infoLabels) + + def setLabel(self, label): + self.label = label + return self.listItem.setLabel(label) + + def setLabel2(self, label): + self.label2 = label + return self.listItem.setLabel2(label) + + def setMimeType(self, mimetype): + return self.listItem.setMimeType(mimetype) + + def setPath(self, path): + self.path = path + return self.listItem.setPath(path) + + def setProperty(self, key, value): + if self._manager: + self._manager._properties[key] = 1 + self.properties[key] = value + self.listItem.setProperty(key, value) + return self + + def setBoolProperty(self, key, boolean): + return self.setProperty(key, boolean and '1' or '') + + def setSubtitles(self, subtitles): + return self.listItem.setSubtitles(subtitles) # List of strings - HELIX + + def setThumbnailImage(self, thumb): + self.thumbnailImage = thumb + return self.listItem.setThumbnailImage(thumb) + + def onDestroy(self): + pass + + +class ManagedControlList(object): + def __init__(self, window, control_id, max_view_index, data_source=None): + self.controlID = control_id + self.control = window.getControl(control_id) + self.items = [] + self._sortKey = None + self._idCounter = 0 + self._maxViewIndex = max_view_index + self._properties = {} + self.dataSource = data_source + + def __getattr__(self, name): + return getattr(self.control, name) + + def __getitem__(self, idx): + if isinstance(idx, slice): + return self.items[idx] + else: + return self.getListItem(idx) + + def __iter__(self): + for i in self.items: + yield i + + def __len__(self): + return self.size() + + def _updateItems(self, bottom=None, top=None): + if bottom is None: + bottom = 0 + top = self.size() + + try: + for idx in range(bottom, top): + li = self.control.getListItem(idx) + mli = self.items[idx] + self._properties.update(mli.properties) + mli._manager = self + mli._listItem = li + mli._updateListItem() + except RuntimeError: + xbmc.log('kodigui.ManagedControlList._updateItems: Runtime error', xbmc.LOGNOTICE) + return False + + return True + + def _nextID(self): + self._idCounter += 1 + return str(self._idCounter) + + def reInit(self, window, control_id): + self.controlID = control_id + self.control = window.getControl(control_id) + self.control.addItems([i._takeListItem(self, self._nextID()) for i in self.items]) + + def setSort(self, sort): + self._sortKey = sort + + def addItem(self, managed_item): + self.items.append(managed_item) + self.control.addItem(managed_item._takeListItem(self, self._nextID())) + + def addItems(self, managed_items): + self.items += managed_items + self.control.addItems([i._takeListItem(self, self._nextID()) for i in managed_items]) + + def replaceItem(self, pos, mli): + self[pos].onDestroy() + self[pos].invalidate() + self.items[pos] = mli + li = self.control.getListItem(pos) + mli._manager = self + mli._listItem = li + mli._updateListItem() + + def replaceItems(self, managed_items): + if not self.items: + self.addItems(managed_items) + return True + + oldSize = self.size() + + for i in self.items: + i.onDestroy() + i.invalidate() + + self.items = managed_items + size = self.size() + if size != oldSize: + pos = self.getSelectedPosition() + + if size > oldSize: + for i in range(0, size - oldSize): + self.control.addItem(xbmcgui.ListItem()) + elif size < oldSize: + diff = oldSize - size + idx = oldSize - 1 + while diff: + self.control.removeItem(idx) + idx -= 1 + diff -= 1 + + if self.positionIsValid(pos): + self.selectItem(pos) + elif pos >= size: + self.selectItem(size - 1) + + self._updateItems(0, self.size()) + + def getListItem(self, pos): + li = self.control.getListItem(pos) + mli = self.items[pos] + mli._listItem = li + return mli + + def getListItemByDataSource(self, data_source): + for mli in self: + if data_source == mli.dataSource: + return mli + return None + + def getSelectedItem(self): + pos = self.control.getSelectedPosition() + if not self.positionIsValid(pos): + pos = self.size() - 1 + + if pos < 0: + return None + return self.getListItem(pos) + + def removeItem(self, index): + old = self.items.pop(index) + old.onDestroy() + old.invalidate() + + self.control.removeItem(index) + top = self.control.size() - 1 + if top < 0: + return + if top < index: + index = top + self.control.selectItem(index) + + def removeManagedItem(self, mli): + self.removeItem(mli.pos()) + + def insertItem(self, index, managed_item): + pos = self.getSelectedPosition() + 1 + + if index >= self.size() or index < 0: + self.addItem(managed_item) + else: + self.items.insert(index, managed_item) + self.control.addItem(managed_item._takeListItem(self, self._nextID())) + self._updateItems(index, self.size()) + + if self.positionIsValid(pos): + self.selectItem(pos) + + def moveItem(self, mli, dest_idx): + source_idx = mli.pos() + if source_idx < dest_idx: + rstart = source_idx + rend = dest_idx + 1 + # dest_idx-=1 + else: + rstart = dest_idx + rend = source_idx + 1 + mli = self.items.pop(source_idx) + self.items.insert(dest_idx, mli) + + self._updateItems(rstart, rend) + + def swapItems(self, pos1, pos2): + if not self.positionIsValid(pos1) or not self.positionIsValid(pos2): + return False + + item1 = self.items[pos1] + item2 = self.items[pos2] + li1 = item1._listItem + li2 = item2._listItem + item1._listItem = li2 + item2._listItem = li1 + + item1._updateListItem() + item2._updateListItem() + self.items[pos1] = item2 + self.items[pos2] = item1 + + return True + + def shiftView(self, shift, hold_selected=False): + if not self._maxViewIndex: + return + selected = self.getSelectedItem() + selectedPos = selected.pos() + viewPos = self.getViewPosition() + + if shift > 0: + pushPos = selectedPos + (self._maxViewIndex - viewPos) + shift + if pushPos >= self.size(): + pushPos = self.size() - 1 + self.selectItem(pushPos) + newViewPos = self._maxViewIndex + elif shift < 0: + pushPos = (selectedPos - viewPos) + shift + if pushPos < 0: + pushPos = 0 + self.selectItem(pushPos) + newViewPos = 0 + + if hold_selected: + self.selectItem(selected.pos()) + else: + diff = newViewPos - viewPos + fix = pushPos - diff + # print '{0} {1} {2}'.format(newViewPos, viewPos, fix) + if self.positionIsValid(fix): + self.selectItem(fix) + + def reset(self): + self.dataSource = None + for i in self.items: + i.onDestroy() + i.invalidate() + self.items = [] + self.control.reset() + + def size(self): + return len(self.items) + + def getViewPosition(self): + try: + return int(xbmc.getInfoLabel('Container({0}).Position'.format(self.controlID))) + except: + return 0 + + def getViewRange(self): + viewPosition = self.getViewPosition() + selected = self.getSelectedPosition() + return range(max(selected - viewPosition, 0), min(selected + (self._maxViewIndex - viewPosition) + 1, self.size() - 1)) + + def positionIsValid(self, pos): + return 0 <= pos < self.size() + + def sort(self, sort=None, reverse=False): + sort = sort or self._sortKey + + self.items.sort(key=sort, reverse=reverse) + + self._updateItems(0, self.size()) + + def reverse(self): + self.items.reverse() + self._updateItems(0, self.size()) + + def getManagedItemPosition(self, mli): + return self.items.index(mli) + + def getListItemFromManagedItem(self, mli): + pos = self.items.index(mli) + return self.control.getListItem(pos) + + def topHasFocus(self): + return self.getSelectedPosition() == 0 + + def bottomHasFocus(self): + return self.getSelectedPosition() == self.size() - 1 + + def invalidate(self): + for item in self.items: + item._listItem = DUMMY_LIST_ITEM + + def newControl(self, window=None, control_id=None): + self.controlID = control_id or self.controlID + self.control = window.getControl(self.controlID) + self.control.addItems([xbmcgui.ListItem() for i in range(self.size())]) + self._updateItems() + + +class _MWBackground(ControlledWindow): + def __init__(self, *args, **kwargs): + self._multiWindow = kwargs.get('multi_window') + self.started = False + BaseWindow.__init__(self, *args, **kwargs) + + def onInit(self): + if self.started: + return + self.started = True + self._multiWindow._open() + self.close() + + +class MultiWindow(object): + def __init__(self, windows=None, default_window=None, **kwargs): + self._windows = windows + self._next = default_window or self._windows[0] + self._properties = {} + self._current = None + self._allClosed = False + self.exitCommand = None + + def __getattr__(self, name): + return getattr(self._current, name) + + def setWindows(self, windows): + self._windows = windows + + def setDefault(self, default): + self._next = default or self._windows[0] + + def windowIndex(self, window): + if hasattr(window, 'MULTI_WINDOW_ID'): + for i, w in enumerate(self._windows): + if window.MULTI_WINDOW_ID == w.MULTI_WINDOW_ID: + return i + return 0 + else: + return self._windows.index(window.__class__) + + def nextWindow(self, window=None): + if window is False: + window = self._windows[self.windowIndex(self._current)] + + if window: + if window.__class__ == self._current.__class__: + return None + else: + idx = self.windowIndex(self._current) + idx += 1 + if idx >= len(self._windows): + idx = 0 + window = self._windows[idx] + + self._next = window + self._current.doClose() + return self._next + + def _setupCurrent(self, cls): + self._current = cls(cls.xmlFile, cls.path, cls.theme, cls.res) + self._current.onFirstInit = self._onFirstInit + self._current.onReInit = self.onReInit + self._current.onClick = self.onClick + self._current.onFocus = self.onFocus + + self._currentOnAction = self._current.onAction + self._current.onAction = self.onAction + + @classmethod + def open(cls, **kwargs): + mw = cls(**kwargs) + b = _MWBackground(mw.bgXML, mw.path, mw.theme, mw.res, multi_window=mw) + b.modal() + del b + import gc + gc.collect(2) + return mw + + def _open(self): + while not xbmc.abortRequested and not self._allClosed: + self._setupCurrent(self._next) + self._current.modal() + + self._current.doClose() + del self._current + del self._next + del self._currentOnAction + + def setProperty(self, key, value): + self._properties[key] = value + self._current.setProperty(key, value) + + def _onFirstInit(self): + for k, v in self._properties.items(): + self._current.setProperty(k, v) + self.onFirstInit() + + def doClose(self): + self._allClosed = True + self._current.doClose() + + def onFirstInit(self): + pass + + def onReInit(self): + pass + + def onAction(self, action): + if action == xbmcgui.ACTION_PREVIOUS_MENU or action == xbmcgui.ACTION_NAV_BACK: + self.doClose() + self._currentOnAction(action) + + def onClick(self, controlID): + pass + + def onFocus(self, controlID): + pass + + +class SafeControlEdit(object): + CHARS_LOWER = 'abcdefghijklmnopqrstuvwxyz' + CHARS_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + CHARS_NUMBERS = '0123456789' + CURSOR = '[COLOR FFCC7B19]|[/COLOR]' + + def __init__(self, control_id, label_id, window, key_callback=None, grab_focus=False): + self.controlID = control_id + self.labelID = label_id + self._win = window + self._keyCallback = key_callback + self.grabFocus = grab_focus + self._text = '' + self._compatibleMode = False + self.setup() + + def setup(self): + self._labelControl = self._win.getControl(self.labelID) + self._winOnAction = self._win.onAction + self._win.onAction = self.onAction + self.updateLabel() + + def setCompatibleMode(self, on): + self._compatibleMode = on + + def onAction(self, action): + try: + controlID = self._win.getFocusId() + if controlID == self.controlID: + if self.processAction(action.getId()): + return + elif self.grabFocus: + if self.processOffControlAction(action.getButtonCode()): + self._win.setFocusId(self.controlID) + return + except: + traceback.print_exc() + + self._winOnAction(action) + + def processAction(self, action_id): + if not self._compatibleMode: + self._text = self._win.getControl(self.controlID).getText() + + if self._keyCallback: + self._keyCallback() + + self. updateLabel() + + return True + + if 61793 <= action_id <= 61818: # Lowercase + self.processChar(self.CHARS_LOWER[action_id - 61793]) + elif 61761 <= action_id <= 61786: # Uppercase + self.processChar(self.CHARS_UPPER[action_id - 61761]) + elif 61744 <= action_id <= 61753: + self.processChar(self.CHARS_NUMBERS[action_id - 61744]) + elif action_id == 61728: # Space + self.processChar(' ') + elif action_id == 61448: + self.delete() + else: + return False + + if self._keyCallback: + self._keyCallback() + + return True + + def processOffControlAction(self, action_id): + if 61505 <= action_id <= 61530: # Lowercase + self.processChar(self.CHARS_LOWER[action_id - 61505]) + elif 192577 <= action_id <= 192602: # Uppercase + self.processChar(self.CHARS_UPPER[action_id - 192577]) + elif 61488 <= action_id <= 61497: + self.processChar(self.CHARS_NUMBERS[action_id - 61488]) + elif 61552 <= action_id <= 61561: + self.processChar(self.CHARS_NUMBERS[action_id - 61552]) + elif action_id == 61472: # Space + self.processChar(' ') + else: + return False + + if self._keyCallback: + self._keyCallback() + + return True + + def _setText(self, text): + self._text = text + + if not self._compatibleMode: + self._win.getControl(self.controlID).setText(text) + self.updateLabel() + + def _getText(self): + if not self._compatibleMode and self._win.getFocusId() == self.controlID: + return self._win.getControl(self.controlID).getText() + else: + return self._text + + def updateLabel(self): + self._labelControl.setLabel(self._getText() + self.CURSOR) + + def processChar(self, char): + self._setText(self.getText() + char) + + def setText(self, text): + self._setText(text) + + def getText(self): + return self._getText() + + def append(self, text): + self._setText(self.getText() + text) + + def delete(self): + self._setText(self.getText()[:-1]) + + +class PropertyTimer(): + def __init__(self, window_id, timeout, property_, value='', init_value='1', addon_id=None, callback=None): + self._winID = window_id + self._timeout = timeout + self._property = property_ + self._value = value + self._initValue = init_value + self._endTime = 0 + self._thread = None + self._addonID = addon_id + self._closeWin = None + self._closed = False + self._callback = callback + + def _onTimeout(self): + self._endTime = 0 + xbmcgui.Window(self._winID).setProperty(self._property, self._value) + if self._addonID: + xbmcgui.Window(10000).setProperty('{0}.{1}'.format(self._addonID, self._property), self._value) + if self._closeWin: + self._closeWin.doClose() + if self._callback: + self._callback() + + def _wait(self): + while not xbmc.abortRequested and time.time() < self._endTime: + xbmc.sleep(100) + if xbmc.abortRequested: + return + if self._endTime == 0: + return + self._onTimeout() + + def _stopped(self): + return not self._thread or not self._thread.isAlive() + + def _reset(self): + self._endTime = time.time() + self._timeout + + def _start(self): + self.init(self._initValue) + self._thread = threading.Thread(target=self._wait) + self._thread.start() + + def stop(self, trigger=False): + self._endTime = trigger and 1 or 0 + if not self._stopped(): + self._thread.join() + + def close(self): + self._closed = True + self.stop() + + def init(self, val): + if val is False: + return + elif val is None: + val = self._initValue + + xbmcgui.Window(self._winID).setProperty(self._property, val) + if self._addonID: + xbmcgui.Window(10000).setProperty('{0}.{1}'.format(self._addonID, self._property), val) + + def reset(self, close_win=None, init=None): + self.init(init) + + if self._closed: + return + + if not self._timeout: + return + + self._closeWin = close_win + self._reset() + + if self._stopped: + self._start() + + +class WindowProperty(): + def __init__(self, win, prop, val='1', end=None): + self.win = win + self.prop = prop + self.val = val + self.end = end + self.old = self.win.getProperty(self.prop) + + def __enter__(self): + self.win.setProperty(self.prop, self.val) + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.win.setProperty(self.prop, self.end or self.old) + + +class GlobalProperty(): + def __init__(self, prop, val='1', end=None): + import xbmcaddon + self._addonID = xbmcaddon.Addon().getAddonInfo('id') + self.prop = prop + self.val = val + self.end = end + self.old = xbmc.getInfoLabel('Window(10000).Property({0}}.{1})'.format(self._addonID, prop)) + + def __enter__(self): + xbmcgui.Window(10000).setProperty('{0}.{1}'.format(self._addonID, self.prop), self.val) + return self + + def __exit__(self, exc_type, exc_value, traceback): + xbmcgui.Window(10000).setProperty('{0}.{1}'.format(self._addonID, self.prop), self.end or self.old) diff --git a/resources/lib/windows/optionsdialog.py b/resources/lib/windows/optionsdialog.py new file mode 100644 index 00000000..19619bd5 --- /dev/null +++ b/resources/lib/windows/optionsdialog.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +import xbmc + +from . import kodigui +from .. import utils, variables as v + + +class OptionsDialog(kodigui.BaseDialog): + xmlFile = 'script-plex-options_dialog.xml' + path = v.ADDON_PATH + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + GROUP_ID = 100 + BUTTON_IDS = (1001, 1002, 1003) + + def __init__(self, *args, **kwargs): + kodigui.BaseDialog.__init__(self, *args, **kwargs) + self.header = kwargs.get('header') + self.info = kwargs.get('info') + self.button0 = kwargs.get('button0') + self.button1 = kwargs.get('button1') + self.button2 = kwargs.get('button2') + self.buttonChoice = None + + def onFirstInit(self): + self.setProperty('header', self.header) + self.setProperty('info', self.info) + + if self.button2: + self.setProperty('button.2', self.button2) + + if self.button1: + self.setProperty('button.1', self.button1) + + if self.button0: + self.setProperty('button.0', self.button0) + + self.setBoolProperty('initialized', True) + xbmc.Monitor().waitForAbort(0.1) + self.setFocusId(self.BUTTON_IDS[0]) + + def onClick(self, controlID): + if controlID in self.BUTTON_IDS: + self.buttonChoice = self.BUTTON_IDS.index(controlID) + self.doClose() + + +def show(header, info, button0=None, button1=None, button2=None): + w = OptionsDialog.open(header=header, info=info, button0=button0, button1=button1, button2=button2) + choice = w.buttonChoice + del w + utils.garbageCollect() + return choice diff --git a/resources/lib/windows/userselect.py b/resources/lib/windows/userselect.py new file mode 100644 index 00000000..82fe4deb --- /dev/null +++ b/resources/lib/windows/userselect.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +:module: plexkodiconnect.userselect +:synopsis: This module shows a dialog to let one choose between different Plex + (home) users +""" +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger +import xbmc +import xbmcgui + +from . import kodigui +from .. import backgroundthread, utils, plex_tv, variables as v + +LOG = getLogger('PLEX.' + __name__) + + +class UserThumbTask(backgroundthread.Task): + def setup(self, users, callback): + self.users = users + self.callback = callback + return self + + def run(self): + for user in self.users: + if self.isCanceled(): + return + thumb, back = user.thumb, '' + self.callback(user, thumb, back) + + +class UserSelectWindow(kodigui.BaseWindow): + xmlFile = 'script-plex-user_select.xml' + path = v.ADDON_PATH + theme = 'Main' + res = '1080i' + width = 1920 + height = 1080 + + USER_LIST_ID = 101 + PIN_ENTRY_GROUP_ID = 400 + SHUTDOWN_BUTTON_ID = 500 # Todo: DELETE + + def __init__(self, *args, **kwargs): + self.task = None + self.user = None + kodigui.BaseWindow.__init__(self, *args, **kwargs) + + def onFirstInit(self): + self.userList = kodigui.ManagedControlList(self, self.USER_LIST_ID, 6) + + self.start() + + def onAction(self, action): + try: + ID = action.getId() + if 57 < ID < 68: + if not xbmc.getCondVisibility('ControlGroup({0}).HasFocus(0)'.format(self.PIN_ENTRY_GROUP_ID)): + item = self.userList.getSelectedItem() + if not item.dataSource.protected: + 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.protected: + 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.protected: + self.setFocusId(self.PIN_ENTRY_GROUP_ID) + else: + self.userSelected(item) + elif 200 < controlID < 212: + self.pinEntryClicked(controlID) + + def onFocus(self, controlID): + if controlID == self.USER_LIST_ID: + item = self.userList.getSelectedItem() + item.setProperty('editing.pin', '') + + def userThumbCallback(self, user, thumb, back): + item = self.userList.getListItemByDataSource(user) + if item: + item.setThumbnailImage(thumb) + item.setProperty('back.image', back) + + def start(self): + self.setProperty('busy', '1') + try: + users = plex_tv.plex_home_users(utils.settings('plexToken')) + + items = [] + for user in users: + # thumb, back = image.getImage(user.thumb, user.id) + # mli = kodigui.ManagedListItem(user.title, thumbnailImage=thumb, data_source=user) + mli = kodigui.ManagedListItem(user.title, user.title[0].upper(), data_source=user) + mli.setProperty('pin', user.title) + # mli.setProperty('back.image', back) + mli.setProperty('protected', user.protected == '1' and '1' or '') + mli.setProperty('admin', user.admin == '1' and '1' or '') + items.append(mli) + + self.userList.addItems(items) + self.task = UserThumbTask().setup(users, self.userThumbCallback) + backgroundthread.BGThreader.addTask(self.task) + + self.setFocusId(self.USER_LIST_ID) + self.setProperty('initialized', '1') + finally: + self.setProperty('busy', '') + + def pinEntryClicked(self, controlID): + item = self.userList.getSelectedItem() + if item.getProperty('editing.pin'): + pin = item.getProperty('editing.pin') + else: + pin = '' + + if len(pin) > 3: + return + + if controlID < 210: + pin += str(controlID - 200) + elif controlID == 210: + pin += '0' + elif controlID == 211: + pin = pin[:-1] + + if pin: + item.setProperty('pin', ' '.join(list(u"\u2022" * len(pin)))) + item.setProperty('editing.pin', pin) + if len(pin) > 3: + self.userSelected(item, pin) + else: + item.setProperty('pin', item.dataSource.title) + item.setProperty('editing.pin', '') + + def userSelected(self, item, pin=None): + self.user = item.dataSource + LOG.info('Home user selected: %s', self.user) + self.user.authToken = plex_tv.switch_home_user( + self.user.id, + pin, + utils.settings('plexToken'), + utils.settings('plex_machineIdentifier')) + if self.user.authToken is None: + self.user = None + item.setProperty('pin', item.dataSource.title) + item.setProperty('editing.pin', '') + # 'Error': 'Login failed with plex.tv for user' + utils.messageDialog(utils.lang(30135), + '%s %s' % (utils.lang(39229), + self.user.username)) + return + self.doClose() + + def finished(self): + if self.task: + self.task.cancel() + + +def start(): + """ + Hit this function to open a dialog to choose the Plex user + + Returns + ======= + user : HomeUser + Or None if user switch failed or aborted by the user) + """ + w = UserSelectWindow.open() + user = w.user + del w + return user diff --git a/resources/settings.xml b/resources/settings.xml index d8728639..11fa8bea 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -26,9 +26,7 @@ - -