├── .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 |
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 |
--------------------------------------------------------------------------------