# -*- coding: utf-8 -*- # MIT License (see LICENSE.txt or https://opensource.org/licenses/MIT) """Implements the main InputStream Helper class""" from __future__ import absolute_import, division, unicode_literals import os from . import config from .kodiutils import (addon_version, delete, exists, get_proxies, get_setting, get_setting_bool, get_setting_float, get_setting_int, jsonrpc, kodi_to_ascii, kodi_version, listdir, localize, log, notification, ok_dialog, progress_dialog, select_dialog, set_setting, set_setting_bool, textviewer, translate_path, yesno_dialog) from .utils import arch, http_download, remove_tree, run_cmd, store, system_os, temp_path, unzip from .widevine.arm import install_widevine_arm, unmount from .widevine.widevine import (backup_path, has_widevinecdm, ia_cdm_path, install_cdm_from_backup, latest_widevine_version, load_widevine_config, missing_widevine_libs, widevine_config_path, widevine_eula, widevinecdm_path) from .unicodes import compat_path # NOTE: Work around issue caused by platform still using os.popen() # This helps to survive 'IOError: [Errno 10] No child processes' if hasattr(os, 'popen'): del os.popen class InputStreamException(Exception): """Stub Exception""" def cleanup_decorator(func): """Decorator which runs cleanup before and after a function""" def clean_before_after(self, *args, **kwargs): # pylint: disable=missing-docstring # pylint only complains about a missing docstring on py2.7? self.cleanup() result = func(self, *args, **kwargs) self.cleanup() return result return clean_before_after class Helper: """The main InputStream Helper class""" def __init__(self, protocol, drm=None): """Initialize InputStream Helper class""" self.protocol = protocol self.drm = drm from platform import uname log(0, 'Platform information: {uname}', uname=uname()) if self.protocol not in config.INPUTSTREAM_PROTOCOLS: raise InputStreamException('UnsupportedProtocol') self.inputstream_addon = config.INPUTSTREAM_PROTOCOLS[self.protocol] if self.drm: if self.drm not in config.DRM_SCHEMES: raise InputStreamException('UnsupportedDRMScheme') self.drm = config.DRM_SCHEMES[drm] # Add proxy support to HTTP requests proxies = get_proxies() if proxies: try: # Python 3 from urllib.request import build_opener, install_opener, ProxyHandler except ImportError: # Python 2 from urllib2 import build_opener, install_opener, ProxyHandler install_opener(build_opener(ProxyHandler(proxies))) def __repr__(self): """String representation of Helper class""" return 'Helper({protocol}, drm={drm})'.format(protocol=self.protocol, drm=self.drm) @staticmethod def disable(): """Disable plugin""" if not get_setting_bool('disabled', False): set_setting_bool('disabled', True) @staticmethod def enable(): """Enable plugin""" if get_setting('disabled', False): set_setting_bool('disabled', False) def _inputstream_version(self): """Return the requested inputstream version""" from xbmcaddon import Addon try: addon = Addon(self.inputstream_addon) except RuntimeError: return None from .unicodes import to_unicode return to_unicode(addon.getAddonInfo('version')) @staticmethod def _get_lib_version(path): if not path or not exists(path): return '(Not found)' import re with open(compat_path(path), 'rb') as library: match = re.search(br'[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+', library.read()) if not match: return '(Undetected)' from .unicodes import to_unicode return to_unicode(match.group(0)) def _has_inputstream(self): """Checks if selected InputStream add-on is installed.""" data = jsonrpc(method='Addons.GetAddonDetails', params=dict(addonid=self.inputstream_addon)) if 'error' in data: log(3, '{addon} is not installed.', addon=self.inputstream_addon) return False log(0, '{addon} is installed.', addon=self.inputstream_addon) return True def _inputstream_enabled(self): """Returns whether selected InputStream add-on is enabled..""" data = jsonrpc(method='Addons.GetAddonDetails', params=dict(addonid=self.inputstream_addon, properties=['enabled'])) if data.get('result', {}).get('addon', {}).get('enabled'): log(0, '{addon} {version} is enabled.', addon=self.inputstream_addon, version=self._inputstream_version()) return True log(3, '{addon} is disabled.', addon=self.inputstream_addon) return False def _enable_inputstream(self): """Enables selected InputStream add-on.""" data = jsonrpc(method='Addons.SetAddonEnabled', params=dict(addonid=self.inputstream_addon, enabled=True)) if 'error' in data: return False return True @staticmethod def _supports_widevine(): """Checks if Widevine is supported on the architecture/operating system/Kodi version.""" if arch() not in config.WIDEVINE_SUPPORTED_ARCHS: log(4, 'Unsupported Widevine architecture found: {arch}', arch=arch()) ok_dialog(localize(30004), localize(30007, arch=arch())) # Widevine not available on this architecture return False if arch() == 'arm64' and system_os() != 'Android': import struct if struct.calcsize('P') * 8 == 64: log(4, 'Unsupported 64-bit userspace found. User needs 32-bit userspace on {arch}', arch=arch()) ok_dialog(localize(30004), localize(30039)) # Widevine not available on ARM64 return False if system_os() not in config.WIDEVINE_SUPPORTED_OS: log(4, 'Unsupported Widevine OS found: {os}', os=system_os()) ok_dialog(localize(30004), localize(30011, os=system_os())) # Operating system not supported by Widevine return False from distutils.version import LooseVersion # pylint: disable=import-error,no-name-in-module,useless-suppression if LooseVersion(config.WIDEVINE_MINIMUM_KODI_VERSION[system_os()]) > LooseVersion(kodi_version()): log(4, 'Unsupported Kodi version for Widevine: {version}', version=kodi_version()) ok_dialog(localize(30004), localize(30010, version=config.WIDEVINE_MINIMUM_KODI_VERSION[system_os()])) # Kodi too old return False if 'WindowsApps' in translate_path('special://xbmcbin/'): # uwp is not supported log(4, 'Unsupported UWP Kodi version detected.') ok_dialog(localize(30004), localize(30012)) # Windows Store Kodi falls short return False return True @staticmethod def _install_widevine_x86(bpath): """Install Widevine CDM on x86 based architectures.""" cdm_version = latest_widevine_version() if not store('download_path'): cdm_os = config.WIDEVINE_OS_MAP[system_os()] cdm_arch = config.WIDEVINE_ARCH_MAP_X86[arch()] url = config.WIDEVINE_DOWNLOAD_URL.format(version=cdm_version, os=cdm_os, arch=cdm_arch) downloaded = http_download(url) else: downloaded = True if downloaded: progress = progress_dialog() progress.create(heading=localize(30043), message=localize(30044)) # Extracting Widevine CDM unzip(store('download_path'), os.path.join(bpath, cdm_version, '')) return (progress, cdm_version) return False def install_and_finish(self, progress, version): """Installs the cdm from backup and runs checks""" progress.update(97, message=localize(30049)) # Installing Widevine CDM install_cdm_from_backup(version) progress.update(98, message=localize(30050)) # Finishing if has_widevinecdm(): wv_check = self._check_widevine() if wv_check: progress.update(100, message=localize(30051)) # Widevine CDM successfully installed. notification(localize(30037), localize(30051)) # Success! Widevine CDM successfully installed. progress.close() return wv_check progress.close() return False @cleanup_decorator def install_widevine(self): """Wrapper function that calls Widevine installer method depending on architecture""" if not self._supports_widevine(): return False if not widevine_eula(): return False if 'x86' in arch(): result = self._install_widevine_x86(backup_path()) else: result = install_widevine_arm(backup_path()) if not result: return result if self.install_and_finish(*result): from time import time set_setting('last_check', time()) return True ok_dialog(localize(30004), localize(30005)) # An error occurred return False @staticmethod def remove_widevine(): """Removes Widevine CDM""" if has_widevinecdm(): widevinecdm = widevinecdm_path() log(0, 'Removed Widevine CDM at {path}', path=widevinecdm) delete(widevinecdm) notification(localize(30037), localize(30052)) # Success! Widevine successfully removed. set_setting('last_modified', '0.0') return True notification(localize(30004), localize(30053)) # Error. Widevine CDM not found. return False @staticmethod def _first_run(): """Check if this add-on version is running for the first time""" # Get versions settings_version = get_setting('version', '0.3.4') # settings_version didn't exist in version 0.3.4 and older # Compare versions from distutils.version import LooseVersion # pylint: disable=import-error,no-name-in-module,useless-suppression if LooseVersion(addon_version()) > LooseVersion(settings_version): # New version found, save addon_version to settings set_setting('version', addon_version()) log(2, '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.""" from time import localtime, strftime, time last_check = get_setting_float('last_check', 0.0) if last_check and not self._first_run(): if last_check + 3600 * 24 * get_setting_int('update_frequency', 14) >= time(): log(2, 'Widevine update check was made on {date}', date=strftime('%Y-%m-%d %H:%M', localtime(last_check))) return wv_config = load_widevine_config() if not wv_config: log(3, 'Widevine config missing. Could not determine current version, forcing update.') current_version = '0' elif 'x86' in arch(): component = 'Widevine CDM' current_version = wv_config['version'] else: component = 'Chrome OS' current_version = wv_config['version'] latest_version = latest_widevine_version() if not latest_version: log(3, 'Updating widevine failed. Could not determine latest version.') return log(0, 'Latest {component} version is {version}', component=component, version=latest_version) log(0, 'Current {component} version installed is {version}', component=component, version=current_version) from distutils.version import LooseVersion # pylint: disable=import-error,no-name-in-module,useless-suppression if LooseVersion(latest_version) > LooseVersion(current_version): log(2, 'There is an update available for {component}', component=component) if yesno_dialog(localize(30040), localize(30033), nolabel=localize(30028), yeslabel=localize(30034)): self.install_widevine() else: log(3, 'User declined to update {component}.', component=component) else: set_setting('last_check', time()) log(0, 'User is on the latest available {component} version.', component=component) def _check_widevine(self): """Checks that all Widevine components are installed and available.""" if system_os() == 'Android': # no checks needed for Android return True if not exists(widevine_config_path()): log(4, 'Widevine or Chrome OS recovery.json is missing. Reinstall is required.') ok_dialog(localize(30001), localize(30031)) # An update of Widevine is required return self.install_widevine() if 'x86' in arch(): # check that widevine arch matches system arch wv_config = load_widevine_config() if config.WIDEVINE_ARCH_MAP_X86[arch()] != wv_config['arch']: log(4, 'Widevine/system arch mismatch. Reinstall is required.') ok_dialog(localize(30001), localize(30031)) # An update of Widevine is required return self.install_widevine() if missing_widevine_libs(): ok_dialog(localize(30004), localize(30032, libs=', '.join(missing_widevine_libs()))) # Missing libraries return False self._update_widevine() return True @staticmethod def cleanup(): """Clean up function after Widevine CDM installation""" unmount() if store('attached_loop_dev'): cmd = ['losetup', '-d', store('loop_dev')] unattach_output = run_cmd(cmd, sudo=True) if unattach_output['success']: store('loop_dev', False) store('attached_loop_dev', False) if store('modprobe_loop'): notification(localize(30035), localize(30036)) # Unload by hand in CLI if not has_widevinecdm(): remove_tree(ia_cdm_path()) remove_tree(temp_path()) return True def _supports_hls(self): """Return if HLS support is available in inputstream.adaptive.""" from distutils.version import LooseVersion # pylint: disable=import-error,no-name-in-module,useless-suppression if LooseVersion(self._inputstream_version()) >= LooseVersion(config.HLS_MINIMUM_IA_VERSION): return True log(3, 'HLS is unsupported on {addon} version {version}', addon=self.inputstream_addon, version=self._inputstream_version()) return False def _check_drm(self): """Main function for ensuring that specified DRM system is installed and available.""" if not self.drm or self.inputstream_addon != 'inputstream.adaptive': return True if self.drm != 'widevine': return True if has_widevinecdm(): return self._check_widevine() if yesno_dialog(localize(30041), localize(30002), nolabel=localize(30028), yeslabel=localize(30038)): # Widevine required return self.install_widevine() return False def _install_inputstream(self): """Install inputstream addon.""" from xbmc import executebuiltin from xbmcaddon import Addon try: # See if there's an installed repo that has it executebuiltin('InstallAddon({})'.format(self.inputstream_addon), wait=True) # Check if InputStream add-on exists! Addon('{}'.format(self.inputstream_addon)) log(0, 'InputStream add-on installed from repo.') return True except RuntimeError: log(3, 'InputStream add-on not installed.') return False def check_inputstream(self): """Main function. Ensures that all components are available for InputStream add-on playback.""" if get_setting_bool('disabled', False): # blindly return True if helper has been disabled log(3, 'InputStreamHelper is disabled in its settings.xml.') return True if self.drm == 'widevine' and not self._supports_widevine(): return False if not self._has_inputstream(): # Try to install InputStream add-on if not self._install_inputstream(): ok_dialog(localize(30004), localize(30008, addon=self.inputstream_addon)) # inputstream is missing on system return False elif not self._inputstream_enabled(): ret = yesno_dialog(localize(30001), localize(30009, addon=self.inputstream_addon)) # inputstream is disabled if not ret: return False self._enable_inputstream() log(0, '{addon} {version} is installed and enabled.', addon=self.inputstream_addon, version=self._inputstream_version()) if self.protocol == 'hls' and not self._supports_hls(): ok_dialog(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() def info_dialog(self): """ Show an Info box with useful info e.g. for bug reports""" text = localize(30800, version=kodi_version(), system=system_os(), arch=arch()) + '\n' # Kodi information text += '\n' disabled_str = ' ({disabled})'.format(disabled=localize(30054)) ishelper_state = disabled_str if get_setting_bool('disabled', False) else '' istream_state = disabled_str if not self._inputstream_enabled() else '' text += localize(30810, version=addon_version(), state=ishelper_state) + '\n' text += localize(30811, version=self._inputstream_version(), state=istream_state) + '\n' text += '\n' if system_os() == 'Android': text += localize(30820) + '\n' else: from time import localtime, strftime if get_setting_float('last_modified', 0.0): wv_updated = strftime('%Y-%m-%d %H:%M', localtime(get_setting_float('last_modified', 0.0))) else: wv_updated = 'Never' text += localize(30821, version=self._get_lib_version(widevinecdm_path()), date=wv_updated) + '\n' if arch() in ('arm', 'arm64'): # Chrome OS version wv_cfg = load_widevine_config() if wv_cfg: text += localize(30822, name=wv_cfg['hwidmatch'].split()[0].lstrip('^'), version=wv_cfg['version']) + '\n' if get_setting_float('last_check', 0.0): wv_check = strftime('%Y-%m-%d %H:%M', localtime(get_setting_float('last_check', 0.0))) else: wv_check = 'Never' text += localize(30823, date=wv_check) + '\n' text += localize(30824, path=ia_cdm_path()) + '\n' text += '\n' text += localize(30830, url=config.SHORT_ISSUE_URL) # Report issues log(2, '\n{info}'.format(info=kodi_to_ascii(text))) textviewer(localize(30901), text) def rollback_libwv(self): """Rollback lib to a version specified by the user""" bpath = backup_path() versions = listdir(bpath) # Return if Widevine is not installed if not exists(widevine_config_path()): notification(localize(30004), localize(30041)) return installed_version = load_widevine_config()['version'] del versions[versions.index(installed_version)] if 'x86' in arch(): show_versions = versions else: show_versions = [] for version in versions: lib_version = self._get_lib_version(os.path.join(bpath, version, config.WIDEVINE_CDM_FILENAME[system_os()])) show_versions.append('{} ({})'.format(lib_version, version)) if not show_versions: notification(localize(30004), localize(30056)) return version = select_dialog(localize(30057), show_versions) if version != -1: log(0, 'Rollback to version {version}', version=versions[version]) install_cdm_from_backup(versions[version]) notification(localize(30037), localize(30051)) # Success! Widevine successfully installed. return