From dbe0339b7154145faa25cfe90ebc2cb8ea6e004d Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 30 Sep 2018 17:35:23 +0200 Subject: [PATCH] Reprogram part 1 --- resources/lib/kodijsonrpc.py | 105 ++++ resources/lib/{service_entry.py => main.py} | 124 ++++- resources/lib/plex.py | 390 +++++++++++++++ resources/lib/plexnet/myplexaccount.py | 4 +- resources/lib/user.py | 9 + resources/lib/util.py | 500 ++++++++++++++++++++ resources/lib/windows/background.py | 24 + service.py | 36 +- 8 files changed, 1170 insertions(+), 22 deletions(-) create mode 100644 resources/lib/kodijsonrpc.py rename resources/lib/{service_entry.py => main.py} (75%) create mode 100644 resources/lib/plex.py create mode 100644 resources/lib/user.py create mode 100644 resources/lib/util.py diff --git a/resources/lib/kodijsonrpc.py b/resources/lib/kodijsonrpc.py new file mode 100644 index 00000000..5ca50121 --- /dev/null +++ b/resources/lib/kodijsonrpc.py @@ -0,0 +1,105 @@ +import xbmc +import json + + +class JSONRPCMethod: + + class Exception(Exception): + pass + + def __init__(self): + self.family = None + + def __getattr__(self, method): + def handler(**kwargs): + command = { + 'jsonrpc': '2.0', + 'id': 1, + 'method': '{0}.{1}'.format(self.family, method) + } + + if kwargs: + command['params'] = kwargs + + # xbmc.log(json.dumps(command)) + ret = json.loads(xbmc.executeJSONRPC(json.dumps(command))) + + if ret: + if 'error' in ret: + raise self.Exception(ret['error']) + else: + return ret['result'] + else: + return None + + return handler + + def __call__(self, family): + self.family = family + return self + + +class KodiJSONRPC: + def __init__(self): + self.methodHandler = JSONRPCMethod() + + def __getattr__(self, family): + return self.methodHandler(family) + + +rpc = KodiJSONRPC() + + +class BuiltInMethod: + + class Exception(Exception): + pass + + def __init__(self): + self.module = None + + def __getattr__(self, method): + def handler(*args, **kwargs): + args = [str(a).replace(',', '\,') for a in args] + for k, v in kwargs.items(): + args.append('{0}={v}'.format(k, str(v).replace(',', '\,'))) + + if args: + command = '{0}.{1}({2})'.format(self.module, method, ','.join(args)) + else: + command = '{0}.{1}'.format(self.module, method) + + xbmc.log(command, xbmc.LOGNOTICE) + + xbmc.executebuiltin(command) + + return handler + + def __call__(self, *args, **kwargs): + args = [str(a).replace(',', '\,') for a in args] + for k, v in kwargs.items(): + args.append('{0}={v}'.format(k, str(v).replace(',', '\,'))) + + if args: + command = '{0}({1})'.format(self.module, ','.join(args)) + else: + command = '{0}'.format(self.module) + + xbmc.log(command, xbmc.LOGNOTICE) + + xbmc.executebuiltin(command) + + def initModule(self, module): + self.module = module + return self + + +class KodiBuiltin: + def __init__(self): + self.methodHandler = BuiltInMethod() + + def __getattr__(self, module): + return self.methodHandler.initModule(module) + + +builtin = KodiBuiltin() diff --git a/resources/lib/service_entry.py b/resources/lib/main.py similarity index 75% rename from resources/lib/service_entry.py rename to resources/lib/main.py index 333fac73..140794bd 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/main.py @@ -3,8 +3,14 @@ from __future__ import absolute_import, division, unicode_literals import logging import sys +import threading +import gc + import xbmc +from . import plex, util, backgroundthread +from .plexnet import plexapp, threadutils + from . import utils from . import userclient from . import initialsetup @@ -23,7 +29,7 @@ from . import loghandler ############################################################################### loghandler.config() -LOG = logging.getLogger("PLEX.service_entry") +LOG = logging.getLogger("PLEX.main") ############################################################################### @@ -264,22 +270,104 @@ class Service(): LOG.info("======== STOP %s ========", v.ADDON_NAME) -def start(): - # Safety net - Kody starts PKC twice upon first installation! - if utils.window('plex_service_started') == 'true': - EXIT = True - else: - utils.window('plex_service_started', value='true') - EXIT = False +def waitForThreads(): + LOG.debug('Checking for any remaining threads') + while len(threading.enumerate()) > 1: + for t in threading.enumerate(): + if t != threading.currentThread(): + if t.isAlive(): + LOG.debug('Waiting on thread: %s', t.name) + if isinstance(t, threading._Timer): + t.cancel() + t.join() + elif isinstance(t, threadutils.KillableThread): + t.kill(force_and_wait=True) + else: + t.join() + LOG.debug('All threads done') - # Delay option - DELAY = int(utils.settings('startupDelay')) - LOG.info("Delaying Plex startup by: %s sec...", DELAY) - if EXIT: - LOG.error('PKC service.py already started - exiting this instance') - elif DELAY and xbmc.Monitor().waitForAbort(DELAY): - # Start the service - LOG.info("Abort requested while waiting. PKC not started.") - else: - Service().ServiceEntryPoint() +def signout(): + util.setSetting('auth.token', '') + LOG.info('Signing out...') + plexapp.ACCOUNT.signOut() + + +def main(): + LOG.info('Starting %s', util.ADDON.getAddonInfo('version')) + LOG.info('User-agent: %s', plex.defaultUserAgent()) + + try: + while not xbmc.abortRequested: + if plex.init(): + while not xbmc.abortRequested: + if ( + not plexapp.ACCOUNT.isOffline and not + plexapp.ACCOUNT.isAuthenticated and + (len(plexapp.ACCOUNT.homeUsers) > 1 or plexapp.ACCOUNT.isProtected) + + ): + result = userselect.start() + if not result: + return + elif result == 'signout': + signout() + break + elif result == 'signin': + break + LOG.info('User selected') + + try: + selectedServer = plexapp.SERVERMANAGER.selectedServer + + if not selectedServer: + LOG.debug('Waiting for selected server...') + for timeout, skip_preferred, skip_owned in ((10, True, False), (10, True, True)): + plex.CallbackEvent(plexapp.APP, 'change:selectedServer', timeout=timeout).wait() + + selectedServer = plexapp.SERVERMANAGER.checkSelectedServerSearch(skip_preferred=skip_preferred, skip_owned=skip_owned) + if selectedServer: + break + else: + LOG.debug('Finished waiting for selected server...') + + LOG.info('Starting with server: %s', selectedServer) + + windowutils.HOME = home.HomeWindow.open() + util.CRON.cancelReceiver(windowutils.HOME) + + if not windowutils.HOME.closeOption: + return + + closeOption = windowutils.HOME.closeOption + + windowutils.shutdownHome() + + if closeOption == 'signout': + signout() + break + elif closeOption == 'switch': + plexapp.ACCOUNT.isAuthenticated = False + finally: + windowutils.shutdownHome() + gc.collect(2) + + else: + break + except: + util.ERROR() + finally: + LOG.info('SHUTTING DOWN...') + # player.shutdown() + plexapp.APP.preShutdown() + if util.CRON: + util.CRON.stop() + backgroundthread.BGThreader.shutdown() + plexapp.APP.shutdown() + waitForThreads() + LOG.info('SHUTDOWN FINISHED') + + from .windows import kodigui + kodigui.MONITOR = None + util.shutdown() + gc.collect(2) diff --git a/resources/lib/plex.py b/resources/lib/plex.py new file mode 100644 index 00000000..e60e594e --- /dev/null +++ b/resources/lib/plex.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +import logging +import sys +import platform +import uuid +import json +import threading +import time +import requests + +import xbmc + +from .plexnet import plexapp, myplex +from . import util + +LOG = logging.getLogger('PLEX.plex') + + +class PlexTimer(plexapp.Timer): + def shouldAbort(self): + return xbmc.abortRequested + + +def abortFlag(): + return util.MONITOR.abortRequested() + + +plexapp.setTimer(PlexTimer) +plexapp.setAbortFlagFunction(abortFlag) + +maxVideoRes = plexapp.Res((3840, 2160)) # INTERFACE.globals["supports4k"] and plexapp.Res((3840, 2160)) or plexapp.Res((1920, 1080)) + +CLIENT_ID = util.getSetting('client.ID') +if not CLIENT_ID: + CLIENT_ID = str(uuid.uuid4()) + util.setSetting('client.ID', CLIENT_ID) + + +def defaultUserAgent(): + """Return a string representing the default user agent.""" + _implementation = platform.python_implementation() + + if _implementation == 'CPython': + _implementation_version = platform.python_version() + elif _implementation == 'PyPy': + _implementation_version = '%s.%s.%s' % (sys.pypy_version_info.major, + sys.pypy_version_info.minor, + sys.pypy_version_info.micro) + if sys.pypy_version_info.releaselevel != 'final': + _implementation_version = ''.join([_implementation_version, sys.pypy_version_info.releaselevel]) + elif _implementation == 'Jython': + _implementation_version = platform.python_version() # Complete Guess + elif _implementation == 'IronPython': + _implementation_version = platform.python_version() # Complete Guess + else: + _implementation_version = 'Unknown' + + try: + p_system = platform.system() + p_release = platform.release() + except IOError: + p_system = 'Unknown' + p_release = 'Unknown' + + return " ".join(['%s/%s' % ('Plex-for-Kodi', util.ADDON.getAddonInfo('version')), + '%s/%s' % ('Kodi', xbmc.getInfoLabel('System.BuildVersion').replace(' ', '-')), + '%s/%s' % (_implementation, _implementation_version), + '%s/%s' % (p_system, p_release)]) + +class PlexInterface(plexapp.AppInterface): + _regs = { + None: {}, + } + _globals = { + 'platform': 'Kodi', + 'appVersionStr': util.ADDON.getAddonInfo('version'), + 'clientIdentifier': CLIENT_ID, + 'platformVersion': xbmc.getInfoLabel('System.BuildVersion'), + 'product': 'Plex for Kodi', + 'provides': 'player', + 'device': util.getPlatform() or plexapp.PLATFORM, + 'model': 'Unknown', + 'friendlyName': 'Kodi Add-on ({0})'.format(platform.node()), + 'supports1080p60': True, + 'vp9Support': True, + 'transcodeVideoQualities': [ + "10", "20", "30", "30", "40", "60", "60", "75", "100", "60", "75", "90", "100", "100" + ], + 'transcodeVideoResolutions': [ + plexapp.Res((220, 180)), + plexapp.Res((220, 128)), + plexapp.Res((284, 160)), + plexapp.Res((420, 240)), + plexapp.Res((576, 320)), + plexapp.Res((720, 480)), + plexapp.Res((1024, 768)), + plexapp.Res((1280, 720)), + plexapp.Res((1280, 720)), + maxVideoRes, maxVideoRes, maxVideoRes, maxVideoRes, maxVideoRes + ], + 'transcodeVideoBitrates': [ + "64", "96", "208", "320", "720", "1500", "2000", "3000", "4000", "8000", "10000", "12000", "20000", "200000" + ], + 'deviceInfo': plexapp.DeviceInfo() + } + + def getPreference(self, pref, default=None): + if pref == 'manual_connections': + return self.getManualConnections() + else: + return util.getSetting(pref, default) + + def getManualConnections(self): + conns = [] + for i in range(2): + ip = util.getSetting('manual_ip_{0}'.format(i)) + if not ip: + continue + port = util.getSetting('manual_port_{0}'.format(i), 32400) + conns.append({'connection': ip, 'port': port}) + return json.dumps(conns) + + def setPreference(self, pref, value): + util.setSetting(pref, value) + + def getRegistry(self, reg, default=None, sec=None): + if sec == 'myplex' and reg == 'MyPlexAccount': + ret = util.getSetting('{0}.{1}'.format(sec, reg), default) + if ret: + return ret + return json.dumps({'authToken': util.getSetting('auth.token')}) + else: + return util.getSetting('{0}.{1}'.format(sec, reg), default) + + def setRegistry(self, reg, value, sec=None): + util.setSetting('{0}.{1}'.format(sec, reg), value) + + def clearRegistry(self, reg, sec=None): + util.setSetting('{0}.{1}'.format(sec, reg), '') + + def addInitializer(self, sec): + pass + + def clearInitializer(self, sec): + pass + + def getGlobal(self, glbl, default=None): + if glbl == 'transcodeVideoResolutions': + maxres = self.getPreference('allow_4k', True) and plexapp.Res((3840, 2160)) or plexapp.Res((1920, 1080)) + self._globals['transcodeVideoResolutions'][-5:] = [maxres] * 5 + return self._globals.get(glbl, default) + + def getCapabilities(self): + return '' + + def LOG(self, msg): + LOG.debug('API: %s', msg) + + def DEBUG_LOG(self, msg): + LOG.debug('API: %s', msg) + + def WARN_LOG(self, msg): + LOG.warn('API: %s', msg) + + def ERROR_LOG(self, msg): + LOG.error('API: %s', msg) + + def ERROR(self, msg=None, err=None): + if err: + LOG.error('%s - %s', msg, err.message) + else: + util.ERROR() + + def supportsAudioStream(self, codec, channels): + return True + # if codec = invalid then return true + + # canDownmix = (m.globals["audioDownmix"][codec] <> invalid) + # supportsSurroundSound = m.SupportsSurroundSound() + + # if not supportsSurroundSound and canDownmix then + # maxChannels = m.globals["audioDownmix"][codec] + # else + # maxChannels = firstOf(m.globals["audioDecoders"][codec], 0) + # end if + + # if maxChannels > 2 and not canDownmix and not supportsSurroundSound then + # ' It's a surround sound codec and we can't do surround sound + # supported = false + # else if maxChannels = 0 or maxChannels < channels then + # ' The codec is either unsupported or can't handle the requested channels + # supported = false + # else + # supported = true + + # return supported + + def supportsSurroundSound(self): + return True + + def getQualityIndex(self, qualityType): + if qualityType == self.QUALITY_LOCAL: + return self.getPreference("local_quality", 13) + elif qualityType == self.QUALITY_ONLINE: + return self.getPreference("online_quality", 8) + else: + return self.getPreference("remote_quality", 13) + + def getMaxResolution(self, quality_type, allow4k=False): + qualityIndex = self.getQualityIndex(quality_type) + + if qualityIndex >= 9: + if self.getPreference('allow_4k', True): + return allow4k and 2160 or 1088 + else: + return 1088 + elif qualityIndex >= 6: + return 720 + elif qualityIndex >= 5: + return 480 + else: + return 360 + + +plexapp.setInterface(PlexInterface()) +plexapp.setUserAgent(defaultUserAgent()) + + +class CallbackEvent(plexapp.CompatEvent): + def __init__(self, context, signal, timeout=15, *args, **kwargs): + threading._Event.__init__(self, *args, **kwargs) + self.start = time.time() + self.context = context + self.signal = signal + self.timeout = timeout + self.context.on(self.signal, self.set) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.wait() + + def __repr__(self): + return '<{0}:{1}>'.format(self.__class__.__name__, self.signal) + + def set(self, **kwargs): + threading._Event.set(self) + + def wait(self): + if not threading._Event.wait(self, self.timeout): + LOG.debug('%s: TIMED-OUT', self) + self.close() + + def triggeredOrTimedOut(self, timeout=None): + try: + if time.time() - self.start() > self.timeout: + LOG.debug('%s: TIMED-OUT', self) + return True + + if timeout: + threading._Event.wait(self, timeout) + finally: + return self.isSet() + + def close(self): + self.set() + self.context.off(self.signal, self.set) + + +def init(): + LOG.info('Initializing') + + with CallbackEvent(plexapp.APP, 'init'): + plexapp.init() + LOG.info('Waiting for account initialization...') + + retry = True + + while retry: + retry = False + if not plexapp.ACCOUNT.authToken: + token = authorize() + + if not token: + LOG.info('FAILED TO AUTHORIZE') + return False + + with CallbackEvent(plexapp.APP, 'account:response'): + plexapp.ACCOUNT.validateToken(token) + LOG.info('Waiting for account initialization') + + # if not PLEX: + # util.messageDialog('Connection Error', u'Unable to connect to any servers') + # util.DEBUG_LOG('SIGN IN: Failed to connect to any servers') + # return False + + # util.DEBUG_LOG('SIGN IN: Connected to server: {0} - {1}'.format(PLEX.friendlyName, PLEX.baseuri)) + success = requirePlexPass() + if success == 'RETRY': + retry = True + continue + + return success + + +def requirePlexPass(): + return True + # if not plexapp.ACCOUNT.hasPlexPass(): + # from windows import signin, background + # background.setSplash(False) + # w = signin.SignInPlexPass.open() + # retry = w.retry + # del w + # util.DEBUG_LOG('PlexPass required. Signing out...') + # plexapp.ACCOUNT.signOut() + # plexapp.SERVERMANAGER.clearState() + # if retry: + # return 'RETRY' + # else: + # return False + + # return True + + +def authorize(): + from .windows import background + with background.BackgroundContext(function=_authorize) as win: + return win.result + + +def _authorize(): + from .windows import signin, background + + background.setSplash(False) + + back = signin.Background.create() + + pre = signin.PreSignInWindow.open() + try: + if not pre.doSignin: + return None + finally: + del pre + + try: + while True: + pinLoginWindow = signin.PinLoginWindow.create() + try: + pl = myplex.PinLogin() + except requests.ConnectionError: + util.ERROR() + util.messageDialog(util.T(32427, 'Failed'), util.T(32449, 'Sign-in failed. Cound not connect to plex.tv')) + return + + pinLoginWindow.setPin(pl.pin) + + try: + pl.startTokenPolling() + while not pl.finished(): + if pinLoginWindow.abort: + util.DEBUG_LOG('SIGN IN: Pin login aborted') + pl.abort() + return None + xbmc.sleep(100) + else: + if not pl.expired(): + if pl.authenticationToken: + pinLoginWindow.setLinking() + return pl.authenticationToken + else: + return None + finally: + pinLoginWindow.doClose() + del pinLoginWindow + + if pl.expired(): + util.DEBUG_LOG('SIGN IN: Pin expired') + expiredWindow = signin.ExpiredWindow.open() + try: + if not expiredWindow.refresh: + util.DEBUG_LOG('SIGN IN: Pin refresh aborted') + return None + finally: + del expiredWindow + finally: + back.doClose() + del back diff --git a/resources/lib/plexnet/myplexaccount.py b/resources/lib/plexnet/myplexaccount.py index 30e81933..29bbf0fd 100644 --- a/resources/lib/plexnet/myplexaccount.py +++ b/resources/lib/plexnet/myplexaccount.py @@ -1,3 +1,4 @@ +import logging import json import time import hashlib @@ -11,6 +12,7 @@ import asyncadapter import util +LOG = logging.getLogger('PLEX.myplexaccount') ACCOUNT = None @@ -71,7 +73,7 @@ class MyPlexAccount(object): def loadState(self): # Look for the new JSON serialization. If it's not there, look for the # old token and Plex Pass values. - + LOG.debug('Loading State') plexapp.APP.addInitializer("myplex") jstring = plexapp.INTERFACE.getRegistry("MyPlexAccount", None, "myplex") diff --git a/resources/lib/user.py b/resources/lib/user.py new file mode 100644 index 00000000..153a2332 --- /dev/null +++ b/resources/lib/user.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +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 diff --git a/resources/lib/util.py b/resources/lib/util.py new file mode 100644 index 00000000..3d6531ce --- /dev/null +++ b/resources/lib/util.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +import gc +import sys +import re +import binascii +import json +import threading +import math +import time +import datetime +import contextlib +import urllib + +from .kodijsonrpc import rpc +import xbmc +import xbmcgui +import xbmcaddon + +from .plexnet import signalsmixin + +DEBUG = True +_SHUTDOWN = False + +ADDON = xbmcaddon.Addon() + +PROFILE = xbmc.translatePath(ADDON.getAddonInfo('profile')).decode('utf-8') + +SETTINGS_LOCK = threading.Lock() + + +class UtilityMonitor(xbmc.Monitor, signalsmixin.SignalsMixin): + def watchStatusChanged(self): + self.trigger('changed.watchstatus') + + def onNotification(self, sender, method, data): + if (sender == 'plugin.video.plexkodiconnect' and + method.endswith('RESTORE')): + from .windows import kodigui + xbmc.executebuiltin('ActivateWindow({0})'.format(kodigui.BaseFunctions.lastWinID)) + + +MONITOR = UtilityMonitor() + + +def T(ID, eng=''): + return ADDON.getLocalizedString(ID) + + +def LOG(msg, level=xbmc.LOGNOTICE): + xbmc.log('PLEX: {0}'.format(msg), level) + + +def DEBUG_LOG(msg): + if _SHUTDOWN: + return + + if not getSetting('debug', False) and not xbmc.getCondVisibility('System.GetBool(debug.showloginfo)'): + return + + LOG(msg) + + +def ERROR(txt='', hide_tb=False, notify=False): + if isinstance(txt, str): + txt = txt.decode("utf-8") + short = str(sys.exc_info()[1]) + if hide_tb: + xbmc.log('PLEX: {0} - {1}'.format(txt, short), xbmc.LOGERROR) + return short + + import traceback + tb = traceback.format_exc() + xbmc.log("_________________________________________________________________________________", xbmc.LOGERROR) + xbmc.log('PLEX: ' + txt, xbmc.LOGERROR) + for l in tb.splitlines(): + xbmc.log(' ' + l, xbmc.LOGERROR) + xbmc.log("_________________________________________________________________________________", xbmc.LOGERROR) + xbmc.log("`", xbmc.LOGERROR) + if notify: + showNotification('ERROR: {0}'.format(short)) + return short + + +def TEST(msg): + xbmc.log('---TEST: {0}'.format(msg), xbmc.LOGNOTICE) + + +def getSetting(key, default=None): + with SETTINGS_LOCK: + setting = ADDON.getSetting(key) + return _processSetting(setting, default) + + +def _processSetting(setting, default): + if not setting: + return default + if isinstance(default, bool): + return setting.lower() == 'true' + elif isinstance(default, float): + return float(setting) + elif isinstance(default, int): + return int(float(setting or 0)) + elif isinstance(default, list): + if setting: + return json.loads(binascii.unhexlify(setting)) + else: + return default + + return setting + + +def setSetting(key, value): + with SETTINGS_LOCK: + value = _processSettingForWrite(value) + ADDON.setSetting(key, value) + + +def _processSettingForWrite(value): + if isinstance(value, list): + value = binascii.hexlify(json.dumps(value)) + elif isinstance(value, bool): + value = value and 'true' or 'false' + return str(value) + + +def setGlobalProperty(key, val): + xbmcgui.Window(10000).setProperty( + 'plugin.video.plexkodiconnect.{0}'.format(key), val) + + +def setGlobalBoolProperty(key, boolean): + xbmcgui.Window(10000).setProperty( + 'plugin.video.plexkodiconnect.{0}'.format(key), boolean and '1' or '') + + +def getGlobalProperty(key): + return xbmc.getInfoLabel( + 'Window(10000).Property(plugin.video.plexkodiconnect.{0})'.format(key)) + + +def showNotification(message, time_ms=3000, icon_path=None, header=ADDON.getAddonInfo('name')): + try: + icon_path = icon_path or xbmc.translatePath(ADDON.getAddonInfo('icon')).decode('utf-8') + xbmc.executebuiltin('Notification({0},{1},{2},{3})'.format(header, message, time_ms, icon_path)) + except RuntimeError: # Happens when disabling the addon + LOG(message) + + +def videoIsPlaying(): + return xbmc.getCondVisibility('Player.HasVideo') + + +def messageDialog(heading='Message', msg=''): + from .windows import optionsdialog + optionsdialog.show(heading, msg, 'OK') + + +def showTextDialog(heading, text): + t = TextBox() + t.setControls(heading, text) + + +def sortTitle(title): + return title.startswith('The ') and title[4:] or title + + +def durationToText(seconds): + """ + Converts seconds to a short user friendly string + Example: 143 -> 2m 23s + """ + days = int(seconds / 86400000) + if days: + return '{0} day{1}'.format(days, days > 1 and 's' or '') + left = seconds % 86400000 + hours = int(left / 3600000) + if hours: + hours = '{0} hr{1} '.format(hours, hours > 1 and 's' or '') + else: + hours = '' + left = left % 3600000 + mins = int(left / 60000) + if mins: + return hours + '{0} min{1}'.format(mins, mins > 1 and 's' or '') + elif hours: + return hours.rstrip() + secs = int(left % 60000) + if secs: + secs /= 1000 + return '{0} sec{1}'.format(secs, secs > 1 and 's' or '') + return '0 seconds' + + +def durationToShortText(seconds): + """ + Converts seconds to a short user friendly string + Example: 143 -> 2m 23s + """ + days = int(seconds / 86400000) + if days: + return '{0} d'.format(days) + left = seconds % 86400000 + hours = int(left / 3600000) + if hours: + hours = '{0} h '.format(hours) + else: + hours = '' + left = left % 3600000 + mins = int(left / 60000) + if mins: + return hours + '{0} m'.format(mins) + elif hours: + return hours.rstrip() + secs = int(left % 60000) + if secs: + secs /= 1000 + return '{0} s'.format(secs) + return '0 s' + + +def cleanLeadingZeros(text): + if not text: + return '' + return re.sub('(?<= )0(\d)', r'\1', text) + + +def removeDups(dlist): + return [ii for n, ii in enumerate(dlist) if ii not in dlist[:n]] + + +SIZE_NAMES = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + + +def simpleSize(size): + """ + Converts bytes to a short user friendly string + Example: 12345 -> 12.06 KB + """ + s = 0 + if size > 0: + i = int(math.floor(math.log(size, 1024))) + p = math.pow(1024, i) + s = round(size / p, 2) + if (s > 0): + return '%s %s' % (s, SIZE_NAMES[i]) + else: + return '0B' + + +def timeDisplay(ms): + h = ms / 3600000 + m = (ms % 3600000) / 60000 + s = (ms % 60000) / 1000 + return '{0:0>2}:{1:0>2}:{2:0>2}'.format(h, m, s) + + +def simplifiedTimeDisplay(ms): + left, right = timeDisplay(ms).rsplit(':', 1) + left = left.lstrip('0:') or '0' + return left + ':' + right + + +def shortenText(text, size): + if len(text) < size: + return text + + return u'{0}\u2026'.format(text[:size - 1]) + + +class TextBox: + # constants + WINDOW = 10147 + CONTROL_LABEL = 1 + CONTROL_TEXTBOX = 5 + + def __init__(self, *args, **kwargs): + # activate the text viewer window + xbmc.executebuiltin("ActivateWindow(%d)" % (self.WINDOW, )) + # get window + self.win = xbmcgui.Window(self.WINDOW) + # give window time to initialize + xbmc.sleep(1000) + + def setControls(self, heading, text): + # set heading + self.win.getControl(self.CONTROL_LABEL).setLabel(heading) + # set text + self.win.getControl(self.CONTROL_TEXTBOX).setText(text) + + +class SettingControl: + def __init__(self, setting, log_display, disable_value=''): + self.setting = setting + self.logDisplay = log_display + self.disableValue = disable_value + self._originalMode = None + self.store() + + def disable(self): + rpc.Settings.SetSettingValue(setting=self.setting, value=self.disableValue) + DEBUG_LOG('{0}: DISABLED'.format(self.logDisplay)) + + def set(self, value): + rpc.Settings.SetSettingValue(setting=self.setting, value=value) + DEBUG_LOG('{0}: SET={1}'.format(self.logDisplay, value)) + + def store(self): + try: + self._originalMode = rpc.Settings.GetSettingValue(setting=self.setting).get('value') + DEBUG_LOG('{0}: Mode stored ({1})'.format(self.logDisplay, self._originalMode)) + except: + ERROR() + + def restore(self): + if self._originalMode is None: + return + rpc.Settings.SetSettingValue(setting=self.setting, value=self._originalMode) + DEBUG_LOG('{0}: RESTORED'.format(self.logDisplay)) + + @contextlib.contextmanager + def suspend(self): + self.disable() + yield + self.restore() + + @contextlib.contextmanager + def save(self): + yield + self.restore() + + +def timeInDayLocalSeconds(): + now = datetime.datetime.now() + sod = datetime.datetime(year=now.year, month=now.month, day=now.day) + sod = int(time.mktime(sod.timetuple())) + return int(time.time() - sod) + + +CRON = None + + +class CronReceiver(): + def tick(self): + pass + + def halfHour(self): + pass + + def day(self): + pass + + +class Cron(threading.Thread): + def __init__(self, interval): + threading.Thread.__init__(self, name='CRON') + self.stopped = threading.Event() + self.force = threading.Event() + self.interval = interval + self._lastHalfHour = self._getHalfHour() + self._receivers = [] + + global CRON + + CRON = self + + def __enter__(self): + self.start() + DEBUG_LOG('Cron started') + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.stop() + self.join() + + def _wait(self): + ct = 0 + while ct < self.interval: + xbmc.sleep(100) + ct += 0.1 + if self.force.isSet(): + self.force.clear() + return True + if xbmc.abortRequested or self.stopped.isSet(): + return False + return True + + def forceTick(self): + self.force.set() + + def stop(self): + self.stopped.set() + + def run(self): + while self._wait(): + self._tick() + DEBUG_LOG('Cron stopped') + + def _getHalfHour(self): + tid = timeInDayLocalSeconds() / 60 + return tid - (tid % 30) + + def _tick(self): + receivers = list(self._receivers) + receivers = self._halfHour(receivers) + for r in receivers: + try: + r.tick() + except: + ERROR() + + def _halfHour(self, receivers): + hh = self._getHalfHour() + if hh == self._lastHalfHour: + return receivers + try: + receivers = self._day(receivers, hh) + ret = [] + for r in receivers: + try: + if not r.halfHour(): + ret.append(r) + except: + ret.append(r) + ERROR() + return ret + finally: + self._lastHalfHour = hh + + def _day(self, receivers, hh): + if hh >= self._lastHalfHour: + return receivers + ret = [] + for r in receivers: + try: + if not r.day(): + ret.append(r) + except: + ret.append(r) + ERROR() + return ret + + def registerReceiver(self, receiver): + if receiver not in self._receivers: + DEBUG_LOG('Cron: Receiver added: {0}'.format(receiver)) + self._receivers.append(receiver) + + def cancelReceiver(self, receiver): + if receiver in self._receivers: + DEBUG_LOG('Cron: Receiver canceled: {0}'.format(receiver)) + self._receivers.pop(self._receivers.index(receiver)) + + +def getPlatform(): + for key in [ + 'System.Platform.Android', + 'System.Platform.Linux.RaspberryPi', + 'System.Platform.Linux', + 'System.Platform.Windows', + 'System.Platform.OSX', + 'System.Platform.IOS', + 'System.Platform.Darwin', + 'System.Platform.ATV2' + ]: + if xbmc.getCondVisibility(key): + return key.rsplit('.', 1)[-1] + + +def getProgressImage(obj): + if not obj.get('viewOffset'): + return '' + pct = int((obj.viewOffset.asInt() / obj.duration.asFloat()) * 100) + pct = pct - pct % 2 # Round to even number - we have even numbered progress only + return 'plugin.video.plexkodiconnect/progress/{0}.png'.format(pct) + + +def trackIsPlaying(track): + return xbmc.getCondVisibility('String.StartsWith(MusicPlayer.Comment,{0})'.format('PLEX-{0}:'.format(track.ratingKey))) + + +def addURLParams(url, params): + if '?' in url: + url += '&' + else: + url += '?' + url += urllib.urlencode(params) + return url + + +def garbageCollect(): + gc.collect(2) + + +def shutdown(): + global MONITOR, ADDON, T, _SHUTDOWN + _SHUTDOWN = True + del MONITOR + del T + del ADDON diff --git a/resources/lib/windows/background.py b/resources/lib/windows/background.py index 057cd053..d1ce08e0 100644 --- a/resources/lib/windows/background.py +++ b/resources/lib/windows/background.py @@ -42,3 +42,27 @@ def setSplash(on=True): def setShutdown(on=True): utils.setGlobalProperty('background.shutdown', on and '1' or '') + + +class BackgroundContext(object): + """ + Context Manager to open a Plex background window - in the background. This + will e.g. ensure that you can capture key-presses + Use like this: + with BackgroundContext(function) as win: + + result = win.result + """ + def __init__(self, function=None): + self.window = None + self.result = None + self.function = function + + def __enter__(self): + self.window = BackgroundWindow.create(function=self.function) + self.window.modal() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.result = self.window.result + del self.window diff --git a/service.py b/service.py index 39657f3b..f4e50e96 100644 --- a/service.py +++ b/service.py @@ -1,8 +1,38 @@ +#!/usr/bin/env python # -*- coding: utf-8 -*- ############################################################################### from __future__ import absolute_import, division, unicode_literals -from resources.lib import service_entry +import xbmc +import xbmcgui +import xbmcaddon -if __name__ == "__main__": - service_entry.start() +def start(): + # Safety net - Kodi starts PKC twice upon first installation! + if xbmc.getInfoLabel( + 'Window(10000).Property(plugin.video.plexkodiconnect.running)').decode('utf-8') == '1': + xbmc.log('PLEX: PlexKodiConnect is already running', + level=xbmc.LOGWARNING) + return + else: + xbmcgui.Window(10000).setProperty( + 'plugin.video.plexkodiconnect.running', '1') + try: + # We might have to wait a bit before starting PKC + delay = int(xbmcaddon.Addon( + id='plugin.video.plexkodiconnect').getSetting('startupDelay')) + xbmc.log('PLEX: Delaying PKC startup by: %s seconds'.format(delay), + level=xbmc.LOGNOTICE) + if delay and xbmc.Monitor().waitForAbort(delay): + xbmc.log('PLEX: Kodi shutdown while waiting for PKC startup', + level=xbmc.LOGWARNING) + return + from resources.lib import main + main.main() + finally: + xbmcgui.Window(10000).setProperty( + 'plugin.video.plexkodiconnect.running', '') + + +if __name__ == '__main__': + start()