Merge pull request #29 from joaosagrath/patch-1
[plugin.video.netflix.git] / resources / lib / MSL.py
index 83ae2e7abfe2b34cfff46f67f3fc4bc0ff4ff196..ef7e93be5a6c20ba6ab7ce85881eb09bfa009dc3 100644 (file)
@@ -1,3 +1,8 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Module: MSL
+# Created on: 26.01.2017
+
 import base64
 import gzip
 import json
@@ -5,21 +10,19 @@ import os
 import pprint
 import random
 from StringIO import StringIO
-from hmac import HMAC
-import hashlib
+
+from datetime import datetime
 import requests
 import zlib
 
 import time
-from Crypto.PublicKey import RSA
-from Crypto.Cipher import PKCS1_OAEP
-from Crypto.Cipher import AES
-from Crypto.Random import get_random_bytes
-# from Crypto.Hash import HMAC, SHA256
-from Crypto.Util import Padding
+from Cryptodome.PublicKey import RSA
+from Cryptodome.Cipher import PKCS1_OAEP
+from Cryptodome.Cipher import AES
+from Cryptodome.Random import get_random_bytes
+from Cryptodome.Hash import HMAC, SHA256
+from Cryptodome.Util import Padding
 import xml.etree.ElementTree as ET
-from common import log
-from common import ADDONUSERDATA
 
 pp = pprint.PrettyPrinter(indent=4)
 
@@ -40,6 +43,7 @@ class MSL:
     last_playback_context = ''
     #esn = "NFCDCH-LX-CQE0NU6PA5714R25VPLXVU2A193T36"
     esn = "WWW-BROWSE-D7GW1G4NPXGR1F0X1H3EQGY3V1F5WE"
+    #esn = "NFCDIE-02-DCH84Q2EK3N6VFVQJ0NLRQ27498N0F"
     current_message_id = 0
     session = requests.session()
     rndm = random.SystemRandom()
@@ -49,44 +53,114 @@ class MSL:
         'license': 'http://www.netflix.com/api/msl/NFCDCH-LX/cadmium/license'
     }
 
-    def __init__(self, email, password):
+    def __init__(self, kodi_helper):
         """
         The Constructor checks for already existing crypto Keys.
         If they exist it will load the existing keys
         """
-        self.email = email
-        self.password = password
+        self.kodi_helper = kodi_helper
         try:
-            os.mkdir(ADDONUSERDATA)
+            os.mkdir(self.kodi_helper.msl_data_path)
         except OSError:
             pass
 
-        if self.file_exists('msl_data.json'):
+        if self.file_exists(self.kodi_helper.msl_data_path, 'msl_data.json'):
+            self.kodi_helper.log(msg='MSL Data exists. Use old Tokens.')
             self.__load_msl_data()
             self.handshake_performed = True
-        elif self.file_exists('rsa_key.bin'):
-            log('RSA Keys do already exist load old ones')
+        elif self.file_exists(self.kodi_helper.msl_data_path, 'rsa_key.bin'):
+            self.kodi_helper.log(msg='RSA Keys do already exist load old ones')
             self.__load_rsa_keys()
             self.__perform_key_handshake()
         else:
-            log('Create new RSA Keys')
+            self.kodi_helper.log(msg='Create new RSA Keys')
             # Create new Key Pair and save
             self.rsa_key = RSA.generate(2048)
             self.__save_rsa_keys()
             self.__perform_key_handshake()
 
     def load_manifest(self, viewable_id):
+        """
+        Loads the manifets for the given viewable_id and returns a mpd-XML-Manifest
+        :param viewable_id: The id of of the viewable
+        :return: MPD XML Manifest or False if no success
+        """
         manifest_request_data = {
             'method': 'manifest',
             'lookupType': 'PREPARE',
             'viewableIds': [viewable_id],
             'profiles': [
-                'playready-h264mpl30-dash',
-                'playready-h264mpl31-dash',
+                "playready-h264bpl30-dash",
+                "playready-h264mpl30-dash",
+                "playready-h264mpl31-dash",
+                "playready-h264mpl40-dash",
+                # "hevc-main-L30-dash-cenc",
+                # "hevc-main-L31-dash-cenc",
+                # "hevc-main-L40-dash-cenc",
+                # "hevc-main-L41-dash-cenc",
+                # "hevc-main-L50-dash-cenc",
+                # "hevc-main-L51-dash-cenc",
+                # "hevc-main10-L30-dash-cenc",
+                # "hevc-main10-L31-dash-cenc",
+                # "hevc-main10-L40-dash-cenc",
+                # "hevc-main10-L41-dash-cenc",
+                # "hevc-main10-L50-dash-cenc",
+                # "hevc-main10-L51-dash-cenc",
+                # "hevc-main10-L30-dash-cenc-prk",
+                # "hevc-main10-L31-dash-cenc-prk",
+                # "hevc-main10-L40-dash-cenc-prk",
+                # "hevc-main10-L41-dash-cenc-prk",
+                # "hevc-main-L30-L31-dash-cenc-tl",
+                # "hevc-main-L31-L40-dash-cenc-tl",
+                # "hevc-main-L40-L41-dash-cenc-tl",
+                # "hevc-main-L50-L51-dash-cenc-tl",
+                # "hevc-main10-L30-L31-dash-cenc-tl",
+                # "hevc-main10-L31-L40-dash-cenc-tl",
+                # "hevc-main10-L40-L41-dash-cenc-tl",
+                # "hevc-main10-L50-L51-dash-cenc-tl",
+                # "hevc-dv-main10-L30-dash-cenc",
+                # "hevc-dv-main10-L31-dash-cenc",
+                # "hevc-dv-main10-L40-dash-cenc",
+                # "hevc-dv-main10-L41-dash-cenc",
+                # "hevc-dv-main10-L50-dash-cenc",
+                # "hevc-dv-main10-L51-dash-cenc",
+                # "hevc-dv5-main10-L30-dash-cenc-prk",
+                # "hevc-dv5-main10-L31-dash-cenc-prk",
+                # "hevc-dv5-main10-L40-dash-cenc-prk",
+                # "hevc-dv5-main10-L41-dash-cenc-prk",
+                # "hevc-dv5-main10-L50-dash-cenc-prk",
+                # "hevc-dv5-main10-L51-dash-cenc-prk",
+                # "hevc-hdr-main10-L30-dash-cenc",
+                # "hevc-hdr-main10-L31-dash-cenc",
+                # "hevc-hdr-main10-L40-dash-cenc",
+                # "hevc-hdr-main10-L41-dash-cenc",
+                # "hevc-hdr-main10-L50-dash-cenc",
+                # "hevc-hdr-main10-L51-dash-cenc",
+                # "hevc-hdr-main10-L30-dash-cenc-prk",
+                # "hevc-hdr-main10-L31-dash-cenc-prk",
+                # "hevc-hdr-main10-L40-dash-cenc-prk",
+                # "hevc-hdr-main10-L41-dash-cenc-prk",
+                # "hevc-hdr-main10-L50-dash-cenc-prk",
+                # "hevc-hdr-main10-L51-dash-cenc-prk"
+
+               # 'playready-h264mpl30-dash',
+                #'playready-h264mpl31-dash',
+                #'playready-h264mpl40-dash',
+                #'hevc-main10-L41-dash-cenc',
+                #'hevc-main10-L50-dash-cenc',
+                #'hevc-main10-L51-dash-cenc',
+
+
+
+                # Audio
                 'heaac-2-dash',
+
+                #subtiltes
                 'dfxp-ls-sdh',
-                'simplesdh',
-                'nflx-cmisc',
+                #'simplesdh',
+                #'nflx-cmisc',
+
+                #unkown
                 'BIF240',
                 'BIF320'
             ],
@@ -106,39 +180,35 @@ class MSL:
             'clientVersion': '4.0004.899.011',
             'uiVersion': 'akira'
         }
-        request_data = self.__generate_msl_request_data(manifest_request_data)
 
-        resp = self.session.post(self.endpoints['manifest'], request_data)
+        # Check if dolby sound is enabled and add to profles
+        if self.kodi_helper.get_dolby_setting():
+            manifest_request_data['profiles'].append('ddplus-2.0-dash')
+            manifest_request_data['profiles'].append('ddplus-5.1-dash')
 
+        request_data = self.__generate_msl_request_data(manifest_request_data)
+        resp = self.session.post(self.endpoints['manifest'], request_data)
 
         try:
+            # if the json() does not fail we have an error because the manifest response is a chuncked json response
             resp.json()
-            log('MANIFEST RESPONE JSON: '+resp.text)
+            self.kodi_helper.log(msg='Error getting Manifest: '+resp.text)
+            return False
         except ValueError:
-            # Maybe we have a CHUNKED response
+            # json() failed so parse the chunked response
+            self.kodi_helper.log(msg='Got chunked Manifest Response: ' + resp.text)
             resp = self.__parse_chunked_msl_response(resp.text)
+            self.kodi_helper.log(msg='Parsed chunked Response: ' + json.dumps(resp))
             data = self.__decrypt_payload_chunk(resp['payloads'][0])
-            # pprint.pprint(data)
             return self.__tranform_to_dash(data)
 
-
     def get_license(self, challenge, sid):
-
         """
-            std::time_t t = std::time(0);  // t is an integer type
-    licenseRequestData["clientTime"] = (int)t;
-    //licenseRequestData["challengeBase64"] = challengeStr;
-    licenseRequestData["licenseType"] = "STANDARD";
-    licenseRequestData["playbackContextId"] = playbackContextId;//"E1-BQFRAAELEB32o6Se-GFvjwEIbvDydEtfj6zNzEC3qwfweEPAL3gTHHT2V8rS_u1Mc3mw5BWZrUlKYIu4aArdjN8z_Z8t62E5jRjLMdCKMsVhlSJpiQx0MNW4aGqkYz-1lPh85Quo4I_mxVBG5lgd166B5NDizA8.";
-    licenseRequestData["drmContextIds"] = Json::arrayValue;
-    licenseRequestData["drmContextIds"].append(drmContextId);
-
-        :param viewable_id:
-        :param challenge:
-        :param kid:
-        :return:
+        Requests and returns a license for the given challenge and sid
+        :param challenge: The base64 encoded challenge
+        :param sid: The sid paired to the challengew
+        :return: Base64 representation of the license key or False if no success
         """
-
         license_request_data = {
             'method': 'license',
             'licenseType': 'STANDARD',
@@ -160,18 +230,19 @@ class MSL:
         resp = self.session.post(self.endpoints['license'], request_data)
 
         try:
+            # If is valid json the request for the licnese failed
             resp.json()
-            log('LICENSE RESPONE JSON: '+resp.text)
+            self.kodi_helper.log(msg='Error getting license: '+resp.text)
+            return False
         except ValueError:
-            # Maybe we have a CHUNKED response
+            # json() failed so we have a chunked json response
             resp = self.__parse_chunked_msl_response(resp.text)
             data = self.__decrypt_payload_chunk(resp['payloads'][0])
-            # pprint.pprint(data)
             if data['success'] is True:
                 return data['result']['licenses'][0]['data']
             else:
-                return ''
-
+                self.kodi_helper.log(msg='Error getting license: ' + json.dumps(data))
+                return False
 
     def __decrypt_payload_chunk(self, payloadchunk):
         payloadchunk = json.JSONDecoder().decode(payloadchunk)
@@ -196,7 +267,7 @@ class MSL:
 
     def __tranform_to_dash(self, manifest):
 
-        self.save_file('manifest.json', json.dumps(manifest))
+        self.save_file(self.kodi_helper.msl_data_path, 'manifest.json', json.dumps(manifest))
         manifest = manifest['result']['viewables'][0]
 
         self.last_playback_context = manifest['playbackContextId']
@@ -208,15 +279,14 @@ class MSL:
             if len(manifest['psshb64']) >= 1:
                 pssh = manifest['psshb64'][0]
 
-
+        seconds = manifest['runtime']/1000
+        init_length = seconds / 2 * 12 + 20*1000
+        duration = "PT"+str(seconds)+".00S"
 
         root = ET.Element('MPD')
         root.attrib['xmlns'] = 'urn:mpeg:dash:schema:mpd:2011'
         root.attrib['xmlns:cenc'] = 'urn:mpeg:cenc:2013'
-
-
-        seconds = manifest['runtime']/1000
-        duration = "PT"+str(seconds)+".00S"
+        root.attrib['mediaPresentationDuration'] = duration
 
         period = ET.SubElement(root, 'Period', start='PT0S', duration=duration)
 
@@ -230,18 +300,30 @@ class MSL:
                 ET.SubElement(protection, 'cenc:pssh').text = pssh
 
             for downloadable in video_track['downloadables']:
+
+                codec = 'h264'
+                if 'hevc' in downloadable['contentProfile']:
+                    codec = 'hevc'
+
+                hdcp_versions = '0.0'
+                for hdcp in downloadable['hdcpVersions']:
+                    if hdcp != 'none':
+                        hdcp_versions = hdcp
+
                 rep = ET.SubElement(video_adaption_set, 'Representation',
                                     width=str(downloadable['width']),
                                     height=str(downloadable['height']),
-                                    bandwidth=str(downloadable['bitrate']*8*1024),
-                                    codecs='h264',
+                                    bandwidth=str(downloadable['bitrate']*1024),
+                                    hdcp=hdcp_versions,
+                                    nflxContentProfile=str(downloadable['contentProfile']),
+                                    codecs=codec,
                                     mimeType='video/mp4')
 
                 #BaseURL
                 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
                 # Init an Segment block
-                segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
-                ET.SubElement(segment_base, 'Initialization', range='0-60000')
+                segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-"+str(init_length), indexRangeExact="true")
+                ET.SubElement(segment_base, 'Initialization', range='0-'+str(init_length))
 
 
 
@@ -252,9 +334,14 @@ class MSL:
                                                contentType='audio',
                                                mimeType='audio/mp4')
             for downloadable in audio_track['downloadables']:
+                codec = 'aac'
+                print downloadable
+                if downloadable['contentProfile'] == 'ddplus-2.0-dash' or downloadable['contentProfile'] == 'ddplus-5.1-dash':
+                    codec = 'ec-3'
+                print "codec is: " + codec
                 rep = ET.SubElement(audio_adaption_set, 'Representation',
-                                    codecs='aac',
-                                    bandwidth=str(downloadable['bitrate'] * 8 * 1024),
+                                    codecs=codec,
+                                    bandwidth=str(downloadable['bitrate']*1024),
                                     mimeType='audio/mp4')
 
                 #AudioChannel Config
@@ -265,8 +352,23 @@ class MSL:
                 #BaseURL
                 ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
                 # Index range
-                segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-60000", indexRangeExact="true")
-                ET.SubElement(segment_base, 'Initialization', range='0-60000')
+                segment_base = ET.SubElement(rep, 'SegmentBase', indexRange="0-"+str(init_length), indexRangeExact="true")
+                ET.SubElement(segment_base, 'Initialization', range='0-'+str(init_length))
+
+        # Multiple Adaption Sets for subtiles
+        for text_track in manifest['textTracks']:
+            if 'downloadables' not in text_track or text_track['downloadables'] is None:
+                continue
+            subtiles_adaption_set = ET.SubElement(period, 'AdaptationSet',
+                                                  lang=text_track['bcp47'],
+                                                  codecs='stpp',
+                                                  contentType='text',
+                                                  mimeType='application/ttml+xml')
+            for downloadable in text_track['downloadables']:
+                rep = ET.SubElement(subtiles_adaption_set, 'Representation',
+                                    nflxProfile=downloadable['contentProfile']
+                                    )
+                ET.SubElement(rep, 'BaseURL').text = self.__get_base_url(downloadable['urls'])
 
 
         xml = ET.tostring(root, encoding='utf-8', method='xml')
@@ -314,7 +416,7 @@ class MSL:
         # Serialize the given Data
         serialized_data = json.dumps(data)
         serialized_data = serialized_data.replace('"', '\\"')
-        serialized_data = '[{},{"headers":{},"path":"/cbp/cadmium-11","payload":{"data":"' + serialized_data + '"},"query":""}]\n'
+        serialized_data = '[{},{"headers":{},"path":"/cbp/cadmium-13","payload":{"data":"' + serialized_data + '"},"query":""}]\n'
 
         compressed_data = self.__compress_data(serialized_data)
 
@@ -332,21 +434,7 @@ class MSL:
             'signature': self.__sign(first_payload_encryption_envelope),
         }
 
-
-        # Create Second Payload
-        second_payload = {
-            "messageid": self.current_message_id,
-            "data": "",
-            "endofmsg": True,
-            "sequencenumber": 2
-        }
-        second_payload_encryption_envelope = self.__encrypt(json.dumps(second_payload))
-        second_payload_chunk = {
-            'payload': base64.standard_b64encode(second_payload_encryption_envelope),
-            'signature': base64.standard_b64encode(self.__sign(second_payload_encryption_envelope)),
-        }
-
-        request_data = json.dumps(header) + json.dumps(first_payload_chunk) # + json.dumps(second_payload_chunk)
+        request_data = json.dumps(header) + json.dumps(first_payload_chunk)
         return request_data
 
 
@@ -399,12 +487,13 @@ class MSL:
             if 'usertoken' in self.tokens:
                 pass
             else:
+                account = self.kodi_helper.get_credentials()
                 # Auth via email and password
                 header_data['userauthdata'] = {
                     'scheme': 'EMAIL_PASSWORD',
                     'authdata': {
-                        'email': self.email,
-                        'password': self.password
+                        'email': account['email'],
+                        'password': account['password']
                     }
                 }
 
@@ -433,18 +522,18 @@ class MSL:
         encryption_envelope['ciphertext'] = base64.standard_b64encode(ciphertext)
         return json.dumps(encryption_envelope)
 
-    def __sign(self, text):
-        #signature = hmac.new(self.sign_key, text, hashlib.sha256).digest()
-        signature = HMAC(self.sign_key, text, hashlib.sha256).digest()
-
 
-        # hmac = HMAC.new(self.sign_key, digestmod=SHA256)
-        # hmac.update(text)
+    def __sign(self, text):
+        """
+        Calculates the HMAC signature for the given text with the current sign key and SHA256
+        :param text:
+        :return: Base64 encoded signature
+        """
+        signature = HMAC.new(self.sign_key, text, SHA256).digest()
         return base64.standard_b64encode(signature)
 
 
     def __perform_key_handshake(self):
-
         header = self.__generate_msl_header(is_key_request=True, is_handshake=True, compressionalgo="", encrypt=False)
         request = {
             'entityauthdata': {
@@ -456,21 +545,20 @@ class MSL:
             'headerdata': base64.standard_b64encode(header),
             'signature': '',
         }
-        log('Key Handshake Request:')
-        log(json.dumps(request))
-
+        self.kodi_helper.log(msg='Key Handshake Request:')
+        self.kodi_helper.log(msg=json.dumps(request))
 
         resp = self.session.post(self.endpoints['manifest'], json.dumps(request, sort_keys=True))
         if resp.status_code == 200:
             resp = resp.json()
             if 'errordata' in resp:
-                log('Key Exchange failed')
-                log(base64.standard_b64decode(resp['errordata']))
+                self.kodi_helper.log(msg='Key Exchange failed')
+                self.kodi_helper.log(msg=base64.standard_b64decode(resp['errordata']))
                 return False
             self.__parse_crypto_keys(json.JSONDecoder().decode(base64.standard_b64decode(resp['headerdata'])))
         else:
-            log('Key Exchange failed')
-            log(resp.text)
+            self.kodi_helper.log(msg='Key Exchange failed')
+            self.kodi_helper.log(msg=resp.text)
 
     def __parse_crypto_keys(self, headerdata):
         self.__set_master_token(headerdata['keyresponsedata']['mastertoken'])
@@ -491,7 +579,19 @@ class MSL:
         self.handshake_performed = True
 
     def __load_msl_data(self):
-        msl_data = json.JSONDecoder().decode(self.load_file('msl_data.json'))
+        msl_data = json.JSONDecoder().decode(self.load_file(self.kodi_helper.msl_data_path, 'msl_data.json'))
+        #Check expire date of the token
+        master_token = json.JSONDecoder().decode(base64.standard_b64decode(msl_data['tokens']['mastertoken']['tokendata']))
+        valid_until = datetime.utcfromtimestamp(int(master_token['expiration']))
+        present = datetime.now()
+        difference = valid_until - present
+        difference = difference.total_seconds() / 60 / 60
+        # If token expires in less then 10 hours or is expires renew it
+        if difference < 10:
+            self.__load_rsa_keys()
+            self.__perform_key_handshake()
+            return
+
         self.__set_master_token(msl_data['tokens']['mastertoken'])
         self.encryption_key = base64.standard_b64decode(msl_data['encryption_key'])
         self.sign_key = base64.standard_b64decode(msl_data['sign_key'])
@@ -509,50 +609,49 @@ class MSL:
             }
         }
         serialized_data = json.JSONEncoder().encode(data)
-        self.save_file('msl_data.json', serialized_data)
+        self.save_file(self.kodi_helper.msl_data_path, 'msl_data.json', serialized_data)
 
     def __set_master_token(self, master_token):
         self.mastertoken = master_token
-        self.sequence_number = json.JSONDecoder().decode(base64.standard_b64decode(master_token['tokendata']))[
-            'sequencenumber']
+        self.sequence_number = json.JSONDecoder().decode(base64.standard_b64decode(master_token['tokendata']))['sequencenumber']
 
     def __load_rsa_keys(self):
-        loaded_key = self.load_file('rsa_key.bin')
+        loaded_key = self.load_file(self.kodi_helper.msl_data_path, 'rsa_key.bin')
         self.rsa_key = RSA.importKey(loaded_key)
 
     def __save_rsa_keys(self):
-        log('Save RSA Keys')
+        self.kodi_helper.log(msg='Save RSA Keys')
         # Get the DER Base64 of the keys
         encrypted_key = self.rsa_key.exportKey()
-        self.save_file('rsa_key.bin', encrypted_key)
+        self.save_file(self.kodi_helper.msl_data_path, 'rsa_key.bin', encrypted_key)
 
     @staticmethod
-    def file_exists(filename):
+    def file_exists(msl_data_path, filename):
         """
         Checks if a given file exists
         :param filename: The filename
         :return: True if so
         """
-        return os.path.isfile(ADDONUSERDATA + filename)
+        return os.path.isfile(msl_data_path + filename)
 
     @staticmethod
-    def save_file(filename, content):
+    def save_file(msl_data_path, filename, content):
         """
         Saves the given content under given filename
         :param filename: The filename
         :param content: The content of the file
         """
-        with open(ADDONUSERDATA + filename, 'w') as file_:
+        with open(msl_data_path + filename, 'w') as file_:
             file_.write(content)
             file_.flush()
 
     @staticmethod
-    def load_file(filename):
+    def load_file(msl_data_path, filename):
         """
         Loads the content of a given filename
         :param filename: The file to load
         :return: The content of the file
         """
-        with open(ADDONUSERDATA + filename) as file_:
+        with open(msl_data_path + filename) as file_:
             file_content = file_.read()
         return file_content