diff --git a/resources/lib/kodiutils.py b/resources/lib/kodiutils.py index 4578f14..60f30a2 100644 --- a/resources/lib/kodiutils.py +++ b/resources/lib/kodiutils.py @@ -26,6 +26,9 @@ DEFAULT_SORT_METHODS = [ 'unsorted', 'title' ] +STREAM_HLS = 'hls' +STREAM_DASH = 'mpd' + _LOGGER = logging.getLogger(__name__) @@ -190,7 +193,7 @@ def show_listing(title_items, category=None, sort=None, content=None, cache=True xbmcplugin.endOfDirectory(routing.handle, succeeded, cacheToDisc=cache) -def play(stream, title=None, art_dict=None, info_dict=None, prop_dict=None): +def play(stream, stream_type=STREAM_HLS, license_key=None, title=None, art_dict=None, info_dict=None, prop_dict=None, stream_dict=None): """Play the given stream""" from resources.lib.addon import routing @@ -201,14 +204,26 @@ def play(stream, title=None, art_dict=None, info_dict=None, prop_dict=None): play_item.setInfo(type='video', infoLabels=info_dict) if prop_dict: play_item.setProperties(prop_dict) + if stream_dict: + play_item.addStreamInfo('video', stream_dict) # Setup Inputstream Adaptive if kodi_version_major() >= 19: play_item.setProperty('inputstream', 'inputstream.adaptive') else: play_item.setProperty('inputstreamaddon', 'inputstream.adaptive') - play_item.setProperty('inputstream.adaptive.manifest_type', 'hls') - play_item.setMimeType('application/vnd.apple.mpegurl') + + if stream_type == STREAM_HLS: + play_item.setProperty('inputstream.adaptive.manifest_type', 'hls') + play_item.setMimeType('application/vnd.apple.mpegurl') + + elif stream_type == STREAM_DASH: + play_item.setProperty('inputstream.adaptive.manifest_type', 'mpd') + play_item.setMimeType('application/dash+xml') + if license_key: + play_item.setProperty('inputstream.adaptive.license_type', 'com.widevine.alpha') + play_item.setProperty('inputstream.adaptive.license_key', license_key) + play_item.setContentLookup(False) xbmcplugin.setResolvedUrl(routing.handle, True, listitem=play_item) diff --git a/resources/lib/modules/player.py b/resources/lib/modules/player.py index ba9600c..b111607 100644 --- a/resources/lib/modules/player.py +++ b/resources/lib/modules/player.py @@ -7,7 +7,7 @@ import logging from resources.lib import kodiutils from resources.lib.modules.menu import Menu -from resources.lib.viervijfzes import CHANNELS +from resources.lib.viervijfzes import CHANNELS, ResolvedStream from resources.lib.viervijfzes.auth import AuthApi from resources.lib.viervijfzes.auth_awsidp import AuthenticationException, InvalidLoginException from resources.lib.viervijfzes.content import ContentApi, GeoblockedException, UnavailableException @@ -51,7 +51,10 @@ class Player: if episode.stream: # We already have a resolved stream. Nice! # We don't need credentials for these streams. - resolved_stream = episode.stream + resolved_stream = ResolvedStream( + uuid=episode.uuid, + url=episode.stream, + ) _LOGGER.debug('Already got a resolved stream: %s', resolved_stream) if episode.uuid: @@ -61,7 +64,21 @@ class Player: if resolved_stream: titleitem = Menu.generate_titleitem(episode) - kodiutils.play(resolved_stream, info_dict=titleitem.info_dict, art_dict=titleitem.art_dict, prop_dict=titleitem.prop_dict) + if resolved_stream.license_url: + # Generate license key + license_key = self.create_license_key(resolved_stream.license_url, + key_headers=dict( + customdata=resolved_stream.auth, + )) + else: + license_key = None + + kodiutils.play(resolved_stream.url, + resolved_stream.stream_type, + license_key, + info_dict=titleitem.info_dict, + art_dict=titleitem.art_dict, + prop_dict=titleitem.prop_dict) def play(self, uuid): """ Play the requested item. @@ -69,7 +86,15 @@ class Player: """ # Lookup the stream resolved_stream = self._resolve_stream(uuid) - kodiutils.play(resolved_stream) + if resolved_stream.license_url: + # Generate license key + license_key = self.create_license_key(resolved_stream.license_url, key_headers=dict( + customdata=resolved_stream.auth, + )) + else: + license_key = None + + kodiutils.play(resolved_stream.url, resolved_stream.stream_type, license_key) @staticmethod def _resolve_stream(uuid): @@ -107,3 +132,31 @@ class Player: except UnavailableException: kodiutils.ok_dialog(heading=kodiutils.localize(30711), message=kodiutils.localize(30712)) # The video is unavailable... return None + + @staticmethod + def create_license_key(key_url, key_type='R', key_headers=None, key_value=None): + """ Create a license key string that we need for inputstream.adaptive. + + :param str key_url: + :param str key_type: + :param dict[str, str] key_headers: + :param str key_value: + :rtype: str + """ + try: # Python 3 + from urllib.parse import quote, urlencode + except ImportError: # Python 2 + from urllib import quote, urlencode + + header = '' + if key_headers: + header = urlencode(key_headers) + + if key_type in ('A', 'R', 'B'): + key_value = key_type + '{SSM}' + elif key_type == 'D': + if 'D{SSM}' not in key_value: + raise ValueError('Missing D{SSM} placeholder') + key_value = quote(key_value) + + return '%s|%s|%s|' % (key_url, header, key_value) diff --git a/resources/lib/viervijfzes/__init__.py b/resources/lib/viervijfzes/__init__.py index c5df88f..1936c1f 100644 --- a/resources/lib/viervijfzes/__init__.py +++ b/resources/lib/viervijfzes/__init__.py @@ -54,3 +54,24 @@ STREAM_DICT = { 'height': 544, 'width': 960, } + + +class ResolvedStream: + """ Defines a stream that we can play""" + + def __init__(self, uuid=None, url=None, stream_type=None, license_url=None, auth=None): + """ + :type uuid: str + :type url: str + :type stream_type: str + :type license_url: str + :type auth: str + """ + self.uuid = uuid + self.url = url + self.stream_type = stream_type + self.license_url = license_url + self.auth = auth + + def __repr__(self): + return "%r" % self.__dict__ diff --git a/resources/lib/viervijfzes/content.py b/resources/lib/viervijfzes/content.py index fc9d73d..12fbfcb 100644 --- a/resources/lib/viervijfzes/content.py +++ b/resources/lib/viervijfzes/content.py @@ -13,7 +13,8 @@ from datetime import datetime import requests from six.moves.html_parser import HTMLParser # pylint: disable=wrong-import-order -from resources.lib.viervijfzes import CHANNELS +from resources.lib.kodiutils import STREAM_DASH, STREAM_HLS +from resources.lib.viervijfzes import CHANNELS, ResolvedStream _LOGGER = logging.getLogger(__name__) @@ -164,6 +165,7 @@ class Category: class ContentApi: """ VIER/VIJF/ZES Content API""" API_ENDPOINT = 'https://api.viervijfzes.be' + API2_ENDPOINT = 'https://api2.viervijfzes.be' SITE_APIS = { 'vier': 'https://www.vier.be/api', 'vijf': 'https://www.vijf.be/api', @@ -340,7 +342,30 @@ class ContentApi: """ response = self._get_url(self.API_ENDPOINT + '/content/%s' % uuid, authentication=True) data = json.loads(response) - return data['video']['S'] + + if 'videoDash' in data: + # DRM protected stream + # See https://docs.unified-streaming.com/documentation/drm/buydrm.html#setting-up-the-client + drm_key = data['drmKey']['S'] + + _LOGGER.debug('Fetching Authentication XML with drm_key %s', drm_key) + response_drm = self._get_url(self.API2_ENDPOINT + '/decode/%s' % drm_key, authentication=True) + data_drm = json.loads(response_drm) + + return ResolvedStream( + uuid=uuid, + url=data['videoDash']['S'], + stream_type=STREAM_DASH, + license_url='https://wv-keyos.licensekeyserver.com/', + auth=data_drm.get('auth'), + ) + + # Normal HLS stream + return ResolvedStream( + uuid=uuid, + url=data['video']['S'], + stream_type=STREAM_HLS, + ) def get_categories(self, channel): """ Get a list of all categories of the specified channel. diff --git a/tests/__init__.py b/tests/__init__.py index ad162e7..0d6603b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -9,7 +9,15 @@ import sys import xbmcaddon -logging.basicConfig(level=logging.INFO) +try: # Python 3 + from http.client import HTTPConnection +except ImportError: # Python 2 + from httplib import HTTPConnection + +logging.basicConfig(level=logging.DEBUG) + +# Add logging to urllib +HTTPConnection.debuglevel = 1 # Make UTF-8 the default encoding in Python 2 if sys.version_info[0] == 2: @@ -17,7 +25,9 @@ if sys.version_info[0] == 2: sys.setdefaultencoding("utf-8") # pylint: disable=no-member # Set credentials based on environment data -if os.environ.get('ADDON_USERNAME') and os.environ.get('ADDON_PASSWORD'): - ADDON = xbmcaddon.Addon() +# Use the .env file with Pipenv to make this work nicely during development +ADDON = xbmcaddon.Addon() +if os.environ.get('ADDON_USERNAME'): ADDON.setSetting('username', os.environ.get('ADDON_USERNAME')) +if os.environ.get('ADDON_PASSWORD'): ADDON.setSetting('password', os.environ.get('ADDON_PASSWORD')) diff --git a/tests/test_api.py b/tests/test_api.py index 8752eef..848c579 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -9,6 +9,7 @@ import logging import unittest import resources.lib.kodiutils as kodiutils +from resources.lib.viervijfzes import ResolvedStream from resources.lib.viervijfzes.auth import AuthApi from resources.lib.viervijfzes.content import ContentApi, Program, Episode, Category, CACHE_PREVENT @@ -58,8 +59,14 @@ class TestApi(unittest.TestCase): self.assertIsInstance(program, Program) episode = program.episodes[0] - video = self._api.get_stream_by_uuid(episode.uuid) - self.assertTrue(video) + resolved_stream = self._api.get_stream_by_uuid(episode.uuid) + self.assertIsInstance(resolved_stream, ResolvedStream) + + @unittest.skipUnless(kodiutils.get_setting('username') and kodiutils.get_setting('password'), 'Skipping since we have no credentials.') + def test_get_drm_stream(self): + # https://www.zestv.be/video/ncis-new-orleans/ncis-new-orleans-seizoen-3/ncis-new-orleans-s3-aflevering-8 + resolved_stream = self._api.get_stream_by_uuid('5bd7211d-de78-490f-b40c-bacbee5919d2') + self.assertIsInstance(resolved_stream, ResolvedStream) if __name__ == '__main__':