diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index f8be1db..9d81f9c 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -121,6 +121,18 @@ msgctxt "#30720" msgid "This video is not available abroad." msgstr "" +msgctxt "#30721" +msgid "You need to install an extra Python module to play this video using an external Widevine device file: {error}." +msgstr "" + +msgctxt "#30722" +msgid "The server returned an error: {error}." +msgstr "" + +msgctxt "#30723" +msgid "The Widevine device file must have a '.wvd' extension." +msgstr "" + ### SETTINGS msgctxt "#30800" @@ -166,3 +178,15 @@ msgstr "" msgctxt "#30886" msgid "Clear cache" msgstr "" + +msgctxt "#30887" +msgid "Widevine DRM" +msgstr "" + +msgctxt "#30888" +msgid "Use external Widevine device file" +msgstr "" + +msgctxt "#30889" +msgid "Widevine device file" +msgstr "" diff --git a/resources/language/resource.language.nl_nl/strings.po b/resources/language/resource.language.nl_nl/strings.po index 329da41..82b181e 100644 --- a/resources/language/resource.language.nl_nl/strings.po +++ b/resources/language/resource.language.nl_nl/strings.po @@ -121,6 +121,18 @@ msgctxt "#30720" msgid "This video is not available abroad." msgstr "Deze video is niet beschikbaar in het buitenland." +msgctxt "#30721" +msgid "You need to install an extra Python module to play this video using an external Widevine device file: {error}." +msgstr "Je moet een extra Python-module installeren om deze video met een extern Widevine-apparaatbestand te kunnen afspelen: {error}." + +msgctxt "#30722" +msgid "The server returned an error: {error}." +msgstr "De server gaf een fout: {error}." + +msgctxt "#30723" +msgid "The Widevine device file must have a '.wvd' extension." +msgstr "Het Widevine-apparaatbestand moet een '.wvd' extensie hebben." + ### SETTINGS msgctxt "#30800" @@ -166,3 +178,15 @@ msgstr "Cache" msgctxt "#30886" msgid "Clear cache" msgstr "Wis cache" + +msgctxt "#30887" +msgid "Widevine DRM" +msgstr "Widevine DRM" + +msgctxt "#30888" +msgid "Use external Widevine device file" +msgstr "Gebruik extern Widevine-apparaatbestand" + +msgctxt "#30889" +msgid "Widevine device file" +msgstr "Widevine-apparaatbestand" diff --git a/resources/lib/goplay/content.py b/resources/lib/goplay/content.py index 2fe2d79..b65b488 100644 --- a/resources/lib/goplay/content.py +++ b/resources/lib/goplay/content.py @@ -9,6 +9,7 @@ import re import time from datetime import datetime +from xml.etree.ElementTree import XML import requests @@ -37,6 +38,14 @@ class GeoblockedException(Exception): """ Is thrown when a geoblocked item is played. """ +class MissingModuleException(Exception): + """ Is thrown when a Python module is missing. """ + + +class ApiException(Exception): + """ Is thrown when the Api return an error. """ + + class Program: """ Defines a Program. """ @@ -353,15 +362,18 @@ def get_stream(self, uuid, content_type): raise UnavailableException # Get DRM license - license_key = None + key_headers = None + device_path = None if data.get('drmXml'): # BuyDRM format # See https://docs.unified-streaming.com/documentation/drm/buydrm.html#setting-up-the-client - # Generate license key - license_key = self.create_license_key(self.LICENSE_URL, key_headers={ + key_headers = { 'customdata': data['drmXml'] - }) + } + + if kodiutils.get_setting_bool('enable_widevine_device') and kodiutils.get_setting('widevine_device'): + device_path = kodiutils.get_setting('widevine_device') # Get manifest url if data.get('manifestUrls'): @@ -372,7 +384,7 @@ def get_stream(self, uuid, content_type): uuid=uuid, url=data['manifestUrls']['dash'], stream_type=STREAM_DASH, - license_key=license_key, + license_key=self.create_license_key(self.LICENSE_URL, key_headers=key_headers, device_path=device_path, manifest_url=data['manifestUrls']['dash']), ) # HLS stream @@ -380,7 +392,7 @@ def get_stream(self, uuid, content_type): uuid=uuid, url=data['manifestUrls']['hls'], stream_type=STREAM_HLS, - license_key=license_key, + license_key=self.create_license_key(self.LICENSE_URL, key_headers=key_headers), ) # No manifest url found, get manifest from Server-Side Ad Insertion service @@ -394,7 +406,7 @@ def get_stream(self, uuid, content_type): uuid=uuid, url=ad_data['stream_manifest'], stream_type=STREAM_DASH, - license_key=license_key, + license_key=self.create_license_key(self.LICENSE_URL, key_headers=key_headers, device_path=device_path, manifest_url=ad_data['stream_manifest']), ) if data.get('message'): raise GeoblockedException(data) @@ -734,8 +746,7 @@ def _parse_clip_data(data): ) return episode - @staticmethod - def create_license_key(key_url, key_type='R', key_headers=None, key_value='', response_value=''): + def create_license_key(self, license_url, key_type='R', key_headers=None, key_value='', response_value='', device_path=None, manifest_url=None): """ Create a license key string that we need for inputstream.adaptive. :type key_url: str :type key_type: str @@ -749,6 +760,11 @@ def create_license_key(key_url, key_type='R', key_headers=None, key_value='', re except ImportError: # Python 2 from urllib import quote, urlencode + if device_path and manifest_url: + pssh_box = self.get_pssh_box(manifest_url) + keys = self.get_decryption_keys(license_url, key_headers, pssh_box, device_path) + return 'org.w3.clearkey|%s' % keys[0] + header = '' if key_headers: header = urlencode(key_headers) @@ -760,84 +776,164 @@ def create_license_key(key_url, key_type='R', key_headers=None, key_value='', re raise ValueError('Missing D{SSM} placeholder') key_value = quote(key_value) - return '%s|%s|%s|%s' % (key_url, header, key_value, response_value) + return '%s|%s|%s|%s' % (license_url, header, key_value, response_value) + + def get_pssh_box(self, manifest_url): + """ Get PSSH Box. + :type manifest_url: str + :rtype str + """ + pssh_box = None + manifest_data = self._get_url(manifest_url) + manifest = XML(manifest_data) + mpd_ns = {'mpd': 'urn:mpeg:dash:schema:mpd:2011'} + cenc_ns = {'cenc': 'urn:mpeg:cenc:2013'} + adaptionset = manifest.find('mpd:Period', mpd_ns).find('mpd:AdaptationSet', mpd_ns) + pssh_box = adaptionset.findall('mpd:ContentProtection', mpd_ns)[1].find('cenc:pssh', cenc_ns).text + return pssh_box + + def get_decryption_keys(self, license_url, headers, pssh_box, device_path): + """Get cenc decryption key from Widevine CDM. + :type license_url: str + :type headers: str + :type pssh_box: str + :type device_path: str + :rtype str + """ + try: + from pywidevine.cdm import Cdm + from pywidevine.device import Device + from pywidevine.pssh import PSSH + except ModuleNotFoundError as exc: + raise MissingModuleException(exc) + + # Load device + device = Device.load(device_path) + + # Load CDM + cdm = Cdm.from_device(device) + + # Open cdm session + session_id = cdm.open() + + # Get license challenge + challenge = cdm.get_license_challenge(session_id, PSSH(pssh_box)) + + # Request + wv_license = self._post_url(license_url, headers=headers, data=challenge) + + # parse license challenge + cdm.parse_license(session_id, wv_license) + + # Get keys + decryption_keys = [] + for key in cdm.get_keys(session_id): + if key.type == 'CONTENT': + decryption_keys.append('{}:{}'.format(key.kid.hex, key.key.hex())) - def _get_url(self, url, params=None, authentication=None): + # close session, disposes of session data + cdm.close(session_id) + + return decryption_keys + + def _get_url(self, url, params=None, headers=None, authentication=None): """ Makes a GET request for the specified URL. :type url: str :type authentication: str :rtype str """ - if authentication: - response = self._session.get(url, params=params, headers={ - 'authorization': authentication, - }, proxies=PROXIES) - else: - response = self._session.get(url, params=params, proxies=PROXIES) - - if response.status_code not in (200, 451): - _LOGGER.error(response.text) - raise Exception('Could not fetch data') + try: + if authentication: + response = self._session.get(url, params=params, headers={ + 'authorization': authentication, + }, proxies=PROXIES) + else: + response = self._session.get(url, params=params, headers=headers, proxies=PROXIES) + response.raise_for_status() + except requests.exceptions.HTTPError: + message = self._get_error_message(response) + _LOGGER.error(message) + raise ApiException(message) return response.text - def _post_url(self, url, params=None, data=None, authentication=None): + def _post_url(self, url, params=None, headers=None, data=None, authentication=None): """ Makes a POST request for the specified URL. :type url: str :type authentication: str :rtype str """ - if authentication: - response = self._session.post(url, params=params, json=data, headers={ - 'authorization': authentication, - }, proxies=PROXIES) - else: - response = self._session.post(url, params=params, json=data, proxies=PROXIES) + try: + if authentication: + response = self._session.post(url, params=params, json=data, headers={ + 'authorization': authentication, + }, proxies=PROXIES) + else: + response = self._session.post(url, params=params, headers=headers, data=data, proxies=PROXIES) + response.raise_for_status() + except requests.exceptions.HTTPError: + message = self._get_error_message(response) + _LOGGER.error(message) + raise ApiException(message) - if response.status_code not in (200, 201): - _LOGGER.error(response.text) - raise Exception('Could not fetch data') + return response.content - return response.text - - def _put_url(self, url, params=None, data=None, authentication=None): + def _put_url(self, url, params=None, headers=None, data=None, authentication=None): """ Makes a PUT request for the specified URL. :type url: str :type authentication: str :rtype str """ - if authentication: - response = self._session.put(url, params=params, json=data, headers={ - 'authorization': authentication, - }, proxies=PROXIES) - else: - response = self._session.put(url, params=params, json=data, proxies=PROXIES) - - if response.status_code not in (200, 201, 204): - _LOGGER.error(response.text) - raise Exception('Could not fetch data') + try: + if authentication: + response = self._session.put(url, params=params, json=data, headers={ + 'authorization': authentication, + }, proxies=PROXIES) + else: + response = self._session.put(url, params=params, headers=headers, json=data, proxies=PROXIES) + response.raise_for_status() + except requests.exceptions.HTTPError: + message = self._get_error_message(response) + _LOGGER.error(message) + raise ApiException(message) return response.text - def _delete_url(self, url, params=None, authentication=None): + def _delete_url(self, url, params=None, headers=None, authentication=None): """ Makes a DELETE request for the specified URL. :type url: str :type authentication: str :rtype str """ - if authentication: - response = self._session.delete(url, params=params, headers={ - 'authorization': authentication, - }, proxies=PROXIES) - else: - response = self._session.delete(url, params=params, proxies=PROXIES) - - if response.status_code not in (200, 202): - _LOGGER.error(response.text) - raise Exception('Could not fetch data') + try: + if authentication: + response = self._session.delete(url, params=params, headers={ + 'authorization': authentication, + }, proxies=PROXIES) + else: + response = self._session.delete(url, params=params, headers=headers, proxies=PROXIES) + response.raise_for_status() + except requests.exceptions.HTTPError: + message = self._get_error_message(response) + _LOGGER.error(message) + raise ApiException(message) return response.text + @staticmethod + def _get_error_message(response): + """ Returns the error message of an Api request. + :type response: requests.Response Object + :rtype str + """ + if response.json().get('message'): + message = response.json().get('message') + elif response.json().get('errormsg'): + message = response.json().get('errormsg') + else: + message = response.text + return message + def _handle_cache(self, key, cache_mode, update, ttl=30 * 24 * 60 * 60): """ Fetch something from the cache, and update if needed """ if cache_mode in [CACHE_AUTO, CACHE_ONLY]: diff --git a/resources/lib/kodiutils.py b/resources/lib/kodiutils.py index 9ba878f..88c5d7c 100644 --- a/resources/lib/kodiutils.py +++ b/resources/lib/kodiutils.py @@ -262,11 +262,17 @@ def play(stream, stream_type=STREAM_HLS, license_key=None, title=None, art_dict= play_item.setMimeType('application/dash+xml') import inputstreamhelper if license_key is not None: - # DRM protected MPEG-DASH - is_helper = inputstreamhelper.Helper('mpd', drm='com.widevine.alpha') - if is_helper.check_inputstream(): - play_item.setProperty('inputstream.adaptive.license_type', 'com.widevine.alpha') - play_item.setProperty('inputstream.adaptive.license_key', license_key) + # Clearkey + if license_key.startswith('org.w3.clearkey'): + is_helper = inputstreamhelper.Helper('mpd') + if is_helper.check_inputstream(): + play_item.setProperty('inputstream.adaptive.drm_legacy', license_key) + else: + # DRM protected MPEG-DASH + is_helper = inputstreamhelper.Helper('mpd', drm='com.widevine.alpha') + if is_helper.check_inputstream(): + play_item.setProperty('inputstream.adaptive.license_type', 'com.widevine.alpha') + play_item.setProperty('inputstream.adaptive.license_key', license_key) else: # Unprotected MPEG-DASH is_helper = inputstreamhelper.Helper('mpd') diff --git a/resources/lib/modules/player.py b/resources/lib/modules/player.py index 51fc5e1..d7ccf4f 100644 --- a/resources/lib/modules/player.py +++ b/resources/lib/modules/player.py @@ -8,7 +8,7 @@ from resources.lib import kodiutils from resources.lib.goplay.auth import AuthApi from resources.lib.goplay.aws.cognito_idp import AuthenticationException, InvalidLoginException -from resources.lib.goplay.content import ContentApi, GeoblockedException, UnavailableException +from resources.lib.goplay.content import ApiException, ContentApi, GeoblockedException, MissingModuleException, UnavailableException _LOGGER = logging.getLogger(__name__) @@ -53,41 +53,59 @@ def play(self, uuid, content_type): return @staticmethod - def _resolve_stream(uuid, content_type): + def check_credentials(): + """ Check if we have credentials + :rtype bool + """ + if not kodiutils.get_setting('username') or not kodiutils.get_setting('password'): + confirm = kodiutils.yesno_dialog( + message=kodiutils.localize(30701)) # To watch a video, you need to enter your credentials. Do you want to enter them now? + if confirm: + kodiutils.open_settings() + kodiutils.end_of_directory() + return False + return True + + def _resolve_stream(self, uuid, content_type): # pylint: disable=too-many-return-statements """ Resolve the stream for the requested item :type uuid: str :type content_type: str """ - try: - # Check if we have credentials - if not kodiutils.get_setting('username') or not kodiutils.get_setting('password'): - confirm = kodiutils.yesno_dialog( - message=kodiutils.localize(30701)) # To watch a video, you need to enter your credentials. Do you want to enter them now? - if confirm: - kodiutils.open_settings() - kodiutils.end_of_directory() - return None - + if self.check_credentials(): # Fetch an auth token now try: auth = AuthApi(kodiutils.get_setting('username'), kodiutils.get_setting('password'), kodiutils.get_tokens_path()) + except (InvalidLoginException, AuthenticationException) as ex: + _LOGGER.exception(ex) + kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex))) + kodiutils.end_of_directory() + return None + + try: # Get stream information resolved_stream = ContentApi(auth).get_stream(uuid, content_type) return resolved_stream - except (InvalidLoginException, AuthenticationException) as ex: - _LOGGER.exception(ex) - kodiutils.ok_dialog(message=kodiutils.localize(30702, error=str(ex))) + except GeoblockedException as ex: + kodiutils.ok_dialog(message=kodiutils.localize(30710, error=str(ex))) # This video is geo-blocked... kodiutils.end_of_directory() return None - except GeoblockedException as ex: - kodiutils.ok_dialog(message=kodiutils.localize(30710, error=str(ex))) # This video is geo-blocked... - kodiutils.end_of_directory() - return None + except UnavailableException: + kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable... + kodiutils.end_of_directory() + return None - except UnavailableException: - kodiutils.ok_dialog(message=kodiutils.localize(30712)) # The video is unavailable... - kodiutils.end_of_directory() - return None + except MissingModuleException as ex: + kodiutils.ok_dialog(message=kodiutils.localize(30721, error=str(ex))) # You need to install an extra Python module... + kodiutils.end_of_directory() + return None + + except ApiException as ex: + kodiutils.ok_dialog(message=kodiutils.localize(30722, error=str(ex))) # The server returned an error... + kodiutils.end_of_directory() + return None + + kodiutils.end_of_directory() + return None diff --git a/resources/lib/service.py b/resources/lib/service.py index d0c7f5c..1e1018d 100644 --- a/resources/lib/service.py +++ b/resources/lib/service.py @@ -46,6 +46,13 @@ def onSettingsChanged(self): # pylint: disable=invalid-name # Refresh container kodiutils.container_refresh() + # Check widevine_device file extension + if kodiutils.get_setting_bool('enable_widevine_device') and kodiutils.get_setting('widevine_device') and not kodiutils.get_setting('widevine_device').endswith('.wvd'): + kodiutils.ok_dialog(message=kodiutils.localize(30723)) + kodiutils.open_settings() + + + @staticmethod def _has_credentials_changed(): """ Check if credentials have changed """ diff --git a/resources/settings.xml b/resources/settings.xml index 18f0a78..a1e5be5 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -76,6 +76,31 @@ RunPlugin(plugin://plugin.video.goplay/cache/clear) + + + + 0 + false + + + Integer.IsGreaterOrEqual(System.AddonVersion(inputstream.adaptive),21) + + + + 0 + + + false + true + + + 30889 + + + true + Integer.IsGreaterOrEqual(System.AddonVersion(inputstream.adaptive),21) + +