Skip to content

Commit

Permalink
Add support for external Widevine device
Browse files Browse the repository at this point in the history
  • Loading branch information
mediaminister committed Sep 29, 2024
1 parent 87c9921 commit d98ac5d
Show file tree
Hide file tree
Showing 7 changed files with 283 additions and 83 deletions.
24 changes: 24 additions & 0 deletions resources/language/resource.language.en_gb/strings.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 ""
24 changes: 24 additions & 0 deletions resources/language/resource.language.nl_nl/strings.po
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
206 changes: 151 additions & 55 deletions resources/lib/goplay/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import re
import time
from datetime import datetime
from xml.etree.ElementTree import XML

import requests

Expand Down Expand Up @@ -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. """

Expand Down Expand Up @@ -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'):
Expand All @@ -372,15 +384,15 @@ 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
return ResolvedStream(
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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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]:
Expand Down
16 changes: 11 additions & 5 deletions resources/lib/kodiutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading

0 comments on commit d98ac5d

Please sign in to comment.