750 lines
36 KiB
Python
750 lines
36 KiB
Python
|
import base64
|
||
|
from datetime import datetime
|
||
|
import gzip
|
||
|
import zlib
|
||
|
import json
|
||
|
import logging
|
||
|
from io import BytesIO
|
||
|
import random
|
||
|
import time
|
||
|
import os
|
||
|
import re
|
||
|
|
||
|
import requests
|
||
|
from Cryptodome.Cipher import AES
|
||
|
from Cryptodome.Cipher import PKCS1_OAEP
|
||
|
from Cryptodome.Hash import HMAC, SHA256
|
||
|
from Cryptodome.PublicKey import RSA
|
||
|
from Cryptodome.Random import get_random_bytes
|
||
|
from Cryptodome.Util import Padding
|
||
|
|
||
|
import pywidevine.downloader.wvdownloaderconfig as wvdl_cfg
|
||
|
import pywidevine.clients.netflix.config as nf_cfg
|
||
|
import pywidevine.clients.netflix.subs as subs
|
||
|
from pywidevine.downloader.tracks import VideoTrack, AudioTrack, SubtitleTrack
|
||
|
|
||
|
|
||
|
# keys are not padded properly
|
||
|
def base64key_decode(payload):
|
||
|
l = len(payload) % 4
|
||
|
if l == 2:
|
||
|
payload += '=='
|
||
|
elif l == 3:
|
||
|
payload += '='
|
||
|
elif l != 0:
|
||
|
raise ValueError('Invalid base64 string')
|
||
|
return base64.urlsafe_b64decode(payload.encode('utf-8'))
|
||
|
|
||
|
|
||
|
class NetflixClient(object):
|
||
|
def __init__(self, client_config):
|
||
|
self.logger = logging.getLogger(__name__)
|
||
|
self.logger.debug("creating NetflixClient object")
|
||
|
self.client_config = client_config
|
||
|
self.session = requests.Session()
|
||
|
self.current_message_id = 0
|
||
|
self.rsa_key = None
|
||
|
self.encryption_key = None
|
||
|
self.sign_key = None
|
||
|
self.sequence_number = None
|
||
|
self.mastertoken = None
|
||
|
self.useridtoken = None
|
||
|
self.playbackContextId = None
|
||
|
self.drmContextId = None
|
||
|
self.tokens = []
|
||
|
self.rndm = random.SystemRandom()
|
||
|
#self.cookies = self.cookie_login()
|
||
|
|
||
|
def login(self):
|
||
|
self.logger.info("acquiring token & key for netflix api")
|
||
|
config_dict = self.client_config.config
|
||
|
|
||
|
#if self.cookies is None:
|
||
|
# self.cookies = self.cookie_login()
|
||
|
|
||
|
if self.file_exists(wvdl_cfg.COOKIES_FOLDER, config_dict['msl_storage']):
|
||
|
self.logger.info("old MSL data found, using")
|
||
|
self.__load_msl_data()
|
||
|
elif self.file_exists(wvdl_cfg.COOKIES_FOLDER, 'rsa_key.bin'):
|
||
|
self.logger.info('old RSA key found, using')
|
||
|
self.__load_rsa_keys()
|
||
|
self.__perform_key_handshake()
|
||
|
else:
|
||
|
self.logger.info('create new RSA Keys')
|
||
|
# Create new Key Pair and save
|
||
|
self.rsa_key = RSA.generate(2048)
|
||
|
self.__save_rsa_keys()
|
||
|
self.__perform_key_handshake()
|
||
|
|
||
|
if self.encryption_key:
|
||
|
self.logger.info("negotiation successful, token & key acquired")
|
||
|
return True
|
||
|
else:
|
||
|
self.logger.error("failed to perform key handshake")
|
||
|
return False
|
||
|
|
||
|
'''def cookie_login(self):
|
||
|
"""Logs into netflix"""
|
||
|
config_dict = self.client_config.config
|
||
|
post_data = {
|
||
|
'email': config_dict['username'],
|
||
|
'password': config_dict['password'],
|
||
|
'rememberMe': 'true',
|
||
|
'mode': 'login',
|
||
|
'action': 'loginAction',
|
||
|
'withFields': 'email,password,rememberMe,nextPage,showPassword',
|
||
|
'nextPage': '',
|
||
|
'showPassword': '',
|
||
|
}
|
||
|
req = self.session.post('https://www.netflix.com/login', post_data)
|
||
|
return req.cookies'''
|
||
|
|
||
|
def get_track_and_init_info(self,all_subs=False,only_subs=False):
|
||
|
config_dict = self.client_config.config
|
||
|
self.logger.info("video information")
|
||
|
manifest_request_data = {
|
||
|
'method': 'manifest',
|
||
|
'lookupType': 'PREPARE',
|
||
|
'viewableIds': [self.client_config.viewable_id],
|
||
|
'profiles': config_dict['profiles'],
|
||
|
'drmSystem': 'widevine',
|
||
|
'appId': '14673889385265',
|
||
|
'sessionParams': {
|
||
|
'pinCapableClient': False,
|
||
|
'uiplaycontext': 'null'
|
||
|
},
|
||
|
'sessionId': '14673889385265',
|
||
|
'trackId': 0,
|
||
|
'flavor': 'PRE_FETCH',
|
||
|
'secureUrls': True,
|
||
|
'supportPreviewContent': True,
|
||
|
'forceClearStreams': False,
|
||
|
'languages': ['en-US'],
|
||
|
'clientVersion': '4.0004.899.011',
|
||
|
'uiVersion': 'akira',
|
||
|
'showAllSubDubTracks': all_subs
|
||
|
}
|
||
|
self.logger.debug("requesting manifest")
|
||
|
request_data = self.__generate_msl_request_data(manifest_request_data)
|
||
|
resp = self.session.post(nf_cfg.MANIFEST_ENDPOINT, request_data, proxies=self.client_config.get_proxies())
|
||
|
|
||
|
data = {}
|
||
|
|
||
|
|
||
|
try:
|
||
|
# if the json() does not fail we have an error because the manifest response is a chuncked json response
|
||
|
resp.json()
|
||
|
self.logger.debug('Error getting Manifest: '+resp.text)
|
||
|
return False, None
|
||
|
except ValueError:
|
||
|
# json() failed so parse the chunked response
|
||
|
self.logger.debug('Got chunked Manifest Response: ' + resp.text)
|
||
|
resp = self.__parse_chunked_msl_response(resp.text)
|
||
|
#print(resp.text)
|
||
|
self.logger.debug('Parsed chunked Response: ' + json.dumps(resp))
|
||
|
data = self.__decrypt_payload_chunk(resp['payloads'])
|
||
|
|
||
|
try:
|
||
|
self.logger.debug("manifest json: %s" % data['result']['viewables'][0])
|
||
|
self.playbackContextId = data['result']['viewables'][0]['playbackContextId']
|
||
|
self.drmContextId = data['result']['viewables'][0]['drmContextId']
|
||
|
except (KeyError, IndexError):
|
||
|
self.logger.error('No viewables found')
|
||
|
if 'errorDisplayMessage' in data['result']:
|
||
|
self.logger.error('MSL Error Message: {}'.format(data['result']['errorDisplayMessage']))
|
||
|
return False, None
|
||
|
|
||
|
self.logger.debug(data)
|
||
|
self.logger.debug("netflix cannot get title name from manifest")
|
||
|
subtitle_tracks_js = data['result']['viewables'][0]['textTracks']
|
||
|
subtitle_tracks_filtered = [x for x in subtitle_tracks_js if "bcp47" in x]
|
||
|
|
||
|
self.logger.info("found {} subtitle tracks".format(len(subtitle_tracks_filtered)))
|
||
|
for subtitle_track in subtitle_tracks_filtered:
|
||
|
self.logger.info(
|
||
|
"Name: {} bcp47: {} type: {}".format(subtitle_track['language'], subtitle_track['bcp47'], subtitle_track['trackType'])
|
||
|
)
|
||
|
|
||
|
if only_subs:
|
||
|
wvdl_sts = []
|
||
|
for i, subtitle in enumerate(subtitle_tracks_filtered):
|
||
|
if not subtitle['isForced']:
|
||
|
wvdl_sts.append(
|
||
|
SubtitleTrack(i, subtitle['language'], subtitle['bcp47'], True, next(iter(subtitle['downloadables'][0]['urls'].values())),
|
||
|
'srt')
|
||
|
)
|
||
|
return True, wvdl_sts
|
||
|
|
||
|
video_tracks_js = data['result']['viewables'][0]['videoTracks'][0]['downloadables']
|
||
|
|
||
|
self.logger.info("found {} video tracks".format(len(video_tracks_js)))
|
||
|
for vid_id, video in enumerate(sorted(video_tracks_js, key= lambda v: int(v['bitrate']))):
|
||
|
self.logger.info(
|
||
|
"{} - Bitrate: {} Profile: {} Size: {} Width: {} Height: {}".format(vid_id, video['bitrate'],
|
||
|
video['contentProfile'],
|
||
|
video['size'],
|
||
|
video['width'],
|
||
|
video['height'])
|
||
|
)
|
||
|
|
||
|
audio_tracks_js = data['result']['viewables'][0]['audioTracks']
|
||
|
audio_tracks_flattened = []
|
||
|
|
||
|
for audio_track in audio_tracks_js:
|
||
|
for downloadable in audio_track['downloadables']:
|
||
|
new_track = audio_track.copy()
|
||
|
new_track['downloadables'] = downloadable
|
||
|
audio_tracks_flattened.append(new_track)
|
||
|
|
||
|
self.logger.info("found {} audio tracks".format(len(audio_tracks_flattened)))
|
||
|
for aud_id, audio_track in enumerate(
|
||
|
sorted(audio_tracks_flattened, key=lambda v: int(v['downloadables']['bitrate']))):
|
||
|
self.logger.info(
|
||
|
"{} = Bitrate: {} Profile: {} Channels: {} Language: {} Lang: {} Size: {}".format(aud_id, audio_track[
|
||
|
'downloadables']['bitrate'],
|
||
|
audio_track[
|
||
|
'downloadables'][
|
||
|
'contentProfile'],
|
||
|
audio_track[
|
||
|
'channels'],
|
||
|
audio_track[
|
||
|
'language'],
|
||
|
audio_track['bcp47'],
|
||
|
audio_track[
|
||
|
'downloadables'][
|
||
|
'size'])
|
||
|
)
|
||
|
|
||
|
|
||
|
self.logger.info("selected tracks")
|
||
|
if config_dict['video_track'] is not None:
|
||
|
self.logger.debug("VIDEO_TRACK_ID ARGUMENT: {}".format(config_dict['video_track']))
|
||
|
if config_dict['video_track'] >= len(video_tracks_js):
|
||
|
self.logger.error("selected video track does not exist")
|
||
|
return False, []
|
||
|
video_track = sorted(video_tracks_js, key= lambda v: int(v['bitrate']))[int(config_dict['video_track'])]
|
||
|
else:
|
||
|
video_track = sorted(video_tracks_js, key= lambda v: int(v['bitrate']), reverse=True)[0]
|
||
|
self.logger.info("VIDEO - Bitrate: {} Profile: {} Size: {} Width: {} Height: {}".format(video_track['bitrate'],
|
||
|
video_track['contentProfile'],
|
||
|
video_track['size'],
|
||
|
video_track['width'],
|
||
|
video_track['height']))
|
||
|
audio_tracks = []
|
||
|
|
||
|
if config_dict['audio_tracks'] != []:
|
||
|
self.logger.debug("AUDIO_TRACK_ID ARUGMENT: {}".format(config_dict['audio_tracks']))
|
||
|
sorted_aud = sorted(audio_tracks_flattened, key=lambda v: int(v['downloadables']['bitrate']))
|
||
|
for t_id in config_dict['audio_tracks']:
|
||
|
audio_tracks.append(sorted_aud[int(t_id)])
|
||
|
else:
|
||
|
selected_track = None
|
||
|
bitrate = 0
|
||
|
channels = 0
|
||
|
profile = None
|
||
|
default_audio_lang = data['result']['viewables'][0]['orderedAudioTracks'][0]
|
||
|
audio_lang_selectors = {
|
||
|
'English': 'en',
|
||
|
'French': 'fr',
|
||
|
'German': 'de',
|
||
|
'Italian': 'it',
|
||
|
'Spanish': 'es',
|
||
|
'Flemish': 'nl-BE',
|
||
|
'Finnish': 'fi',
|
||
|
'No Dialogue': 'zxx',
|
||
|
'Czech': 'cs',
|
||
|
'European Spanish': 'es-ES',
|
||
|
'Japanese': 'ja',
|
||
|
'Brazilian Portuguese': 'pt-BR',
|
||
|
'Polish': 'pl',
|
||
|
'Turkish': 'tr',
|
||
|
'Mandarin': 'zh',
|
||
|
'Cantonese': 'yue',
|
||
|
'Arabic': 'ar',
|
||
|
'Korean': 'ko',
|
||
|
'Hebrew': 'he',
|
||
|
'Norwegian': 'nb'
|
||
|
}
|
||
|
aud_profile_table = {
|
||
|
'heaac-2-dash': 0,
|
||
|
'ddplus-2.0-dash': 1,
|
||
|
'ddplus-5.1-dash': 2,
|
||
|
'dd-5.1-dash': 3
|
||
|
}
|
||
|
sorted_aud = sorted(audio_tracks_flattened, key=lambda v: int(v['downloadables']['bitrate']), reverse=True)
|
||
|
|
||
|
audio_selected = ['en']
|
||
|
if config_dict['audio_language'] is not None:
|
||
|
audio_selected = config_dict['audio_language']
|
||
|
|
||
|
for audio_select in audio_selected:
|
||
|
for aud_track_sorted in sorted_aud:
|
||
|
if aud_track_sorted['bcp47'] == audio_select:
|
||
|
audio_tracks.append(aud_track_sorted)
|
||
|
break
|
||
|
|
||
|
# if selected_track == None:
|
||
|
# selected_track = aud_track_sorted
|
||
|
# bitrate = aud_track_sorted['downloadables']['bitrate']
|
||
|
# channels = aud_track_sorted['channels']
|
||
|
# profile = aud_profile_table[aud_track_sorted['downloadables']['contentProfile']]
|
||
|
# if (bitrate < aud_track_sorted['downloadables']['bitrate']) and (
|
||
|
# channels <= aud_track_sorted['channels']) and (
|
||
|
# profile <= aud_profile_table[aud_track_sorted['downloadables']['contentProfile']]):
|
||
|
# selected_track = aud_track_sorted
|
||
|
# bitrate = aud_track_sorted['downloadables']['bitrate']
|
||
|
# channels = aud_track_sorted['channels']
|
||
|
# profile = aud_profile_table[aud_track_sorted['downloadables']['contentProfile']]
|
||
|
|
||
|
# audio_tracks.append(selected_track)
|
||
|
# if selected_track == None:
|
||
|
# self.logger.error("netfix cannot auto select audio track, please specify at least one")
|
||
|
# return False, []
|
||
|
for audio_track in audio_tracks:
|
||
|
self.logger.info("AUDIO - Bitrate: {} Profile: {} Channels: {} Language: {} Size: {}".format(
|
||
|
audio_track['downloadables']['bitrate'],
|
||
|
audio_track['downloadables']['contentProfile'],
|
||
|
audio_track['channels'],
|
||
|
audio_track['language'],
|
||
|
audio_track['downloadables']['size']))
|
||
|
|
||
|
if config_dict['subtitle_languages'] is not None:
|
||
|
self.logger.debug("SUBTITLE_LANGUAGE_ARGUMENT: {}".format(config_dict['subtitle_languages']))
|
||
|
if 'all' in config_dict['subtitle_languages']:
|
||
|
subtitle_tracks = subtitle_tracks_filtered
|
||
|
else:
|
||
|
subtitle_tracks = [x for x in subtitle_tracks_filtered if
|
||
|
x['bcp47'] in config_dict['subtitle_languages']]
|
||
|
else:
|
||
|
subtitle_tracks = None
|
||
|
if subtitle_tracks:
|
||
|
for subtitle in subtitle_tracks:
|
||
|
self.logger.info("SUBTITLE - Name: {} bcp47: {}".format(subtitle['language'], subtitle['bcp47']))
|
||
|
|
||
|
init_data_b64 = data['result']['viewables'][0]['psshb64'][0]
|
||
|
|
||
|
cert_data_b64 = data['result']['viewables'][0]['cert']
|
||
|
|
||
|
wvdl_vt = VideoTrack(True, video_track['size'], 0, next(iter(video_track['urls'].values())),
|
||
|
video_track['contentProfile'], video_track['bitrate'],
|
||
|
video_track['width'], video_track['height'])
|
||
|
|
||
|
wvdl_ats = []
|
||
|
audio_languages = []
|
||
|
|
||
|
|
||
|
for id, audio_track in enumerate(audio_tracks):
|
||
|
audio_languages.append(audio_track['bcp47'])
|
||
|
wvdl_ats.append(
|
||
|
AudioTrack(False, audio_track['downloadables']['size'], id, next(iter(audio_track['downloadables']['urls'].values())),
|
||
|
audio_track['downloadables']['contentProfile'], audio_track['downloadables']['bitrate'], audio_track['language'])
|
||
|
)
|
||
|
|
||
|
wvdl_sts = []
|
||
|
use_captions = { 'en': True }
|
||
|
if subtitle_tracks:
|
||
|
for track in subtitle_tracks:
|
||
|
use_captions.update({track['bcp47']: True})
|
||
|
for track in subtitle_tracks:
|
||
|
if track['bcp47'] == 'en' and track['trackType'] == "SUBTITLES" and not track['isForced']:
|
||
|
use_captions.update({track['bcp47']:False})
|
||
|
for i, subtitle in enumerate(subtitle_tracks):
|
||
|
if 'en' in audio_languages:
|
||
|
if subtitle['isForced'] and subtitle['bcp47'] == 'en' and (subtitle['trackType'] == "SUBTITLES" or use_captions[subtitle['bcp47']] == True):
|
||
|
wvdl_sts.append(
|
||
|
SubtitleTrack(i, 'Forced', subtitle['bcp47'], True, next(iter(subtitle['downloadables'][0]['urls'].values())),
|
||
|
'srt')
|
||
|
)
|
||
|
elif not subtitle['isForced'] and (subtitle['trackType'] == "SUBTITLES" or use_captions[subtitle['bcp47']] == True):
|
||
|
wvdl_sts.append(
|
||
|
SubtitleTrack(i, subtitle['language'], subtitle['bcp47'], False, next(iter(subtitle['downloadables'][0]['urls'].values())),
|
||
|
'srt')
|
||
|
)
|
||
|
else:
|
||
|
if not subtitle['isForced'] and subtitle['bcp47'] == 'en' and (subtitle['trackType'] == "SUBTITLES" or use_captions[subtitle['bcp47']] == True):
|
||
|
wvdl_sts.append(
|
||
|
SubtitleTrack(i, subtitle['language'], subtitle['bcp47'], True, next(iter(subtitle['downloadables'][0]['urls'].values())),
|
||
|
'srt')
|
||
|
)
|
||
|
elif not subtitle['isForced'] and (subtitle['trackType'] == "SUBTITLES" or use_captions[subtitle['bcp47']] == True):
|
||
|
wvdl_sts.append(
|
||
|
SubtitleTrack(i, subtitle['language'], subtitle['bcp47'], False, next(iter(subtitle['downloadables'][0]['urls'].values())),
|
||
|
'srt')
|
||
|
)
|
||
|
|
||
|
return True, [wvdl_vt, wvdl_ats, wvdl_sts, init_data_b64, cert_data_b64]
|
||
|
|
||
|
|
||
|
def get_license(self, challenge):
|
||
|
#
|
||
|
self.logger.info("doing license request")
|
||
|
self.logger.debug("challenge - {}".format(base64.b64encode(challenge)))
|
||
|
license_request_data = {
|
||
|
'method': 'license',
|
||
|
'licenseType': 'STANDARD',
|
||
|
'clientVersion': '4.0004.899.011',
|
||
|
'uiVersion': 'akira',
|
||
|
'languages': ['en-US'],
|
||
|
'playbackContextId': self.playbackContextId,
|
||
|
'drmContextIds': [self.drmContextId],
|
||
|
'challenges': [{
|
||
|
'dataBase64': base64.b64encode(challenge).decode('utf-8'),
|
||
|
'sessionId': "14673889385265"
|
||
|
}],
|
||
|
'clientTime': int(time.time()),
|
||
|
'xid': int((int(time.time()) + 0.1612) * 1000)
|
||
|
|
||
|
}
|
||
|
request_data = self.__generate_msl_request_data(license_request_data)
|
||
|
|
||
|
resp = self.session.post(nf_cfg.LICENSE_ENDPOINT, request_data, proxies=self.client_config.get_proxies())
|
||
|
|
||
|
try:
|
||
|
# If is valid json the request for the licnese failed
|
||
|
resp.json()
|
||
|
self.logger.debug('Error getting license: '+resp.text)
|
||
|
exit(1)
|
||
|
except ValueError:
|
||
|
# json() failed so we have a chunked json response
|
||
|
resp = self.__parse_chunked_msl_response(resp.text)
|
||
|
data = self.__decrypt_payload_chunk(resp['payloads'])
|
||
|
if data['success'] is True:
|
||
|
return data['result']['licenses'][0]['data']
|
||
|
else:
|
||
|
self.logger.debug('Error getting license: ' + json.dumps(data))
|
||
|
exit(1)
|
||
|
|
||
|
|
||
|
def __get_base_url(self, urls):
|
||
|
for key in urls:
|
||
|
return urls[key]
|
||
|
|
||
|
def __decrypt_payload_chunk(self, payloadchunks):
|
||
|
decrypted_payload = ''
|
||
|
for chunk in payloadchunks:
|
||
|
payloadchunk = json.loads(chunk)
|
||
|
try:
|
||
|
encryption_envelope = str(bytes(payloadchunk['payload'], encoding="utf-8").decode('utf8'))
|
||
|
except TypeError:
|
||
|
encryption_envelope = payloadchunk['payload']
|
||
|
# Decrypt the text
|
||
|
cipher = AES.new(self.encryption_key, AES.MODE_CBC, base64.standard_b64decode(json.loads(base64.standard_b64decode(encryption_envelope))['iv']))
|
||
|
plaintext = cipher.decrypt(base64.standard_b64decode(json.loads(base64.standard_b64decode(encryption_envelope))['ciphertext']))
|
||
|
# unpad the plaintext
|
||
|
plaintext = json.loads((Padding.unpad(plaintext, 16)))
|
||
|
data = plaintext['data']
|
||
|
|
||
|
# uncompress data if compressed
|
||
|
if plaintext['compressionalgo'] == 'GZIP':
|
||
|
data = zlib.decompress(base64.standard_b64decode(data), 16 + zlib.MAX_WBITS)
|
||
|
else:
|
||
|
data = base64.standard_b64decode(data)
|
||
|
|
||
|
decrypted_payload += data.decode('utf-8')
|
||
|
decrypted_payload = json.loads(decrypted_payload)[1]['payload']['data']
|
||
|
decrypted_payload = base64.standard_b64decode(decrypted_payload)
|
||
|
return json.loads(decrypted_payload)
|
||
|
|
||
|
def __parse_chunked_msl_response(self, message):
|
||
|
header = message.split('}}')[0] + '}}'
|
||
|
payloads = re.split(',\"signature\":\"[0-9A-Za-z=/+]+\"}', message.split('}}')[1])
|
||
|
payloads = [x + '}' for x in payloads][:-1]
|
||
|
|
||
|
return {
|
||
|
'header': header,
|
||
|
'payloads': payloads
|
||
|
}
|
||
|
|
||
|
def __generate_msl_request_data(self, data):
|
||
|
header_encryption_envelope = self.__encrypt(self.__generate_msl_header())
|
||
|
header = {
|
||
|
'headerdata': base64.standard_b64encode(header_encryption_envelope.encode('utf-8')).decode('utf-8'),
|
||
|
'signature': self.__sign(header_encryption_envelope).decode('utf-8'),
|
||
|
'mastertoken': self.mastertoken,
|
||
|
}
|
||
|
# Serialize the given Data
|
||
|
serialized_data = json.dumps(data)
|
||
|
serialized_data = serialized_data.replace('"', '\\"')
|
||
|
serialized_data = '[{},{"headers":{},"path":"/cbp/cadmium-13","payload":{"data":"' + serialized_data + '"},"query":""}]\n'
|
||
|
compressed_data = self.__compress_data(serialized_data)
|
||
|
|
||
|
# Create FIRST Payload Chunks
|
||
|
first_payload = {
|
||
|
"messageid": self.current_message_id,
|
||
|
"data": compressed_data.decode('utf-8'),
|
||
|
"compressionalgo": "GZIP",
|
||
|
"sequencenumber": 1,
|
||
|
"endofmsg": True
|
||
|
}
|
||
|
first_payload_encryption_envelope = self.__encrypt(json.dumps(first_payload))
|
||
|
first_payload_chunk = {
|
||
|
'payload': base64.standard_b64encode(first_payload_encryption_envelope.encode('utf-8')).decode('utf-8'),
|
||
|
'signature': self.__sign(first_payload_encryption_envelope).decode('utf-8'),
|
||
|
}
|
||
|
request_data = json.dumps(header) + json.dumps(first_payload_chunk)
|
||
|
return request_data
|
||
|
|
||
|
def __compress_data(self, data):
|
||
|
# GZIP THE DATA
|
||
|
out = BytesIO()
|
||
|
with gzip.GzipFile(fileobj=out, mode="w") as f:
|
||
|
f.write(data.encode('utf-8'))
|
||
|
return base64.standard_b64encode(out.getvalue())
|
||
|
|
||
|
def __generate_msl_header(self, is_handshake=False, is_key_request=False, compressionalgo="GZIP", encrypt=True):
|
||
|
"""
|
||
|
Function that generates a MSL header dict
|
||
|
:return: The base64 encoded JSON String of the header
|
||
|
"""
|
||
|
self.current_message_id = self.rndm.randint(0, pow(2, 52))
|
||
|
|
||
|
header_data = {
|
||
|
'sender': self.client_config.config['esn'],
|
||
|
'handshake': is_handshake,
|
||
|
'nonreplayable': False,
|
||
|
'capabilities': {
|
||
|
'languages': ["en-US"],
|
||
|
'compressionalgos': [],
|
||
|
'encoderformats' : ['JSON'],
|
||
|
},
|
||
|
'recipient': 'Netflix',
|
||
|
'renewable': True,
|
||
|
'messageid': self.current_message_id,
|
||
|
'timestamp': 1467733923,
|
||
|
}
|
||
|
|
||
|
# Add compression algo if not empty
|
||
|
if compressionalgo is not "":
|
||
|
header_data['capabilities']['compressionalgos'].append(compressionalgo)
|
||
|
|
||
|
# If this is a keyrequest act diffrent then other requests
|
||
|
if is_key_request:
|
||
|
public_key = base64.standard_b64encode(self.rsa_key.publickey().exportKey(format='DER')).decode('utf-8')
|
||
|
header_data['keyrequestdata'] = [{
|
||
|
'scheme': 'ASYMMETRIC_WRAPPED',
|
||
|
'keydata': {
|
||
|
'publickey': public_key,
|
||
|
'mechanism': 'JWK_RSA',
|
||
|
'keypairid': 'superKeyPair'
|
||
|
}
|
||
|
}]
|
||
|
#header_data['userauthdata'] = {
|
||
|
# 'scheme' : 'NETFLIXID',
|
||
|
# 'authdata' : {
|
||
|
# 'netflixid' : self.cookies['NetflixId'],
|
||
|
# 'securenetflixid' : self.cookies['SecureNetflixId'],
|
||
|
# }
|
||
|
#}
|
||
|
#header_data['keyrequestdata'] = [{
|
||
|
# 'scheme': 'WIDEVINE',
|
||
|
# 'keydata': {
|
||
|
# 'keyrequest':'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',
|
||
|
# },
|
||
|
#}]
|
||
|
else:
|
||
|
if 'usertoken' in self.tokens:
|
||
|
pass
|
||
|
else:
|
||
|
# Auth via email and password
|
||
|
header_data['userauthdata'] = {
|
||
|
'scheme': 'EMAIL_PASSWORD',
|
||
|
'authdata': {
|
||
|
'email': self.client_config.config['username'],
|
||
|
'password': self.client_config.config['password']
|
||
|
}
|
||
|
}
|
||
|
return json.dumps(header_data)
|
||
|
|
||
|
def __encrypt(self, plaintext):
|
||
|
"""
|
||
|
Encrypt the given Plaintext with the encryption key
|
||
|
:param plaintext:
|
||
|
:return: Serialized JSON String of the encryption Envelope
|
||
|
"""
|
||
|
iv = get_random_bytes(16)
|
||
|
encryption_envelope = {
|
||
|
'ciphertext': '',
|
||
|
'keyid': self.client_config.config['esn'] + '_' + str(self.sequence_number),
|
||
|
'sha256': 'AA==',
|
||
|
'iv': base64.standard_b64encode(iv).decode('utf-8')
|
||
|
}
|
||
|
# Padd the plaintext
|
||
|
plaintext = Padding.pad(plaintext.encode('utf-8'), 16)
|
||
|
# Encrypt the text
|
||
|
cipher = AES.new(self.encryption_key, AES.MODE_CBC, iv)
|
||
|
ciphertext = cipher.encrypt(plaintext)
|
||
|
encryption_envelope['ciphertext'] = base64.standard_b64encode(ciphertext).decode('utf-8')
|
||
|
return json.dumps(encryption_envelope)
|
||
|
|
||
|
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.encode('utf-8'), 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': {
|
||
|
'scheme': 'NONE',
|
||
|
'authdata': {
|
||
|
'identity': self.client_config.config['esn']
|
||
|
}
|
||
|
},
|
||
|
'headerdata': base64.standard_b64encode(header.encode('utf-8')).decode('utf-8'),
|
||
|
'signature': '',
|
||
|
}
|
||
|
self.logger.debug('Key Handshake Request:')
|
||
|
self.logger.debug(json.dumps(request))
|
||
|
|
||
|
resp = self.session.post(nf_cfg.MANIFEST_ENDPOINT, json.dumps(request, sort_keys=True), proxies=self.client_config.get_proxies())
|
||
|
if resp.status_code == 200:
|
||
|
resp = resp.json()
|
||
|
if 'errordata' in resp:
|
||
|
self.logger.debug('Key Exchange failed')
|
||
|
self.logger.debug(base64.standard_b64decode(resp['errordata']))
|
||
|
return False
|
||
|
self.logger.debug(resp)
|
||
|
self.logger.debug('Key Exchange Sucessful')
|
||
|
self.__parse_crypto_keys(json.JSONDecoder().decode(base64.standard_b64decode(resp['headerdata']).decode('utf-8')))
|
||
|
else:
|
||
|
self.logger.debug('Key Exchange failed')
|
||
|
self.logger.debug(resp.text)
|
||
|
|
||
|
def __parse_crypto_keys(self, headerdata):
|
||
|
self.__set_master_token(headerdata['keyresponsedata']['mastertoken'])
|
||
|
#self.__set_userid_token(headerdata['useridtoken'])
|
||
|
# Init Decryption
|
||
|
encrypted_encryption_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['encryptionkey'])
|
||
|
encrypted_sign_key = base64.standard_b64decode(headerdata['keyresponsedata']['keydata']['hmackey'])
|
||
|
cipher_rsa = PKCS1_OAEP.new(self.rsa_key)
|
||
|
|
||
|
# Decrypt encryption key
|
||
|
encryption_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_encryption_key).decode('utf-8'))
|
||
|
self.encryption_key = base64key_decode(encryption_key_data['k'])
|
||
|
|
||
|
# Decrypt sign key
|
||
|
sign_key_data = json.JSONDecoder().decode(cipher_rsa.decrypt(encrypted_sign_key).decode('utf-8'))
|
||
|
self.sign_key = base64key_decode(sign_key_data['k'])
|
||
|
|
||
|
self.__save_msl_data()
|
||
|
self.handshake_performed = True
|
||
|
|
||
|
def __load_msl_data(self):
|
||
|
msl_data = json.JSONDecoder().decode(
|
||
|
self.load_file(wvdl_cfg.COOKIES_FOLDER, self.client_config.config['msl_storage']).decode('utf-8'))
|
||
|
# Check expire date of the token
|
||
|
master_token = json.JSONDecoder().decode(
|
||
|
base64.standard_b64decode(msl_data['tokens']['mastertoken']['tokendata']).decode('utf-8'))
|
||
|
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.__set_userid_token(msl_data['tokens']['useridtoken'])
|
||
|
self.encryption_key = base64.standard_b64decode(msl_data['encryption_key'])
|
||
|
self.sign_key = base64.standard_b64decode(msl_data['sign_key'])
|
||
|
|
||
|
def __save_msl_data(self):
|
||
|
"""
|
||
|
Saves the keys and tokens in json file
|
||
|
:return:
|
||
|
"""
|
||
|
data = {
|
||
|
"encryption_key": base64.standard_b64encode(self.encryption_key).decode('utf-8'),
|
||
|
'sign_key': base64.standard_b64encode(self.sign_key).decode('utf-8'),
|
||
|
'tokens': {
|
||
|
'mastertoken': self.mastertoken,
|
||
|
#'useridtoken': self.useridtoken,
|
||
|
}
|
||
|
}
|
||
|
serialized_data = json.JSONEncoder().encode(data)
|
||
|
self.save_file(wvdl_cfg.COOKIES_FOLDER, self.client_config.config['msl_storage'], serialized_data.encode('utf-8'))
|
||
|
|
||
|
def __set_master_token(self, master_token):
|
||
|
self.mastertoken = master_token
|
||
|
self.sequence_number = json.JSONDecoder().decode(base64.standard_b64decode(master_token['tokendata']).decode('utf-8'))[
|
||
|
'sequencenumber']
|
||
|
|
||
|
def __set_userid_token(self, userid_token):
|
||
|
self.useridtoken = userid_token
|
||
|
|
||
|
def __load_rsa_keys(self):
|
||
|
loaded_key = self.load_file(wvdl_cfg.COOKIES_FOLDER, self.client_config.config['rsa_key'])
|
||
|
self.rsa_key = RSA.importKey(loaded_key)
|
||
|
|
||
|
def __save_rsa_keys(self):
|
||
|
self.logger.debug('Save RSA Keys')
|
||
|
# Get the DER Base64 of the keys
|
||
|
encrypted_key = self.rsa_key.exportKey()
|
||
|
self.save_file(wvdl_cfg.COOKIES_FOLDER, self.client_config.config['rsa_key'], encrypted_key)
|
||
|
|
||
|
@staticmethod
|
||
|
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(os.path.join(msl_data_path, filename))
|
||
|
|
||
|
@staticmethod
|
||
|
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(os.path.join(msl_data_path,filename), 'wb') as file_:
|
||
|
file_.write(content)
|
||
|
file_.flush()
|
||
|
file_.close()
|
||
|
|
||
|
@staticmethod
|
||
|
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(os.path.join(msl_data_path,filename), 'rb') as file_:
|
||
|
file_content = file_.read()
|
||
|
file_.close()
|
||
|
return file_content
|
||
|
|
||
|
|
||
|
def get_track_download(self, track):
|
||
|
return self.session.get(track.url, stream=True, proxies=self.client_config.get_proxies())
|
||
|
|
||
|
def get_subtitle_download(self, track):
|
||
|
try:
|
||
|
req = self.session.get(track.url, proxies=self.client_config.get_proxies())
|
||
|
except:
|
||
|
while True:
|
||
|
try:
|
||
|
req = self.session.get(track.url, proxies=self.client_config.get_proxies())
|
||
|
return req
|
||
|
except:
|
||
|
continue
|
||
|
return req
|
||
|
|
||
|
|
||
|
def get_wvconfig_options(self):
|
||
|
return {'server_cert_required': True, 'pssh_header': True}
|
||
|
|
||
|
def needs_ffmpeg(self):
|
||
|
return True
|
||
|
|
||
|
def finagle_subs(self, subtitles):
|
||
|
return subs.to_srt(subtitles)
|
||
|
|