├── .gitattributes ├── widevine-master.zip ├── requirements.txt ├── config ├── DISNEYPLUS.yml ├── __init__.py ├── AMAZON.yml └── NETFLIX.yml ├── README.md ├── services ├── videoland.py ├── rte.py ├── boomerang.py ├── disneyplus.py ├── amazon.py ├── __init__.py └── netflix.py └── vinetrimmer.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /widevine-master.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/merlinepedra25/WIDEVINE-DECRYTION-SCRIPT/HEAD/widevine-master.zip -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML 2 | urllib3 3 | tqdm 4 | requests 5 | pycaption 6 | colorama 7 | protobuf 8 | pycryptodomex 9 | pycountry 10 | beautifulsoup4>=4.8.1 11 | xmltodict -------------------------------------------------------------------------------- /config/DISNEYPLUS.yml: -------------------------------------------------------------------------------- 1 | # taken from web application, firefox, linux 2 | device_api_key: ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84 3 | 4 | endpoints: 5 | login: https://edge.bamgrid.com/idp/login 6 | grant: https://edge.bamgrid.com/accounts/grant 7 | devices: https://edge.bamgrid.com/devices 8 | token: https://edge.bamgrid.com/token 9 | licence: https://edge.bamgrid.com/widevine/v1/obtain-license -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vinetrimmer 2 | Widevine Decryption Script for Python 3 | 4 | 5 | # Modules 6 | - Amazon 7 | - Netflix (with h264@mpl support) 8 | - Disney+ 9 | - VideoLand 10 | - Boomerang 11 | - RTE.ie 12 | 13 |

14 | 15 |

16 | 17 |

Hello Fellow < Developers/ >!

18 |

19 |

20 | 21 | 22 | 23 |
Hi! My name is WVDUMP. I am Leaking the scripts to punish few idiots :smile: 24 |
25 | 26 |

About Me

27 | 28 | Github 29 | 30 | 31 | - 👯 Sharing is caring 32 | 33 | 34 | - ⚡ CDM L1 BUY it from wvfuck@protonmail.com ⚡ 35 | 36 | 37 |
38 |
39 |
40 | 41 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- 1 | # Standard Libraries 2 | import os 3 | from collections import namedtuple 4 | 5 | class Directories: 6 | def __init__(self): 7 | self.base_dir = os.getcwd() 8 | self.data = os.path.join(self.base_dir, "data") 9 | self.configuration = os.path.join(self.base_dir, "config") 10 | self.output = os.path.join(self.base_dir, "Downloads") 11 | self.temp = os.path.join(self.data, ".tmp") 12 | self.cookies = os.path.join(self.data, "Cookies") 13 | self.login = os.path.join(self.data, "Login") 14 | self.cdm_devices = os.path.join(self.data, "CDM_Devices") 15 | directories = Directories() 16 | class Filenames: 17 | def __init__(self): 18 | self.track = "{filename}_{track_type}_{track_no}_" 19 | self.subtitles = os.path.join(directories.temp, "{filename}_subtitles_{language_code}_{id}.srt") 20 | self.encrypted = os.path.join(directories.temp, f"{self.track}encrypted.mp4") 21 | self.decrypted = os.path.join(directories.temp, f"{self.track}decrypted.mp4") 22 | self.muxed = os.path.join(directories.output, "{filename}.mkv") 23 | filenames = Filenames() 24 | class Proxies: 25 | def __init__(self): 26 | self.us = {"https": ""} 27 | self.ca = self.us 28 | self.de = None 29 | self.uk = None 30 | self.jp = None 31 | proxies = Proxies() 32 | common_headers = { 33 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' 34 | '(KHTML, like Gecko) Chrome/77.0.3865.75 Safari/537.36', 35 | 'Accept': 'application/json', 36 | 'Accept-Encoding': 'gzip, deflate, br', 37 | 'Accept-Language': 'en-US,en;q=0.8' 38 | } 39 | iso639_2 = { 40 | 'English': 'eng', 41 | 'Spanish': 'spa', 42 | 'European Spanish': 'spa', 43 | 'Brazilian Portuguese': 'por', 44 | 'Polish': 'pol', 45 | 'Turkish': 'tur', 46 | 'French': 'fre', 47 | 'German': 'ger', 48 | 'Italian': 'ita', 49 | 'Czech': 'cze', 50 | 'Japanese': 'jpn', 51 | 'Hebrew': 'heb', 52 | 'Norwegian': 'nor' 53 | } 54 | -------------------------------------------------------------------------------- /config/AMAZON.yml: -------------------------------------------------------------------------------- 1 | device_type: AOAGZA014O5RE 2 | 3 | endpoints: 4 | browse: https://{base_url}/cdp/catalog/Browse?firmware=1&deviceTypeID={deviceTypeID}&deviceID={deviceID}&format=json&version=2&formatVersion=3&marketplaceId={marketplace_id}&IncludeAll=T&AID=T&version=2&SeasonASIN={series_asin}&Detailed=T&tag=1&ContentType=TVEpisode,MOVIE&IncludeBlackList=T&NumberOfResults=1000&StartIndex=0 5 | playback: https://{base_url}/cdp/catalog/GetPlaybackResources?asin={asin}&consumptionType=Streaming&desiredResources=AudioVideoUrls%2CSubtitleUrls&deviceTypeID={deviceTypeID}&deviceID={deviceID}&firmware=1&marketplaceID={marketplace_id}&resourceUsage=CacheResources&videoMaterialType=Feature&operatingSystemName=Windows&operatingSystemVersion=10.0&customerID={customer_id}&token={token}&deviceDrmOverride=CENC&deviceStreamingTechnologyOverride=DASH&deviceProtocolOverride=Https&deviceBitrateAdaptationsOverride={bitrate}&audioTrackId=all&titleDecorationScheme=primary-content&deviceVideoCodecOverride={profile}&clientId={client_id} 6 | licence: https://{base_url}/cdp/catalog/GetPlaybackResources?asin={asin}&consumptionType=Streaming&desiredResources=Widevine2License&deviceTypeID={deviceTypeID}&deviceID={deviceID}&firmware=1&marketplaceID={marketplace_id}&resourceUsage=ImmediateConsumption&videoMaterialType=Feature&operatingSystemName=Windows&operatingSystemVersion=10.0&customerID={customer_id}&token={token}&deviceDrmOverride=CENC&deviceStreamingTechnologyOverride=DASH 7 | 8 | regions: 9 | us: 10 | base: www.amazon.com 11 | base_manifest: atv-ps.amazon.com 12 | marketplace_id: ATVPDKIKX0DER 13 | client_id: f22dbddb-ef2c-48c5-8876-bed0d47594fd 14 | account_token: 9a7208e26fa8a5d195698a8f88f9e4be 15 | account_id: A1SDJQ7W4W2U7U 16 | uk: 17 | base: www.amazon.co.uk 18 | base_manifest: atv-ps-eu.amazon.co.uk 19 | marketplace_id: A2IR4J4NTCP2M5 # A1F83G8C2ARO7P is also another marketplace_id 20 | client_id: f22dbddb-ef2c-48c5-8876-bed0d47594fd 21 | account_token: 9a7208e26fa8a5d195698a8f88f9e4be 22 | account_id: A1SDJQ7W4W2U7U 23 | de: 24 | base: www.amazon.de 25 | base_manifest: atv-ps-eu.amazon.de 26 | marketplace_id: A1PA6795UKMFR9 27 | client_id: f22dbddb-ef2c-48c5-8876-bed0d47594fd 28 | account_token: 9a7208e26fa8a5d195698a8f88f9e4be 29 | account_id: A1SDJQ7W4W2U7U 30 | jp: 31 | base: www.amazon.co.jp 32 | base_manifest: atv-ps-fe.amazon.co.jp 33 | marketplace_id: A1VC38T7YXB528 34 | client_id: f22dbddb-ef2c-48c5-8876-bed0d47594fd 35 | account_token: 9a7208e26fa8a5d195698a8f88f9e4be 36 | account_id: A1SDJQ7W4W2U7U -------------------------------------------------------------------------------- /services/videoland.py: -------------------------------------------------------------------------------- 1 | # Standard 2 | import base64 3 | # Package Dependencies 4 | import xmltodict 5 | # Custom 6 | from modules import Module 7 | 8 | 9 | class VIDEOLAND(Module): 10 | 11 | def __init__(self, config, args): 12 | # Store various stuff to the parent class object 13 | self.args = args 14 | self.config = config 15 | self.source_tag = "VL" 16 | self.origin = "www.videoland.com" 17 | # videoland specific globals 18 | self.vl_lic_url = None 19 | self.vl_api_headers = { 20 | "Accept": "application/json", 21 | "Content-Type": "application/json", 22 | "videoland-platform": "videoland" 23 | } 24 | # call base __init__ 25 | Module.__init__(self) 26 | 27 | def get_titles(self): 28 | # Get Information on the title by the Title ID 29 | metadata = self.session.get( 30 | url=f"https://www.videoland.com/api/v3/{'movies' if self.args.movie else 'series'}/{self.args.title}", 31 | headers=self.vl_api_headers 32 | ).json() 33 | self.title_name = metadata["title"] 34 | self.titles = [metadata] if self.args.movie else sorted( 35 | sorted( 36 | [ 37 | Ep for Season in [ 38 | [ 39 | dict(x, **{'season': Season["position"]}) for i, x in self.session.get( 40 | url=f"https://www.videoland.com/api/v3/episodes/{self.args.title}/{Season['id']}", 41 | headers=self.vl_api_headers 42 | ).json()["details"].items() 43 | ] for Season in [ 44 | x for i, x in metadata["details"].items() if 45 | x["type"] == "season" and (x["position"] == self.args.season if self.args.season else True) 46 | ] 47 | ] for Ep in Season 48 | ], 49 | key=lambda e: e['position'] 50 | ), 51 | key=lambda e: e['season'] 52 | ) 53 | if not self.titles: 54 | raise Exception("No titles returned!") 55 | 56 | def get_title_information(self, title): 57 | season = title["season"] if not self.args.movie else 0 58 | episode = title["position"] if not self.args.movie else 0 59 | title_year = title["year"] if self.args.movie else None 60 | episode_name = title["title"] if not self.args.movie else None 61 | return season, episode, title_year, episode_name 62 | 63 | def get_title_tracks(self, title): 64 | # Get the Stream MPD and License Url from the VideoLand Manifest API 65 | Manifest = self.session.get( 66 | url=f"https://www.videoland.com/api/v3/stream/{title['id']}/widevine?edition=", 67 | headers=self.vl_api_headers 68 | ).json() 69 | if "code" in Manifest: 70 | raise Exception(f"Failed to fetch the manifest for \"{title['id']}\", {Manifest['code']}, {Manifest['message']}") 71 | mpd_url = Manifest["stream"]["dash"] 72 | mpd = xmltodict.parse( 73 | self.session.get(url=mpd_url).text 74 | )["MPD"]["Period"]["AdaptationSet"] 75 | self.vl_lic_url = Manifest["drm"]["widevine"]["license"] 76 | # Create Track Dictionaries 77 | videos = [{ 78 | "id": 0, 79 | "type": "video", 80 | "encrypted": True, 81 | # todo ; this should be gotten from the MPD or Playback Manifest 82 | "language": None, 83 | "track_name": None, 84 | "url": mpd_url, 85 | "downloader": "youtube-dl", 86 | "codec": "?", 87 | "bitrate": 0, 88 | "width": 0, 89 | "height": 0, 90 | "default": True, 91 | "fix": False, 92 | "info": { 93 | "fps": "?" 94 | } 95 | }] 96 | audio = [{ 97 | "id": 0, 98 | "type": "audio", 99 | "encrypted": True, 100 | # todo ; this should be gotten from the MPD or Playback Manifest 101 | "language": [x["@lang"] for x in mpd if x["@contentType"] == "audio"][0], 102 | "track_name": None, 103 | "url": mpd_url, 104 | "downloader": "youtube-dl", 105 | "codec": "?", 106 | "default": True, 107 | "fix": False 108 | }] 109 | subtitles = [] 110 | # Download Tracks 111 | self.widevine_cdm.pssh = [x for x in mpd[0]["ContentProtection"] 112 | if x["@schemeIdUri"] == self.widevine_cdm.urn][0]["cenc:pssh"] 113 | return videos, audio, subtitles 114 | 115 | def certificate(self, title, challenge): 116 | return self.license(title, challenge) 117 | 118 | def license(self, title, challenge): 119 | return base64.b64encode(self.session.post( 120 | url=self.vl_lic_url, 121 | data=base64.b64decode(challenge) 122 | ).content).decode("utf-8") 123 | -------------------------------------------------------------------------------- /vinetrimmer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # Standard Libraries 4 | import sys 5 | import os.path 6 | import argparse 7 | # PyPI Dependencies 8 | import yaml 9 | # Custom Scripts 10 | from utils import DictToObj 11 | import config as appcfg 12 | 13 | # -------------------------------- 14 | # Arguments 15 | # -------------------------------- 16 | ArgParser = argparse.ArgumentParser() 17 | sps = ArgParser.add_subparsers(help='sub-command help', dest='service') 18 | # global 19 | ArgParser.add_argument('-v', '--verbose', help='Verbose Mode, used for debugging', action='store_true', required=False) 20 | ArgParser.add_argument('--proxy', help='Proxy to make the all requests under', required=False) 21 | ArgParser.add_argument('--cdm', help='Override which Widevine Content Decryption Module to use for decryption', 22 | default='nexus6_lvl1', required=False) # With Netflix, chromecdm_903 + cdm_ke == 1080p @ MPL 23 | ArgParser.add_argument('--keys', help='Only obtain the keys, skip downloading and all logic behind it', 24 | action='store_true', required=False) 25 | ArgParser.add_argument('--keysonly', help='Disable all logs except for the output of the keys from --keys', 26 | action='store_true', required=False) 27 | ArgParser.add_argument('--quiet', 28 | help='Silent operation, does not return any logs during usage', 29 | action='store_true', required=False) 30 | ArgParser.add_argument('-p', '--profile', 31 | help='Cookies and settings will be applied based on the profile', 32 | required=True) 33 | ArgParser.add_argument('-t', '--title', help='Title ID of the content you wish to download', required=True) 34 | ArgParser.add_argument('-q', '--quality', help='Download Resolution, defaults to best available', type=int) 35 | ArgParser.add_argument('-m', '--movie', help='If it\'s a movie, use this flag', action='store_true', required=False) 36 | ArgParser.add_argument('-s', '--season', help='Season Number to download, exclude it to download all seasons', type=int, 37 | required=False) 38 | ArgParser.add_argument('-e', '--episode', help='Episode Number to download, exclude it to download all episodes', 39 | type=int, required=False) 40 | ArgParser.add_argument('--skip', help='Skip n Episodes', type=int) 41 | # netflix 42 | sp_netflix = sps.add_parser('NETFLIX', help='https://netflix.com') 43 | sp_netflix.add_argument('--vcodec', help='Video Codec - H264 will retrieve BPL and MPL AVC profiles', default='H264', 44 | choices=['H264', 'H264@HPL', 'H265', 'H265-HDR', 'VP9', 'AV1']) 45 | sp_netflix.add_argument('--acodec', help='Audio Codec', default='DOLBY', choices=['AAC', 'VORB', 'DOLBY']) 46 | sp_netflix.add_argument('--esn', help='Netflix ESN to use for the Manifest and License API\'s', 47 | default='NFANDROID1-PRV-P-XIAOMMI=9=SE-12034-6EA8A15D39427309D0A97686A1A315C6A0ABFE46BECD14BB740EC56C65168E44') 48 | sp_netflix.add_argument('--cdm_ke', help='Use Widevine Content Decryption Module Key Exchange', action='store_true', 49 | default=False, required=False) 50 | # amazon 51 | sp_amazon = sps.add_parser('AMAZON', help='https://amazon.com') 52 | sp_amazon.add_argument('--marketid', help='Marketplace ID (mid)', required=False) 53 | sp_amazon.add_argument('--vcodec', help='Video Codec', default='H264', choices=['H264', 'H265']) 54 | sp_amazon.add_argument('--vbitrate', help='Video Bitrate Mode to download in (CVBR recommended)', default='CVBR', 55 | choices=['CVBR+CBR', 'CVBR', 'CBR']) 56 | sp_amazon.add_argument('--cdn', help='CDN to download from, defaults to the cdn with the highest weight set by Amazon') 57 | # videoland 58 | sp_videoland = sps.add_parser('VIDEOLAND', help='https://videoland.com') 59 | # boomerang 60 | sp_boomerang = sps.add_parser('BOOMERANG', help='https://boomerang.com') 61 | # disney+ 62 | sp_disneyplus = sps.add_parser('DISNEYPLUS', help='https://disneyplus.com') 63 | sp_disneyplus.add_argument('ctr', help='DRM CTR AES Counter Block Encryption Mode', default='handset-drm-ctr', choices=['handset-drm-ctr', 'restricted-drm-ctr-sw']) 64 | # rte 65 | sp_rte = sps.add_parser('RTE', help='https://rte.ie/player') 66 | # rakutentv 67 | sp_rakutentv = sps.add_parser('RAKUTENTV', help='https://rakuten.tv') 68 | sp_rakutentv.add_argument('--hdr', help='Audio Channels', default='HDR10', choices=['NONE', 'HDR10']) 69 | sp_rakutentv.add_argument('--vquality', help='Video Quality', default='UHD', choices=['SD', 'HD', 'FHD', 'UHD']) 70 | sp_rakutentv.add_argument('--achannels', help='Audio Channels', default='5.1', choices=['2.0', '5.1']) 71 | 72 | args = ArgParser.parse_args() 73 | 74 | # -------------------------------- 75 | # Load Service 76 | # From there, the service will take care of the rest 77 | # -------------------------------- 78 | if args.keysonly: 79 | sys.stdout = open(os.devnull, 'w') 80 | print(f"Starting {args.service} Service") 81 | config_path = os.path.join(appcfg.directories.configuration, f"{args.service}.yml") 82 | if os.path.exists(config_path) and os.path.isfile(config_path): 83 | with open(config_path, "r") as f: 84 | cfg = DictToObj(yaml.safe_load(f)) 85 | else: 86 | cfg = None 87 | m = getattr( 88 | __import__("services." + args.service.lower(), globals(), locals(), [args.service], 0), 89 | args.service 90 | )(cfg, args) 91 | -------------------------------------------------------------------------------- /services/rte.py: -------------------------------------------------------------------------------- 1 | # Standard 2 | import re 3 | import urllib.parse 4 | # Package Dependencies 5 | import xmltodict 6 | # Custom 7 | from modules import Module 8 | 9 | 10 | class RTE(Module): 11 | 12 | def __init__(self, config, args): 13 | # Store various stuff to the parent class object 14 | self.args = args 15 | self.config = config 16 | self.source_tag = "RTE" 17 | self.origin = "www.rte.ie" 18 | # rte specific globals 19 | self.rte_feed = None 20 | self.rte_media_smil = None 21 | # call base __init__ 22 | Module.__init__(self) 23 | 24 | def get_titles(self): 25 | print("Asking RTE Player for the title manifest...") 26 | self.rte_feed = self.session.get( 27 | url="https://feed.entertainment.tv.theplatform.eu/f/1uC-gC/rte-prd-prd-all-movies-series?byGuid=" + 28 | self.args.title 29 | ).json()["entries"][0] 30 | series_id = self.rte_feed["id"].split("/")[-1] 31 | available_season_ids = "|".join( 32 | x.split("/")[-1] for x in self.rte_feed["plprogramavailability$availableTvSeasonIds"]) 33 | self.title_name = self.rte_feed["title"] 34 | self.titles = self.session.get( 35 | "https://feed.entertainment.tv.theplatform.eu/f/1uC-gC/rte-prd-prd-all-programs?bySeriesId=" + series_id + 36 | ("&bytvSeasonId=" + available_season_ids if available_season_ids else "") + 37 | "&byProgramType=episode&sort=tvSeasonEpisodeNumber&byMediaAvailabilityTags=ROI|IOI|Europe%2015" 38 | "|WW%20ex%20US.CA|WW|WW%20ex%20GB.NIR|WW%20ex%20CN|WW%20ex%20France|WW%20ex%20Aus.%20Asia,desktop" 39 | "&range=-500" 40 | ).json()["entries"] 41 | if not self.titles: 42 | raise Exception("No titles returned!") 43 | 44 | def get_title_information(self, title): 45 | self.rte_media_smil = self.session.get( 46 | title["plprogramavailability$media"][0]["plmedia$publicUrl"] + 47 | "?assetTypes=default:isl&sdk=PDK%205.9.3&formats=mpeg-dash&format=SMIL&tracking=true" 48 | ).text 49 | actual_sxxexx = re.search( 50 | r'384p is available 146 | restricted_drm_ctr_sw = self.session.get( 147 | url=f"https://us.edge.bamgrid.com/media/{title['mediaId']}/scenarios/{self.args.ctr}", 148 | headers={ 149 | "TE": "Trailers", 150 | "Referer": "https://www.disneyplus.com/nl/video/4da34214-4803-4c80-8e66-b9b4b46e1bf8", 151 | "authorization": self.account_exchange_access_token, 152 | "Accept": "application/vnd.media-service+json; version=2", 153 | "x-bamsdk-version": "3.10", 154 | "x-bamsdk-platform": "windows" 155 | } 156 | ).json() 157 | # ================================ # 158 | # 2d. Parse Received Playback M3U8 159 | # ================================ # 160 | master = M3U8(self.session) 161 | master.load(restricted_drm_ctr_sw["stream"]["complete"]) 162 | 163 | # ========================================================== # 164 | # 2e. Parse M3U8 and select tracks based on what's available 165 | # ========================================================== # 166 | bitrates, streams = master.getStreams() 167 | stream = streams[bitrates[0]] 168 | for bitrate in bitrates: 169 | rep = streams[bitrate].copy() 170 | del rep["VIDEO"] 171 | rep["AUDIO"] = rep["AUDIO"]["CODEC"] 172 | rep["SUBTITLES"] = ",".join([x["NAME"] for x in rep["SUBTITLES"]]) 173 | # Video 174 | videos = [] 175 | for i, rep in enumerate(streams): 176 | bitrate = rep 177 | rep = streams[bitrate] 178 | videos.append({ 179 | "id": i, 180 | "type": "video", 181 | "encrypted": True, 182 | # todo ; this should be gotten from the MPD or Playback Manifest 183 | "language": None, 184 | "track_name": None, 185 | "m3u8": rep["VIDEO"], 186 | "downloader": "m3u8", 187 | "codec": rep["CODEC"], 188 | "bitrate": bitrate, 189 | "width": rep['RESOLUTION'].split('x')[0], 190 | "height": rep['RESOLUTION'].split('x')[1], 191 | "default": True, 192 | "fix": True, 193 | "info": { 194 | "fps": rep["FRAMERATE"] 195 | } 196 | }) 197 | # Audio 198 | audio = [] 199 | for i, rep in enumerate(stream["AUDIO"]["STREAMS"]): 200 | audio.append({ 201 | "id": i, 202 | "type": "audio", 203 | "encrypted": True, 204 | "language": rep["LANGUAGE"], 205 | "track_name": rep["NAME"], 206 | "m3u8": rep["URI"], 207 | "downloader": "m3u8", 208 | "codec": rep["GROUP-ID"], 209 | "default": rep["LANGUAGE"] == "en", 210 | "fix": False 211 | }) 212 | # Subtitles 213 | subtitles = [] 214 | for i, sub in enumerate(stream["SUBTITLES"]): 215 | subtitles.append({ 216 | "id": i, 217 | "type": "subtitle", 218 | "encrypted": False, 219 | "language": sub["LANGUAGE"], 220 | "track_name": sub["LANGUAGE"], 221 | "m3u8": sub["URI"], 222 | "downloader": "m3u8", 223 | "codec": "vtt", 224 | "default": sub["LANGUAGE"] == "en", # todo ; this will set every subtitle as default, this is bad!!! 225 | "fix": False 226 | }) 227 | self.widevine_cdm.pssh = [x for x in master.getSessionKeys() if x["KEYFORMAT"] == 228 | "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"][0]["URI"].split(',')[-1] 229 | return videos, audio, subtitles 230 | 231 | def certificate(self, title, challenge): 232 | return self.license(title, challenge) 233 | 234 | def license(self, title, challenge): 235 | import base64 236 | return base64.b64encode(self.session.post( 237 | url=self.config.endpoints.licence, 238 | headers={ 239 | "TE": "Trailers", 240 | "Referer": "https://www.disneyplus.com/nl/video/4da34214-4803-4c80-8e66-b9b4b46e1bf8", 241 | "authorization": self.account_exchange_access_token, 242 | "Accept": "application/vnd.media-service+json; version=2", 243 | "x-bamsdk-version": "3.10", 244 | "x-bamsdk-platform": "windows" 245 | }, 246 | data=base64.b64decode(challenge) # needs to be raw/bytes 247 | ).content) 248 | -------------------------------------------------------------------------------- /services/amazon.py: -------------------------------------------------------------------------------- 1 | # Standard Libraries 2 | import hashlib 3 | import os 4 | import re 5 | # PyPI Dependencies 6 | import xmltodict 7 | # Custom Scripts 8 | from services import Service 9 | import config as appcfg 10 | 11 | class AMAZON(Service): 12 | 13 | def __init__(self, cfg, args): 14 | # Store various stuff to the parent class object 15 | self.args = args 16 | self.cfg = cfg 17 | self.source_tag = "AMZN" 18 | # Discover region from cookies 19 | with open(os.path.join( 20 | appcfg.directories.cookies, self.args.service, f"{self.args.profile}.txt" 21 | ), "r") as f: 22 | match = None 23 | while match is None: 24 | # todo ; if no match occurs, it will infinitely loop 25 | match = re.search(r"^\.amazon.([^\t]*)", f.readline()) 26 | tld = match.group(1) 27 | if tld == "com": 28 | region = "us" 29 | elif tld == "co.uk": 30 | region = "uk" 31 | else: 32 | region = tld 33 | self.cfg.region = getattr(self.cfg.regions, region) 34 | self.cfg.region.code = region 35 | print(f"Selected the region \"{region}\" based on, {tld} domain tld found inside the profile's cookies") 36 | # If the region uses a proxy, set it 37 | self.args.proxy = getattr(appcfg.proxies, self.cfg.region.code) 38 | if self.args.proxy is not None: 39 | print(f"Global Configuration for the Region {self.cfg.region.code} has a proxy configured, " 40 | f"set {str(self.args.proxy)}") 41 | # Set Marketplace ID if provided via argument 42 | if self.args.marketid: 43 | self.cfg.region.marketplace_id = self.args.marketid 44 | # Set Various Variables 45 | self.origin = self.cfg.region.base 46 | # call base __init__ 47 | Service.__init__(self) 48 | 49 | def get_titles(self): 50 | # create a device id based on user agent 51 | self.cfg.device_id = hashlib.sha224( 52 | ("CustomerID" + appcfg.common_headers["User-Agent"]).encode('utf-8') 53 | ).hexdigest() 54 | browse_request = self.session.get( 55 | url=self.cfg.endpoints.browse.format( 56 | base_url=self.cfg.region.base_manifest, 57 | deviceTypeID=self.cfg.device_type, 58 | deviceID=self.cfg.device_id, 59 | series_asin=self.args.title, 60 | marketplace_id=self.cfg.region.marketplace_id 61 | ), 62 | headers={ 63 | "Accept": "application/json" 64 | } 65 | ) 66 | if browse_request.status_code == 403: 67 | raise Exception( 68 | "Amazon refused your connection to the Browse Endpoint!\n" 69 | "This can often happen if your cookies are invalid or expired." 70 | ) 71 | browse_request = browse_request.json() 72 | # todo ; add a check for an error response here in json 73 | self.titles = browse_request["message"]["body"]["titles"] 74 | self.title_name = (self.titles if self.args.movie else 75 | [x for x in self.titles[0]['ancestorTitles'] if x["contentType"] == "SERIES"])[0]["title"] if self.titles else None 76 | self.titles = [ 77 | t for t in self.titles 78 | if t["contentType"] == ("MOVIE" if self.args.movie else "EPISODE") and (t["number"] != 0 if "number" in t else True) 79 | ] 80 | if not self.titles: 81 | raise Exception( 82 | "No titles returned!\n" 83 | "Correct ASIN?\n" 84 | "Is the title available in the same region as the profile?\n" 85 | f"This title is a {('movie' if self.args.movie else 'tv show/episode')} right?" 86 | ) 87 | 88 | def get_title_information(self, title): 89 | season = [x for x in title["ancestorTitles"] if x["contentType"] == "SEASON"][0]["number"] \ 90 | if not self.args.movie else 0 91 | episode = title["number"] if not self.args.movie else 0 92 | title_year = title["releaseOrFirstAiringDate"]["valueFormatted"][:4] if self.args.movie and "releaseOrFirstAiringDate" in title else None 93 | episode_name = title["title"] if not self.args.movie else None 94 | return season, episode, title_year, episode_name 95 | 96 | def get_title_tracks(self, title): 97 | # Get Manifest 98 | # todo ; support primevideo.com by appending &gascEnabled=true when using primevideo.com cookies 99 | manifest = self.session.get( 100 | url=self.cfg.endpoints.playback.format( 101 | asin=title["titleId"], 102 | base_url=self.cfg.region.base_manifest, 103 | marketplace_id=self.cfg.region.marketplace_id, 104 | customer_id=self.cfg.region.account_id, 105 | token=self.cfg.region.account_token, 106 | profile=self.args.vcodec, 107 | client_id=self.cfg.region.client_id, 108 | bitrate=self.args.vbitrate.replace("+", "%2C"), 109 | deviceTypeID=self.cfg.device_type, 110 | deviceID=self.cfg.device_id 111 | ) + ("&deviceVideoQualityOverride=UHD&deviceHdrFormatsOverride=Hdr10" 112 | if self.args.quality and self.args.quality >= 2160 else "") 113 | ).json() 114 | if "error" in manifest: 115 | raise Exception( 116 | "Amazon reported an error when obtaining the Playback Manifest.\n" + 117 | f"Error message: {manifest['error']['message']}" 118 | ) 119 | if "rightsException" in manifest['returnedTitleRendition']['selectedEntitlement']: 120 | raise Exception( 121 | "Amazon denied this profile from receiving playback information.\n" + 122 | "This is usually caused by not purchasing or renting the content.\n" + 123 | "Error code: " + 124 | manifest['returnedTitleRendition']['selectedEntitlement']['rightsException']['errorCode'] 125 | ) 126 | if "errorsByResource" in manifest: 127 | raise Exception( 128 | "Amazon had an error occur with the resource.\n" + 129 | "These errors tend to be on Amazon's end and can sometimes be worked around by changing --vbitrate.\n" + 130 | "Error code: " + 131 | manifest['errorsByResource']['AudioVideoUrls']['errorCode'] + ", " + manifest['errorsByResource']['AudioVideoUrls']['message'] 132 | ) 133 | # List Audio Tracks 134 | av_cdn_url_sets = manifest["audioVideoUrls"]["avCdnUrlSets"] 135 | # Choose CDN to use 136 | print("CDN's: {}".format(", ".join([f"{x['cdn']} ({x['cdnWeightsRank']})" for x in av_cdn_url_sets]))) 137 | if self.args.cdn is not None: 138 | self.args.cdn = self.args.cdn.lower() 139 | cdn = [x for x in av_cdn_url_sets if x["cdn"].lower() == self.args.cdn] 140 | if not cdn: 141 | raise Exception(f"Selected CDN does not exist, CDN {self.args.cdn} is not an option.") 142 | cdn = cdn[0] 143 | else: 144 | # use whatever cdn amazon recommends 145 | cdn = sorted(av_cdn_url_sets, key=lambda x: int(x['cdnWeightsRank']))[0] 146 | # Obtain and parse MPD Manifest from CDN 147 | mpd_url = re.match(r'(https?://.*/)d.{0,1}/.*~/(.*)', cdn['avUrlInfoList'][0]['url']) 148 | mpd_url = mpd_url.group(1) + mpd_url.group(2) 149 | mpd = xmltodict.parse(self.session.get(mpd_url).text) 150 | adaptation_sets = mpd['MPD']['Period']['AdaptationSet'] 151 | # Video 152 | videos = [] 153 | for i, rep in enumerate(self.flatten([x['Representation'] for x in 154 | [x for x in adaptation_sets if x['@group'] == "2"]])): 155 | videos.append({ 156 | "id": i, 157 | "type": "video", 158 | "encrypted": True, 159 | "language": None, 160 | "track_name": None, 161 | "size": sorted( 162 | rep["SegmentList"]["SegmentURL"], 163 | key=lambda x: int(x["@mediaRange"].split('-')[1]), 164 | reverse=True 165 | )[0]["@mediaRange"].split('-')[1], 166 | "url": mpd_url.rsplit('/', 1)[0] + "/" + rep["BaseURL"], 167 | "codec": rep["@codecs"], 168 | "bitrate": rep["@bandwidth"], 169 | "width": rep["@width"], 170 | "height": rep["@height"], 171 | "default": True, 172 | "fix": False, 173 | "info": { 174 | "fps": rep['@frameRate'] 175 | } 176 | }) 177 | # Audio 178 | audio = [] 179 | audio_meta = [x for x in manifest["audioVideoUrls"]["audioTrackMetadata"] if x["audioSubtype"] == "dialog"] 180 | for i, rep in enumerate(sorted( 181 | self.flatten([x["Representation"] for x in [x for x in adaptation_sets if x["@group"] == "1" and (x["@audioTrackSubtype"] == "dialog" if "@audioTrackSubtype" in x else True)]]), 182 | key=lambda x: int(x["BaseURL"].split('_')[-1][:-4]) 183 | )): 184 | audio.append({ 185 | "id": i, 186 | "type": "audio", 187 | "encrypted": True, 188 | "language": audio_meta[0]["languageCode"], 189 | "track_name": audio_meta[0]["displayName"], 190 | "size": sorted( 191 | rep["SegmentList"]["SegmentURL"], 192 | key=lambda x: int(x["@mediaRange"].split('-')[1]), 193 | reverse=True 194 | )[0]["@mediaRange"].split("-")[1], 195 | "url": f"{mpd_url.rsplit('/', 1)[0]}/{rep['BaseURL']}", 196 | "codec": rep["@codecs"], 197 | "bitrate": rep["@bandwidth"], 198 | "default": True, # this is fine as long as there's only one audio track 199 | "fix": False, 200 | "info": { 201 | "sampling_rate": rep["@audioSamplingRate"] 202 | } 203 | }) 204 | # Subtitles 205 | subtitles = [] 206 | for i, sub in enumerate(manifest["subtitleUrls"]): 207 | subtitles.append({ 208 | "id": i, 209 | "type": "subtitle", 210 | "encrypted": False, 211 | "language": sub["languageCode"], 212 | "track_name": sub["displayName"], 213 | "url": sub["url"], 214 | "codec": sub["format"].lower(), 215 | "default": sub["languageCode"] == audio_meta[0]["languageCode"] and sub["index"] == 0, 216 | "fix": False 217 | }) 218 | self.widevine_cdm.pssh = [x for x in mpd["MPD"]["Period"]["AdaptationSet"][0]["ContentProtection"] 219 | if x["@schemeIdUri"] == self.widevine_cdm.urn][0]["cenc:pssh"] 220 | return videos, audio, subtitles 221 | 222 | def certificate(self, title, challenge): 223 | return self.license(title, challenge) 224 | 225 | def license(self, title, challenge): 226 | # todo ; support primevideo.com by appending &gascEnabled=true when using primevideo.com cookies 227 | lic = self.session.post( 228 | url=self.cfg.endpoints.licence.format( 229 | asin=title["titleId"], 230 | base_url=self.cfg.region.base_manifest, 231 | marketplace_id=self.cfg.region.marketplace_id, 232 | customer_id=self.cfg.region.account_id, 233 | token=self.cfg.region.account_token, 234 | deviceTypeID=self.cfg.device_type, 235 | deviceID=self.cfg.device_id 236 | ), 237 | headers={ 238 | "Accept": "application/json", 239 | "Content-Type": "application/x-www-form-urlencoded" 240 | }, 241 | data={ 242 | "widevine2Challenge": challenge, 243 | "includeHdcpTestKeyInLicense": "true" 244 | } 245 | ).json() 246 | if "errorsByResource" in lic: 247 | if lic["errorsByResource"]["Widevine2License"]["errorCode"] == "PRS.NoRights.AnonymizerIP": 248 | raise Exception( 249 | f"Amazon detected a Proxy/VPN and refused to return a license!\n" + 250 | lic["errorsByResource"]["Widevine2License"]["errorCode"] 251 | ) 252 | raise Exception( 253 | "Amazon reported an error when obtaining the License.\n" 254 | f"Error message: {lic['errorsByResource']['Widevine2License']['errorCode']}" 255 | f", {lic['errorsByResource']['Widevine2License']['message']}" 256 | ) 257 | return lic["widevine2License"]["license"] 258 | -------------------------------------------------------------------------------- /services/__init__.py: -------------------------------------------------------------------------------- 1 | # Standard Libraries 2 | import os.path 3 | import requests 4 | import re 5 | import subprocess 6 | import time 7 | import unicodedata 8 | from collections import namedtuple, Sequence 9 | # PyPI Dependencies 10 | import pycaption 11 | import pycountry 12 | import urllib3 13 | from tqdm import tqdm 14 | # Custom Scripts 15 | import config as appcfg 16 | 17 | class Service: 18 | 19 | # stubbed, set by an inheriting class, just here to prevent linter warnings 20 | # we can't pop these in __init__ or it will overwrite whats passed by the inheritor 21 | args = None 22 | source_tag = None 23 | origin = None 24 | 25 | def __init__(self): 26 | # settings 27 | appcfg.common_headers["Origin"] = f"https://{self.origin}" 28 | # create a requests session 29 | self.session = self.get_session() 30 | # create a widevine cdm 31 | self.widevine_cdm = self.get_cdm() 32 | # title data 33 | self.title_name = "" 34 | self.titles = [] 35 | # tracks 36 | self.tracks = namedtuple("_", "selected videos audio subtitles") 37 | self.tracks.selected = namedtuple("_", "video audio subtitles") 38 | self.tracks.selected.audio = [] 39 | self.tracks.selected.subtitles = [] 40 | self.tracks.videos = [] 41 | self.tracks.audio = [] 42 | self.tracks.subtitles = [] 43 | # let's fucking go! 44 | print(f"Retrieving Titles from {self.args.service} for '{self.args.title}'...") 45 | self.get_titles() 46 | if not self.args.movie: 47 | print(f"{len(self.titles)} Titles for {self.title_name} received...") 48 | for title in self.titles: 49 | season, episode, title_year, episode_name = self.get_title_information(title) 50 | # Is this a requested title or should it be skipped? 51 | if not self.args.movie and \ 52 | (self.args.season is not None and season != self.args.season) or \ 53 | (self.args.skip is not None and episode <= self.args.skip) or \ 54 | (self.args.episode is not None and episode != self.args.episode): 55 | continue 56 | # Parse a filename 57 | self.filename = self.title_name + " " 58 | if season and episode and episode_name: 59 | self.filename += f"S{str(season).zfill(2)}E{str(episode).zfill(2)}.{episode_name}" 60 | else: 61 | self.filename += str(title_year) 62 | self.filename = re.sub(r"[\\*!?,'’\"()<>:|]", "", "".join( 63 | (c for c in unicodedata.normalize( 64 | "NFD", 65 | self.filename + f".{self.source_tag}.WEB-DL-PHOENiX" 66 | ) if unicodedata.category(c) != "Mn") 67 | )).replace(" ", ".").replace(".-.", ".").replace("/", ".&.").replace("..", ".") 68 | print(f"Downloading to \"{self.filename}\"") 69 | # Get Tracks for the current Title 70 | videos, audio, subtitles = self.get_title_tracks(title) 71 | self.tracks.videos = [Track(x) for x in videos] 72 | self.tracks.audio = [Track(x) for x in audio] 73 | self.tracks.subtitles = [Track(x) for x in subtitles] 74 | self.list_tracks() 75 | if self.widevine_cdm.IDENTITY == "widevine_cdm_api": 76 | self.widevine_cdm.title_id = self.args.title 77 | self.widevine_cdm.title_type = "MOVIE" if self.args.movie else "EPISODE" 78 | self.widevine_cdm.season = season 79 | self.widevine_cdm.episode = episode 80 | elif self.widevine_cdm.IDENTITY == "widevine_cdm": 81 | self.widevine_cdm.certificate = lambda challenge: self.certificate(title, challenge) 82 | self.widevine_cdm.license = lambda challenge: self.license(title, challenge) 83 | if self.args.keys: 84 | import json 85 | print(json.dumps(self.widevine_cdm.get_decryption_keys())) 86 | continue 87 | # start process of downloading the content 88 | if not isinstance(self.tracks.videos, list) or len(self.tracks.videos) == 0: 89 | raise Exception("No video track to download...") 90 | if not isinstance(self.tracks.audio, list) or len(self.tracks.audio) == 0: 91 | raise Exception("No audio tracks to download...") 92 | # Cleanup interrupted files from a previous session 93 | self.cleanup() 94 | # Download tracks 95 | video_track, audio_tracks, subtitle_tracks = self.select_tracks() 96 | self.download_tracks(video_track, audio_tracks, subtitle_tracks) 97 | # Decrypt encrypted tracks if cdm addon exists 98 | encrypted_tracks = [t for t in ([video_track] + audio_tracks) if t.encrypted] 99 | if not encrypted_tracks: 100 | raise Exception("No tracks provided are encrypted, this isn't right! Aborting!") 101 | # Create a Command Line argument list for mp4decrypt containing all the decryption keys 102 | cl = ["mp4decrypt"] 103 | for key in self.widevine_cdm.get_decryption_keys(): 104 | cl.append("--key") 105 | cl.append(key) 106 | for track in encrypted_tracks: 107 | print(f"Decrypting {track.type} track #{track.id + 1}...") 108 | t_cl = cl.copy() 109 | t_cl.extend([ 110 | appcfg.filenames.encrypted.format( 111 | filename=self.filename, 112 | track_type=track.type, 113 | track_no=track.id 114 | ), 115 | appcfg.filenames.decrypted.format( 116 | filename=self.filename, 117 | track_type=track.type, 118 | track_no=track.id 119 | ) 120 | ]) 121 | subprocess.Popen( 122 | t_cl, 123 | stdout=subprocess.PIPE, 124 | stderr=subprocess.STDOUT 125 | ).wait() 126 | print("Decrypted all tracks") 127 | for track in ([video_track] + audio_tracks + subtitle_tracks): 128 | if track.fix: 129 | original_file = appcfg.filenames.decrypted.format( 130 | filename=self.filename, 131 | track_type=track.type, 132 | track_no=track.id 133 | ) 134 | print(f"Fixing {os.path.basename(original_file)} via FFMPEG") 135 | fixed_file = (appcfg.filenames.decrypted + "_fixed.mkv").format( 136 | filename=self.filename, 137 | track_type=track.type, 138 | track_no=track.id 139 | ) 140 | subprocess.run([ 141 | "ffmpeg", "-hide_banner", 142 | "-loglevel", "panic", 143 | "-i", original_file, 144 | "-codec", "copy", 145 | fixed_file 146 | ]) 147 | os.remove(original_file) 148 | os.rename(fixed_file, original_file) 149 | # Mux Tracks 150 | self.mux(video_track, audio_tracks, subtitle_tracks) 151 | # Cleanup 152 | self.cleanup() 153 | print("Processed all titles...") 154 | 155 | # stubs 156 | # these should be defined by the service 157 | # just here to prevent linter warnings 158 | def get_titles(self): 159 | pass 160 | 161 | def get_title_information(self, title): 162 | return 0, 0, 0, "" 163 | 164 | def get_title_tracks(self, title): 165 | return [], [], [] 166 | 167 | def certificate(self, title, challenge): 168 | return None 169 | 170 | def license(self, title, challenge): 171 | return None 172 | 173 | def get_session(self): 174 | """ 175 | Creates requests session, disables certificate verification (and it's warnings), and adds any proxies, headers 176 | and cookies that may exist. 177 | :return: prepared request session 178 | """ 179 | session = requests.Session() 180 | session.verify = False 181 | urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) 182 | if self.args.proxy: 183 | session.proxies.update({"https": f"http://{self.args.proxy}"} if isinstance(self.args.proxy, str) else 184 | self.args.proxy) 185 | session.headers.update(appcfg.common_headers) 186 | session.cookies.update(self.get_cookies()) 187 | return session 188 | 189 | def get_cookies(self): 190 | """ 191 | Obtain cookies for the given profile 192 | :return: dictionary list of cookies 193 | """ 194 | cookie_file = os.path.join( 195 | appcfg.directories.cookies, 196 | self.args.service, 197 | self.args.profile + ".txt" 198 | ) 199 | cookies = {} 200 | if os.path.exists(cookie_file) and os.path.isfile(cookie_file): 201 | with open(cookie_file, "r") as f: 202 | for l in f: 203 | if not re.match(r"^#", l) and not re.match(r"^\n", l): 204 | line_fields = l.strip().split('\t') 205 | cookies[line_fields[5]] = line_fields[6] 206 | print(f"Loaded cookies from Profile: \"{self.args.profile}\"") 207 | return cookies 208 | 209 | def get_login(self): 210 | """ 211 | If login data exists, return them 212 | :return: login data as a list, 0 = user, 1 = password 213 | """ 214 | # self.session.cookies.get_dict()["vt_user"] ["vt_pass"] from profiles as a cookie file instead maybe 215 | data_file = os.path.join(appcfg.directories.login, self.args.service, self.args.profile + ".txt") 216 | if not os.path.exists(data_file) or not os.path.isfile(data_file): 217 | return None 218 | with open(data_file, 'r') as f: 219 | for l in [x for x in f if ":" in x]: 220 | return l.strip().split(':') 221 | 222 | def get_cdm(self): 223 | """ 224 | Attempt to load a Widevine CDM handler if one exists, throws FileNotFoundError on fail 225 | :return: CDM Handler 226 | """ 227 | for addon in ['widevine_cdm', 'widevine_cdm_api']: 228 | try: 229 | addon_import = __import__("utils.cdms." + addon, globals(), locals(), [addon], 0) 230 | print(f"Created a Widevine CDM Object using {addon}") 231 | if addon == "widevine_cdm": 232 | return addon_import.AddOn(os.path.join(appcfg.directories.cdm_devices, self.args.cdm)) 233 | elif addon == "widevine_cdm_api": 234 | return addon_import.AddOn( 235 | service=self.args.service, 236 | profile=self.args.profile, 237 | device=self.args.cdm, 238 | proxy=self.args.proxy or None 239 | ) 240 | except ImportError: 241 | pass 242 | raise ImportError( 243 | "Cannot create a widevine cdm instance, a widevine cdm addon is required but none are found.") 244 | 245 | def cleanup(self): 246 | """ 247 | Delete all files from the temporary data directory, including the directory itself 248 | """ 249 | if os.path.exists(appcfg.directories.temp): 250 | for f in os.listdir(appcfg.directories.temp): 251 | os.remove(os.path.join(appcfg.directories.temp, f)) 252 | os.rmdir(appcfg.directories.temp) 253 | 254 | def mux(self, video_track, audio_tracks, subtitle_tracks): 255 | """ 256 | Takes the Video, Audio and Subtitle Tracks, and muxes them into an MKV file. 257 | It will attempt to detect Forced/Default tracks, and will try to parse the language codes of the Tracks 258 | """ 259 | print("Muxing Video, Audio and Subtitles to an MKV") 260 | # Initialise the Command Line Arguments for MKVMERGE 261 | cl = [ 262 | "mkvmerge", 263 | "-q", 264 | "--output", 265 | appcfg.filenames.muxed.format(filename=self.filename) 266 | ] 267 | # Add the Video Track 268 | cl = cl + [ 269 | "--language", "0:und", 270 | "(", appcfg.filenames.decrypted.format( 271 | filename=self.filename, 272 | track_type=video_track.type, 273 | track_no=video_track.id 274 | ), ")" 275 | ] 276 | # Add the Audio Tracks 277 | for at in audio_tracks: 278 | # todo ; 0: track id may need to be properly offset 279 | if at.track_name: 280 | cl = cl + ["--track-name", "0:" + at.track_name] 281 | cl = cl + [ 282 | "--language", ("0:" + pycountry.languages.lookup(at.language.lower().split('-')[0]).alpha_3), 283 | "(", appcfg.filenames.decrypted.format( 284 | filename=self.filename, 285 | track_type=at.type, 286 | track_no=at.id 287 | ), ")" 288 | ] 289 | # Add the Subtitle Tracks 290 | if subtitle_tracks is not None: 291 | for st in subtitle_tracks: 292 | forced = ("yes" if st.track_name and "forced" in st.track_name.lower() else "no") 293 | lang_code = st.language.lower().split('-')[0] 294 | try: 295 | lang_code = pycountry.languages.get(alpha_2=lang_code).bibliographic 296 | except AttributeError: 297 | pass 298 | # todo ; 0: track id may need to be properly offset 299 | if st.track_name: 300 | cl = cl + ["--track-name", "0:" + st.track_name] 301 | cl = cl + [ 302 | "--language", "0:" + lang_code, 303 | "--sub-charset", "0:UTF-8", 304 | "--forced-track", "0:" + forced, 305 | "--default-track", "0:" + ("yes" if (st.default or forced == "yes") else "no"), 306 | "(", appcfg.filenames.subtitles.format( 307 | filename=self.filename, 308 | language_code=st.language, 309 | id=st.id 310 | ), ")" 311 | ] 312 | # Run MKVMERGE with the arguments 313 | subprocess.run(cl) 314 | print("Muxed") 315 | 316 | def download_tracks(self, video_track, audio_tracks, subtitle_tracks): 317 | """ 318 | Takes the Video, Audio and Subtitle Tracks, and download them. 319 | If needed, it will convert the subtitles to SRT. 320 | """ 321 | os.makedirs(appcfg.directories.temp) 322 | if not os.path.exists(appcfg.directories.output): 323 | os.makedirs(appcfg.directories.output) 324 | # Download Video, Audio and Subtitle Tracks 325 | for track in [video_track] + audio_tracks + subtitle_tracks: 326 | filename = appcfg.filenames.encrypted if track.encrypted else appcfg.filenames.decrypted 327 | filename = filename.format(filename=self.filename, track_type=track.type, track_no=track.id) 328 | print(f"Downloading {track.type} track #{track.id + 1}...") 329 | if track.downloader == "curl": 330 | curl = [ 331 | "curl", 332 | "-o", filename, 333 | "--url", track.url 334 | ] 335 | #if self.args.proxy: 336 | # curl.append("--proxy") 337 | # curl.append(list(self.args.proxy.values())[0]) 338 | if track.size: 339 | curl.append("--range") 340 | curl.append(f"0-{track.size}") 341 | failures = 0 342 | while subprocess.run(curl).returncode != 0: 343 | failures += 1 344 | if failures == 5: 345 | print("Curl failed too many time's. Aborting.") 346 | exit(1) 347 | print(f"Curl failed, retrying in {3 * failures} seconds...") 348 | time.sleep(3 * failures) 349 | elif track.downloader == "youtube-dl": 350 | from utils.parsers.youtube_dl import YoutubeDL 351 | with YoutubeDL({ 352 | "listformats": False, 353 | "format": "best" + track.type + ( 354 | f"[format_id*={track.arguments['format_id']}]" 355 | if "format_id" in track.arguments else ""), 356 | "output": filename, 357 | "retries": 25, 358 | "fixup": "never", 359 | "outtmpl": filename, 360 | "force_generic_extractor": True, 361 | "no_warnings": True 362 | }) as ydl: 363 | ydl.download([track.url]) 364 | elif track.downloader == "m3u8": 365 | from utils.parsers.m3u8 import M3U8 366 | m3u8 = M3U8(self.session) 367 | m3u8.load(track.m3u8) 368 | f = open(filename, "wb") 369 | for i in tqdm(m3u8.media_segment, unit="segments"): 370 | try: 371 | if "EXT-X-MAP" in i: 372 | f.write(self.session.get(m3u8.get_full_url(i["EXT-X-MAP"]["URI"])).content) 373 | f.write(self.session.get(m3u8.get_full_url(i["URI"])).content) 374 | except requests.exceptions.RequestException as e: 375 | print(e) 376 | exit(1) 377 | f.close() 378 | else: 379 | print(f"{track.downloader} is an invalid downloader") 380 | exit(1) 381 | if track.type == "subtitle": 382 | with open(filename, "r") as sc: 383 | s = sc.read() 384 | if track.codec == "dfxp": 385 | s = pycaption.SRTWriter().write(pycaption.DFXPReader().read(s.replace('tt:', ''))) 386 | elif track.codec == "vtt": 387 | try: 388 | s = pycaption.SRTWriter().write( 389 | pycaption.WebVTTReader().read( 390 | s.replace('\r', '').replace('\n\n\n', '\n \n\n').replace('\n\n<', '\n<') 391 | ) 392 | ) 393 | except pycaption.exceptions.CaptionReadSyntaxError: 394 | print("Syntax error occurred with the subtitle file, is the subtitle invalid?") 395 | exit(1) 396 | elif track.codec != "srt": 397 | print("Unknown Subtitle Format. Aborting.") 398 | exit(1) 399 | with open(appcfg.filenames.subtitles.format( 400 | filename=self.filename, 401 | language_code=track.language, 402 | id=track.id 403 | ), 'w', encoding='utf-8') as f: 404 | f.write(s) 405 | print("Downloaded") 406 | 407 | def select_tracks(self): 408 | # Video 409 | # let's just take the best bitrate track of the requested resolution (or best resolution) 410 | if self.args.quality is not None: 411 | selected_video = sorted([x for x in self.tracks.videos if 412 | x.height == self.args.quality or 413 | x.width == int((self.args.quality / 9) * 16)], 414 | key=lambda x: x.bitrate, 415 | reverse=True) 416 | if selected_video: 417 | selected_video = selected_video[0] 418 | else: 419 | print(f"There's no {self.args.quality}p resolution video track. Aborting.") 420 | exit(1) 421 | else: 422 | selected_video = sorted(self.tracks.videos, key=lambda x: int(x.bitrate), reverse=True)[0] 423 | # Audio 424 | # we are only selecting the best audio track, perhaps there's multiple tracks of interest? 425 | selected_audio = [sorted( 426 | sorted( 427 | self.tracks.audio, 428 | key=lambda x: ("" if x.language == "en" else (x.language if x.language else "")), 429 | reverse=False 430 | ), 431 | key=lambda x: (int(x.bitrate) if x.bitrate else 0), 432 | reverse=True 433 | )[0]] 434 | # Subtitle Track Selection 435 | # don't need to do anything, as we want all of them 436 | 437 | # print the string representations 438 | print("Selected Tracks:") 439 | for track in [selected_video] + selected_audio + self.tracks.subtitles: 440 | print(track) 441 | return selected_video, selected_audio, self.tracks.subtitles 442 | 443 | def order_tracks(self, tracks): 444 | sort = sorted( 445 | sorted( 446 | tracks, 447 | key=lambda x: ("" if x.language == "en" else x.language) if x.language else "" 448 | ), 449 | key=lambda x: x.bitrate if x.bitrate else 0, 450 | reverse=True 451 | ) 452 | for i, _ in enumerate(sort): 453 | sort[i].id = i 454 | return sort 455 | 456 | def list_tracks(self): 457 | self.tracks.videos = self.order_tracks(self.tracks.videos) 458 | self.tracks.audio = self.order_tracks(self.tracks.audio) 459 | self.tracks.subtitles = self.order_tracks(self.tracks.subtitles) 460 | for track in self.tracks.videos + self.tracks.audio + self.tracks.subtitles: 461 | if track.id == 0: 462 | count = len(self.tracks.videos if track.type == 'video' else self.tracks.audio 463 | if track.type == 'audio' else self.tracks.subtitles) 464 | print(f"{count} {track.type.title()} Tracks:") 465 | print(track) 466 | 467 | def flatten(self, l): 468 | return list(self.flatten_g(l)) 469 | 470 | def flatten_g(self, l): 471 | basestring = (str, bytes) 472 | for el in l: 473 | if isinstance(el, Sequence) and not isinstance(el, basestring): 474 | for sub in self.flatten_g(el): 475 | yield sub 476 | else: 477 | yield el 478 | 479 | 480 | class Track: 481 | 482 | def __init__(self, arguments): 483 | self.id = arguments["id"] if "id" in arguments else None 484 | self.type = arguments["type"] if "type" in arguments else None 485 | self.encrypted = bool(arguments["encrypted"]) if "encrypted" in arguments else None 486 | self.language = arguments["language"] if "language" in arguments else None 487 | self.track_name = arguments["track_name"] if "track_name" in arguments else None 488 | self.size = int(arguments["size"]) if "size" in arguments else None 489 | self.url = arguments["url"] if "url" in arguments else None 490 | self.m3u8 = arguments["m3u8"] if "m3u8" in arguments else None 491 | self.codec = arguments["codec"] if "codec" in arguments else None 492 | self.bitrate = int(arguments["bitrate"]) if "bitrate" in arguments else None 493 | self.width = int(arguments["width"]) if "width" in arguments else None 494 | self.height = int(arguments["height"]) if "height" in arguments else None 495 | self.default = bool(arguments["default"]) if "default" in arguments else None 496 | self.fix = bool(arguments["fix"]) if "fix" in arguments else False 497 | self.info = arguments["info"] if "info" in arguments else {} 498 | # optional 499 | self.downloader = arguments["downloader"] if "downloader" in arguments else "curl" 500 | self.arguments = arguments["arguments"] if "arguments" in arguments else {} 501 | 502 | def __str__(self): 503 | if self.type == "video": 504 | return f"{self.type.upper()[:3]} | BR: {self.bitrate} - " \ 505 | f"{self.width}x{self.height}{(' @ ' + self.info['fps'] if 'fps' in self.info else '')} / " \ 506 | f"{self.codec}" 507 | if self.type == "audio": 508 | return f"{self.type.upper()[:3]} | BR: {self.bitrate} - " \ 509 | f"{self.language} / {self.codec}" 510 | if self.type == "subtitle": 511 | return f"{self.type.upper()[:3]} | Language: {self.language} / {self.track_name}" 512 | -------------------------------------------------------------------------------- /services/netflix.py: -------------------------------------------------------------------------------- 1 | # Standard Libraries 2 | import base64 3 | import gzip 4 | import json 5 | import os 6 | import random 7 | import re 8 | import time 9 | import urllib 10 | import zlib 11 | from collections import namedtuple 12 | from datetime import datetime 13 | from io import BytesIO 14 | # PyPI Dependencies 15 | from Cryptodome.Cipher import AES, PKCS1_OAEP 16 | from Cryptodome.Hash import HMAC, SHA256 17 | from Cryptodome.PublicKey import RSA 18 | from Cryptodome.Random import get_random_bytes 19 | from Cryptodome.Util import Padding 20 | # Custom Scripts 21 | from services import Service 22 | import config as appcfg 23 | 24 | 25 | class NETFLIX(Service): 26 | 27 | def __init__(self, cfg, args): 28 | # Store various stuff to the parent class object 29 | self.args = args 30 | self.cfg = cfg 31 | self.source_tag = "NF" 32 | self.origin = "netflix.com" 33 | # call base __init__ 34 | Service.__init__(self) 35 | 36 | def get_titles(self): 37 | # Get an MSL object 38 | self.msl = MSL( 39 | cdm=self.widevine_cdm, 40 | session=self.session, 41 | msl_bin=os.path.join(appcfg.directories.cookies, self.args.service, "msl.bin"), 42 | rsa_bin=os.path.join(appcfg.directories.cookies, self.args.service, "rsa.bin"), 43 | esn=self.args.esn, 44 | widevine_key_exchange=self.args.cdm_ke is not None, 45 | manifest_endpoint=self.cfg.endpoints.manifest 46 | ) 47 | build_txt = os.path.join(appcfg.directories.cookies, self.args.service, "build.txt") 48 | if not os.path.exists(build_txt) or not os.path.isfile(build_txt): 49 | # No Cached Cookies/Build, Login and get them 50 | build = re.search( 51 | r'"BUILD_IDENTIFIER":"([a-z0-9]+)"', 52 | self.session.get(appcfg.common_headers["Origin"]).text 53 | ) 54 | if build: 55 | build = build.group(1) 56 | with open(build_txt, "w") as f: 57 | f.write(build) 58 | else: 59 | with open(build_txt, "r") as f: 60 | build = f.read().strip() 61 | if not build: 62 | raise Exception( 63 | "Couldn't find a Build ID from the homepage or cache, cookies or cache file may be invalid.") 64 | # fetch metadata from shakti 65 | metadata = self.session.get( 66 | f"https://www.netflix.com/api/shakti/{build}/metadata?movieid={self.args.title}" 67 | ).text 68 | try: 69 | metadata = json.loads(metadata) 70 | except json.JSONDecodeError: 71 | print( 72 | "Failed to fetch Metadata via Shakti API for " + self.args.title + " using BUILD Identifier " + build + 73 | ", is this title available on your IP's region?") 74 | exit(1) 75 | 76 | # For every title 77 | self.title_name = metadata["video"]["title"] 78 | self.titles = [metadata["video"]] if self.args.movie else [ 79 | Ep for Season in [ 80 | [ 81 | dict(x, **{"season": Season["seq"]}) for x in Season["episodes"] 82 | ] for Season in [ 83 | x for x in metadata["video"]["seasons"] 84 | if (x["seq"] == self.args.season if self.args.season else True) 85 | ] 86 | ] for Ep in Season 87 | ] 88 | if not self.titles: 89 | raise Exception("No titles returned!") 90 | 91 | def get_title_information(self, title): 92 | season = title["season"] if not self.args.movie else 0 93 | episode = title["seq"] if not self.args.movie else 0 94 | title_year = title["year"] if self.args.movie else None 95 | episode_name = title["title"] if not self.args.movie else None 96 | return season, episode, title_year, episode_name 97 | 98 | def get_title_tracks(self, title): 99 | profiles = self.cfg.profiles.video.h264.bpl + self.cfg.profiles.video.h264.mpl 100 | if self.args.vcodec == "H264@HPL": 101 | profiles += self.cfg.profiles.video.h264.hpl 102 | if self.args.vcodec == "H265": 103 | profiles += self.cfg.profiles.video.h265.sdr 104 | if self.args.vcodec == "H265-HDR": 105 | profiles += self.cfg.profiles.video.h265.hdr.hdr10 + \ 106 | self.cfg.profiles.video.h265.hdr.dolbyvision.dv1 + \ 107 | self.cfg.profiles.video.h265.hdr.dolbyvision.dv5 108 | if self.args.vcodec == "VP9": 109 | profiles += self.cfg.profiles.video.vp9.p0 + \ 110 | self.cfg.profiles.video.vp9.p1 + \ 111 | self.cfg.profiles.video.vp9.p2 112 | if self.args.vcodec == "AV1": 113 | profiles += self.cfg.profiles.video.av1 114 | if self.args.acodec == "AAC": 115 | profiles += self.cfg.profiles.audio.aac 116 | if self.args.acodec == "VORB": 117 | profiles += self.cfg.profiles.audio.vorb 118 | if self.args.acodec == "DOLBY": 119 | profiles += self.cfg.profiles.audio.dolby 120 | manifest_response = self.session.post( 121 | url=self.cfg.endpoints.manifest, 122 | data=self.msl.generate_msl_request_data({ 123 | "method": "manifest", 124 | "lookupType": "PREPARE", 125 | "viewableIds": [title["episodeId"] if "episodeId" in title else title["id"]], 126 | "profiles": profiles + self.cfg.profiles.subtitles, 127 | "drmSystem": "widevine", 128 | "appId": "14673889385265", 129 | "sessionParams": { 130 | "pinCapableClient": False, 131 | "uiplaycontext": "null" 132 | }, 133 | "sessionId": "14673889385265", 134 | "trackId": 0, 135 | "flavor": "PRE_FETCH", 136 | "secureUrls": True, 137 | "supportPreviewContent": True, 138 | "forceClearStreams": False, 139 | "languages": ["en-US"], 140 | "clientVersion": "6.0011.511.011", # 4.0004.899.011 141 | "uiVersion": "shakti-vb45817f4", # akira 142 | "titleSpecificData": { 143 | title["episodeId"] if "episodeId" in title else title["id"]: {"unletterboxed": False} 144 | }, 145 | "videoOutputInfo": [{ 146 | "type": "DigitalVideoOutputDescriptor", 147 | "outputType": "unknown", 148 | "supportedHdcpVersions": [], 149 | "isHdcpEngaged": True 150 | }], 151 | "isNonMember": False, 152 | "showAllSubDubTracks": True, 153 | "preferAssistiveAudio": False, 154 | "supportsPreReleasePin": True, 155 | "supportsWatermark": True, 156 | "isBranching": False, 157 | "useHttpsStreams": False, 158 | "imageSubtitleHeight": 1080, 159 | }) 160 | ) 161 | try: 162 | # if the json() does not fail we have an error because the manifest response is a chunked json response 163 | raise Exception("Failed to retrieve Manifest: " + 164 | json.loads( 165 | base64.standard_b64decode(manifest_response.json()["errordata"]).decode('utf-8') 166 | )["errormsg"] 167 | ) 168 | except ValueError: 169 | manifest = self.msl.decrypt_payload_chunk( 170 | self.msl.parse_chunked_msl_response(manifest_response.text)["payloads"]) 171 | try: 172 | self.playback_context_id = manifest["result"]["viewables"][0]["playbackContextId"] 173 | self.drm_context_id = manifest["result"]["viewables"][0]["drmContextId"] 174 | except (KeyError, IndexError): 175 | if "errorDisplayMessage" in manifest["result"]: 176 | raise Exception(f"MSL Error Message: {manifest['result']['errorDisplayMessage']}") 177 | else: 178 | raise Exception("Unknown error occurred.") 179 | 180 | # -------------------------------- 181 | # Looking good, time to compile information on the Titles 182 | # This will just make it easier for the rest of the code 183 | # -------------------------------- 184 | manifest = manifest["result"]["viewables"][0] 185 | # Video 186 | videos = [] 187 | for i, rep in enumerate(manifest["videoTracks"][0]["downloadables"]): 188 | videos.append({ 189 | "id": i, 190 | "type": "video", 191 | "encrypted": rep['isEncrypted'], 192 | # todo ; this should be gotten from the MPD or Playback Manifest 193 | "language": None, 194 | "track_name": None, 195 | # todo ; size isn't really needed anymore, we might not need this 196 | "size": rep["size"], 197 | "url": next(iter(rep["urls"].values())), 198 | "codec": rep["contentProfile"], 199 | "bitrate": rep["bitrate"], 200 | "width": rep["width"], 201 | "height": rep["height"], 202 | "default": True, 203 | "fix": True 204 | }) 205 | # Audio 206 | audio = [] 207 | original_language = [x for x in manifest["orderedAudioTracks"] 208 | if x.endswith("[Original]")][0].replace("[Original]", "").strip() 209 | audio_tracks = [] 210 | for audio_track in [t for t in manifest["audioTracks"] 211 | if t["trackType"] == "PRIMARY"]: 212 | audio_track["language"] = audio_track["language"].replace("[Original]", "").strip() 213 | for downloadable in audio_track["downloadables"]: 214 | new_audio_track = audio_track.copy() 215 | new_audio_track["downloadables"] = downloadable 216 | audio_tracks.append(new_audio_track) 217 | for i, rep in enumerate([t for t in audio_tracks if t["language"] == original_language]): 218 | audio.append({ 219 | "id": i, 220 | "type": "audio", 221 | "encrypted": False, 222 | "language": rep["language"], 223 | "track_name": None, 224 | # todo ; size isn't really needed anymore, we might not need this 225 | "size": rep["downloadables"]["size"], 226 | "url": next(iter(rep["downloadables"]["urls"].values())), 227 | "codec": rep["downloadables"]["contentProfile"], 228 | "bitrate": rep["downloadables"]["bitrate"], 229 | "default": True, # this is fine as long as there's only one audio track 230 | "fix": True 231 | }) 232 | # Subtitles 233 | subtitles = [] 234 | for i, sub in enumerate([x for x in manifest["textTracks"] 235 | if x["downloadables"] is not None and x["language"] != "Off"]): 236 | subtitles.append({ 237 | "id": i, 238 | "type": "subtitle", 239 | "encrypted": False, 240 | "language": sub["bcp47"], 241 | "track_name": f"{sub['language']} " 242 | f"[{sub['trackType'].replace('CLOSEDCAPTIONS', 'CC').replace('SUBTITLES', 'SUB')}]", 243 | "url": next(iter(sub["downloadables"][0]["urls"].values())), 244 | "codec": "vtt" if sub["downloadables"][0]["contentProfile"].startswith("webvtt") else "srt", 245 | "default": sub["language"] == original_language, 246 | "fix": False 247 | }) 248 | self.widevine_cdm.pssh = manifest["psshb64"][0] 249 | self.cert = manifest["cert"] 250 | return videos, audio, subtitles 251 | 252 | def certificate(self, title, challenge): 253 | return self.cert 254 | 255 | def license(self, title, challenge): 256 | challenge_response = self.session.post( 257 | url=self.cfg.endpoints.licence, 258 | data=self.msl.generate_msl_request_data({ 259 | 'method': 'license', 260 | 'licenseType': 'STANDARD', 261 | 'clientVersion': '4.0004.899.011', 262 | 'uiVersion': 'akira', 263 | 'languages': ['en-US'], 264 | 'playbackContextId': self.playback_context_id, 265 | 'drmContextIds': [self.drm_context_id], 266 | 'challenges': [{ 267 | 'dataBase64': challenge, 268 | 'sessionId': "14673889385265" 269 | }], 270 | 'clientTime': int(time.time()), 271 | 'xid': int((int(time.time()) + 0.1612) * 1000) 272 | }) 273 | ) 274 | try: 275 | # If is valid json the request for the license failed 276 | challenge_response.json() 277 | raise Exception(f"Error getting license (A): {challenge_response.text}") 278 | except ValueError: 279 | # json() failed so we have a chunked json response 280 | payload = self.msl.decrypt_payload_chunk(self.msl.parse_chunked_msl_response( 281 | challenge_response.text 282 | )["payloads"]) 283 | if payload["success"] is False: 284 | raise Exception(f"Error getting license (B): {json.dumps(payload)}") 285 | return payload["result"]["licenses"][0]["data"] 286 | 287 | 288 | class MSL: 289 | 290 | files = namedtuple("_", "msl rsa") 291 | 292 | def __init__(self, cdm, session, msl_bin, rsa_bin, esn, widevine_key_exchange, manifest_endpoint): 293 | # settings 294 | self.cdm = cdm 295 | self.session = session 296 | self.files.msl = msl_bin 297 | self.files.rsa = rsa_bin 298 | self.esn = esn 299 | self.widevine_key_exchange = widevine_key_exchange 300 | self.manifest_endpoint = manifest_endpoint 301 | # msl stuff 302 | self.message_id = 0 303 | self.keys = namedtuple("_", "encryption sign rsa") 304 | self.master_token = None 305 | self.sequence_number = None 306 | # create an end-to-end encryption by negotiating encryption keys 307 | self.negotiate_keys() 308 | 309 | def load_cached_data(self): 310 | # Check if one exists 311 | if not os.path.isfile(self.files.msl): 312 | return False 313 | # Load it 314 | with open(self.files.msl, "r") as f: 315 | msl_bin = json.JSONDecoder().decode(f.read()) 316 | # If its expired or close to, return None as its unusable 317 | if ((datetime.utcfromtimestamp(int(json.JSONDecoder().decode( 318 | base64.standard_b64decode(msl_bin["tokens"]["mastertoken"]["tokendata"]).decode("utf-8") 319 | )["expiration"])) - datetime.now()).total_seconds() / 60 / 60) < 10: 320 | return False 321 | # Set the MasterToken of this Cached BIN 322 | self.set_master_token(msl_bin["tokens"]["mastertoken"]) 323 | self.keys.encryption = base64.standard_b64decode(msl_bin["encryption_key"]) 324 | self.keys.sign = base64.standard_b64decode(msl_bin["sign_key"]) 325 | print("Cached MSL data found and loaded") 326 | return True 327 | 328 | def load_cached_rsa_key(self): 329 | if not os.path.isfile(self.files.rsa): 330 | return False 331 | with open(self.files.rsa, "rb") as f: 332 | self.keys.rsa = RSA.importKey(f.read()) 333 | print("Cached RSA Key found and loaded") 334 | return True 335 | 336 | def generate_msl_header(self, is_handshake=False, is_key_request=False, compression="GZIP"): 337 | """ 338 | Function that generates a MSL header dict 339 | :return: The base64 encoded JSON String of the header 340 | """ 341 | self.message_id = random.randint(0, pow(2, 52)) 342 | header_data = { 343 | "sender": self.esn, 344 | "renewable": True, 345 | "capabilities": { 346 | "languages": ["en-US"], 347 | "compressionalgos": [], 348 | "encoderformats": ["JSON"], 349 | }, 350 | "handshake": is_handshake, 351 | "nonreplayable": False, 352 | "recipient": "Netflix", 353 | "messageid": self.message_id, 354 | "timestamp": int(time.time()), # verify later 355 | } 356 | # Add compression algorithm if being requested 357 | if compression: 358 | header_data["capabilities"]["compressionalgos"].append(compression) 359 | if is_key_request: 360 | if self.widevine_key_exchange: 361 | self.cdm.pssh = None 362 | self.cdm.pssh_raw = b'\x0A\x7A\x00\x6C\x38\x2B' 363 | self.cdm.open(offline=True) 364 | # key request 365 | header_data["keyrequestdata"] = [{ 366 | "scheme": ("WIDEVINE" if self.widevine_key_exchange else "ASYMMETRIC_WRAPPED"), 367 | "keydata": ({ 368 | "keyrequest": self.cdm.get_license() 369 | } if self.widevine_key_exchange else { 370 | "keypairid": "superKeyPair", 371 | "mechanism": "JWK_RSA", 372 | "publickey": base64.standard_b64encode( 373 | self.keys.rsa.publickey().exportKey(format="DER") 374 | ).decode("utf-8") 375 | }) 376 | }] 377 | else: 378 | # regular request (identity proof) 379 | header_data["userauthdata"] = { 380 | "scheme": "EMAIL_PASSWORD", 381 | "authdata": { 382 | "email": urllib.parse.unquote(self.session.cookies.get_dict()["auth_data_email"]), 383 | "password": urllib.parse.unquote(self.session.cookies.get_dict()["auth_data_pass"]) 384 | } 385 | } 386 | # Return completed Header 387 | return json.dumps(header_data) 388 | 389 | def set_master_token(self, master_token): 390 | self.master_token = master_token 391 | self.sequence_number = json.JSONDecoder().decode( 392 | base64.standard_b64decode(master_token["tokendata"]).decode("utf-8") 393 | )["sequencenumber"] 394 | 395 | @staticmethod 396 | def get_widevine_key(kid, keys, permissions): 397 | for key in keys: 398 | if key.kid != kid: 399 | continue 400 | if key.type != "OPERATOR_SESSION": 401 | print("wv key exchange: Wrong key type (not operator session) key %s" % key) 402 | continue 403 | if not set(permissions) <= set(key.permissions): 404 | print("wv key exchange: Incorrect permissions, key %s, needed perms %s" % (key, permissions)) 405 | continue 406 | return key.key 407 | return None 408 | 409 | def negotiate_keys(self): 410 | if not self.load_cached_data(): 411 | # Ok no saved MSL BIN, that's OK! 412 | if not self.widevine_key_exchange: 413 | # Let's either use a Cached RSA Key, or create a new random 2048 bit key 414 | if not self.load_cached_rsa_key(): 415 | self.keys.rsa = RSA.generate(2048) # create 416 | with open(self.files.rsa, "wb") as f: 417 | f.write(self.keys.rsa.exportKey()) # cache #.exportKey(format='DER')???? 418 | # We now have the necessary data to perform a key handshake (either via widevine or an rsa key) 419 | # We don't need to do this if we have cached MSL Data as long as it hasn't expired 420 | key_exchange = self.session.post( 421 | url=self.manifest_endpoint, 422 | data=json.dumps({ 423 | "entityauthdata": { 424 | "scheme": "NONE", 425 | "authdata": { 426 | "identity": self.esn 427 | } 428 | }, 429 | "headerdata": base64.standard_b64encode( 430 | self.generate_msl_header( 431 | is_key_request=True, 432 | is_handshake=True, 433 | compression="" 434 | ).encode("utf-8") 435 | ).decode("utf-8"), 436 | "signature": "" 437 | }, sort_keys=True) 438 | ) 439 | if key_exchange.status_code != 200: 440 | raise Exception(f"Key Exchange failed, response data is unexpected: {key_exchange.text}") 441 | key_exchange = key_exchange.json() 442 | if "errordata" in key_exchange: 443 | raise Exception("Key Exchange failed (A): " + base64.standard_b64decode( 444 | key_exchange["errordata"] 445 | ).decode('utf-8')) 446 | # parse the crypto keys 447 | key_response_data = json.JSONDecoder().decode(base64.standard_b64decode( 448 | key_exchange["headerdata"] 449 | ).decode('utf-8'))["keyresponsedata"] 450 | self.set_master_token(key_response_data["mastertoken"]) 451 | if key_response_data["scheme"] != ("WIDEVINE" if self.widevine_key_exchange else "ASYMMETRIC_WRAPPED"): 452 | raise Exception("Key Exchange scheme mismatch occurred") 453 | key_data = key_response_data["keydata"] 454 | if self.widevine_key_exchange: 455 | self.cdm.provide_license(key_data["cdmkeyresponse"]) 456 | keys = self.cdm.get_keys(content_only=False) 457 | self.keys.encryption = self.get_widevine_key( 458 | kid=base64.standard_b64decode(key_data["encryptionkeyid"]), 459 | keys=keys, 460 | permissions=["AllowEncrypt", "AllowDecrypt"] 461 | ) 462 | self.keys.sign = self.get_widevine_key( 463 | kid=base64.standard_b64decode(key_data["hmackeyid"]), 464 | keys=keys, 465 | permissions=["AllowSign", "AllowSignatureVerify"] 466 | ) 467 | else: 468 | cipher_rsa = PKCS1_OAEP.new(self.keys.rsa) 469 | self.keys.encryption = self.base64key_decode( 470 | json.JSONDecoder().decode(cipher_rsa.decrypt( 471 | base64.standard_b64decode(key_data["encryptionkey"]) 472 | ).decode("utf-8"))["k"] 473 | ) 474 | self.keys.sign = self.base64key_decode( 475 | json.JSONDecoder().decode(cipher_rsa.decrypt( 476 | base64.standard_b64decode(key_data["hmackey"]) 477 | ).decode("utf-8"))["k"] 478 | ) 479 | with open(self.files.msl, "wb") as f: 480 | f.write(json.JSONEncoder().encode({ 481 | "encryption_key": base64.standard_b64encode(self.keys.encryption).decode("utf-8"), 482 | "sign_key": base64.standard_b64encode(self.keys.sign).decode("utf-8"), 483 | "tokens": { 484 | "mastertoken": self.master_token, 485 | } 486 | }).encode("utf-8")) 487 | print("E2E-Negotiation Successful") 488 | return True 489 | 490 | def generate_msl_request_data(self, data): 491 | header = self.encrypt(self.generate_msl_header()) 492 | payload_chunk = self.encrypt(json.dumps({ 493 | "messageid": self.message_id, 494 | "data": self.gzip_compress( 495 | '[{},{"headers":{},"path":"/cbp/cadmium-13","payload":{"data":"' + 496 | json.dumps(data).replace('"', '\\"') + 497 | '"},"query":""}]\n' 498 | ).decode('utf-8'), 499 | "compressionalgo": "GZIP", 500 | "sequencenumber": 1, # todo ; use self.sequence_number from master token instead? 501 | "endofmsg": True 502 | })) 503 | # Header and Payload Chunk - E2E Encrypted, with Signatures 504 | return json.dumps({ 505 | "headerdata": base64.standard_b64encode(header.encode("utf-8")).decode("utf-8"), 506 | "signature": self.sign(header).decode("utf-8"), 507 | "mastertoken": self.master_token, 508 | }) + json.dumps({ 509 | "payload": base64.standard_b64encode(payload_chunk.encode("utf-8")).decode("utf-8"), 510 | "signature": self.sign(payload_chunk).decode("utf-8"), 511 | }) 512 | 513 | @staticmethod 514 | def parse_chunked_msl_response(message): 515 | payloads = re.split(',\"signature\":\"[0-9A-Za-z=/+]+\"}', message.split('}}')[1]) 516 | payloads = [x + "}" for x in payloads][:-1] 517 | return { 518 | "header": message.split("}}")[0] + "}}", 519 | "payloads": payloads 520 | } 521 | 522 | def decrypt_payload_chunk(self, payload_chunks): 523 | """ 524 | Decrypt and merge payload chunks into a JSON Object 525 | :param payload_chunks: 526 | :return: json object 527 | """ 528 | merged_payload = "" 529 | for payload in [ 530 | json.JSONDecoder().decode( 531 | base64.standard_b64decode(json.JSONDecoder().decode(x).get('payload')).decode('utf-8') 532 | ) for x in payload_chunks 533 | ]: 534 | # Decrypt the payload 535 | payload_decrypted = AES.new( 536 | self.keys.encryption, 537 | AES.MODE_CBC, 538 | base64.standard_b64decode(payload["iv"]) 539 | ).decrypt(base64.standard_b64decode(payload.get("ciphertext"))) 540 | # un-pad the decrypted payload 541 | payload_decrypted = json.JSONDecoder().decode(Padding.unpad(payload_decrypted, 16).decode("utf-8")) 542 | payload_data = base64.standard_b64decode(payload_decrypted.get("data")) 543 | # uncompress data if compressed 544 | if payload_decrypted.get("compressionalgo") == "GZIP": 545 | payload_data = zlib.decompress(payload_data, 16 + zlib.MAX_WBITS) 546 | # decode decrypted payload chunks' bytes to a utf-8 string, and append it to the merged_payload string 547 | merged_payload += payload_data.decode("utf-8") 548 | return json.JSONDecoder().decode(base64.standard_b64decode( 549 | json.JSONDecoder().decode(merged_payload)[1]["payload"]["data"] 550 | ).decode('utf-8')) 551 | 552 | @staticmethod 553 | def gzip_compress(data): 554 | out = BytesIO() 555 | with gzip.GzipFile(fileobj=out, mode="w") as f: 556 | f.write(data.encode("utf-8")) 557 | return base64.standard_b64encode(out.getvalue()) 558 | 559 | @staticmethod 560 | def base64key_decode(payload): 561 | length = len(payload) % 4 562 | if length == 2: 563 | payload += "==" 564 | elif length == 3: 565 | payload += "=" 566 | elif length != 0: 567 | raise ValueError("Invalid base64 string") 568 | return base64.urlsafe_b64decode(payload.encode("utf-8")) 569 | 570 | def encrypt(self, plaintext): 571 | """ 572 | Encrypt the given Plaintext with the encryption key 573 | :param plaintext: 574 | :return: Serialized JSON String of the encryption Envelope 575 | """ 576 | iv = get_random_bytes(16) 577 | return json.dumps({ 578 | "ciphertext": base64.standard_b64encode( 579 | AES.new( 580 | self.keys.encryption, 581 | AES.MODE_CBC, 582 | iv 583 | ).encrypt( 584 | Padding.pad(plaintext.encode("utf-8"), 16) 585 | ) 586 | ).decode("utf-8"), 587 | "keyid": f"{self.esn}_{self.sequence_number}", 588 | "sha256": "AA==", 589 | "iv": base64.standard_b64encode(iv).decode("utf-8") 590 | }) 591 | 592 | def sign(self, text): 593 | """ 594 | Calculates the HMAC signature for the given text with the current sign key and SHA256 595 | :param text: 596 | :return: Base64 encoded signature 597 | """ 598 | return base64.standard_b64encode(HMAC.new(self.keys.sign, text.encode("utf-8"), SHA256).digest()) 599 | --------------------------------------------------------------------------------