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)
+
+