From a9af3519ad4872235db08b703995dc8e2ea30ba3 Mon Sep 17 00:00:00 2001 From: Dag Wieers Date: Mon, 16 Sep 2019 23:19:17 +0200 Subject: [PATCH 1/3] Add our own kodiutils library This PR includes: - Introduce a kodiutils library - Import some library to blocks where they are needed - Import only the stuff we need from libraries - Add proxy-support testing --- .travis.yml | 4 +- Makefile | 11 +- lib/inputstreamhelper/__init__.py | 350 +++++++++++------------------ lib/inputstreamhelper/api.py | 3 +- lib/inputstreamhelper/config.py | 29 ++- lib/inputstreamhelper/kodiutils.py | 129 +++++++++++ requirements.txt | 1 + test/test_api.py | 3 + test/test_ishelper_android_arm.py | 3 + test/test_ishelper_linux_arm.py | 3 + test/test_ishelper_linux_x64.py | 3 + test/test_ishelper_macos_x64.py | 3 + test/test_ishelper_windows_x64.py | 3 + test/test_proxy.py | 59 +++++ test/userdata/addon_settings.json | 10 +- test/userdata/global_settings.json | 8 +- test/xbmc.py | 4 + test/xbmcextra.py | 11 - test/xbmcgui.py | 16 +- 19 files changed, 389 insertions(+), 264 deletions(-) create mode 100644 lib/inputstreamhelper/kodiutils.py create mode 100644 test/test_proxy.py diff --git a/.travis.yml b/.travis.yml index 6f8e8458..5a595bc8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,8 +19,10 @@ script: - pylint lib/ test/ #- kodi-addon-checker . --branch=krypton #- kodi-addon-checker . --branch=leia -- coverage run default.py +- coverage run -a default.py +- proxy.py & - coverage run -m unittest discover +- pkill -ef proxy.py after_success: - codecov diff --git a/Makefile b/Makefile index 3c09b398..1d19d5ef 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ zip_dir = $(name)/ blue = \e[1;34m white = \e[1;37m -reset = \e[0m +reset = \e[0;39m .PHONY: test @@ -43,9 +43,14 @@ addon: clean unit: clean @echo -e "$(white)=$(blue) Starting unit tests$(reset)" - python default.py + -pkill -ef proxy.py + proxy.py & python -m unittest discover -# coverage run -m unittest discover + pkill -ef proxy.py + +run: + @echo -e "$(white)=$(blue) Run CLI$(reset)" + python default.py zip: clean @echo -e "$(white)=$(blue) Building new package$(reset)" diff --git a/lib/inputstreamhelper/__init__.py b/lib/inputstreamhelper/__init__.py index 62f86a5f..0704e301 100644 --- a/lib/inputstreamhelper/__init__.py +++ b/lib/inputstreamhelper/__init__.py @@ -4,34 +4,28 @@ import os import platform -import zipfile import json -import time import subprocess import shutil -import re from distutils.version import LooseVersion # pylint: disable=import-error,no-name-in-module from datetime import datetime, timedelta -import struct try: # Python 3 + from http.client import BadStatusLine from urllib.error import HTTPError from urllib.request import build_opener, install_opener, ProxyHandler, urlopen except ImportError: # Python 2 + from httplib import BadStatusLine from urllib2 import build_opener, HTTPError, install_opener, ProxyHandler, urlopen from inputstreamhelper import config import xbmc -import xbmcaddon -import xbmcgui +from xbmcaddon import Addon +from xbmcgui import Dialog, DialogProgress import xbmcvfs -from .unicodehelper import to_unicode, from_unicode - -ADDON = xbmcaddon.Addon('script.module.inputstreamhelper') -ADDON_PROFILE = to_unicode(xbmc.translatePath(ADDON.getAddonInfo('profile'))) -ADDON_ID = to_unicode(ADDON.getAddonInfo('id')) -ADDON_VERSION = to_unicode(ADDON.getAddonInfo('version')) +from .kodiutils import execute_jsonrpc, get_addon_info, get_proxies, get_setting, localize, log, set_setting +from .unicodehelper import to_unicode # NOTE: Work around issue caused by platform still using os.popen() # This helps to survive 'IOError: [Errno 10] No child processes' @@ -43,43 +37,6 @@ class InputStreamException(Exception): ''' Stub Exception ''' -class SafeDict(dict): - ''' A safe dictionary implementation that does not break down on missing keys ''' - def __missing__(self, key): - ''' Replace missing keys with the original placeholder ''' - return '{' + key + '}' - - -def log(msg, **kwargs): - ''' InputStream Helper log method ''' - xbmc.log(msg=from_unicode('[{addon}-{version}]: {msg}'.format(addon=ADDON_ID, version=ADDON_VERSION, msg=msg.format(**kwargs))), level=xbmc.LOGDEBUG) - - -def localize(string_id, **kwargs): - ''' Return the translated string from the .po language files, optionally translating variables ''' - if kwargs: - import string - return string.Formatter().vformat(ADDON.getLocalizedString(string_id), (), SafeDict(**kwargs)) - - return ADDON.getLocalizedString(string_id) - - -def has_socks(): - ''' Test if socks is installed, and remember this information ''' - - # If it wasn't stored before, check if socks is installed - if not hasattr(has_socks, 'installed'): - try: - import socks # noqa: F401; pylint: disable=unused-variable,unused-import - has_socks.installed = True - except ImportError: - has_socks.installed = False - return None # Detect if this is the first run - - # Return the stored value - return has_socks.installed - - def system_os(): ''' Get system platform, and remember this information ''' @@ -121,7 +78,7 @@ def __init__(self, protocol, drm=None): self.drm = config.DRM_SCHEMES[drm] # Add proxy support to HTTP requests - install_opener(build_opener(ProxyHandler(self._get_proxies()))) + install_opener(build_opener(ProxyHandler(get_proxies()))) def __repr__(self): ''' String representation of Helper class ''' @@ -136,7 +93,7 @@ def _diskspace(cls): @classmethod def _temp_path(cls): ''' Return temporary path, usually ~/.kodi/userdata/addon_data/script.module.inputstreamhelper/temp ''' - temp_path = xbmc.translatePath(os.path.join(ADDON.getSetting('temp_path'), 'temp')) + temp_path = xbmc.translatePath(os.path.join(get_setting('temp_path', 'special://masterprofile/addon_data/script.module.inputstreamhelper'), 'temp')) if not xbmcvfs.exists(temp_path): xbmcvfs.mkdir(temp_path) @@ -154,7 +111,7 @@ def _mnt_path(cls): @classmethod def _ia_cdm_path(cls): ''' Return the specified CDM path for inputstream.adaptive, usually ~/.kodi/cdm ''' - addon = xbmcaddon.Addon('inputstream.adaptive') + addon = Addon('inputstream.adaptive') cdm_path = to_unicode(xbmc.translatePath(addon.getSetting('DECRYPTERPATH'))) if not xbmcvfs.exists(cdm_path): xbmcvfs.mkdir(cdm_path) @@ -197,14 +154,17 @@ def _kodi_version(cls): def _arch(cls): """Map together and return the system architecture.""" arch = platform.machine() - if arch == 'aarch64' and (struct.calcsize('P') * 8) == 32: - # Detected 64-bit kernel in 32-bit userspace, use 32-bit arm widevine - arch = 'arm' + if arch == 'aarch64': + import struct + if struct.calcsize('P') * 8 == 32: + # Detected 64-bit kernel in 32-bit userspace, use 32-bit arm widevine + arch = 'arm' if arch == 'AMD64': arch_bit = platform.architecture()[0] if arch_bit == '32bit': arch = 'x86' # else, arch = AMD64 elif 'armv' in arch: + import re arm_version = re.search(r'\d+', arch.split('v')[1]) if arm_version: arch = 'armv' + arm_version.group() @@ -233,13 +193,13 @@ def _update_temp_path(self, new_temp_path): """"Updates temp_path and merges files.""" old_temp_path = self._temp_path() - ADDON.setSetting('temp_path', new_temp_path) + set_setting('temp_path', new_temp_path) shutil.move(old_temp_path, new_temp_path) def _helper_disabled(self): """Return if inputstreamhelper has been disabled in settings.xml.""" - disabled = ADDON.getSetting('disabled') - if disabled is None: + disabled = get_setting('disabled') + if not disabled: # This is either None or '' if unset self.disable() # Create default entry disabled = 'true' @@ -252,23 +212,24 @@ def _helper_disabled(self): @staticmethod def disable(): ''' Disable plugin ''' - if ADDON.getSetting('disabled') == 'false': - ADDON.setSetting('disabled', 'true') + if get_setting('disabled', 'false') == 'false': + set_setting('disabled', 'true') @staticmethod def enable(): ''' Enable plugin ''' - if ADDON.getSetting('disabled') == 'true': - ADDON.setSetting('disabled', 'false') + if get_setting('disabled', 'false') == 'true': + set_setting('disabled', 'false') def _inputstream_version(self): ''' Return the requested inputstream version ''' - addon = xbmcaddon.Addon(self.inputstream_addon) + addon = Addon(self.inputstream_addon) return to_unicode(addon.getAddonInfo('version')) @staticmethod def _get_lib_version(path): if path: + import re with open(path, 'rb') as library: match = re.search(r'[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+', str(library.read())) return match.group(0).lstrip('0') @@ -291,10 +252,12 @@ def _chromeos_offset(self, bin_path): return str(offset * config.CHROMEOS_BLOCK_SIZE) log('Failed to calculate losetup offset.') - return False + return '0' def _run_cmd(self, cmd, sudo=False, shell=False): ''' Run subprocess command and return if it succeeds as a bool ''' + output = '' + success = False if sudo and os.getuid() != 0 and self._cmd_exists('sudo'): cmd.insert(0, 'sudo') try: @@ -303,11 +266,8 @@ def _run_cmd(self, cmd, sudo=False, shell=False): log('{cmd} cmd executed successfully.', cmd=cmd) except subprocess.CalledProcessError as error: output = error.output - success = False log('{cmd} cmd failed.', cmd=cmd) except OSError as error: - output = '' - success = False log('{cmd} cmd doesn\'t exist. {error}', cmd=cmd, error=error) if output.rstrip(): log('{cmd} cmd output:\n{output}', cmd=cmd, output=output) @@ -373,27 +333,23 @@ def _has_widevine(self): log('Widevine is not installed.') return False - @staticmethod - def _json_rpc_request(payload): - """Kodi JSON-RPC request. Return the response in a dictionary.""" - log('jsonrpc payload: {payload}', payload=payload) - response = xbmc.executeJSONRPC(json.dumps(payload)) - log('jsonrpc response: {response}', response=response) - - return json.loads(response) - @staticmethod def _http_get(url): ''' Perform an HTTP GET request and return content ''' log('Request URL: {url}', url=url) - try: - req = urlopen(url) - log('Response code: {code}', code=req.getcode()) - if 400 <= req.getcode() < 600: - raise HTTPError('HTTP %s Error for url: %s' % (req.getcode(), url), response=req) - except HTTPError: - xbmcgui.Dialog().ok(localize(30004), localize(30013, filename=url.split('/')[-1])) # Failed to retrieve file - return None + + for retry in (False, True): + try: + req = urlopen(url) + log('Response code: {code}', code=req.getcode()) + if 400 <= req.getcode() < 600: + raise HTTPError('HTTP %s Error for url: %s' % (req.getcode(), url), response=req) + break + except HTTPError: + if retry: + Dialog().ok(localize(30004), localize(30013, filename=url.split('/')[-1])) # Failed to retrieve file + return None + content = req.read() # NOTE: Do not log reponse (as could be large) # log('Response: {response}', response=content) @@ -403,21 +359,25 @@ def _http_download(self, url, message=None): """Makes HTTP request and displays a progress dialog on download.""" log('Request URL: {url}', url=url) filename = url.split('/')[-1] - try: - req = urlopen(url) - log('Response code: {code}', code=req.getcode()) - if 400 <= req.getcode() < 600: - raise HTTPError('HTTP %s Error for url: %s' % (req.getcode(), url), response=req) - except HTTPError: - xbmcgui.Dialog().ok(localize(30004), localize(30013, filename=filename)) # Failed to retrieve file - return False + + for retry in (False, True): + try: + req = urlopen(url) + log('Response code: {code}', code=req.getcode()) + if 400 <= req.getcode() < 600: + raise HTTPError('HTTP %s Error for url: %s' % (req.getcode(), url), response=req) + break + except (HTTPError, BadStatusLine): + if retry: + Dialog().ok(localize(30004), localize(30013, filename=filename)) # Failed to retrieve file + return False if not message: # display "downloading [filename]" message = localize(30015, filename=filename) # Downloading file self._download_path = os.path.join(self._temp_path(), filename) total_length = float(req.info().get('content-length')) - progress_dialog = xbmcgui.DialogProgress() + progress_dialog = DialogProgress() progress_dialog.create(localize(30014), message) # Download in progress chunk_size = 32 * 1024 @@ -441,15 +401,7 @@ def _http_download(self, url, message=None): def _has_inputstream(self): """Checks if selected InputStream add-on is installed.""" - payload = { - 'jsonrpc': '2.0', - 'id': 1, - 'method': 'Addons.GetAddonDetails', - 'params': { - 'addonid': self.inputstream_addon - } - } - data = self._json_rpc_request(payload) + data = execute_jsonrpc(dict(jsonrpc='2.0', id=1, method='Addons.GetAddonDetails', params=dict(addonid=self.inputstream_addon))) if 'error' in data: log('{addon} is not installed.', addon=self.inputstream_addon) return False @@ -459,16 +411,7 @@ def _has_inputstream(self): def _inputstream_enabled(self): """Returns whether selected InputStream add-on is enabled..""" - payload = { - 'jsonrpc': '2.0', - 'id': 1, - 'method': 'Addons.GetAddonDetails', - 'params': { - 'addonid': self.inputstream_addon, - 'properties': ['enabled'] - } - } - data = self._json_rpc_request(payload) + data = execute_jsonrpc(dict(jsonrpc='2.0', id=1, method='Addons.GetAddonDetails', params=dict(addonid=self.inputstream_addon, properties=['enabled']))) if data['result']['addon']['enabled']: log('{addon} {version} is enabled.', addon=self.inputstream_addon, version=self._inputstream_version()) return True @@ -478,41 +421,31 @@ def _inputstream_enabled(self): def _enable_inputstream(self): """Enables selected InputStream add-on.""" - payload = { - 'jsonrpc': '2.0', - 'id': 1, - 'method': 'Addons.SetAddonEnabled', - 'params': { - 'addonid': self.inputstream_addon, - 'enabled': True - } - } - data = self._json_rpc_request(payload) + data = execute_jsonrpc(dict(jsonrpc='2.0', id=1, method='Addons.SetAddonEnabled', params=dict(addonid=self.inputstream_addon, enabled=True))) if 'error' in data: return False - return True def _supports_widevine(self): """Checks if Widevine is supported on the architecture/operating system/Kodi version.""" if self._arch() not in config.WIDEVINE_SUPPORTED_ARCHS: log('Unsupported Widevine architecture found: {arch}', arch=self._arch()) - xbmcgui.Dialog().ok(localize(30004), localize(30007)) # Widevine not available on this architecture + Dialog().ok(localize(30004), localize(30007)) # Widevine not available on this architecture return False if system_os() not in config.WIDEVINE_SUPPORTED_OS: log('Unsupported Widevine OS found: {os}', os=system_os()) - xbmcgui.Dialog().ok(localize(30004), localize(30011, os=system_os())) # Operating system not supported by Widevine + Dialog().ok(localize(30004), localize(30011, os=system_os())) # Operating system not supported by Widevine return False if LooseVersion(config.WIDEVINE_MINIMUM_KODI_VERSION[system_os()]) > LooseVersion(self._kodi_version()): log('Unsupported Kodi version for Widevine: {version}', version=self._kodi_version()) - xbmcgui.Dialog().ok(localize(30004), localize(30010, version=config.WIDEVINE_MINIMUM_KODI_VERSION[system_os()])) # Kodi too old + Dialog().ok(localize(30004), localize(30010, version=config.WIDEVINE_MINIMUM_KODI_VERSION[system_os()])) # Kodi too old return False if 'WindowsApps' in xbmc.translatePath('special://xbmcbin/'): # uwp is not supported log('Unsupported UWP Kodi version detected.') - xbmcgui.Dialog().ok(localize(30004), localize(30012)) # Windows Store Kodi falls short + Dialog().ok(localize(30004), localize(30012)) # Windows Store Kodi falls short return False return True @@ -525,7 +458,7 @@ def _select_best_chromeos_image(devices): for device in devices: # Select ARM hardware only for arm_hwid in config.CHROMEOS_RECOVERY_ARM_HWIDS: - if arm_hwid in device['hwidmatch']: + if '^{0} '.format(arm_hwid) in device['hwidmatch']: hwid = arm_hwid break # We found an ARM device, rejoice ! else: @@ -544,13 +477,19 @@ def _select_best_chromeos_image(devices): # Select the newest version if LooseVersion(device['version']) > LooseVersion(best['version']): - log('{device[hwid]} ({device[version]}) is newer than {best[hwid]} ({best[version]})', device=device, best=best) # pylint: disable=invalid-format-index + log('{device[hwid]} ({device[version]}) is newer than {best[hwid]} ({best[version]})', # pylint: disable=invalid-format-index + device=device, + best=best) best = device # Select the smallest image (disk space requirement) elif LooseVersion(device['version']) == LooseVersion(best['version']): if int(device['filesize']) + int(device['zipfilesize']) < int(best['filesize']) + int(best['zipfilesize']): - log('{device[hwid]} ({device_size}) is smaller than {best[hwid]} ({best_size})', device=device, best=best, device_size=int(device['filesize']) + int(device['zipfilesize']), best_size=int(best['filesize']) + int(best['zipfilesize'])) # pylint: disable=invalid-format-index + log('{device[hwid]} ({device_size}) is smaller than {best[hwid]} ({best_size})', # pylint: disable=invalid-format-index + device=device, + best=best, + device_size=int(device['filesize']) + int(device['zipfilesize']), + best_size=int(best['filesize']) + int(best['zipfilesize'])) best = device return best @@ -562,7 +501,8 @@ def _latest_widevine_version(self, eula=False): versions = self._http_get(url) return versions.split()[-1] - ADDON.setSetting('last_update', str(time.mktime(datetime.utcnow().timetuple()))) + import time + set_setting('last_update', str(time.mktime(datetime.utcnow().timetuple()))) if 'x86' in self._arch(): url = config.WIDEVINE_VERSIONS_URL versions = self._http_get(url) @@ -572,7 +512,7 @@ def _latest_widevine_version(self, eula=False): arm_device = self._select_best_chromeos_image(devices) if arm_device is None: log('We could not find an ARM device in recovery.conf') - xbmcgui.Dialog().ok(localize(30004), localize(30005)) + Dialog().ok(localize(30004), localize(30005)) return '' return arm_device['version'] @@ -605,7 +545,7 @@ def _install_widevine_x86(self): downloaded = self._http_download(url) if downloaded: - progress_dialog = xbmcgui.DialogProgress() + progress_dialog = DialogProgress() progress_dialog.create(heading=localize(30043), line1=localize(30044)) # Extracting Widevine CDM progress_dialog.update(94, line1=localize(30049)) # Installing Widevine CDM self._unzip(self._ia_cdm_path()) @@ -622,12 +562,12 @@ def _install_widevine_x86(self): wv_check = self._check_widevine() if wv_check: progress_dialog.update(100, line1=localize(30051)) # Widevine CDM successfully installed. - xbmcgui.Dialog().notification(localize(30037), localize(30051)) # Success! Widevine successfully installed. + Dialog().notification(localize(30037), localize(30051)) # Success! Widevine successfully installed. progress_dialog.close() return wv_check progress_dialog.close() - xbmcgui.Dialog().ok(localize(30004), localize(30005)) # An error occurred + Dialog().ok(localize(30004), localize(30005)) # An error occurred return False @@ -638,38 +578,38 @@ def _install_widevine_arm(self): # pylint: disable=too-many-statements arm_device = self._select_best_chromeos_image(devices) if arm_device is None: log('We could not find an ARM device in recovery.conf') - xbmcgui.Dialog().ok(localize(30004), localize(30005)) + Dialog().ok(localize(30004), localize(30005)) return '' required_diskspace = int(arm_device['filesize']) + int(arm_device['zipfilesize']) - if xbmcgui.Dialog().yesno(localize(30001), # Due to distributing issues, this takes a long time - localize(30006, diskspace=self._sizeof_fmt(required_diskspace))) and self._widevine_eula(): + if Dialog().yesno(localize(30001), # Due to distributing issues, this takes a long time + localize(30006, diskspace=self._sizeof_fmt(required_diskspace))) and self._widevine_eula(): if system_os() != 'Linux': - xbmcgui.Dialog().ok(localize(30004), localize(30019, os=system_os())) + Dialog().ok(localize(30004), localize(30019, os=system_os())) return False while required_diskspace >= self._diskspace(): - if xbmcgui.Dialog().yesno(localize(30004), localize(30055)): # not enough space, alternative path? - self._update_temp_path(xbmcgui.Dialog().browseSingle(3, localize(30909), 'files')) # temporary path + if Dialog().yesno(localize(30004), localize(30055)): # Not enough space, alternative path? + self._update_temp_path(Dialog().browseSingle(3, localize(30909), 'files')) # Temporary path continue else: - xbmcgui.Dialog().ok(localize(30004), # Not enough free disk space - localize(30018, diskspace=self._sizeof_fmt(required_diskspace))) + Dialog().ok(localize(30004), # Not enough free disk space + localize(30018, diskspace=self._sizeof_fmt(required_diskspace))) return False if not self._cmd_exists('fdisk') and not self._cmd_exists('parted'): - xbmcgui.Dialog().ok(localize(30004), localize(30020, command1='fdisk', command2='parted')) # Commands are missing + Dialog().ok(localize(30004), localize(30020, command1='fdisk', command2='parted')) # Commands are missing return False if not self._cmd_exists('mount'): - xbmcgui.Dialog().ok(localize(30004), localize(30021, command='mount')) # Mount command is missing + Dialog().ok(localize(30004), localize(30021, command='mount')) # Mount command is missing return False if not self._cmd_exists('losetup'): - xbmcgui.Dialog().ok(localize(30004), localize(30021, command='losetup')) # Losetup command is missing + Dialog().ok(localize(30004), localize(30021, command='losetup')) # Losetup command is missing return False if os.getuid() != 0: # ask for permissions to run cmds as root - if not xbmcgui.Dialog().yesno(localize(30001), localize(30030, cmds=', '.join(root_cmds)), yeslabel=localize(30027), nolabel=localize(30028)): + if not Dialog().yesno(localize(30001), localize(30030, cmds=', '.join(root_cmds)), yeslabel=localize(30027), nolabel=localize(30028)): return False # Clean up any remaining mounts @@ -678,7 +618,7 @@ def _install_widevine_arm(self): # pylint: disable=too-many-statements url = arm_device['url'] downloaded = self._http_download(url, message=localize(30022)) # Downloading the recovery image if downloaded: - progress_dialog = xbmcgui.DialogProgress() + progress_dialog = DialogProgress() progress_dialog.create(heading=localize(30043), line1=localize(30044)) # Extracting Widevine CDM bin_filename = url.split('/')[-1].replace('.zip', '') bin_path = os.path.join(self._temp_path(), bin_filename) @@ -698,13 +638,13 @@ def _install_widevine_arm(self): # pylint: disable=too-many-statements progress_dialog.update(97, line1=localize(30050)) # Finishing self._cleanup() if self._has_widevine(): - ADDON.setSetting('chromeos_version', arm_device['version']) + set_setting('chromeos_version', arm_device['version']) with open(self._widevine_config_path(), 'w') as config_file: config_file.write(json.dumps(devices, indent=4)) wv_check = self._check_widevine() if wv_check: progress_dialog.update(100, line1=localize(30051)) # Widevine CDM successfully installed. - xbmcgui.Dialog().notification(localize(30037), localize(30051)) # Success! Widevine CDM successfully installed. + Dialog().notification(localize(30037), localize(30051)) # Success! Widevine CDM successfully installed. progress_dialog.close() return wv_check else: @@ -712,7 +652,7 @@ def _install_widevine_arm(self): # pylint: disable=too-many-statements self._cleanup() progress_dialog.close() - xbmcgui.Dialog().ok(localize(30004), localize(30005)) # An error occurred + Dialog().ok(localize(30004), localize(30005)) # An error occurred return False @@ -732,9 +672,9 @@ def remove_widevine(self): if widevinecdm and xbmcvfs.exists(widevinecdm): log('Remove Widevine CDM at {path}', path=widevinecdm) xbmcvfs.delete(widevinecdm) - xbmcgui.Dialog().notification(localize(30037), localize(30052)) # Success! Widevine successfully removed. + Dialog().notification(localize(30037), localize(30052)) # Success! Widevine successfully removed. return True - xbmcgui.Dialog().notification(localize(30004), localize(30053)) # Error. Widevine CDM not found. + Dialog().notification(localize(30004), localize(30053)) # Error. Widevine CDM not found. return False @staticmethod @@ -742,24 +682,23 @@ def _first_run(): """Check if this add-on version is running for the first time""" # Get versions - settings_version = ADDON.getSetting('version') - if settings_version == '': - settings_version = '0.3.4' # settings_version didn't exist in version 0.3.4 and older + settings_version = get_setting('version', '0.3.4') # settings_version didn't exist in version 0.3.4 and older + addon_version = get_addon_info('version') # Compare versions - if LooseVersion(ADDON_VERSION) > LooseVersion(settings_version): + if LooseVersion(addon_version) > LooseVersion(settings_version): # New version found, save addon_version to settings - ADDON.setSetting('version', ADDON_VERSION) - log('inputstreamhelper version {version} is running for the first time', version=ADDON_VERSION) + set_setting('version', addon_version) + log('inputstreamhelper version {version} is running for the first time', version=addon_version) return True return False def _update_widevine(self): """Prompts user to upgrade Widevine CDM when a newer version is available.""" - last_update = ADDON.getSetting('last_update') + last_update = get_setting('last_update') if last_update and not self._first_run(): - last_update_dt = datetime.fromtimestamp(float(ADDON.getSetting('last_update'))) - if last_update_dt + timedelta(days=int(ADDON.getSetting('update_frequency'))) >= datetime.utcnow(): + last_update_dt = datetime.fromtimestamp(float(get_setting('last_update'))) + if last_update_dt + timedelta(days=int(get_setting('update_frequency', '14'))) >= datetime.utcnow(): log('Widevine update check was made on {date}', date=last_update_dt.isoformat()) return @@ -776,7 +715,7 @@ def _update_widevine(self): if LooseVersion(latest_version) > LooseVersion(current_version): log('There is an update available for {component}', component=component) - if xbmcgui.Dialog().yesno(localize(30040), localize(30033), yeslabel=localize(30034), nolabel=localize(30028)): + if Dialog().yesno(localize(30040), localize(30033), yeslabel=localize(30034), nolabel=localize(30028)): self.install_widevine() else: log('User declined to update {component}.', component=component) @@ -796,11 +735,12 @@ def _widevine_eula(self): if not downloaded: return False + import zipfile with zipfile.ZipFile(self._download_path) as archive: with archive.open(config.WIDEVINE_LICENSE_FILE) as file_obj: eula = file_obj.read().decode().strip().replace('\n', ' ') - return xbmcgui.Dialog().yesno(localize(30026), eula, yeslabel=localize(30027), nolabel=localize(30028)) # Widevine CDM EULA + return Dialog().yesno(localize(30026), eula, yeslabel=localize(30027), nolabel=localize(30028)) # Widevine CDM EULA def _extract_widevine_from_img(self): ''' Extract the Widevine CDM binary from the mounted Chrome OS image ''' @@ -845,9 +785,11 @@ def _missing_widevine_libs(self): log('There are no missing Widevine libraries! :-)') return None - if self._arch() == 'arm64' and (struct.calcsize('P') * 8) == 64: - log('ARM64 ldd check failed. User needs 32-bit userspace.') - xbmcgui.Dialog().ok(localize(30004), localize(30039)) # Widevine not available on ARM64 + if self._arch() == 'arm64': + import struct + if struct.calcsize('P') * 8 == 64: + log('ARM64 ldd check failed. User needs 32-bit userspace.') + Dialog().ok(localize(30004), localize(30039)) # Widevine not available on ARM64 log('Failed to check for missing Widevine libraries.') return None @@ -859,18 +801,18 @@ def _check_widevine(self): if not os.path.exists(self._widevine_config_path()): log('Widevine or recovery config is missing. Reinstall is required.') - xbmcgui.Dialog().ok(localize(30001), localize(30031)) # An update of Widevine is required + Dialog().ok(localize(30001), localize(30031)) # An update of Widevine is required return self.install_widevine() if 'x86' in self._arch(): # check that widevine arch matches system arch wv_config = self._load_widevine_config() if config.WIDEVINE_ARCH_MAP_X86[self._arch()] != wv_config['arch']: log('Widevine/system arch mismatch. Reinstall is required.') - xbmcgui.Dialog().ok(localize(30001), localize(30031)) # An update of Widevine is required + Dialog().ok(localize(30001), localize(30031)) # An update of Widevine is required return self.install_widevine() if self._missing_widevine_libs(): - xbmcgui.Dialog().ok(localize(30004), localize(30032, libs=', '.join(self._missing_widevine_libs()))) # Missing libraries + Dialog().ok(localize(30004), localize(30032, libs=', '.join(self._missing_widevine_libs()))) # Missing libraries return False self._update_widevine() @@ -880,6 +822,7 @@ def _unzip(self, unzip_dir, file_to_unzip=None): ''' Unzip files to specified path ''' ret = False + import zipfile zip_obj = zipfile.ZipFile(self._download_path) for filename in zip_obj.namelist(): if file_to_unzip and filename != file_to_unzip: @@ -913,7 +856,7 @@ def _cleanup(self): if unattach_output['success']: self._loop_dev = False if self._modprobe_loop: - xbmcgui.Dialog().notification(localize(30035), localize(30036)) # Unload by hand in CLI + Dialog().notification(localize(30035), localize(30036)) # Unload by hand in CLI if not self._has_widevine(): shutil.rmtree(self._ia_cdm_path()) @@ -939,7 +882,7 @@ def _check_drm(self): if self._has_widevine(): return self._check_widevine() - if xbmcgui.Dialog().yesno(localize(30041), localize(30002), yeslabel=localize(30038), nolabel=localize(30028)): # Widevine required + if Dialog().yesno(localize(30041), localize(30002), yeslabel=localize(30038), nolabel=localize(30028)): # Widevine required return self.install_widevine() return False @@ -951,7 +894,7 @@ def _install_inputstream(self): xbmc.executebuiltin('InstallAddon({})'.format(self.inputstream_addon), wait=True) # Check if InputStream add-on exists! - xbmcaddon.Addon('{}'.format(self.inputstream_addon)) + Addon('{}'.format(self.inputstream_addon)) log('inputstream addon installed from repo') return True @@ -968,65 +911,22 @@ def check_inputstream(self): if not self._has_inputstream(): # Try to install InputStream add-on if not self._install_inputstream(): - xbmcgui.Dialog().ok(localize(30004), localize(30008, addon=self.inputstream_addon)) # inputstream is missing on system + Dialog().ok(localize(30004), localize(30008, addon=self.inputstream_addon)) # inputstream is missing on system return False elif not self._inputstream_enabled(): - ret = xbmcgui.Dialog().yesno(localize(30001), localize(30009, addon=self.inputstream_addon)) # inputstream is disabled + ret = Dialog().yesno(localize(30001), localize(30009, addon=self.inputstream_addon)) # inputstream is disabled if ret: self._enable_inputstream() return False log('{addon} {version} is installed and enabled.', addon=self.inputstream_addon, version=self._inputstream_version()) if self.protocol == 'hls' and not self._supports_hls(): - xbmcgui.Dialog().ok(localize(30004), # HLS Minimum version is needed - localize(30017, addon=self.inputstream_addon, version=config.HLS_MINIMUM_IA_VERSION)) + Dialog().ok(localize(30004), # HLS Minimum version is needed + localize(30017, addon=self.inputstream_addon, version=config.HLS_MINIMUM_IA_VERSION)) return False return self._check_drm() - @staticmethod - def _get_global_setting(setting): - json_result = xbmc.executeJSONRPC('{"jsonrpc": "2.0", "method": "Settings.GetSettingValue", "params": {"setting": "%s"}, "id": 1}' % setting) - return json.loads(json_result)['result']['value'] - - def _get_proxies(self): - usehttpproxy = self._get_global_setting('network.usehttpproxy') - if usehttpproxy is False: - return None - - httpproxytype = self._get_global_setting('network.httpproxytype') - - socks_supported = has_socks() - if httpproxytype != 0 and not socks_supported: - # Only open the dialog the first time (to avoid multiple popups) - if socks_supported is None: - xbmcgui.Dialog().ok('', localize(30042)) # Requires PySocks - return None - - proxy_types = ['http', 'socks4', 'socks4a', 'socks5', 'socks5h'] - if 0 <= httpproxytype <= 5: - httpproxyscheme = proxy_types[httpproxytype] - else: - httpproxyscheme = 'http' - - httpproxyserver = self._get_global_setting('network.httpproxyserver') - httpproxyport = self._get_global_setting('network.httpproxyport') - httpproxyusername = self._get_global_setting('network.httpproxyusername') - httpproxypassword = self._get_global_setting('network.httpproxypassword') - - if httpproxyserver and httpproxyport and httpproxyusername and httpproxypassword: - proxy_address = '%s://%s:%s@%s:%s' % (httpproxyscheme, httpproxyusername, httpproxypassword, httpproxyserver, httpproxyport) - elif httpproxyserver and httpproxyport and httpproxyusername: - proxy_address = '%s://%s@%s:%s' % (httpproxyscheme, httpproxyusername, httpproxyserver, httpproxyport) - elif httpproxyserver and httpproxyport: - proxy_address = '%s://%s:%s' % (httpproxyscheme, httpproxyserver, httpproxyport) - elif httpproxyserver: - proxy_address = '%s://%s' % (httpproxyscheme, httpproxyserver) - else: - return None - - return dict(http=proxy_address, https=proxy_address) - def info_dialog(self): """ Show an Info box with useful info e.g. for bug reports""" disabled_str = ' ({disabled}'.format(disabled=localize(30054)) @@ -1034,16 +934,16 @@ def info_dialog(self): kodi_info = [localize(30801, version=self._kodi_version()), localize(30802, platform=system_os(), arch=self._arch())] - ishelper_state = disabled_str if not ADDON.getSetting('disabled') == 'false' else '' + ishelper_state = disabled_str if get_setting('disabled', 'false') != 'false' else '' istream_state = disabled_str if not self._inputstream_enabled() else '' - is_info = [localize(30811, version=ADDON_VERSION, state=ishelper_state), + is_info = [localize(30811, version=get_addon_info('version'), state=ishelper_state), localize(30812, version=self._inputstream_version(), state=istream_state)] - wv_updated = datetime.fromtimestamp(float(ADDON.getSetting('last_update'))).strftime("%Y-%m-%d %H:%M") if ADDON.getSetting('last_update') else 'Never' + wv_updated = datetime.fromtimestamp(float(get_setting('last_update'))).strftime("%Y-%m-%d %H:%M") if get_setting('last_update') else 'Never' wv_info = [localize(30821, version=self._get_lib_version(self._widevine_path()), date=wv_updated), localize(30822, path=self._ia_cdm_path())] if platform == 'arm': - wv_info.append(localize(30823, version=ADDON.getSetting('chromeos_version'))) + wv_info.append(localize(30823, version=get_setting('chromeos_version'))) text = (localize(30800) + "\n - " + "\n - ".join(kodi_info) + "\n\n" @@ -1053,4 +953,4 @@ def info_dialog(self): + "\n - ".join(wv_info) + "\n\n" + localize(30830, url=config.ISSUE_URL)) - xbmcgui.Dialog().textviewer(localize(30901), text) + Dialog().textviewer(localize(30901), text) diff --git a/lib/inputstreamhelper/api.py b/lib/inputstreamhelper/api.py index 0185b587..a977afc7 100644 --- a/lib/inputstreamhelper/api.py +++ b/lib/inputstreamhelper/api.py @@ -2,7 +2,8 @@ ''' This is the actual InputStream Helper API script ''' from __future__ import absolute_import, division, unicode_literals -from inputstreamhelper import ADDON, Helper, log +from inputstreamhelper import Helper +from .kodiutils import ADDON, log def run(params): diff --git a/lib/inputstreamhelper/config.py b/lib/inputstreamhelper/config.py index 312a4e59..9596c318 100644 --- a/lib/inputstreamhelper/config.py +++ b/lib/inputstreamhelper/config.py @@ -80,26 +80,37 @@ # Last updated: 2019-08-20 (version 12239.67.0) CHROMEOS_RECOVERY_ARM_HWIDS = [ - 'BOB', - 'WHITETIP', - 'SKATE', - 'SPRING', - 'SNOW', - 'ELM', - 'HANA', + # 'ARKHAM', 'BIG', 'BLAZE', - 'RELM', + 'BOB', + # 'DAISY', + 'DRUWL', 'DUMO', - 'SCARLET', + 'ELM', + 'EXPRESSO', 'FIEVEL', + 'HANA', 'JAQ', 'JERRY', + 'KEVIN', + 'KITTY', 'MICKEY', 'MIGHTY', 'MINNIE', + 'PHASER', + 'PHASER360', + 'PI', + 'PIT', + 'RELM', + 'SCARLET', + 'SKATE', + 'SNOW', 'SPEEDY', + 'SPRING', 'TIGER', + # 'WHIRLWIND', + 'WHITETIP', ] CHROMEOS_BLOCK_SIZE = 512 diff --git a/lib/inputstreamhelper/kodiutils.py b/lib/inputstreamhelper/kodiutils.py new file mode 100644 index 00000000..937a0806 --- /dev/null +++ b/lib/inputstreamhelper/kodiutils.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +''' Implements Kodi Helper functions ''' +from __future__ import absolute_import, division, unicode_literals +import xbmc +from xbmcgui import Dialog +from xbmcaddon import Addon +from.unicodehelper import from_unicode, to_unicode + +ADDON = Addon('script.module.inputstreamhelper') + + +class SafeDict(dict): + ''' A safe dictionary implementation that does not break down on missing keys ''' + def __missing__(self, key): + ''' Replace missing keys with the original placeholder ''' + return '{' + key + '}' + + +def has_socks(): + ''' Test if socks is installed, and remember this information ''' + + # If it wasn't stored before, check if socks is installed + if not hasattr(has_socks, 'installed'): + try: + import socks # noqa: F401; pylint: disable=unused-variable,unused-import + has_socks.installed = True + except ImportError: + has_socks.installed = False + return None # Detect if this is the first run + + # Return the stored value + return has_socks.installed + + +def localize(string_id, **kwargs): + ''' Return the translated string from the .po language files, optionally translating variables ''' + if kwargs: + import string + return string.Formatter().vformat(ADDON.getLocalizedString(string_id), (), SafeDict(**kwargs)) + + return ADDON.getLocalizedString(string_id) + + +def get_setting(setting_id, default=None): + ''' Get an add-on setting ''' + value = to_unicode(ADDON.getSetting(setting_id)) + if value == '' and default is not None: + return default + return value + + +def set_setting(setting_id, setting_value): + ''' Set an add-on setting ''' + return ADDON.setSetting(setting_id, setting_value) + + +def get_global_setting(setting): + ''' Get a Kodi setting ''' + result = execute_jsonrpc(dict(jsonrpc='2.0', id=1, method='Settings.GetSettingValue', params=dict(setting='%s' % setting))) + return result.get('result', dict()).get('value') + + +def get_proxies(): + ''' Return a usable proxies dictionary from Kodi proxy settings ''' + usehttpproxy = get_global_setting('network.usehttpproxy') + if usehttpproxy is not True: + return None + + try: + httpproxytype = int(get_global_setting('network.httpproxytype')) + except ValueError: + httpproxytype = 0 + + socks_supported = has_socks() + if httpproxytype != 0 and not socks_supported: + # Only open the dialog the first time (to avoid multiple popups) + if socks_supported is None: + Dialog().ok('', localize(30042)) # Requires PySocks + return None + + proxy_types = ['http', 'socks4', 'socks4a', 'socks5', 'socks5h'] + if 0 <= httpproxytype < 5: + httpproxyscheme = proxy_types[httpproxytype] + else: + httpproxyscheme = 'http' + + httpproxyserver = get_global_setting('network.httpproxyserver') + httpproxyport = get_global_setting('network.httpproxyport') + httpproxyusername = get_global_setting('network.httpproxyusername') + httpproxypassword = get_global_setting('network.httpproxypassword') + + if httpproxyserver and httpproxyport and httpproxyusername and httpproxypassword: + proxy_address = '%s://%s:%s@%s:%s' % (httpproxyscheme, httpproxyusername, httpproxypassword, httpproxyserver, httpproxyport) + elif httpproxyserver and httpproxyport and httpproxyusername: + proxy_address = '%s://%s@%s:%s' % (httpproxyscheme, httpproxyusername, httpproxyserver, httpproxyport) + elif httpproxyserver and httpproxyport: + proxy_address = '%s://%s:%s' % (httpproxyscheme, httpproxyserver, httpproxyport) + elif httpproxyserver: + proxy_address = '%s://%s' % (httpproxyscheme, httpproxyserver) + else: + return None + + return dict(http=proxy_address, https=proxy_address) + + +def get_userdata_path(): + ''' Return the profile's userdata path ''' + return to_unicode(xbmc.translatePath(ADDON.getAddonInfo('profile'))) + + +def get_addon_info(key): + ''' Return addon information ''' + return to_unicode(ADDON.getAddonInfo(key)) + + +def execute_jsonrpc(payload): + ''' Kodi JSON-RPC request. Return the response in a dictionary. ''' + import json + log('jsonrpc payload: {payload}', payload=payload) + response = xbmc.executeJSONRPC(json.dumps(payload)) + log('jsonrpc response: {response}', response=response) + return json.loads(response) + + +def log(msg, **kwargs): + ''' InputStream Helper log method ''' + xbmc.log(msg=from_unicode('[{addon}]: {msg}'.format(addon=get_addon_info('id'), msg=msg.format(**kwargs))), level=xbmc.LOGDEBUG) diff --git a/requirements.txt b/requirements.txt index 3528333c..ab3f192e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ codecov kodi-addon-checker polib +proxy.py pylint tox-travis diff --git a/test/test_api.py b/test/test_api.py index 93fa8dfd..f7581c8b 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + # pylint: disable=invalid-name,missing-docstring from __future__ import absolute_import, division, print_function, unicode_literals diff --git a/test/test_ishelper_android_arm.py b/test/test_ishelper_android_arm.py index 88e74155..73bd036e 100644 --- a/test/test_ishelper_android_arm.py +++ b/test/test_ishelper_android_arm.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + # pylint: disable=duplicate-code,invalid-name,missing-docstring,protected-access from __future__ import absolute_import, division, print_function, unicode_literals diff --git a/test/test_ishelper_linux_arm.py b/test/test_ishelper_linux_arm.py index da8d3b8d..b8581936 100644 --- a/test/test_ishelper_linux_arm.py +++ b/test/test_ishelper_linux_arm.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + # pylint: disable=duplicate-code,invalid-name,missing-docstring,protected-access from __future__ import absolute_import, division, print_function, unicode_literals diff --git a/test/test_ishelper_linux_x64.py b/test/test_ishelper_linux_x64.py index d13eb133..9d8b24e4 100644 --- a/test/test_ishelper_linux_x64.py +++ b/test/test_ishelper_linux_x64.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + # pylint: disable=duplicate-code,invalid-name,missing-docstring,protected-access from __future__ import absolute_import, division, print_function, unicode_literals diff --git a/test/test_ishelper_macos_x64.py b/test/test_ishelper_macos_x64.py index 43049a1f..4be012c6 100644 --- a/test/test_ishelper_macos_x64.py +++ b/test/test_ishelper_macos_x64.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + # pylint: disable=duplicate-code,invalid-name,missing-docstring,protected-access from __future__ import absolute_import, division, print_function, unicode_literals diff --git a/test/test_ishelper_windows_x64.py b/test/test_ishelper_windows_x64.py index 058de49e..e3ccd65c 100644 --- a/test/test_ishelper_windows_x64.py +++ b/test/test_ishelper_windows_x64.py @@ -1,4 +1,7 @@ # -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + # pylint: disable=duplicate-code,invalid-name,missing-docstring,protected-access from __future__ import absolute_import, division, print_function, unicode_literals diff --git a/test/test_proxy.py b/test/test_proxy.py new file mode 100644 index 00000000..4b743b2e --- /dev/null +++ b/test/test_proxy.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Dag Wieers (@dagwieers) +# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# pylint: disable=duplicate-code,invalid-name,missing-docstring,protected-access + +from __future__ import absolute_import, division, print_function, unicode_literals +import unittest +import platform +import inputstreamhelper + +xbmc = __import__('xbmc') +xbmcaddon = __import__('xbmcaddon') +xbmcgui = __import__('xbmcgui') +xbmcvfs = __import__('xbmcvfs') + +xbmc.GLOBAL_SETTINGS['network.usehttpproxy'] = True +xbmc.GLOBAL_SETTINGS['network.httpproxytype'] = 0 +xbmc.GLOBAL_SETTINGS['network.httpproxyserver'] = '127.0.0.1' +xbmc.GLOBAL_SETTINGS['network.httpproxyport'] = '8899' + + +class LinuxProxyTests(unittest.TestCase): + + def test_check_inputstream_mpd(self): + inputstreamhelper.system_os = lambda: 'Linux' + platform.machine = lambda: 'x86_64' + is_helper = inputstreamhelper.Helper('mpd', drm='com.widevine.alpha') + is_helper.remove_widevine() + is_installed = is_helper.check_inputstream() + self.assertTrue(is_installed, True) + + def test_check_inputstream_hls_again(self): + inputstreamhelper.system_os = lambda: 'Linux' + platform.machine = lambda: 'AMD64' + platform.architecture = lambda: ['64bit', ''] + is_helper = inputstreamhelper.Helper('hls', drm='com.widevine.alpha') + is_installed = is_helper.check_inputstream() + self.assertTrue(is_installed, True) + + def test_check_inputstream_rtmp(self): + inputstreamhelper.system_os = lambda: 'Linux' + platform.machine = lambda: 'x86_64' + is_helper = inputstreamhelper.Helper('rtmp') + is_installed = is_helper.check_inputstream() + self.assertTrue(is_installed, True) + + def test_check_inputstream_disabled(self): + inputstreamhelper.system_os = lambda: 'Linux' + platform.machine = lambda: 'x86_64' + is_helper = inputstreamhelper.Helper('mpd', drm='com.widevine.alpha') + is_helper.disable() + is_installed = is_helper.check_inputstream() + is_helper.enable() + self.assertTrue(is_installed, True) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/userdata/addon_settings.json b/test/userdata/addon_settings.json index f99a954d..67b48826 100644 --- a/test/userdata/addon_settings.json +++ b/test/userdata/addon_settings.json @@ -4,10 +4,10 @@ }, "inputstream.rtmp": {}, "script.module.inputstreamhelper": { - "disabled": "false", - "last_update": "", - "temp_dir": "special://masterprofile/addon_data/script.module.inputstreamhelper", - "version": "", - "update_frequency": "14" + "disabled": "false", + "last_update": "", + "temp_path": "special://masterprofile/addon_data/script.module.inputstreamhelper", + "update_frequency": "14", + "version": "" } } diff --git a/test/userdata/global_settings.json b/test/userdata/global_settings.json index 7ae7d526..4d6a54df 100644 --- a/test/userdata/global_settings.json +++ b/test/userdata/global_settings.json @@ -1,3 +1,9 @@ { - "locale.language": "resource.language.en_gb" + "locale.language": "resource.language.en_gb", + "network.usehttpproxy": false, + "network.httpproxytype": 0, + "network.httpproxyserver": "http://proxy.server/", + "network.httpproxyport": "8899", + "network.httpproxyusername": "", + "network.httpproxypassword": "" } diff --git a/test/xbmc.py b/test/xbmc.py index 9d0969f6..3e9bd842 100644 --- a/test/xbmc.py +++ b/test/xbmc.py @@ -100,6 +100,10 @@ def executeJSONRPC(jsonrpccommand): if command.get('params', {}).get('addonid') == 'script.module.inputstreamhelper': return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(addon=dict(enabled='true', version='0.3.5')))) return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(addon=dict(enabled='true', version='1.2.3')))) + if command.get('method') == 'Textures.GetTextures': + return json.dumps(dict(id=1, jsonrpc='2.0', result=dict(textures=[dict(cachedurl="", imagehash="", lasthashcheck="", textureid=4837, url="")]))) + if command.get('method') == 'Textures.RemoveTexture': + return json.dumps(dict(id=1, jsonrpc='2.0', result="OK")) log("executeJSONRPC does not implement method '{method}'".format(**command), 'Error') return json.dumps(dict(error=dict(code=-1, message='Not implemented'), id=1, jsonrpc='2.0')) diff --git a/test/xbmcextra.py b/test/xbmcextra.py index fab07d30..9b42dfb8 100644 --- a/test/xbmcextra.py +++ b/test/xbmcextra.py @@ -110,17 +110,6 @@ def addon_settings(): print("Error: Cannot use 'test/userdata/addon_settings.json' : %s" % e) settings = {} - # Read credentials from credentials.json - try: - with open('test/userdata/credentials.json') as f: - settings.update(json.load(f)) - except (IOError, OSError) as e: - if 'VRTNU_USERNAME' in os.environ and 'VRTNU_PASSWORD' in os.environ: - print('Using credentials from the environment variables VRTNU_USERNAME and VRTNU_PASSWORD') - settings['username'] = os.environ.get('VRTNU_USERNAME') - settings['password'] = os.environ.get('VRTNU_PASSWORD') - else: - print("Error: Cannot use 'test/userdata/credentials.json' : %s" % e) return settings diff --git a/test/xbmcgui.py b/test/xbmcgui.py index 0204dc65..bcd87d4a 100644 --- a/test/xbmcgui.py +++ b/test/xbmcgui.py @@ -20,14 +20,14 @@ def notification(heading, message, icon=None, time=None, sound=None): ''' A working implementation for the xbmcgui Dialog class notification() method ''' heading = kodi_to_ansi(heading) message = kodi_to_ansi(message) - print('\033[37;100mNOTIFICATION:\033[35;0m [%s] \033[35;0m%s\033[39;0m' % (heading, message)) + print('\033[37;44;1mNOTIFICATION:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, message)) @staticmethod def ok(heading, line1, line2=None, line3=None): ''' A stub implementation for the xbmcgui Dialog class ok() method ''' heading = kodi_to_ansi(heading) line1 = kodi_to_ansi(line1) - print('\033[37;100mOK:\033[35;0m [%s] \033[35;0m%s\033[39;0m' % (heading, line1)) + print('\033[37;44;1mOK:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, line1)) @staticmethod def info(listitem): @@ -38,7 +38,7 @@ def yesno(heading, line1, line2=None, line3=None, nolabel=None, yeslabel=None, a ''' A stub implementation for the xbmcgui Dialog class yesno() method ''' heading = kodi_to_ansi(heading) line1 = kodi_to_ansi(line1) - print('\033[37;100mYESNO:\033[35;0m [%s] \033[35;0m%s\033[39;0m' % (heading, line1)) + print('\033[37;44;1mYESNO:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, line1)) return True @staticmethod @@ -46,12 +46,12 @@ def textviewer(heading, text=None, usemono=None): ''' A stub implementation for the xbmcgui Dialog class textviewer() method ''' heading = kodi_to_ansi(heading) text = kodi_to_ansi(text) - print('\033[37;100mTEXTVIEWER:\033[35;0m [%s]\n\033[35;0m%s\033[39;0m' % (heading, text)) + print('\033[37;44;1mTEXTVIEWER:\033[35;49;1m [%s]\n\033[37;1m%s\033[39;0m' % (heading, text)) @staticmethod def browseSingle(type, heading, shares, mask=None, useThumbs=None, treatAsFolder=None, default=None): # pylint: disable=redefined-builtin ''' A stub implementation for the xbmcgui Dialog class browseSingle() method ''' - print('\033[37;100mBROWSESINGLE:\033[35;0m [%s] \033[35;0m%s\033[39;0m' % (type, heading)) + print('\033[37;44;1mBROWSESINGLE:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (type, heading)) return 'special://masterprofile/addon_data/script.module.inputstreamhelper/' @@ -72,7 +72,7 @@ def create(heading, line1, line2=None, line3=None): ''' A stub implementation for the xbmcgui DialogProgress class create() method ''' heading = kodi_to_ansi(heading) line1 = kodi_to_ansi(line1) - print('\033[37;100mPROGRESS:\033[35;0m [%s] \033[35;0m%s\033[39;0m' % (heading, line1)) + print('\033[37;44;1mPROGRESS:\033[35;49;1m [%s] \033[37;1m%s\033[39;0m' % (heading, line1)) @staticmethod def iscanceled(): @@ -87,9 +87,9 @@ def update(self, percentage, line1=None, line2=None, line3=None): line2 = kodi_to_ansi(line2) line3 = kodi_to_ansi(line3) if line1 or line2 or line3: - print('\033[37;100mPROGRESS:\033[35;0m [%d%%] \033[35;0m%s\033[39;0m' % (percentage, line1 or line2 or line3)) + print('\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%] \033[37;1m%s\033[39;0m' % (percentage, line1 or line2 or line3)) else: - print('\033[1G\033[37;100mPROGRESS:\033[35;0m [%d%%]\033[39;0m' % (percentage), end='') + print('\033[1G\033[37;44;1mPROGRESS:\033[35;49;1m [%d%%]\033[39;0m' % (percentage), end='') class DialogBusy: From 4e136310df2a2632187d0a1f30efbdba3fd60f6b Mon Sep 17 00:00:00 2001 From: Dag Wieers Date: Tue, 17 Sep 2019 18:21:37 +0200 Subject: [PATCH 2/3] Add translate_path() function --- .travis.yml | 1 - lib/inputstreamhelper/__init__.py | 8 ++++---- lib/inputstreamhelper/kodiutils.py | 7 ++++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5a595bc8..ac2c9618 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,7 +22,6 @@ script: - coverage run -a default.py - proxy.py & - coverage run -m unittest discover -- pkill -ef proxy.py after_success: - codecov diff --git a/lib/inputstreamhelper/__init__.py b/lib/inputstreamhelper/__init__.py index 0704e301..6ecfb30c 100644 --- a/lib/inputstreamhelper/__init__.py +++ b/lib/inputstreamhelper/__init__.py @@ -24,7 +24,7 @@ from xbmcaddon import Addon from xbmcgui import Dialog, DialogProgress import xbmcvfs -from .kodiutils import execute_jsonrpc, get_addon_info, get_proxies, get_setting, localize, log, set_setting +from .kodiutils import execute_jsonrpc, get_addon_info, get_proxies, get_setting, localize, log, set_setting, translate_path from .unicodehelper import to_unicode # NOTE: Work around issue caused by platform still using os.popen() @@ -93,7 +93,7 @@ def _diskspace(cls): @classmethod def _temp_path(cls): ''' Return temporary path, usually ~/.kodi/userdata/addon_data/script.module.inputstreamhelper/temp ''' - temp_path = xbmc.translatePath(os.path.join(get_setting('temp_path', 'special://masterprofile/addon_data/script.module.inputstreamhelper'), 'temp')) + temp_path = translate_path(os.path.join(get_setting('temp_path', 'special://masterprofile/addon_data/script.module.inputstreamhelper'), 'temp')) if not xbmcvfs.exists(temp_path): xbmcvfs.mkdir(temp_path) @@ -112,7 +112,7 @@ def _mnt_path(cls): def _ia_cdm_path(cls): ''' Return the specified CDM path for inputstream.adaptive, usually ~/.kodi/cdm ''' addon = Addon('inputstream.adaptive') - cdm_path = to_unicode(xbmc.translatePath(addon.getSetting('DECRYPTERPATH'))) + cdm_path = translate_path(addon.getSetting('DECRYPTERPATH')) if not xbmcvfs.exists(cdm_path): xbmcvfs.mkdir(cdm_path) @@ -443,7 +443,7 @@ def _supports_widevine(self): Dialog().ok(localize(30004), localize(30010, version=config.WIDEVINE_MINIMUM_KODI_VERSION[system_os()])) # Kodi too old return False - if 'WindowsApps' in xbmc.translatePath('special://xbmcbin/'): # uwp is not supported + if 'WindowsApps' in translate_path('special://xbmcbin/'): # uwp is not supported log('Unsupported UWP Kodi version detected.') Dialog().ok(localize(30004), localize(30012)) # Windows Store Kodi falls short return False diff --git a/lib/inputstreamhelper/kodiutils.py b/lib/inputstreamhelper/kodiutils.py index 937a0806..3d9458ad 100644 --- a/lib/inputstreamhelper/kodiutils.py +++ b/lib/inputstreamhelper/kodiutils.py @@ -51,6 +51,11 @@ def get_setting(setting_id, default=None): return value +def translate_path(path): + ''' Translate special xbmc paths ''' + return to_unicode(xbmc.translatePath(path)) + + def set_setting(setting_id, setting_value): ''' Set an add-on setting ''' return ADDON.setSetting(setting_id, setting_value) @@ -107,7 +112,7 @@ def get_proxies(): def get_userdata_path(): ''' Return the profile's userdata path ''' - return to_unicode(xbmc.translatePath(ADDON.getAddonInfo('profile'))) + return translate_path(ADDON.getAddonInfo('profile')) def get_addon_info(key): From bca8461761432e6f3338bfd33ccc740d759fdbc6 Mon Sep 17 00:00:00 2001 From: Dag Wieers Date: Tue, 17 Sep 2019 18:49:27 +0200 Subject: [PATCH 3/3] Simplify HTTP requests --- lib/inputstreamhelper/__init__.py | 35 ++++++++++++++++--------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/lib/inputstreamhelper/__init__.py b/lib/inputstreamhelper/__init__.py index 6ecfb30c..15071243 100644 --- a/lib/inputstreamhelper/__init__.py +++ b/lib/inputstreamhelper/__init__.py @@ -334,9 +334,10 @@ def _has_widevine(self): return False @staticmethod - def _http_get(url): - ''' Perform an HTTP GET request and return content ''' + def _http_request(url): + ''' Perform an HTTP request and return request ''' log('Request URL: {url}', url=url) + filename = url.split('/')[-1] for retry in (False, True): try: @@ -346,9 +347,19 @@ def _http_get(url): raise HTTPError('HTTP %s Error for url: %s' % (req.getcode(), url), response=req) break except HTTPError: + Dialog().ok(localize(30004), localize(30013, filename=filename)) # Failed to retrieve file + return None + except BadStatusLine: if retry: - Dialog().ok(localize(30004), localize(30013, filename=url.split('/')[-1])) # Failed to retrieve file + Dialog().ok(localize(30004), localize(30013, filename=filename)) # Failed to retrieve file return None + return req + + def _http_get(self, url): + ''' Perform an HTTP GET request and return content ''' + req = self._http_request(url) + if req is None: + return None content = req.read() # NOTE: Do not log reponse (as could be large) @@ -357,21 +368,11 @@ def _http_get(url): def _http_download(self, url, message=None): """Makes HTTP request and displays a progress dialog on download.""" - log('Request URL: {url}', url=url) - filename = url.split('/')[-1] - - for retry in (False, True): - try: - req = urlopen(url) - log('Response code: {code}', code=req.getcode()) - if 400 <= req.getcode() < 600: - raise HTTPError('HTTP %s Error for url: %s' % (req.getcode(), url), response=req) - break - except (HTTPError, BadStatusLine): - if retry: - Dialog().ok(localize(30004), localize(30013, filename=filename)) # Failed to retrieve file - return False + req = self._http_request(url) + if req is None: + return None + filename = url.split('/')[-1] if not message: # display "downloading [filename]" message = localize(30015, filename=filename) # Downloading file