Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for external Widevine device #11

Merged
merged 1 commit into from
Sep 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading