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