New
This commit is contained in:
parent
98dd671f16
commit
4297654052
2
DOWNLOAD.cmd
Normal file
2
DOWNLOAD.cmd
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
python bad37.py --url https://www.paramountplus.com/shows/mayor-of-kingstown/ -s 1 -e 3 --alang es-la en --slang es-la en --flang es-la
|
||||||
|
pause
|
1
KEYS/PARAMOUNTPLUS.txt
Normal file
1
KEYS/PARAMOUNTPLUS.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
##### One KEY per line. #####
|
40
README.md
40
README.md
@ -1,2 +1,38 @@
|
|||||||
# Paramount Plus 4k Downloader
|
|
||||||
Tool To Get Downloads up to 4k from Paramount+
|
<div size='20px'> Paramount 4K Downloader
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div size='20px'> Tool To Get Downloads up to 4k from Paramount+ :smile:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img width="200" src="https://github.com/Kathryn-Jie/Kathryn-Jie/blob/main/kathryn.png">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1> Hello Fellow < Developers/ >! <img src = "https://raw.githubusercontent.com/MartinHeinz/MartinHeinz/master/wave.gif" width = 30px> </h1>
|
||||||
|
<p align='center'>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div size='20px'> Hi! My name is WVDUMP. I am Leaking the scripts to punish few idiots :smile:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2> About Me <img src = "https://media0.giphy.com/media/KDDpcKigbfFpnejZs6/giphy.gif?cid=ecf05e47oy6f4zjs8g1qoiystc56cu7r9tb8a1fe76e05oty&rid=giphy.gif" width = 100px></h2>
|
||||||
|
|
||||||
|
<img width="55%" align="right" alt="Github" src="https://raw.githubusercontent.com/onimur/.github/master/.resources/git-header.svg" />
|
||||||
|
|
||||||
|
- 🔭 I’m currently working on Java scripts
|
||||||
|
|
||||||
|
- 🌱 I’m currently learning Python
|
||||||
|
|
||||||
|
- 👯 Sharing is caring
|
||||||
|
|
||||||
|
|
||||||
|
- ⚡ CDM IS NOT INCLUDED BUY it from wvfuck@cyberfiends.net ⚡
|
||||||
|
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
128
bad37.py
Normal file
128
bad37.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Module: BAD Project
|
||||||
|
# Created on: 01-06-2021
|
||||||
|
# Authors: JUNi
|
||||||
|
# Version: 1.0
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
#Common:
|
||||||
|
parser.add_argument("content", nargs="?", help="Content URL or ID")
|
||||||
|
parser.add_argument("--url", dest="url_season", help="If set, it will download all assets from the season provided.")
|
||||||
|
parser.add_argument("--tqdm", dest="tqmd_mode", help="If set, will download with threading", action="store_true")
|
||||||
|
parser.add_argument("--nv", "--no-video", dest="novideo", help="If set, don't download video", action="store_true")
|
||||||
|
parser.add_argument("--na", "--no-audio", dest="noaudio", help="If set, don't download audio", action="store_true")
|
||||||
|
parser.add_argument("--ns", "--no-subs", dest="nosubs", help="If set, don't download subs", action="store_true")
|
||||||
|
parser.add_argument("--all-season", dest="all_season", help="If set, active download mode.", action="store_true")
|
||||||
|
parser.add_argument("-e", "--episode", dest="episodeStart", help="If set, it will start downloading the season from that episode.")
|
||||||
|
parser.add_argument("-s", dest="season", help="If set, it will download all assets from the season provided.")
|
||||||
|
parser.add_argument("--tag", type=str, required=False, help="Release group tag to use for filenames")
|
||||||
|
parser.add_argument("-q", "--quality", dest="customquality", type=lambda x: [x.rstrip('p')], help="For configure quality of video.", default=[])
|
||||||
|
parser.add_argument("-o", "--output", dest="output", default="downloads", help="If set, it will download all assets to directory provided.")
|
||||||
|
parser.add_argument("--keep", dest="keep", help="If set, it will list all formats available.", action="store_true")
|
||||||
|
parser.add_argument("--info", help="If set, it will print manifest infos and exit.", action="store_true")
|
||||||
|
parser.add_argument("--no-mux", dest="nomux", help="If set, dont mux.", action="store_true")
|
||||||
|
#parser.add_argument("--force-mux", dest="force_mux", nargs=1, help="If set, force mux.", default=[])
|
||||||
|
#parser.add_argument("--langtag", dest="langtag", nargs=1, help="For configure language tag of MKV.", default=[])
|
||||||
|
parser.add_argument("--only-2ch-audio", dest="only_2ch_audio", help="If set, no clean tag subtitles.", action="store_true")
|
||||||
|
parser.add_argument("--alang", "--audio-language", dest="audiolang", nargs="*", help="If set, download only selected audio languages", default=[])
|
||||||
|
parser.add_argument("--slang", "--subtitle-language", dest="sublang", nargs="*", help="If set, download only selected subtitle languages", default=[])
|
||||||
|
parser.add_argument("--flang", "--forced-language", dest="forcedlang", nargs="*", help="If set, download only selected forced subtitle languages", default=[])
|
||||||
|
parser.add_argument("--no-cleansubs", dest="nocleansubs", help="If set, no clean tag subtitles.", action="store_true")
|
||||||
|
parser.add_argument("--hevc", dest="hevc", help="If set, it will return HEVC manifest", action="store_true")
|
||||||
|
parser.add_argument("--uhd", dest="uhd", help="If set, it will return UHD manifest", action="store_true")
|
||||||
|
parser.add_argument("--license", dest="license", help="Only print keys, don't download", action="store_true")
|
||||||
|
parser.add_argument("-licenses-as-json", help="Save the wv keys as json instead", action="store_true")
|
||||||
|
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
||||||
|
parser.add_argument("--aformat-51ch", "--audio-format-51ch", dest="aformat_51ch", help="For configure format of audio.")
|
||||||
|
parser.add_argument("--nc", "--no-chapters", dest="nochpaters", help="If set, don't download chapters", action="store_true")
|
||||||
|
parser.add_argument("-c", "--codec", choices=["widevine", "playready"], help="Video type to download", default="playready")
|
||||||
|
parser.add_argument("--ap", dest="audiocodec", default="atmos", choices=["aac", "ac3", "atmos"], help="audio codec profile")
|
||||||
|
|
||||||
|
#HBOMAX
|
||||||
|
parser.add_argument("--atmos", dest="atmos", help="If set, it will return Atmos MPDs", action="store_true")
|
||||||
|
parser.add_argument("--ad", "--desc-audio", action="store_true", dest="desc_audio", help="Download descriptive audio instead of normal dialogue")
|
||||||
|
parser.add_argument("--hdr", dest="hdr", help="If set, it will return HDR manifest", action="store_true")
|
||||||
|
parser.add_argument("-r", "--region", choices=["la", "us"], help="HBO Max video region", default="us")
|
||||||
|
parser.add_argument("--vp", dest="videocodec", default="h264", choices=["h264", "hevc", "hdr"], help="video codec profile")
|
||||||
|
|
||||||
|
#Clarovideo:
|
||||||
|
parser.add_argument("--m3u8", dest="m3u8mode", help="If set, it will return M3U8 manifest", action="store_true")
|
||||||
|
parser.add_argument("--file", dest="txtpath", help="If set, it will download links of an txt file")
|
||||||
|
|
||||||
|
#DisneyPlus:
|
||||||
|
parser.add_argument("--tlang", "--title-language", dest="titlelang", nargs=1, help="If set, it will change title language", default="es-419")
|
||||||
|
parser.add_argument("--scenario1", dest="scenarioDSNP", help="Video API from DisneyPlus", default="chromecast-drm-cbcs")
|
||||||
|
parser.add_argument("--scenario2", dest="scenarioSTAR", help="Video API from DisneyPlus", default="restricted-drm-ctr-sw")
|
||||||
|
|
||||||
|
#PROXY:
|
||||||
|
parser.add_argument("--proxy", dest="proxy", help="Proxy URL to use for both fetching metadata and downloading")
|
||||||
|
#proxy format: http://email@email:password@host:port
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
if args.debug:
|
||||||
|
import logging
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
currentFile = '__main__'
|
||||||
|
realPath = os.path.realpath(currentFile)
|
||||||
|
dirPath = os.path.dirname(realPath)
|
||||||
|
dirName = os.path.basename(dirPath)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if args.content:
|
||||||
|
args.url_season = args.content
|
||||||
|
|
||||||
|
if not args.url_season:
|
||||||
|
print('Please specify the URL of the content to download.')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if (args.url_season and 'hbomax' in args.url_season):
|
||||||
|
mode = 'hbomax'
|
||||||
|
import hbomax
|
||||||
|
hbomax.main(args)
|
||||||
|
elif (args.url_season and 'clarovideo' in args.url_season):
|
||||||
|
mode = 'clarovideo'
|
||||||
|
import clarovideo
|
||||||
|
clarovideo.main(args)
|
||||||
|
elif (args.url_season and 'blim' in args.url_season):
|
||||||
|
mode = 'blimtv'
|
||||||
|
import blimtv
|
||||||
|
blimtv.main(args)
|
||||||
|
elif (args.url_season and 'nowonline' in args.url_season):
|
||||||
|
mode = 'nowonline'
|
||||||
|
import nowonline
|
||||||
|
nowonline.main(args)
|
||||||
|
elif (args.url_season and 'globo' in args.url_season):
|
||||||
|
mode = 'globoplay'
|
||||||
|
import globoplay
|
||||||
|
globoplay.main(args)
|
||||||
|
elif (args.url_season and 'paramountplus.com' in args.url_season):
|
||||||
|
mode = 'paramountplus'
|
||||||
|
import paramountplus
|
||||||
|
paramountplus.main(args)
|
||||||
|
elif (args.url_season and 'disneyplus' in args.url_season):
|
||||||
|
mode = 'disneyplus'
|
||||||
|
import disneyplus
|
||||||
|
disneyplus.main(args)
|
||||||
|
elif (args.url_season and 'starplus.com' in args.url_season):
|
||||||
|
mode = 'starplus'
|
||||||
|
import starplus
|
||||||
|
starplus.main(args)
|
||||||
|
elif (args.url_season and 'tv.apple.com' in args.url_season):
|
||||||
|
mode = 'appletv'
|
||||||
|
import appletv
|
||||||
|
appletv.main(args)
|
||||||
|
elif (args.url_season and 'telecine' in args.url_season):
|
||||||
|
mode = 'telecine'
|
||||||
|
import telecineplay
|
||||||
|
telecineplay.main(args)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Error! This url or mode is not recognized.")
|
||||||
|
sys.exit(0)
|
0
cookies/cookies_pmnp.txt
Normal file
0
cookies/cookies_pmnp.txt
Normal file
910
paramountplus.py
Normal file
910
paramountplus.py
Normal file
@ -0,0 +1,910 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Module: Paramount Plus Downloader
|
||||||
|
# Created on: 19-02-2021
|
||||||
|
# Authors: JUNi
|
||||||
|
# Version: 2.0
|
||||||
|
|
||||||
|
import urllib.parse
|
||||||
|
import re, base64, requests, sys, os
|
||||||
|
import subprocess, shutil
|
||||||
|
import xmltodict, isodate
|
||||||
|
import json, ffmpy, math
|
||||||
|
import http, html, time, pathlib, glob
|
||||||
|
|
||||||
|
from unidecode import unidecode
|
||||||
|
from http.cookiejar import MozillaCookieJar
|
||||||
|
from titlecase import titlecase
|
||||||
|
from pymediainfo import MediaInfo
|
||||||
|
from m3u8 import parse as m3u8parser
|
||||||
|
|
||||||
|
import pywidevine.clients.paramountplus.config as pmnp_cfg
|
||||||
|
from pywidevine.clients.proxy_config import ProxyConfig
|
||||||
|
from pywidevine.muxer.muxer import Muxer
|
||||||
|
|
||||||
|
from pywidevine.clients.paramountplus.downloader import WvDownloader
|
||||||
|
from pywidevine.clients.paramountplus.config import WvDownloaderConfig
|
||||||
|
|
||||||
|
|
||||||
|
currentFile = 'paramountplus'
|
||||||
|
realPath = os.path.realpath(currentFile)
|
||||||
|
dirPath = os.path.dirname(realPath)
|
||||||
|
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36'
|
||||||
|
SESSION = requests.Session()
|
||||||
|
|
||||||
|
def main(args):
|
||||||
|
global _id
|
||||||
|
|
||||||
|
proxies = {}
|
||||||
|
proxy_meta = args.proxy
|
||||||
|
if proxy_meta == 'none':
|
||||||
|
proxies['meta'] = {'http': None, 'https': None}
|
||||||
|
elif proxy_meta:
|
||||||
|
proxies['meta'] = {'http': proxy_meta, 'https': proxy_meta}
|
||||||
|
SESSION.proxies = proxies.get('meta')
|
||||||
|
proxy_cfg = ProxyConfig(proxies)
|
||||||
|
|
||||||
|
if not os.path.exists(dirPath + '/KEYS'):
|
||||||
|
os.makedirs(dirPath + '/KEYS')
|
||||||
|
else:
|
||||||
|
keys_file = dirPath + '/KEYS/PARAMOUNTPLUS.txt'
|
||||||
|
try:
|
||||||
|
keys_file_pmnp = open(keys_file, 'r')
|
||||||
|
keys_file_txt = keys_file_pmnp.readlines()
|
||||||
|
except Exception:
|
||||||
|
with open(keys_file, 'a', encoding='utf8') as (file):
|
||||||
|
file.write('##### One KEY per line. #####\n')
|
||||||
|
keys_file_pmnp = open(keys_file, 'r', encoding='utf8')
|
||||||
|
keys_file_txt = keys_file_pmnp.readlines()
|
||||||
|
|
||||||
|
def alphanumericSort(l):
|
||||||
|
def convert(text):
|
||||||
|
if text.isdigit():
|
||||||
|
return int(text)
|
||||||
|
else:
|
||||||
|
return text
|
||||||
|
|
||||||
|
def alphanum_key(key):
|
||||||
|
return [convert(c) for c in re.split('([0-9]+)', key)]
|
||||||
|
|
||||||
|
return sorted(l, key=alphanum_key)
|
||||||
|
|
||||||
|
def convert_size(size_bytes):
|
||||||
|
if size_bytes == 0:
|
||||||
|
return '0bps'
|
||||||
|
else:
|
||||||
|
s = round(size_bytes / 1000, 0)
|
||||||
|
return '%ikbps' % s
|
||||||
|
|
||||||
|
def get_size(size):
|
||||||
|
power = 1024
|
||||||
|
n = 0
|
||||||
|
Dic_powerN = {0:'', 1:'K', 2:'M', 3:'G', 4:'T'}
|
||||||
|
while size > power:
|
||||||
|
size /= power
|
||||||
|
n += 1
|
||||||
|
return str(round(size, 2)) + Dic_powerN[n] + 'B'
|
||||||
|
|
||||||
|
def getKeyId(name):
|
||||||
|
mp4dump = subprocess.Popen([pmnp_cfg.MP4DUMP, name], stdout=(subprocess.PIPE))
|
||||||
|
mp4dump = str(mp4dump.stdout.read())
|
||||||
|
A = find_str(mp4dump, 'default_KID')
|
||||||
|
KEY_ID_ORI = ''
|
||||||
|
KEY_ID_ORI = mp4dump[A:A + 63].replace('default_KID = ', '').replace('[', '').replace(']', '').replace(' ', '')
|
||||||
|
if KEY_ID_ORI == '' or KEY_ID_ORI == "'":
|
||||||
|
KEY_ID_ORI = 'nothing'
|
||||||
|
return KEY_ID_ORI
|
||||||
|
|
||||||
|
def find_str(s, char):
|
||||||
|
index = 0
|
||||||
|
if char in s:
|
||||||
|
c = char[0]
|
||||||
|
for ch in s:
|
||||||
|
if ch == c:
|
||||||
|
if s[index:index + len(char)] == char:
|
||||||
|
return index
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def mediainfo_(file):
|
||||||
|
mediainfo_output = subprocess.Popen([pmnp_cfg.MEDIAINFO, '--Output=JSON', '-f', file], stdout=(subprocess.PIPE))
|
||||||
|
mediainfo_json = json.load(mediainfo_output.stdout)
|
||||||
|
return mediainfo_json
|
||||||
|
|
||||||
|
def replace_words(x):
|
||||||
|
x = re.sub(r'[]¡!"#$%\'()*+,:;<=>¿?@\\^_`{|}~[-]', '', x)
|
||||||
|
x = x.replace('/', '-')
|
||||||
|
return unidecode(x)
|
||||||
|
|
||||||
|
def ReplaceSubs1(X):
|
||||||
|
pattern1 = re.compile('(?!<i>|<b>|<u>|<\\/i>|<\\/b>|<\\/u>)(<)(?:[A-Za-z0-9_ -=]*)(>)')
|
||||||
|
pattern2 = re.compile('(?!<\\/i>|<\\/b>|<\\/u>)(<\\/)(?:[A-Za-z0-9_ -=]*)(>)')
|
||||||
|
X = X.replace('‏', '').replace('{\\an1}', '').replace('{\\an2}', '').replace('{\\an3}', '').replace('{\\an4}', '').replace('{\\an5}', '').replace('{\\an6}', '').replace('{\\an7}', '').replace('{\\an8}', '').replace('{\\an9}', '').replace('‎', '')
|
||||||
|
X = pattern1.sub('', X)
|
||||||
|
X = pattern2.sub('', X)
|
||||||
|
return X
|
||||||
|
|
||||||
|
def replace_code_lang(X):
|
||||||
|
X = X.lower()
|
||||||
|
X = X.replace('es-mx', 'es-la').replace('pt-BR', 'pt-br').replace('dolby digital', 'en').replace('dd+', 'en')
|
||||||
|
return X
|
||||||
|
|
||||||
|
def get_cookies(file_path):
|
||||||
|
try:
|
||||||
|
cj = http.cookiejar.MozillaCookieJar(file_path)
|
||||||
|
cj.load()
|
||||||
|
except Exception:
|
||||||
|
print('\nCookies not found! Please dump the cookies with the Chrome extension https://chrome.google.com/webstore/detail/cookiestxt/njabckikapfpffapmjgojcnbfjonfjfg and place the generated file in ' + file_path)
|
||||||
|
print('\nWarning, do not click on "download all cookies", you have to click on "click here".\n')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
cookies = str()
|
||||||
|
for cookie in cj:
|
||||||
|
cookie.value = urllib.parse.unquote(html.unescape(cookie.value))
|
||||||
|
cookies = cookies + cookie.name + '=' + cookie.value + ';'
|
||||||
|
|
||||||
|
cookies = list(cookies)
|
||||||
|
del cookies[-1]
|
||||||
|
cookies = ''.join(cookies)
|
||||||
|
return cookies
|
||||||
|
|
||||||
|
cookies_file = 'cookies_pmnp.txt'
|
||||||
|
cookies = get_cookies(dirPath + '/cookies/' + cookies_file)
|
||||||
|
pmnp_headers = {
|
||||||
|
'Accept':'application/json, text/plain, */*',
|
||||||
|
'Access-Control-Allow-Origin':'*',
|
||||||
|
'cookie':cookies,
|
||||||
|
'User-Agent':USER_AGENT
|
||||||
|
}
|
||||||
|
|
||||||
|
def mpd_parsing(mpd_url):
|
||||||
|
base_url = mpd_url.split('stream.mpd')[0]
|
||||||
|
r = SESSION.get(url=mpd_url)
|
||||||
|
r.raise_for_status()
|
||||||
|
xml = xmltodict.parse(r.text)
|
||||||
|
mpdf = json.loads(json.dumps(xml))
|
||||||
|
length = isodate.parse_duration(mpdf['MPD']['@mediaPresentationDuration']).total_seconds()
|
||||||
|
tracks = mpdf['MPD']['Period']['AdaptationSet']
|
||||||
|
|
||||||
|
def get_pssh(track):
|
||||||
|
pssh = ''
|
||||||
|
for t in track["ContentProtection"]:
|
||||||
|
if t['@schemeIdUri'].lower() == 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed':
|
||||||
|
pssh = t["cenc:pssh"]
|
||||||
|
return pssh
|
||||||
|
|
||||||
|
def force_instance(x):
|
||||||
|
if isinstance(x['Representation'], list):
|
||||||
|
X = x['Representation']
|
||||||
|
else:
|
||||||
|
X = [x['Representation']]
|
||||||
|
return X
|
||||||
|
|
||||||
|
baseUrl = ''
|
||||||
|
video_list = []
|
||||||
|
for video_tracks in tracks:
|
||||||
|
if video_tracks['@contentType'] == 'video':
|
||||||
|
pssh = get_pssh(video_tracks)
|
||||||
|
for x in force_instance(video_tracks):
|
||||||
|
try:
|
||||||
|
codecs = x['@codecs']
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
codecs = video_tracks['@codecs']
|
||||||
|
try:
|
||||||
|
baseUrl = x["BaseURL"]
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
pass
|
||||||
|
video_dict = {
|
||||||
|
'Height':x['@height'],
|
||||||
|
'Width':x['@width'],
|
||||||
|
'Bandwidth':x['@bandwidth'],
|
||||||
|
'ID':x['@id'],
|
||||||
|
'TID':video_tracks['@id'],
|
||||||
|
'URL':baseUrl,
|
||||||
|
'Codec':codecs}
|
||||||
|
video_list.append(video_dict)
|
||||||
|
|
||||||
|
video_list = sorted(video_list, key=(lambda k: int(k['Bandwidth'])))
|
||||||
|
|
||||||
|
while args.customquality != [] and int(video_list[(-1)]['Height']) > int(args.customquality[0]):
|
||||||
|
video_list.pop(-1)
|
||||||
|
|
||||||
|
audio_list = []
|
||||||
|
for audio_tracks in tracks:
|
||||||
|
if audio_tracks['@contentType'] == 'audio':
|
||||||
|
for x in force_instance(audio_tracks):
|
||||||
|
try:
|
||||||
|
codecs = x['@codecs']
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
codecs = audio_tracks['@codecs']
|
||||||
|
try:
|
||||||
|
baseUrl = x["BaseURL"]
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
audio_dict = {
|
||||||
|
'Bandwidth':x['@bandwidth'],
|
||||||
|
'ID':x['@id'],
|
||||||
|
'TID':audio_tracks['@id'],
|
||||||
|
'Language':replace_code_lang(audio_tracks['@lang']),
|
||||||
|
'URL':baseUrl,
|
||||||
|
'Codec':codecs}
|
||||||
|
audio_list.append(audio_dict)
|
||||||
|
|
||||||
|
audio_list = sorted(audio_list, key=(lambda k: (str(k['Language']))), reverse=True)
|
||||||
|
|
||||||
|
if args.only_2ch_audio:
|
||||||
|
c = 0
|
||||||
|
while c != len(audio_list):
|
||||||
|
if '-3' in audio_list[c]['Codec'].split('=')[0]:
|
||||||
|
audio_list.remove(audio_list[c])
|
||||||
|
else:
|
||||||
|
c += 1
|
||||||
|
|
||||||
|
BitrateList = []
|
||||||
|
AudioLanguageList = []
|
||||||
|
for x in audio_list:
|
||||||
|
BitrateList.append(x['Bandwidth'])
|
||||||
|
AudioLanguageList.append(x['Language'])
|
||||||
|
|
||||||
|
BitrateList = alphanumericSort(list(set(BitrateList)))
|
||||||
|
AudioLanguageList = alphanumericSort(list(set(AudioLanguageList)))
|
||||||
|
audioList_new = []
|
||||||
|
audio_Dict_new = {}
|
||||||
|
for y in AudioLanguageList:
|
||||||
|
counter = 0
|
||||||
|
for x in audio_list:
|
||||||
|
if x['Language'] == y and counter == 0:
|
||||||
|
audio_Dict_new = {
|
||||||
|
'Language':x['Language'],
|
||||||
|
'Bandwidth':x['Bandwidth'],
|
||||||
|
'Codec': x['Codec'],
|
||||||
|
'TID':x['TID'],
|
||||||
|
'URL':x['URL'],
|
||||||
|
'ID':x['ID']}
|
||||||
|
audioList_new.append(audio_Dict_new)
|
||||||
|
counter = counter + 1
|
||||||
|
|
||||||
|
audioList = audioList_new
|
||||||
|
audio_list = sorted(audioList, key=(lambda k: (int(k['Bandwidth']), str(k['Language']))))
|
||||||
|
|
||||||
|
audioList_new = []
|
||||||
|
if args.audiolang:
|
||||||
|
for x in audio_list:
|
||||||
|
langAbbrev = x['Language']
|
||||||
|
if langAbbrev in list(args.audiolang):
|
||||||
|
audioList_new.append(x)
|
||||||
|
audio_list = audioList_new
|
||||||
|
|
||||||
|
if 'precon' in mpd_url:
|
||||||
|
sub_url = mpd_url.replace('_cenc_precon_dash/stream.mpd', '_fp_precon_hls/master.m3u8')
|
||||||
|
else:
|
||||||
|
sub_url = mpd_url.replace('_cenc_dash/stream.mpd', '_fp_hls/master.m3u8')
|
||||||
|
print(sub_url)
|
||||||
|
|
||||||
|
return base_url, length, video_list, audio_list, get_subtitles(sub_url), pssh, mpdf
|
||||||
|
|
||||||
|
def download_subs(filename, sub_url):
|
||||||
|
urlm3u8_base_url = re.split('(/)(?i)', sub_url)
|
||||||
|
del urlm3u8_base_url[-1]
|
||||||
|
urlm3u8_base_url = ''.join(urlm3u8_base_url)
|
||||||
|
urlm3u8_request = requests.get(sub_url).text
|
||||||
|
m3u8_json = m3u8parser(urlm3u8_request)
|
||||||
|
|
||||||
|
urls = []
|
||||||
|
for segment in m3u8_json['segments']:
|
||||||
|
if 'https://' not in segment['uri']:
|
||||||
|
segment_url = urlm3u8_base_url + segment['uri']
|
||||||
|
urls.append(segment_url)
|
||||||
|
|
||||||
|
print('\n' + filename)
|
||||||
|
aria2c_infile = ""
|
||||||
|
num_segments = len(urls)
|
||||||
|
digits = math.floor(math.log10(num_segments)) + 1
|
||||||
|
for (i, url) in enumerate(urls):
|
||||||
|
aria2c_infile += f"{url}\n"
|
||||||
|
aria2c_infile += f"\tout={filename}.{i:0{digits}d}.vtt\n"
|
||||||
|
aria2c_infile += f"\tdir={filename}\n"
|
||||||
|
subprocess.run([pmnp_cfg.ARIA2C, "--allow-overwrite=true", "--file-allocation=none",
|
||||||
|
"--console-log-level=warn", "--download-result=hide", "--summary-interval=0",
|
||||||
|
"-x16", "-j16", "-s1", "-i-"],
|
||||||
|
input=aria2c_infile.encode("utf-8"))
|
||||||
|
|
||||||
|
source_files = pathlib.Path(filename).rglob(r'./*.vtt')
|
||||||
|
with open(filename + '.vtt', mode='wb') as (destination):
|
||||||
|
for vtt in source_files:
|
||||||
|
with open(vtt, mode='rb') as (source):
|
||||||
|
shutil.copyfileobj(source, destination)
|
||||||
|
|
||||||
|
if os.path.exists(filename):
|
||||||
|
shutil.rmtree(filename)
|
||||||
|
|
||||||
|
print('\nConverting subtitles...')
|
||||||
|
for f in glob.glob(f'{filename}*.vtt'):
|
||||||
|
with open(f, 'r+', encoding='utf-8-sig') as (x):
|
||||||
|
old = x.read().replace('STYLE\n::cue() {\nfont-family: Arial, Helvetica, sans-serif;\n}', '').replace('WEBVTT', '').replace('X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:9000', '').replace('\n\n\n', '\n')
|
||||||
|
with open(f, 'w+', encoding='utf-8-sig') as (x):
|
||||||
|
x.write(ReplaceSubs1(old))
|
||||||
|
SubtitleEdit_process = subprocess.Popen([pmnp_cfg.SUBTITLE_EDIT, '/convert', filename + ".vtt", "srt", "/MergeSameTexts"], shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE).wait()
|
||||||
|
for f in glob.glob(f'{filename}*.vtt'):
|
||||||
|
os.remove(f)
|
||||||
|
|
||||||
|
'''
|
||||||
|
for f in glob.glob(f'{filename}*.srt'):
|
||||||
|
with open(f, 'r+', encoding='utf-8-sig') as (x):
|
||||||
|
old = x.read().replace('STYLE\n::cue() {\nfont-family: Arial, Helvetica, sans-serif;\n}', '').replace('WEBVTT', '').replace('\nX-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:9000\n', '').replace('\n\n\n', '\n')
|
||||||
|
with open(f, 'w+', encoding='utf-8-sig') as (x):
|
||||||
|
if not args.nocleansubs:
|
||||||
|
x.write(ReplaceSubs1(old))
|
||||||
|
else:
|
||||||
|
x.write(ReplaceSubs2(old))
|
||||||
|
for f in glob.glob(f'{filename}*.vtt'):
|
||||||
|
os.remove(f)
|
||||||
|
'''
|
||||||
|
|
||||||
|
print('Done!')
|
||||||
|
|
||||||
|
def get_episodes(ep_str, num_eps):
|
||||||
|
eps = ep_str.split(',')
|
||||||
|
eps_final = []
|
||||||
|
|
||||||
|
for ep in eps:
|
||||||
|
if '-' in ep:
|
||||||
|
(start, end) = ep.split('-')
|
||||||
|
start = int(start)
|
||||||
|
end = int(end or num_eps)
|
||||||
|
eps_final += list(range(start, end + 1))
|
||||||
|
else:
|
||||||
|
eps_final.append(int(ep))
|
||||||
|
|
||||||
|
return eps_final
|
||||||
|
|
||||||
|
_id = args.url_season.split('/')[-2]
|
||||||
|
if '/video/' in args.url_season:
|
||||||
|
content_regex = r'(\/shows\/)([\w-]+)(\/video\/)([\w-]+)'
|
||||||
|
url_match = re.search(content_regex, args.url_season)
|
||||||
|
_id = url_match[2]
|
||||||
|
|
||||||
|
def get_content_info():
|
||||||
|
if 'shows' in args.url_season:
|
||||||
|
pmnp_season_url = 'https://www.paramountplus.com/shows/{}/xhr/episodes/page/0/size/100/xs/0/season/{}/'.format(_id, '')
|
||||||
|
season_req = requests.get(url=pmnp_season_url, headers=pmnp_headers, proxies=proxy_cfg.get_proxy('meta'))
|
||||||
|
|
||||||
|
if not args.season:
|
||||||
|
args.season = 'all'
|
||||||
|
|
||||||
|
seasons = []
|
||||||
|
if args.season:
|
||||||
|
if args.season == 'all':
|
||||||
|
seasons = 'all'
|
||||||
|
elif ',' in args.season:
|
||||||
|
seasons = [int(x) for x in args.season.split(',')]
|
||||||
|
elif '-' in args.season:
|
||||||
|
(start, end) = args.season.split('-')
|
||||||
|
seasons = list(range(int(start), int(end) + 1))
|
||||||
|
else:
|
||||||
|
seasons = [int(args.season)]
|
||||||
|
|
||||||
|
if seasons == 'all':
|
||||||
|
seasons_list = [x['season_number'] for x in season_req.json()['result']['data']]
|
||||||
|
seasons = sorted(set(seasons_list))
|
||||||
|
|
||||||
|
for season_num in seasons:
|
||||||
|
pmnp_season_url = 'https://www.paramountplus.com/shows/{}/xhr/episodes/page/0/size/500/xs/0/season/{}/'.format(_id, season_num)
|
||||||
|
season_req = requests.get(url=pmnp_season_url, headers=pmnp_headers, proxies=proxy_cfg.get_proxy('meta'))
|
||||||
|
if season_req.json()['result']['total'] < 1:
|
||||||
|
print('This season doesnt exist!')
|
||||||
|
exit()
|
||||||
|
|
||||||
|
for num, ep in enumerate(season_req.json()['result']['data'], start=1):
|
||||||
|
episodeNumber = ep['episode_number']
|
||||||
|
seasonNumber = ep['season_number']
|
||||||
|
|
||||||
|
if ' - ' in ep['series_title']:
|
||||||
|
seriesTitle = ep['series_title'].split(' - ')[0]
|
||||||
|
else:
|
||||||
|
seriesTitle = ep['series_title']
|
||||||
|
episodeTitle = replace_words(ep['label'])
|
||||||
|
seriesName = f'{replace_words(seriesTitle)} S{seasonNumber:0>2}E{episodeNumber:0>2}'
|
||||||
|
folderName = f'{replace_words(seriesTitle)} S{seasonNumber:0>2}'
|
||||||
|
raw_url = urllib.parse.urljoin('https://www.paramountplus.com', ep['metaData']['contentUrl'])
|
||||||
|
|
||||||
|
episodes_list_new = []
|
||||||
|
episodes_dict = {
|
||||||
|
'id': ep['content_id'],
|
||||||
|
'raw_url': raw_url,
|
||||||
|
'pid':ep['metaData']['pid'],
|
||||||
|
'seriesName':seriesName,
|
||||||
|
'folderName':folderName,
|
||||||
|
'episodeNumber': num,
|
||||||
|
'seasonNumber':seasonNumber,
|
||||||
|
'pmnpType': 'show'}
|
||||||
|
episodes_list_new.append(episodes_dict)
|
||||||
|
episodes_list = []
|
||||||
|
for x in episodes_list_new:
|
||||||
|
episodes_list.append(x)
|
||||||
|
#episodes_list = sorted(episodes_list, key=lambda x: x['episodeNumber'])
|
||||||
|
|
||||||
|
if args.episodeStart:
|
||||||
|
eps = get_episodes(args.episodeStart, len(episodes_list))
|
||||||
|
episodes_list = [x for x in episodes_list if x['episodeNumber'] in eps]
|
||||||
|
|
||||||
|
if 'video' in args.url_season:
|
||||||
|
episodes_list = [x for x in episodes_list if x['id'] in url_match.group(4)]
|
||||||
|
|
||||||
|
for content_json in episodes_list:
|
||||||
|
start_process(content_json)
|
||||||
|
|
||||||
|
if 'movies' in args.url_season:
|
||||||
|
while 1:
|
||||||
|
resp = requests.get(url=args.url_season + '/', headers=pmnp_headers, proxies=proxy_cfg.get_proxy('meta'))
|
||||||
|
if resp.ok:
|
||||||
|
break
|
||||||
|
|
||||||
|
html_data = resp
|
||||||
|
html_data = html_data.text.replace('\r\n', '').replace('\n', '').replace('\r', '').replace('\t', '').replace(' ', '')
|
||||||
|
html_data_list = re.split('(</div>)(?i)', html_data)
|
||||||
|
json_web = []
|
||||||
|
for div in html_data_list:
|
||||||
|
if 'player.paramsVO.adCallParams' in div:
|
||||||
|
print()
|
||||||
|
rg = re.compile('(player.metaData = )(.*)(;player.tms_program_id)')
|
||||||
|
m = rg.search(div)
|
||||||
|
if m:
|
||||||
|
json_web = m.group(2)
|
||||||
|
json_web = json.loads(json_web)
|
||||||
|
|
||||||
|
content_dict = {}
|
||||||
|
episodes_list = []
|
||||||
|
year_regex = r'(\d{4})'
|
||||||
|
movieTitle = replace_words(json_web['seriesTitle'])
|
||||||
|
try:
|
||||||
|
r = re.search(year_regex, json_web['airdate'])
|
||||||
|
except KeyError:
|
||||||
|
r = re.search(year_regex, json_web['airdate_tv'])
|
||||||
|
seriesName = f'{movieTitle} ({r.group(0)})'
|
||||||
|
|
||||||
|
content_dict = {
|
||||||
|
'id':json_web['contentId'],
|
||||||
|
'raw_url': str(args.url_season),
|
||||||
|
'pid': json_web['pid'],
|
||||||
|
'seriesName':seriesName,
|
||||||
|
'folderName':None,
|
||||||
|
'episodeNumber':1,
|
||||||
|
'seasonNumber':1,
|
||||||
|
'pmnpType': 'movie'}
|
||||||
|
episodes_list.append(content_dict)
|
||||||
|
|
||||||
|
for content_json in episodes_list:
|
||||||
|
start_process(content_json)
|
||||||
|
|
||||||
|
def get_license(id_json):
|
||||||
|
while 1:
|
||||||
|
resp = requests.get(url=id_json['raw_url'], headers=pmnp_headers, proxies=proxy_cfg.get_proxy('meta'))
|
||||||
|
if resp.ok:
|
||||||
|
break
|
||||||
|
|
||||||
|
html_data = resp
|
||||||
|
html_data = html_data.text.replace('\r\n', '').replace('\n', '').replace('\r', '').replace('\t', '').replace(' ', '')
|
||||||
|
html_data_list = re.split('(</div>)(?i)', html_data)
|
||||||
|
json_web = []
|
||||||
|
for div in html_data_list:
|
||||||
|
if '(!window.CBS.Registry.drmPromise) {' in div:
|
||||||
|
rg = re.compile('(player.drm = )(.*)(;}player.enableCP)')
|
||||||
|
m = rg.search(div)
|
||||||
|
if m:
|
||||||
|
json_web = m.group(2)
|
||||||
|
json_web = json.loads(json_web)
|
||||||
|
|
||||||
|
lic_url = json_web['widevine']['url']
|
||||||
|
header_auth = json_web['widevine']['header']['Authorization']
|
||||||
|
if not lic_url:
|
||||||
|
print('Too many requests...')
|
||||||
|
return lic_url, header_auth
|
||||||
|
|
||||||
|
global folderdownloader
|
||||||
|
if args.output:
|
||||||
|
if not os.path.exists(args.output):
|
||||||
|
os.makedirs(args.output)
|
||||||
|
os.chdir(args.output)
|
||||||
|
if ":" in str(args.output):
|
||||||
|
folderdownloader = str(args.output).replace('/','\\').replace('.\\','\\')
|
||||||
|
else:
|
||||||
|
folderdownloader = dirPath + '\\' + str(args.output).replace('/','\\').replace('.\\','\\')
|
||||||
|
else:
|
||||||
|
folderdownloader = dirPath.replace('/','\\').replace('.\\','\\')
|
||||||
|
|
||||||
|
def get_subtitles(url):
|
||||||
|
master_base_url = re.split('(/)(?i)', url)
|
||||||
|
del master_base_url[-1]
|
||||||
|
master_base_url = ''.join(master_base_url)
|
||||||
|
urlm3u8_request = requests.get(url).text
|
||||||
|
m3u8_json = m3u8parser(urlm3u8_request)
|
||||||
|
|
||||||
|
subs_list = []
|
||||||
|
for media in m3u8_json['media']:
|
||||||
|
if media['type'] == 'SUBTITLES':
|
||||||
|
if 'https://' not in media['uri']:
|
||||||
|
full_url = master_base_url + media['uri']
|
||||||
|
Full_URL_Type = False
|
||||||
|
else:
|
||||||
|
full_url = media['uri']
|
||||||
|
Full_URL_Type = True
|
||||||
|
subs_dict = {
|
||||||
|
'Type':'subtitles',
|
||||||
|
'trackType':'NORMAL',
|
||||||
|
'Language':media['name'],
|
||||||
|
'LanguageID':replace_code_lang(media['language']),
|
||||||
|
'Profile':media['group_id'],
|
||||||
|
'URL':full_url}
|
||||||
|
subs_list.append(subs_dict)
|
||||||
|
|
||||||
|
subsList_new = []
|
||||||
|
if args.sublang:
|
||||||
|
for x in subs_list:
|
||||||
|
sub_lang = x['LanguageID']
|
||||||
|
if sub_lang in list(args.sublang):
|
||||||
|
subsList_new.append(x)
|
||||||
|
|
||||||
|
subs_list = subsList_new
|
||||||
|
|
||||||
|
return subs_list
|
||||||
|
|
||||||
|
def get_manifest(id_json):
|
||||||
|
api_manifest = 'https://link.theplatform.com/s/dJ5BDC/{}?format=SMIL&manifest=m3u&Tracking=true&mbr=true'.format(id_json['pid'])
|
||||||
|
r = requests.get(url=api_manifest, headers=pmnp_headers, proxies=proxy_cfg.get_proxy('meta'))
|
||||||
|
smil = json.loads(json.dumps(xmltodict.parse(r.text)))
|
||||||
|
videoSrc = []
|
||||||
|
try:
|
||||||
|
for x in smil['smil']['body']['seq']['switch']:
|
||||||
|
videoSrc = x['video']['@src']
|
||||||
|
except Exception:
|
||||||
|
videoSrc = smil['smil']['body']['seq']['switch']['video']['@src']
|
||||||
|
lic_url, header_auth = get_license(id_json)
|
||||||
|
return {'mpd_url': videoSrc, 'wvLicense': lic_url, 'wvHeader': header_auth}
|
||||||
|
|
||||||
|
def start_process(content_info):
|
||||||
|
drm_info = get_manifest(content_info)
|
||||||
|
base_url, length, video_list, audio_list, subs_list, pssh, xml = mpd_parsing(drm_info['mpd_url'])
|
||||||
|
video_bandwidth = dict(video_list[(-1)])['Bandwidth']
|
||||||
|
video_height = str(dict(video_list[(-1)])['Height'])
|
||||||
|
video_width = str(dict(video_list[(-1)])['Width'])
|
||||||
|
video_codec = str(dict(video_list[(-1)])['Codec'])
|
||||||
|
video_format_id = str(dict(video_list[(-1)])['ID'])
|
||||||
|
video_track_id = str(dict(video_list[(-1)])['TID'])
|
||||||
|
if not args.license:
|
||||||
|
if not args.novideo:
|
||||||
|
print('\nVIDEO - Bitrate: ' + convert_size(int(video_bandwidth)) + ' - Profile: ' + video_codec.split('=')[0] + ' - Size: ' + get_size(length * float(video_bandwidth) * 0.125) + ' - Dimensions: ' + video_width + 'x' + video_height)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if not args.noaudio:
|
||||||
|
if audio_list != []:
|
||||||
|
for x in audio_list:
|
||||||
|
audio_bandwidth = x['Bandwidth']
|
||||||
|
audio_representation_id = str(x['Codec'])
|
||||||
|
audio_lang = x['Language']
|
||||||
|
print('AUDIO - Bitrate: ' + convert_size(int(audio_bandwidth)) + ' - Profile: ' + audio_representation_id.split('=')[0] + ' - Size: ' + get_size(length * float(audio_bandwidth) * 0.125) + ' - Language: ' + audio_lang)
|
||||||
|
print()
|
||||||
|
|
||||||
|
if not args.nosubs:
|
||||||
|
if subs_list != []:
|
||||||
|
for z in subs_list:
|
||||||
|
sub_lang = z['LanguageID']
|
||||||
|
print('SUBTITLE - Profile: NORMAL - Language: ' + sub_lang)
|
||||||
|
print()
|
||||||
|
|
||||||
|
print('Name: ' + content_info['seriesName'])
|
||||||
|
|
||||||
|
if content_info['pmnpType'] == 'show':
|
||||||
|
CurrentName = content_info['seriesName']
|
||||||
|
CurrentHeigh = str(video_height)
|
||||||
|
VideoOutputName = folderdownloader + '\\' + str(content_info['folderName']) + '\\' + str(CurrentName) + ' [' + str(CurrentHeigh) + 'p].mkv'
|
||||||
|
else:
|
||||||
|
CurrentName = content_info['seriesName']
|
||||||
|
CurrentHeigh = str(video_height)
|
||||||
|
VideoOutputName = folderdownloader + '\\' + str(CurrentName) + '\\' + ' [' + str(CurrentHeigh) + 'p].mkv'
|
||||||
|
|
||||||
|
if args.license:
|
||||||
|
keys_all = get_keys(drm_info, pssh)
|
||||||
|
with open(keys_file, 'a', encoding='utf8') as (file):
|
||||||
|
file.write(CurrentName + '\n')
|
||||||
|
print('\n' + CurrentName)
|
||||||
|
for key in keys_all:
|
||||||
|
with open(keys_file, 'a', encoding='utf8') as (file):
|
||||||
|
file.write(key + '\n')
|
||||||
|
print(key)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
if not args.novideo or (not args.noaudio):
|
||||||
|
print("\nGetting KEYS...")
|
||||||
|
try:
|
||||||
|
keys_all = get_keys(drm_info, pssh)
|
||||||
|
except KeyError:
|
||||||
|
print('License request failed, using keys from txt')
|
||||||
|
keys_all = keys_file_txt
|
||||||
|
print("Done!")
|
||||||
|
|
||||||
|
if not os.path.isfile(VideoOutputName):
|
||||||
|
aria2c_input = ''
|
||||||
|
if not args.novideo:
|
||||||
|
inputVideo = CurrentName + ' [' + str(CurrentHeigh) + 'p].mp4'
|
||||||
|
if os.path.isfile(inputVideo):
|
||||||
|
print('\n' + inputVideo + '\nFile has already been successfully downloaded previously.\n')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
wvdl_cfg = WvDownloaderConfig(xml, base_url, inputVideo, video_track_id, video_format_id)
|
||||||
|
wvdownloader = WvDownloader(wvdl_cfg)
|
||||||
|
wvdownloader.run()
|
||||||
|
except KeyError:
|
||||||
|
url = urllib.parse.urljoin(base_url, video_list[(-1)]['URL'])
|
||||||
|
aria2c_input += f'{url}\n'
|
||||||
|
aria2c_input += f'\tdir={folderdownloader}\n'
|
||||||
|
aria2c_input += f'\tout={inputVideo}\n'
|
||||||
|
|
||||||
|
if not args.noaudio:
|
||||||
|
for x in audio_list:
|
||||||
|
langAbbrev = x['Language']
|
||||||
|
format_id = x['ID']
|
||||||
|
inputAudio = CurrentName + ' ' + '(' + langAbbrev + ').mp4'
|
||||||
|
inputAudio_demuxed = CurrentName + ' ' + '(' + langAbbrev + ')' + '.m4a'
|
||||||
|
if os.path.isfile(inputAudio) or os.path.isfile(inputAudio_demuxed):
|
||||||
|
print('\n' + inputAudio + '\nFile has already been successfully downloaded previously.\n')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
wvdl_cfg = WvDownloaderConfig(xml, base_url, inputAudio, x['TID'], x['ID'])
|
||||||
|
wvdownloader = WvDownloader(wvdl_cfg)
|
||||||
|
wvdownloader.run()
|
||||||
|
except KeyError:
|
||||||
|
url = urllib.parse.urljoin(base_url, x['URL'])
|
||||||
|
aria2c_input += f'{url}\n'
|
||||||
|
aria2c_input += f'\tdir={folderdownloader}\n'
|
||||||
|
aria2c_input += f'\tout={inputAudio}\n'
|
||||||
|
|
||||||
|
aria2c_infile = os.path.join(folderdownloader, 'aria2c_infile.txt')
|
||||||
|
with open(aria2c_infile, 'w') as fd:
|
||||||
|
fd.write(aria2c_input)
|
||||||
|
aria2c_opts = [
|
||||||
|
pmnp_cfg.ARIA2C,
|
||||||
|
'--allow-overwrite=true',
|
||||||
|
'--download-result=hide',
|
||||||
|
'--console-log-level=warn',
|
||||||
|
'-x16', '-s16', '-j16',
|
||||||
|
'-i', aria2c_infile]
|
||||||
|
subprocess.run(aria2c_opts, check=True)
|
||||||
|
|
||||||
|
if not args.nosubs:
|
||||||
|
if subs_list != []:
|
||||||
|
for z in subs_list:
|
||||||
|
langAbbrev = z['LanguageID']
|
||||||
|
inputSubtitle = CurrentName + ' ' + '(' + langAbbrev + ')'
|
||||||
|
if os.path.isfile(inputSubtitle + '.vtt') or os.path.isfile(inputSubtitle + '.srt'):
|
||||||
|
print('\n' + inputSubtitle + '\nFile has already been successfully downloaded previously.\n')
|
||||||
|
else:
|
||||||
|
download_subs(inputSubtitle, z['URL'])
|
||||||
|
|
||||||
|
CorrectDecryptVideo = False
|
||||||
|
if not args.novideo:
|
||||||
|
inputVideo = CurrentName + ' [' + str(CurrentHeigh) + 'p].mp4'
|
||||||
|
if os.path.isfile(inputVideo):
|
||||||
|
CorrectDecryptVideo = DecryptVideo(inputVideo=inputVideo, keys_video=keys_all)
|
||||||
|
else:
|
||||||
|
CorrectDecryptVideo = True
|
||||||
|
|
||||||
|
CorrectDecryptAudio = False
|
||||||
|
if not args.noaudio:
|
||||||
|
for x in audio_list:
|
||||||
|
langAbbrev = x['Language']
|
||||||
|
inputAudio = CurrentName + ' ' + '(' + langAbbrev + ')' + '.mp4'
|
||||||
|
if os.path.isfile(inputAudio):
|
||||||
|
CorrectDecryptAudio = DecryptAudio(inputAudio=inputAudio, keys_audio=keys_all)
|
||||||
|
else:
|
||||||
|
CorrectDecryptAudio = True
|
||||||
|
|
||||||
|
if not args.nomux:
|
||||||
|
if not args.novideo:
|
||||||
|
if not args.noaudio:
|
||||||
|
if CorrectDecryptVideo == True:
|
||||||
|
if CorrectDecryptAudio == True:
|
||||||
|
print('\nMuxing...')
|
||||||
|
|
||||||
|
pmnpType = content_info['pmnpType']
|
||||||
|
folderName = content_info['folderName']
|
||||||
|
|
||||||
|
if pmnpType=="show":
|
||||||
|
MKV_Muxer=Muxer(CurrentName=CurrentName,
|
||||||
|
SeasonFolder=folderName,
|
||||||
|
CurrentHeigh=CurrentHeigh,
|
||||||
|
Type=pmnpType,
|
||||||
|
mkvmergeexe=pmnp_cfg.MKVMERGE)
|
||||||
|
|
||||||
|
else:
|
||||||
|
MKV_Muxer=Muxer(CurrentName=CurrentName,
|
||||||
|
SeasonFolder=None,
|
||||||
|
CurrentHeigh=CurrentHeigh,
|
||||||
|
Type=pmnpType,
|
||||||
|
mkvmergeexe=pmnp_cfg.MKVMERGE)
|
||||||
|
|
||||||
|
MKV_Muxer.mkvmerge_muxer(lang="English")
|
||||||
|
|
||||||
|
if args.tag:
|
||||||
|
inputName = CurrentName + ' [' + CurrentHeigh + 'p].mkv'
|
||||||
|
release_group(base_filename=inputName,
|
||||||
|
default_filename=CurrentName,
|
||||||
|
folder_name=folderName,
|
||||||
|
type=pmnpType,
|
||||||
|
video_height=CurrentHeigh)
|
||||||
|
|
||||||
|
if not args.keep:
|
||||||
|
for f in os.listdir():
|
||||||
|
if re.fullmatch(re.escape(CurrentName) + r'.*\.(mp4|m4a|h264|h265|eac3|ac3|srt|txt|avs|lwi|mpd)', f):
|
||||||
|
os.remove(f)
|
||||||
|
print("Done!")
|
||||||
|
else:
|
||||||
|
print("\nFile '" + str(VideoOutputName) + "' already exists.")
|
||||||
|
|
||||||
|
def release_group(base_filename, default_filename, folder_name, type, video_height):
|
||||||
|
if type=='show':
|
||||||
|
video_mkv = os.path.join(folder_name, base_filename)
|
||||||
|
else:
|
||||||
|
video_mkv = base_filename
|
||||||
|
|
||||||
|
mediainfo = mediainfo_(video_mkv)
|
||||||
|
for v in mediainfo['media']['track']: # mediainfo do video
|
||||||
|
if v['@type'] == 'Video':
|
||||||
|
video_format = v['Format']
|
||||||
|
|
||||||
|
video_codec = ''
|
||||||
|
if video_format == "AVC":
|
||||||
|
video_codec = 'H.264'
|
||||||
|
elif video_format == "HEVC":
|
||||||
|
video_codec = 'H.265'
|
||||||
|
|
||||||
|
for m in mediainfo['media']['track']: # mediainfo do audio
|
||||||
|
if m['@type'] == 'Audio':
|
||||||
|
codec_name = m['Format']
|
||||||
|
channels_number = m['Channels']
|
||||||
|
|
||||||
|
audio_codec = ''
|
||||||
|
audio_channels = ''
|
||||||
|
if codec_name == "AAC":
|
||||||
|
audio_codec = 'AAC'
|
||||||
|
elif codec_name == "AC-3":
|
||||||
|
audio_codec = "DD"
|
||||||
|
elif codec_name == "E-AC-3":
|
||||||
|
audio_codec = "DDP"
|
||||||
|
elif codec_name == "E-AC-3 JOC":
|
||||||
|
audio_codec = "ATMOS"
|
||||||
|
|
||||||
|
if channels_number == "2":
|
||||||
|
audio_channels = "2.0"
|
||||||
|
elif channels_number == "6":
|
||||||
|
audio_channels = "5.1"
|
||||||
|
|
||||||
|
audio_ = audio_codec + audio_channels
|
||||||
|
|
||||||
|
# renomear arquivo
|
||||||
|
default_filename = default_filename.replace('&', '.and.')
|
||||||
|
default_filename = re.sub(r'[]!"#$%\'()*+,:;<=>?@\\^_`{|}~[-]', '', default_filename)
|
||||||
|
default_filename = default_filename.replace(' ', '.')
|
||||||
|
default_filename = re.sub(r'\.{2,}', '.', default_filename)
|
||||||
|
default_filename = unidecode(default_filename)
|
||||||
|
|
||||||
|
output_name = '{}.{}p.PMNP.WEB-DL.{}.{}-{}'.format(default_filename, video_height, audio_, video_codec, args.tag)
|
||||||
|
if type=='show':
|
||||||
|
outputName = os.path.join(folder_name, output_name + '.mkv')
|
||||||
|
else:
|
||||||
|
outputName = output_name + '.mkv'
|
||||||
|
|
||||||
|
os.rename(video_mkv, outputName)
|
||||||
|
print("{} -> {}".format(base_filename, output_name))
|
||||||
|
|
||||||
|
from pywidevine.decrypt.wvdecryptcustom import WvDecrypt
|
||||||
|
from pywidevine.cdm import cdm, deviceconfig
|
||||||
|
|
||||||
|
def get_keys(licInfo, pssh):
|
||||||
|
device = deviceconfig.device_asus_x00dd
|
||||||
|
wvdecrypt = WvDecrypt(init_data_b64=bytes(pssh.encode()), cert_data_b64=None, device=device)
|
||||||
|
license_req = SESSION.post(url=licInfo['wvLicense'], headers={'authorization':licInfo['wvHeader']}, data=wvdecrypt.get_challenge(), proxies=proxy_cfg.get_proxy('meta')).content
|
||||||
|
license_b64 = base64.b64encode(license_req)
|
||||||
|
|
||||||
|
wvdecrypt.update_license(license_b64)
|
||||||
|
status, keys = wvdecrypt.start_process()
|
||||||
|
return keys
|
||||||
|
|
||||||
|
def DecryptAudio(inputAudio, keys_audio):
|
||||||
|
key_audio_id_original = getKeyId(inputAudio)
|
||||||
|
outputAudioTemp = inputAudio.replace(".mp4", "_dec.mp4")
|
||||||
|
if key_audio_id_original != "nothing":
|
||||||
|
for key in keys_audio:
|
||||||
|
key_id=key[0:32]
|
||||||
|
if key_id == key_audio_id_original:
|
||||||
|
print("\nDecrypting audio...")
|
||||||
|
print ("Using KEY: " + key)
|
||||||
|
wvdecrypt_process = subprocess.Popen([pmnp_cfg.MP4DECRYPT, "--show-progress", "--key", key, inputAudio, outputAudioTemp])
|
||||||
|
stdoutdata, stderrdata = wvdecrypt_process.communicate()
|
||||||
|
wvdecrypt_process.wait()
|
||||||
|
time.sleep (50.0/1000.0)
|
||||||
|
os.remove(inputAudio)
|
||||||
|
print("\nDemuxing audio...")
|
||||||
|
mediainfo = MediaInfo.parse(outputAudioTemp)
|
||||||
|
audio_info = next(x for x in mediainfo.tracks if x.track_type == "Audio")
|
||||||
|
codec_name = audio_info.format
|
||||||
|
|
||||||
|
ext = ''
|
||||||
|
if codec_name == "AAC":
|
||||||
|
ext = '.m4a'
|
||||||
|
elif codec_name == "E-AC-3":
|
||||||
|
ext = ".eac3"
|
||||||
|
elif codec_name == "AC-3":
|
||||||
|
ext = ".ac3"
|
||||||
|
outputAudio = outputAudioTemp.replace("_dec.mp4", ext)
|
||||||
|
print("{} -> {}".format(outputAudioTemp, outputAudio))
|
||||||
|
ff = ffmpy.FFmpeg(executable=pmnp_cfg.FFMPEG, inputs={outputAudioTemp: None}, outputs={outputAudio: '-c copy'}, global_options="-y -hide_banner -loglevel warning")
|
||||||
|
ff.run()
|
||||||
|
time.sleep (50.0/1000.0)
|
||||||
|
os.remove(outputAudioTemp)
|
||||||
|
print("Done!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif key_audio_id_original == "nothing":
|
||||||
|
return True
|
||||||
|
|
||||||
|
def DecryptVideo(inputVideo, keys_video):
|
||||||
|
key_video_id_original = getKeyId(inputVideo)
|
||||||
|
inputVideo = inputVideo
|
||||||
|
outputVideoTemp = inputVideo.replace('.mp4', '_dec.mp4')
|
||||||
|
outputVideo = inputVideo
|
||||||
|
if key_video_id_original != 'nothing':
|
||||||
|
for key in keys_video:
|
||||||
|
key_id = key[0:32]
|
||||||
|
if key_id == key_video_id_original:
|
||||||
|
print('\nDecrypting video...')
|
||||||
|
print('Using KEY: ' + key)
|
||||||
|
wvdecrypt_process = subprocess.Popen([pmnp_cfg.MP4DECRYPT, '--show-progress', '--key', key, inputVideo, outputVideoTemp])
|
||||||
|
stdoutdata, stderrdata = wvdecrypt_process.communicate()
|
||||||
|
wvdecrypt_process.wait()
|
||||||
|
print('\nRemuxing video...')
|
||||||
|
ff = ffmpy.FFmpeg(executable=pmnp_cfg.FFMPEG, inputs={outputVideoTemp: None}, outputs={outputVideo: '-c copy'}, global_options='-y -hide_banner -loglevel warning')
|
||||||
|
ff.run()
|
||||||
|
time.sleep(0.05)
|
||||||
|
os.remove(outputVideoTemp)
|
||||||
|
print('Done!')
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif key_video_id_original == 'nothing':
|
||||||
|
return True
|
||||||
|
|
||||||
|
def DemuxAudio(inputAudio):
|
||||||
|
if os.path.isfile(inputAudio):
|
||||||
|
print('\nDemuxing audio...')
|
||||||
|
mediainfo = mediainfo_(inputAudio)
|
||||||
|
for m in mediainfo['media']['track']:
|
||||||
|
if m['@type'] == 'Audio':
|
||||||
|
codec_name = m['Format']
|
||||||
|
try:
|
||||||
|
codec_tag_string = m['Format_Commercial_IfAny']
|
||||||
|
except Exception:
|
||||||
|
codec_tag_string = ''
|
||||||
|
|
||||||
|
ext = ''
|
||||||
|
if codec_name == 'AAC':
|
||||||
|
ext = '.m4a'
|
||||||
|
else:
|
||||||
|
if codec_name == 'E-AC-3':
|
||||||
|
ext = '.eac3'
|
||||||
|
else:
|
||||||
|
if codec_name == 'AC-3':
|
||||||
|
ext = '.ac3'
|
||||||
|
outputAudio = inputAudio.replace('.mp4', ext)
|
||||||
|
print('{} -> {}'.format(inputAudio, outputAudio))
|
||||||
|
ff = ffmpy.FFmpeg(executable=pmnp_cfg.FFMPEG,
|
||||||
|
inputs={inputAudio: None},
|
||||||
|
outputs={outputAudio: '-c copy'},
|
||||||
|
global_options='-y -hide_banner -loglevel warning')
|
||||||
|
ff.run()
|
||||||
|
time.sleep(0.05)
|
||||||
|
os.remove(inputAudio)
|
||||||
|
print('Done!')
|
||||||
|
|
||||||
|
get_content_info()
|
0
pywidevine/cdm/__init__.py
Normal file
0
pywidevine/cdm/__init__.py
Normal file
380
pywidevine/cdm/cdm.py
Normal file
380
pywidevine/cdm/cdm.py
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import binascii
|
||||||
|
|
||||||
|
from google.protobuf.message import DecodeError
|
||||||
|
from google.protobuf import text_format
|
||||||
|
|
||||||
|
from pywidevine.cdm.formats import wv_proto2_pb2 as wv_proto2
|
||||||
|
from pywidevine.cdm.session import Session
|
||||||
|
from pywidevine.cdm.key import Key
|
||||||
|
from Crypto.Random import get_random_bytes
|
||||||
|
from Crypto.Random import random
|
||||||
|
from Crypto import Random
|
||||||
|
from Crypto.Cipher import PKCS1_OAEP, AES
|
||||||
|
from Crypto.Hash import CMAC, SHA256, HMAC, SHA1
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
from Crypto.Signature import pss
|
||||||
|
from Crypto.Util import Padding
|
||||||
|
from pywidevine.cdm import cdmapi
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
class Cdm:
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.sessions = {}
|
||||||
|
|
||||||
|
def open_session(self, init_data_b64, device, raw_init_data=None, offline=False):
|
||||||
|
self.logger.debug("open_session(init_data_b64={}, device={}".format(init_data_b64, device))
|
||||||
|
self.logger.info("opening new cdm session")
|
||||||
|
if device.session_id_type == 'android':
|
||||||
|
# format: 16 random hexdigits, 2 digit counter, 14 0s
|
||||||
|
rand_ascii = ''.join(random.choice('ABCDEF0123456789') for _ in range(16))
|
||||||
|
counter = '01' # this resets regularly so its fine to use 01
|
||||||
|
rest = '00000000000000'
|
||||||
|
session_id = rand_ascii + counter + rest
|
||||||
|
session_id = session_id.encode('ascii')
|
||||||
|
elif device.session_id_type == 'chrome':
|
||||||
|
rand_bytes = get_random_bytes(16)
|
||||||
|
session_id = rand_bytes
|
||||||
|
else:
|
||||||
|
# other formats NYI
|
||||||
|
self.logger.error("device type is unusable")
|
||||||
|
return 1
|
||||||
|
if raw_init_data and isinstance(raw_init_data, (bytes, bytearray)):
|
||||||
|
# used for NF key exchange, where they don't provide a valid PSSH
|
||||||
|
init_data = raw_init_data
|
||||||
|
self.raw_pssh = True
|
||||||
|
else:
|
||||||
|
init_data = self._parse_init_data(init_data_b64)
|
||||||
|
self.raw_pssh = False
|
||||||
|
|
||||||
|
if init_data:
|
||||||
|
new_session = Session(session_id, init_data, device, offline)
|
||||||
|
else:
|
||||||
|
self.logger.error("unable to parse init data")
|
||||||
|
return 1
|
||||||
|
self.sessions[session_id] = new_session
|
||||||
|
self.logger.info("session opened and init data parsed successfully")
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
def _parse_init_data(self, init_data_b64):
|
||||||
|
parsed_init_data = wv_proto2.WidevineCencHeader()
|
||||||
|
try:
|
||||||
|
self.logger.debug("trying to parse init_data directly")
|
||||||
|
parsed_init_data.ParseFromString(base64.b64decode(init_data_b64)[32:])
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.debug("unable to parse as-is, trying with removed pssh box header")
|
||||||
|
try:
|
||||||
|
id_bytes = parsed_init_data.ParseFromString(base64.b64decode(init_data_b64)[32:])
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("unable to parse, unsupported init data format")
|
||||||
|
return None
|
||||||
|
self.logger.debug("init_data:")
|
||||||
|
for line in text_format.MessageToString(parsed_init_data).splitlines():
|
||||||
|
self.logger.debug(line)
|
||||||
|
return parsed_init_data
|
||||||
|
|
||||||
|
def close_session(self, session_id):
|
||||||
|
self.logger.debug("close_session(session_id={})".format(session_id))
|
||||||
|
self.logger.info("closing cdm session")
|
||||||
|
if session_id in self.sessions:
|
||||||
|
self.sessions.pop(session_id)
|
||||||
|
self.logger.info("cdm session closed")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
self.logger.info("session {} not found".format(session_id))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def set_service_certificate(self, session_id, cert_b64):
|
||||||
|
self.logger.debug("set_service_certificate(session_id={}, cert={})".format(session_id, cert_b64))
|
||||||
|
self.logger.info("setting service certificate")
|
||||||
|
|
||||||
|
if session_id not in self.sessions:
|
||||||
|
self.logger.error("session id doesn't exist")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session = self.sessions[session_id]
|
||||||
|
|
||||||
|
message = wv_proto2.SignedMessage()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message.ParseFromString(base64.b64decode(cert_b64))
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("failed to parse cert as SignedMessage")
|
||||||
|
|
||||||
|
service_certificate = wv_proto2.SignedDeviceCertificate()
|
||||||
|
|
||||||
|
if message.Type:
|
||||||
|
self.logger.debug("service cert provided as signedmessage")
|
||||||
|
try:
|
||||||
|
service_certificate.ParseFromString(message.Msg)
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("failed to parse service certificate")
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
self.logger.debug("service cert provided as signeddevicecertificate")
|
||||||
|
try:
|
||||||
|
service_certificate.ParseFromString(base64.b64decode(cert_b64))
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("failed to parse service certificate")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
self.logger.debug("service certificate:")
|
||||||
|
for line in text_format.MessageToString(service_certificate).splitlines():
|
||||||
|
self.logger.debug(line)
|
||||||
|
|
||||||
|
session.service_certificate = service_certificate
|
||||||
|
session.privacy_mode = True
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def sign_license_request(self, data):
|
||||||
|
em = binascii.b2a_hex((pss._EMSA_PSS_ENCODE(data, 2047, Random.get_random_bytes, lambda x, y: pss.MGF1(x, y, data), 20)))
|
||||||
|
sig = cdmapi.encrypt(em.decode('utf-8'))
|
||||||
|
return (binascii.a2b_hex(sig))
|
||||||
|
|
||||||
|
def get_license_request(self, session_id):
|
||||||
|
self.logger.debug("get_license_request(session_id={})".format(session_id))
|
||||||
|
self.logger.info("getting license request")
|
||||||
|
|
||||||
|
if session_id not in self.sessions:
|
||||||
|
self.logger.error("session ID does not exist")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session = self.sessions[session_id]
|
||||||
|
|
||||||
|
# raw pssh will be treated as bytes and not parsed
|
||||||
|
if self.raw_pssh:
|
||||||
|
license_request = wv_proto2.SignedLicenseRequestRaw()
|
||||||
|
else:
|
||||||
|
license_request = wv_proto2.SignedLicenseRequest()
|
||||||
|
client_id = wv_proto2.ClientIdentification()
|
||||||
|
|
||||||
|
if not os.path.exists(session.device_config.device_client_id_blob_filename):
|
||||||
|
self.logger.error("no client ID blob available for this device")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with open(session.device_config.device_client_id_blob_filename, "rb") as f:
|
||||||
|
try:
|
||||||
|
cid_bytes = client_id.ParseFromString(f.read())
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("client id failed to parse as protobuf")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
self.logger.debug("building license request")
|
||||||
|
if not self.raw_pssh:
|
||||||
|
license_request.Type = wv_proto2.SignedLicenseRequest.MessageType.Value('LICENSE_REQUEST')
|
||||||
|
license_request.Msg.ContentId.CencId.Pssh.CopyFrom(session.init_data)
|
||||||
|
else:
|
||||||
|
license_request.Type = wv_proto2.SignedLicenseRequestRaw.MessageType.Value('LICENSE_REQUEST')
|
||||||
|
license_request.Msg.ContentId.CencId.Pssh = session.init_data # bytes
|
||||||
|
|
||||||
|
if session.offline:
|
||||||
|
license_type = wv_proto2.LicenseType.Value('OFFLINE')
|
||||||
|
else:
|
||||||
|
license_type = wv_proto2.LicenseType.Value('DEFAULT')
|
||||||
|
license_request.Msg.ContentId.CencId.LicenseType = license_type
|
||||||
|
license_request.Msg.ContentId.CencId.RequestId = session_id
|
||||||
|
license_request.Msg.Type = wv_proto2.LicenseRequest.RequestType.Value('NEW')
|
||||||
|
license_request.Msg.RequestTime = int(time.time())
|
||||||
|
license_request.Msg.ProtocolVersion = wv_proto2.ProtocolVersion.Value('CURRENT')
|
||||||
|
if session.device_config.send_key_control_nonce:
|
||||||
|
license_request.Msg.KeyControlNonce = random.randrange(1, 2**31)
|
||||||
|
|
||||||
|
if session.privacy_mode:
|
||||||
|
|
||||||
|
if session.device_config.vmp:
|
||||||
|
self.logger.debug("vmp required, adding to client_id")
|
||||||
|
self.logger.debug("reading vmp hashes")
|
||||||
|
vmp_hashes = wv_proto2.FileHashes()
|
||||||
|
with open(session.device_config.device_vmp_blob_filename, "rb") as f:
|
||||||
|
try:
|
||||||
|
vmp_bytes = vmp_hashes.ParseFromString(f.read())
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("vmp hashes failed to parse as protobuf")
|
||||||
|
return 1
|
||||||
|
client_id._FileHashes.CopyFrom(vmp_hashes)
|
||||||
|
self.logger.debug("privacy mode & service certificate loaded, encrypting client id")
|
||||||
|
self.logger.debug("unencrypted client id:")
|
||||||
|
for line in text_format.MessageToString(client_id).splitlines():
|
||||||
|
self.logger.debug(line)
|
||||||
|
cid_aes_key = get_random_bytes(16)
|
||||||
|
cid_iv = get_random_bytes(16)
|
||||||
|
|
||||||
|
cid_cipher = AES.new(cid_aes_key, AES.MODE_CBC, cid_iv)
|
||||||
|
|
||||||
|
encrypted_client_id = cid_cipher.encrypt(Padding.pad(client_id.SerializeToString(), 16))
|
||||||
|
|
||||||
|
service_public_key = RSA.importKey(session.service_certificate._DeviceCertificate.PublicKey)
|
||||||
|
|
||||||
|
service_cipher = PKCS1_OAEP.new(service_public_key)
|
||||||
|
|
||||||
|
encrypted_cid_key = service_cipher.encrypt(cid_aes_key)
|
||||||
|
|
||||||
|
encrypted_client_id_proto = wv_proto2.EncryptedClientIdentification()
|
||||||
|
|
||||||
|
encrypted_client_id_proto.ServiceId = session.service_certificate._DeviceCertificate.ServiceId
|
||||||
|
encrypted_client_id_proto.ServiceCertificateSerialNumber = session.service_certificate._DeviceCertificate.SerialNumber
|
||||||
|
encrypted_client_id_proto.EncryptedClientId = encrypted_client_id
|
||||||
|
encrypted_client_id_proto.EncryptedClientIdIv = cid_iv
|
||||||
|
encrypted_client_id_proto.EncryptedPrivacyKey = encrypted_cid_key
|
||||||
|
|
||||||
|
license_request.Msg.EncryptedClientId.CopyFrom(encrypted_client_id_proto)
|
||||||
|
else:
|
||||||
|
license_request.Msg.ClientId.CopyFrom(client_id)
|
||||||
|
|
||||||
|
if session.device_config.private_key_available:
|
||||||
|
key = RSA.importKey(open(session.device_config.device_private_key_filename).read())
|
||||||
|
print(key)
|
||||||
|
session.device_key = key
|
||||||
|
else:
|
||||||
|
self.logger.info("need device private key, other methods unimplemented")
|
||||||
|
# return 1
|
||||||
|
|
||||||
|
self.logger.debug("signing license request")
|
||||||
|
|
||||||
|
hash = SHA1.new(license_request.Msg.SerializeToString())
|
||||||
|
if(session.device_key is not None):
|
||||||
|
signature = pss.new(key).sign(hash)
|
||||||
|
else:
|
||||||
|
signature = self.sign_license_request(hash)
|
||||||
|
|
||||||
|
license_request.Signature = signature
|
||||||
|
|
||||||
|
session.license_request = license_request
|
||||||
|
|
||||||
|
self.logger.debug("license request:")
|
||||||
|
for line in text_format.MessageToString(session.license_request).splitlines():
|
||||||
|
self.logger.debug(line)
|
||||||
|
self.logger.info("license request created")
|
||||||
|
self.logger.debug("license request b64: {}".format(base64.b64encode(license_request.SerializeToString())))
|
||||||
|
return license_request.SerializeToString()
|
||||||
|
|
||||||
|
def provide_license(self, session_id, license_b64):
|
||||||
|
self.logger.debug("provide_license(session_id={}, license_b64={})".format(session_id, license_b64))
|
||||||
|
self.logger.info("decrypting provided license")
|
||||||
|
|
||||||
|
if session_id not in self.sessions:
|
||||||
|
self.logger.error("session does not exist")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session = self.sessions[session_id]
|
||||||
|
|
||||||
|
if not session.license_request:
|
||||||
|
self.logger.error("generate a license request first!")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
license = wv_proto2.SignedLicense()
|
||||||
|
try:
|
||||||
|
|
||||||
|
license.ParseFromString(base64.b64decode(license_b64))
|
||||||
|
|
||||||
|
except DecodeError:
|
||||||
|
self.logger.error("unable to parse license - check protobufs")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session.license = license
|
||||||
|
|
||||||
|
self.logger.debug("license:")
|
||||||
|
for line in text_format.MessageToString(license).splitlines():
|
||||||
|
self.logger.debug(line)
|
||||||
|
|
||||||
|
self.logger.debug("deriving keys from session key")
|
||||||
|
|
||||||
|
oaep_cipher = PKCS1_OAEP.new(session.device_key)
|
||||||
|
|
||||||
|
if(session.device_key is not None):
|
||||||
|
session.session_key = oaep_cipher.decrypt(license.SessionKey)
|
||||||
|
else:
|
||||||
|
session_key = cdmapi.decrypt(binascii.b2a_hex(license.SessionKey).decode('utf-8'))
|
||||||
|
session.session_key = binascii.a2b_hex(session_key)
|
||||||
|
|
||||||
|
lic_req_msg = session.license_request.Msg.SerializeToString()
|
||||||
|
|
||||||
|
enc_key_base = b"ENCRYPTION\000" + lic_req_msg + b"\0\0\0\x80"
|
||||||
|
auth_key_base = b"AUTHENTICATION\0" + lic_req_msg + b"\0\0\2\0"
|
||||||
|
|
||||||
|
enc_key = b"\x01" + enc_key_base
|
||||||
|
auth_key_1 = b"\x01" + auth_key_base
|
||||||
|
auth_key_2 = b"\x02" + auth_key_base
|
||||||
|
auth_key_3 = b"\x03" + auth_key_base
|
||||||
|
auth_key_4 = b"\x04" + auth_key_base
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(enc_key)
|
||||||
|
|
||||||
|
enc_cmac_key = cmac_obj.digest()
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(auth_key_1)
|
||||||
|
auth_cmac_key_1 = cmac_obj.digest()
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(auth_key_2)
|
||||||
|
auth_cmac_key_2 = cmac_obj.digest()
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(auth_key_3)
|
||||||
|
auth_cmac_key_3 = cmac_obj.digest()
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(auth_key_4)
|
||||||
|
auth_cmac_key_4 = cmac_obj.digest()
|
||||||
|
|
||||||
|
auth_cmac_combined_1 = auth_cmac_key_1 + auth_cmac_key_2
|
||||||
|
auth_cmac_combined_2 = auth_cmac_key_3 + auth_cmac_key_4
|
||||||
|
|
||||||
|
session.derived_keys['enc'] = enc_cmac_key
|
||||||
|
session.derived_keys['auth_1'] = auth_cmac_combined_1
|
||||||
|
session.derived_keys['auth_2'] = auth_cmac_combined_2
|
||||||
|
|
||||||
|
self.logger.debug('verifying license signature')
|
||||||
|
|
||||||
|
lic_hmac = HMAC.new(session.derived_keys['auth_1'], digestmod=SHA256)
|
||||||
|
lic_hmac.update(license.Msg.SerializeToString())
|
||||||
|
|
||||||
|
self.logger.debug("calculated sig: {} actual sig: {}".format(lic_hmac.hexdigest(), binascii.hexlify(license.Signature)))
|
||||||
|
|
||||||
|
if lic_hmac.digest() != license.Signature:
|
||||||
|
self.logger.info("license signature doesn't match - writing bin so they can be debugged")
|
||||||
|
with open("original_lic.bin", "wb") as f:
|
||||||
|
f.write(base64.b64decode(license_b64))
|
||||||
|
with open("parsed_lic.bin", "wb") as f:
|
||||||
|
f.write(license.SerializeToString())
|
||||||
|
self.logger.info("continuing anyway")
|
||||||
|
|
||||||
|
self.logger.debug("key count: {}".format(len(license.Msg.Key)))
|
||||||
|
for key in license.Msg.Key:
|
||||||
|
if key.Id:
|
||||||
|
key_id = key.Id
|
||||||
|
else:
|
||||||
|
key_id = wv_proto2.License.KeyContainer.KeyType.Name(key.Type).encode('utf-8')
|
||||||
|
encrypted_key = key.Key
|
||||||
|
iv = key.Iv
|
||||||
|
type = wv_proto2.License.KeyContainer.KeyType.Name(key.Type)
|
||||||
|
|
||||||
|
cipher = AES.new(session.derived_keys['enc'], AES.MODE_CBC, iv=iv)
|
||||||
|
decrypted_key = cipher.decrypt(encrypted_key)
|
||||||
|
if type == "OPERATOR_SESSION":
|
||||||
|
permissions = []
|
||||||
|
perms = key._OperatorSessionKeyPermissions
|
||||||
|
for (descriptor, value) in perms.ListFields():
|
||||||
|
if value == 1:
|
||||||
|
permissions.append(descriptor.name)
|
||||||
|
|
||||||
|
else:
|
||||||
|
permissions = []
|
||||||
|
session.keys.append(Key(key_id, type, Padding.unpad(decrypted_key, 16), permissions))
|
||||||
|
|
||||||
|
self.logger.info("decrypted all keys")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_keys(self, session_id):
|
||||||
|
if session_id in self.sessions:
|
||||||
|
return self.sessions[session_id].keys
|
||||||
|
else:
|
||||||
|
self.logger.error("session not found")
|
||||||
|
return 1
|
324
pywidevine/cdm/cdm_debug.py
Normal file
324
pywidevine/cdm/cdm_debug.py
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import binascii
|
||||||
|
|
||||||
|
from google.protobuf.message import DecodeError
|
||||||
|
from google.protobuf import text_format
|
||||||
|
|
||||||
|
from pywidevine.cdm.formats import wv_proto2_pb2 as wv_proto2
|
||||||
|
from pywidevine.cdm.session import Session
|
||||||
|
from pywidevine.cdm.key import Key
|
||||||
|
from Cryptodome.Random import get_random_bytes
|
||||||
|
from Cryptodome.Random import random
|
||||||
|
from Cryptodome.Cipher import PKCS1_OAEP, AES
|
||||||
|
from Cryptodome.Hash import CMAC, SHA256, HMAC, SHA1
|
||||||
|
from Cryptodome.PublicKey import RSA
|
||||||
|
from Cryptodome.Signature import pss
|
||||||
|
from Cryptodome.Util import Padding
|
||||||
|
import logging
|
||||||
|
|
||||||
|
class Cdm:
|
||||||
|
def __init__(self):
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.sessions = {}
|
||||||
|
|
||||||
|
def open_session(self, init_data_b64, device):
|
||||||
|
print("open_session(init_data_b64={}, device={}".format(init_data_b64, device))
|
||||||
|
print("opening new cdm session")
|
||||||
|
if device.session_id_type == 'android':
|
||||||
|
# format: 16 random hexdigits, 2 digit counter, 14 0s
|
||||||
|
rand_ascii = ''.join(random.choice('ABCDEF0123456789') for _ in range(16))
|
||||||
|
counter = '01' # this resets regularly so its fine to use 01
|
||||||
|
rest = '00000000000000'
|
||||||
|
session_id = rand_ascii + counter + rest
|
||||||
|
session_id = session_id.encode('ascii')
|
||||||
|
elif device.session_id_type == 'chrome':
|
||||||
|
rand_bytes = get_random_bytes(16)
|
||||||
|
session_id = rand_bytes
|
||||||
|
else:
|
||||||
|
# other formats NYI
|
||||||
|
print("device type is unusable")
|
||||||
|
return 1
|
||||||
|
init_data = self._parse_init_data(init_data_b64)
|
||||||
|
if init_data:
|
||||||
|
new_session = Session(session_id, init_data, device)
|
||||||
|
else:
|
||||||
|
print("unable to parse init data")
|
||||||
|
return 1
|
||||||
|
self.sessions[session_id] = new_session
|
||||||
|
print("session opened and init data parsed successfully")
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
def _parse_init_data(self, init_data_b64):
|
||||||
|
parsed_init_data = wv_proto2.WidevineCencHeader()
|
||||||
|
try:
|
||||||
|
print("trying to parse init_data directly")
|
||||||
|
parsed_init_data.ParseFromString(base64.b64decode(init_data_b64))
|
||||||
|
except DecodeError:
|
||||||
|
print("unable to parse as-is, trying with removed pssh box header")
|
||||||
|
try:
|
||||||
|
id_bytes = parsed_init_data.ParseFromString(base64.b64decode(init_data_b64)[32:])
|
||||||
|
except DecodeError:
|
||||||
|
print("unable to parse, unsupported init data format")
|
||||||
|
return None
|
||||||
|
print("init_data:")
|
||||||
|
for line in text_format.MessageToString(parsed_init_data).splitlines():
|
||||||
|
print(line)
|
||||||
|
return parsed_init_data
|
||||||
|
|
||||||
|
def close_session(self, session_id):
|
||||||
|
print("close_session(session_id={})".format(session_id))
|
||||||
|
print("closing cdm session")
|
||||||
|
if session_id in self.sessions:
|
||||||
|
self.sessions.pop(session_id)
|
||||||
|
print("cdm session closed")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("session {} not found".format(session_id))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
def set_service_certificate(self, session_id, cert_b64):
|
||||||
|
print("set_service_certificate(session_id={}, cert={})".format(session_id, cert_b64))
|
||||||
|
print("setting service certificate")
|
||||||
|
|
||||||
|
if session_id not in self.sessions:
|
||||||
|
print("session id doesn't exist")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session = self.sessions[session_id]
|
||||||
|
|
||||||
|
message = wv_proto2.SignedMessage()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message.ParseFromString(base64.b64decode(cert_b64))
|
||||||
|
except DecodeError:
|
||||||
|
print("failed to parse cert as SignedMessage")
|
||||||
|
|
||||||
|
service_certificate = wv_proto2.SignedDeviceCertificate()
|
||||||
|
|
||||||
|
if message.Type:
|
||||||
|
print("service cert provided as signedmessage")
|
||||||
|
try:
|
||||||
|
service_certificate.ParseFromString(message.Msg)
|
||||||
|
except DecodeError:
|
||||||
|
print("failed to parse service certificate")
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
print("service cert provided as signeddevicecertificate")
|
||||||
|
try:
|
||||||
|
service_certificate.ParseFromString(base64.b64decode(cert_b64))
|
||||||
|
except DecodeError:
|
||||||
|
print("failed to parse service certificate")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("service certificate:")
|
||||||
|
for line in text_format.MessageToString(service_certificate).splitlines():
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
session.service_certificate = service_certificate
|
||||||
|
session.privacy_mode = True
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_license_request(self, session_id):
|
||||||
|
print("get_license_request(session_id={})".format(session_id))
|
||||||
|
print("getting license request")
|
||||||
|
|
||||||
|
if session_id not in self.sessions:
|
||||||
|
print("session ID does not exist")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session = self.sessions[session_id]
|
||||||
|
|
||||||
|
license_request = wv_proto2.SignedLicenseRequest()
|
||||||
|
client_id = wv_proto2.ClientIdentification()
|
||||||
|
|
||||||
|
if not os.path.exists(session.device_config.device_client_id_blob_filename):
|
||||||
|
print("no client ID blob available for this device")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with open(session.device_config.device_client_id_blob_filename, "rb") as f:
|
||||||
|
try:
|
||||||
|
cid_bytes = client_id.ParseFromString(f.read())
|
||||||
|
except DecodeError:
|
||||||
|
print("client id failed to parse as protobuf")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("building license request")
|
||||||
|
|
||||||
|
license_request.Type = wv_proto2.SignedLicenseRequest.MessageType.Value('LICENSE_REQUEST')
|
||||||
|
license_request.Msg.ContentId.CencId.Pssh.CopyFrom(session.init_data)
|
||||||
|
license_request.Msg.ContentId.CencId.LicenseType = wv_proto2.LicenseType.Value('DEFAULT')
|
||||||
|
license_request.Msg.ContentId.CencId.RequestId = session_id
|
||||||
|
license_request.Msg.Type = wv_proto2.LicenseRequest.RequestType.Value('NEW')
|
||||||
|
license_request.Msg.RequestTime = int(time.time())
|
||||||
|
license_request.Msg.ProtocolVersion = wv_proto2.ProtocolVersion.Value('CURRENT')
|
||||||
|
if session.device_config.send_key_control_nonce:
|
||||||
|
license_request.Msg.KeyControlNonce = random.randrange(1, 2**31)
|
||||||
|
|
||||||
|
if session.privacy_mode:
|
||||||
|
print("privacy mode & service certificate loaded, encrypting client id")
|
||||||
|
print("unencrypted client id:")
|
||||||
|
for line in text_format.MessageToString(client_id).splitlines():
|
||||||
|
print(line)
|
||||||
|
cid_aes_key = get_random_bytes(16)
|
||||||
|
cid_iv = get_random_bytes(16)
|
||||||
|
|
||||||
|
cid_cipher = AES.new(cid_aes_key, AES.MODE_CBC, cid_iv)
|
||||||
|
|
||||||
|
encrypted_client_id = cid_cipher.encrypt(Padding.pad(client_id.SerializeToString(), 16))
|
||||||
|
|
||||||
|
service_public_key = RSA.importKey(session.service_certificate._DeviceCertificate.PublicKey)
|
||||||
|
|
||||||
|
service_cipher = PKCS1_OAEP.new(service_public_key)
|
||||||
|
|
||||||
|
encrypted_cid_key = service_cipher.encrypt(cid_aes_key)
|
||||||
|
|
||||||
|
encrypted_client_id_proto = wv_proto2.EncryptedClientIdentification()
|
||||||
|
|
||||||
|
encrypted_client_id_proto.ServiceId = session.service_certificate._DeviceCertificate.ServiceId
|
||||||
|
encrypted_client_id_proto.ServiceCertificateSerialNumber = session.service_certificate._DeviceCertificate.SerialNumber
|
||||||
|
encrypted_client_id_proto.EncryptedClientId = encrypted_client_id
|
||||||
|
encrypted_client_id_proto.EncryptedClientIdIv = cid_iv
|
||||||
|
encrypted_client_id_proto.EncryptedPrivacyKey = encrypted_cid_key
|
||||||
|
|
||||||
|
license_request.Msg.EncryptedClientId.CopyFrom(encrypted_client_id_proto)
|
||||||
|
else:
|
||||||
|
license_request.Msg.ClientId.CopyFrom(client_id)
|
||||||
|
|
||||||
|
if session.device_config.private_key_available:
|
||||||
|
key = RSA.importKey(open(session.device_config.device_private_key_filename).read())
|
||||||
|
session.device_key = key
|
||||||
|
else:
|
||||||
|
print("need device private key, other methods unimplemented")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("signing license request")
|
||||||
|
|
||||||
|
hash = SHA1.new(license_request.Msg.SerializeToString())
|
||||||
|
signature = pss.new(key).sign(hash)
|
||||||
|
|
||||||
|
license_request.Signature = signature
|
||||||
|
|
||||||
|
session.license_request = license_request
|
||||||
|
|
||||||
|
print("license request:")
|
||||||
|
for line in text_format.MessageToString(session.license_request).splitlines():
|
||||||
|
print(line)
|
||||||
|
print("license request created")
|
||||||
|
print("license request b64: {}".format(base64.b64encode(license_request.SerializeToString())))
|
||||||
|
return license_request.SerializeToString()
|
||||||
|
|
||||||
|
def provide_license(self, session_id, license_b64):
|
||||||
|
print("provide_license(session_id={}, license_b64={})".format(session_id, license_b64))
|
||||||
|
print("decrypting provided license")
|
||||||
|
|
||||||
|
if session_id not in self.sessions:
|
||||||
|
print("session does not exist")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session = self.sessions[session_id]
|
||||||
|
|
||||||
|
if not session.license_request:
|
||||||
|
print("generate a license request first!")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
license = wv_proto2.SignedLicense()
|
||||||
|
try:
|
||||||
|
license.ParseFromString(base64.b64decode(license_b64))
|
||||||
|
except DecodeError:
|
||||||
|
print("unable to parse license - check protobufs")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
session.license = license
|
||||||
|
|
||||||
|
print("license:")
|
||||||
|
for line in text_format.MessageToString(license).splitlines():
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
print("deriving keys from session key")
|
||||||
|
|
||||||
|
oaep_cipher = PKCS1_OAEP.new(session.device_key)
|
||||||
|
|
||||||
|
session.session_key = oaep_cipher.decrypt(license.SessionKey)
|
||||||
|
|
||||||
|
lic_req_msg = session.license_request.Msg.SerializeToString()
|
||||||
|
|
||||||
|
enc_key_base = b"ENCRYPTION\000" + lic_req_msg + b"\0\0\0\x80"
|
||||||
|
auth_key_base = b"AUTHENTICATION\0" + lic_req_msg + b"\0\0\2\0"
|
||||||
|
|
||||||
|
enc_key = b"\x01" + enc_key_base
|
||||||
|
auth_key_1 = b"\x01" + auth_key_base
|
||||||
|
auth_key_2 = b"\x02" + auth_key_base
|
||||||
|
auth_key_3 = b"\x03" + auth_key_base
|
||||||
|
auth_key_4 = b"\x04" + auth_key_base
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(enc_key)
|
||||||
|
|
||||||
|
enc_cmac_key = cmac_obj.digest()
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(auth_key_1)
|
||||||
|
auth_cmac_key_1 = cmac_obj.digest()
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(auth_key_2)
|
||||||
|
auth_cmac_key_2 = cmac_obj.digest()
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(auth_key_3)
|
||||||
|
auth_cmac_key_3 = cmac_obj.digest()
|
||||||
|
|
||||||
|
cmac_obj = CMAC.new(session.session_key, ciphermod=AES)
|
||||||
|
cmac_obj.update(auth_key_4)
|
||||||
|
auth_cmac_key_4 = cmac_obj.digest()
|
||||||
|
|
||||||
|
auth_cmac_combined_1 = auth_cmac_key_1 + auth_cmac_key_2
|
||||||
|
auth_cmac_combined_2 = auth_cmac_key_3 + auth_cmac_key_4
|
||||||
|
|
||||||
|
session.derived_keys['enc'] = enc_cmac_key
|
||||||
|
session.derived_keys['auth_1'] = auth_cmac_combined_1
|
||||||
|
session.derived_keys['auth_2'] = auth_cmac_combined_2
|
||||||
|
|
||||||
|
print('verifying license signature')
|
||||||
|
|
||||||
|
lic_hmac = HMAC.new(session.derived_keys['auth_1'], digestmod=SHA256)
|
||||||
|
lic_hmac.update(license.Msg.SerializeToString())
|
||||||
|
|
||||||
|
print("calculated sig: {} actual sig: {}".format(lic_hmac.hexdigest(), binascii.hexlify(license.Signature)))
|
||||||
|
|
||||||
|
if lic_hmac.digest() != license.Signature:
|
||||||
|
print("license signature doesn't match - writing bin so they can be debugged")
|
||||||
|
with open("original_lic.bin", "wb") as f:
|
||||||
|
f.write(base64.b64decode(license_b64))
|
||||||
|
with open("parsed_lic.bin", "wb") as f:
|
||||||
|
f.write(license.SerializeToString())
|
||||||
|
print("continuing anyway")
|
||||||
|
|
||||||
|
print("key count: {}".format(len(license.Msg.Key)))
|
||||||
|
for key in license.Msg.Key:
|
||||||
|
if key.Id:
|
||||||
|
key_id = key.Id
|
||||||
|
else:
|
||||||
|
key_id = wv_proto2.License.KeyContainer.KeyType.Name(key.Type).encode('utf-8')
|
||||||
|
encrypted_key = key.Key
|
||||||
|
iv = key.Iv
|
||||||
|
type = wv_proto2.License.KeyContainer.KeyType.Name(key.Type)
|
||||||
|
|
||||||
|
cipher = AES.new(session.derived_keys['enc'], AES.MODE_CBC, iv=iv)
|
||||||
|
decrypted_key = cipher.decrypt(encrypted_key)
|
||||||
|
|
||||||
|
session.keys.append(Key(key_id, type, Padding.unpad(decrypted_key, 16)))
|
||||||
|
|
||||||
|
print("decrypted all keys")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_keys(self, session_id):
|
||||||
|
if session_id in self.sessions:
|
||||||
|
return self.sessions[session_id].keys
|
||||||
|
else:
|
||||||
|
print("session not found")
|
||||||
|
return 1
|
BIN
pywidevine/cdm/cdmapi.pyd
Normal file
BIN
pywidevine/cdm/cdmapi.pyd
Normal file
Binary file not shown.
54
pywidevine/cdm/deviceconfig.py
Normal file
54
pywidevine/cdm/deviceconfig.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
device_asus_x00dd = {
|
||||||
|
'name': 'asus_x00dd',
|
||||||
|
'description': 'asus cdm',
|
||||||
|
'security_level': 1,
|
||||||
|
'session_id_type': 'android',
|
||||||
|
'private_key_available': True,
|
||||||
|
'vmp': False,
|
||||||
|
'send_key_control_nonce': True
|
||||||
|
}
|
||||||
|
|
||||||
|
devices_available = [device_asus_x00dd]
|
||||||
|
|
||||||
|
FILES_FOLDER = 'devices'
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceConfig:
|
||||||
|
def __init__(self, device):
|
||||||
|
self.device_name = device['name']
|
||||||
|
self.description = device['description']
|
||||||
|
self.security_level = device['security_level']
|
||||||
|
self.session_id_type = device['session_id_type']
|
||||||
|
self.private_key_available = device['private_key_available']
|
||||||
|
self.vmp = device['vmp']
|
||||||
|
self.send_key_control_nonce = device['send_key_control_nonce']
|
||||||
|
|
||||||
|
if 'keybox_filename' in device:
|
||||||
|
self.keybox_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['keybox_filename'])
|
||||||
|
else:
|
||||||
|
self.keybox_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'keybox')
|
||||||
|
|
||||||
|
if 'device_cert_filename' in device:
|
||||||
|
self.device_cert_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['device_cert_filename'])
|
||||||
|
else:
|
||||||
|
self.device_cert_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'device_cert')
|
||||||
|
|
||||||
|
if 'device_private_key_filename' in device:
|
||||||
|
self.device_private_key_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['device_private_key_filename'])
|
||||||
|
else:
|
||||||
|
self.device_private_key_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'device_private_key')
|
||||||
|
|
||||||
|
if 'device_client_id_blob_filename' in device:
|
||||||
|
self.device_client_id_blob_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['device_client_id_blob_filename'])
|
||||||
|
else:
|
||||||
|
self.device_client_id_blob_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'device_client_id_blob')
|
||||||
|
|
||||||
|
if 'device_vmp_blob_filename' in device:
|
||||||
|
self.device_vmp_blob_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], device['device_vmp_blob_filename'])
|
||||||
|
else:
|
||||||
|
self.device_vmp_blob_filename = os.path.join(os.path.dirname(__file__), FILES_FOLDER, device['name'], 'device_vmp_blob')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "DeviceConfig(name={}, description={}, security_level={}, session_id_type={}, private_key_available={}, vmp={})".format(self.device_name, self.description, self.security_level, self.session_id_type, self.private_key_available, self.vmp)
|
88
pywidevine/cdm/deviceconfig.py.example
Normal file
88
pywidevine/cdm/deviceconfig.py.example
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
device_nexus6_lvl1 = {
|
||||||
|
'name': 'nexus6_lvl1',
|
||||||
|
'description': 'nexus 6 firmware 5.1.1 lvl1 security level',
|
||||||
|
'security_level': 1,
|
||||||
|
'session_id_type': 'android',
|
||||||
|
'private_key_available': True,
|
||||||
|
'vmp': False,
|
||||||
|
'send_key_control_nonce': True
|
||||||
|
}
|
||||||
|
|
||||||
|
device_chromecdm_903 = {
|
||||||
|
'name': 'chromecdm_903',
|
||||||
|
'description': 'chrome cdm windows 903',
|
||||||
|
'security_level': 3,
|
||||||
|
'session_id_type': 'chrome',
|
||||||
|
'private_key_available': True,
|
||||||
|
'vmp': False,
|
||||||
|
'send_key_control_nonce': False
|
||||||
|
}
|
||||||
|
|
||||||
|
device_chromecdm_984 = {
|
||||||
|
'name': 'chromecdm_984',
|
||||||
|
'description': 'chrome cdm windows 984',
|
||||||
|
'security_level': 3,
|
||||||
|
'session_id_type': 'chrome',
|
||||||
|
'private_key_available': True,
|
||||||
|
'vmp': True,
|
||||||
|
'send_key_control_nonce': False
|
||||||
|
}
|
||||||
|
|
||||||
|
device_chromecdm_1022 = {
|
||||||
|
'name': 'chromecdm_1022',
|
||||||
|
'description': 'chrome cdm windows 1022',
|
||||||
|
'security_level': 3,
|
||||||
|
'session_id_type': 'chrome',
|
||||||
|
'private_key_available': True,
|
||||||
|
'vmp': True,
|
||||||
|
'send_key_control_nonce': False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
devices_available = [device_nexus6_lvl1, device_chromecdm_903, device_chromecdm_984, device_chromecdm_1022]
|
||||||
|
|
||||||
|
from __main__ import dirPath
|
||||||
|
#main_folder = os.path.dirname(__file__)
|
||||||
|
main_folder = os.path.join(dirPath, 'pywidevine', 'cdm')
|
||||||
|
|
||||||
|
FILES_FOLDER = 'devices'
|
||||||
|
|
||||||
|
class DeviceConfig:
|
||||||
|
def __init__(self, device):
|
||||||
|
self.device_name = device['name']
|
||||||
|
self.description = device['description']
|
||||||
|
self.security_level = device['security_level']
|
||||||
|
self.session_id_type = device['session_id_type']
|
||||||
|
self.private_key_available = device['private_key_available']
|
||||||
|
self.vmp = device['vmp']
|
||||||
|
self.send_key_control_nonce = device['send_key_control_nonce']
|
||||||
|
|
||||||
|
if 'keybox_filename' in device:
|
||||||
|
self.keybox_filename = os.path.join(main_folder, FILES_FOLDER, device['name'], device['keybox_filename'])
|
||||||
|
else:
|
||||||
|
self.keybox_filename = os.path.join(main_folder, FILES_FOLDER, device['name'], 'keybox')
|
||||||
|
|
||||||
|
if 'device_cert_filename' in device:
|
||||||
|
self.device_cert_filename = os.path.join(main_folder, FILES_FOLDER, device['name'], device['device_cert_filename'])
|
||||||
|
else:
|
||||||
|
self.device_cert_filename = os.path.join(main_folder, FILES_FOLDER, device['name'], 'device_cert')
|
||||||
|
|
||||||
|
if 'device_private_key_filename' in device:
|
||||||
|
self.device_private_key_filename = os.path.join(main_folder, FILES_FOLDER, device['name'], device['device_private_key_filename'])
|
||||||
|
else:
|
||||||
|
self.device_private_key_filename = os.path.join(main_folder, FILES_FOLDER, device['name'], 'device_private_key')
|
||||||
|
|
||||||
|
if 'device_client_id_blob_filename' in device:
|
||||||
|
self.device_client_id_blob_filename = os.path.join(main_folder, FILES_FOLDER, device['name'], device['device_client_id_blob_filename'])
|
||||||
|
else:
|
||||||
|
self.device_client_id_blob_filename = os.path.join(main_folder, FILES_FOLDER, device['name'], 'device_client_id_blob')
|
||||||
|
|
||||||
|
if 'device_vmp_blob_filename' in device:
|
||||||
|
self.device_vmp_blob_filename = os.path.join(main_folder, FILES_FOLDER, device['name'], device['device_vmp_blob_filename'])
|
||||||
|
else:
|
||||||
|
self.device_vmp_blob_filename = os.path.join(main_folder, FILES_FOLDER, device['name'], 'device_vmp_blob')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return "DeviceConfig(name={}, description={}, security_level={}, session_id_type={}, private_key_available={}, vmp={})".format(self.device_name, self.description, self.security_level, self.session_id_type, self.private_key_available, self.vmp)
|
0
pywidevine/cdm/devices/.gitkeep
Normal file
0
pywidevine/cdm/devices/.gitkeep
Normal file
BIN
pywidevine/cdm/devices/chromecdm_2209/device_client_id_blob
Normal file
BIN
pywidevine/cdm/devices/chromecdm_2209/device_client_id_blob
Normal file
Binary file not shown.
BIN
pywidevine/cdm/devices/chromecdm_2209/device_vmp_blob
Normal file
BIN
pywidevine/cdm/devices/chromecdm_2209/device_vmp_blob
Normal file
Binary file not shown.
0
pywidevine/cdm/formats/__init__.py
Normal file
0
pywidevine/cdm/formats/__init__.py
Normal file
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-37.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-39.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-39.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-37.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-37.pyc
Normal file
Binary file not shown.
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-39.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-39.pyc
Normal file
Binary file not shown.
466
pywidevine/cdm/formats/wv_proto2.proto
Normal file
466
pywidevine/cdm/formats/wv_proto2.proto
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
syntax = "proto2";
|
||||||
|
|
||||||
|
// from x86 (partial), most of it from the ARM version:
|
||||||
|
message ClientIdentification {
|
||||||
|
enum TokenType {
|
||||||
|
KEYBOX = 0;
|
||||||
|
DEVICE_CERTIFICATE = 1;
|
||||||
|
REMOTE_ATTESTATION_CERTIFICATE = 2;
|
||||||
|
}
|
||||||
|
message NameValue {
|
||||||
|
required string Name = 1;
|
||||||
|
required string Value = 2;
|
||||||
|
}
|
||||||
|
message ClientCapabilities {
|
||||||
|
enum HdcpVersion {
|
||||||
|
HDCP_NONE = 0;
|
||||||
|
HDCP_V1 = 1;
|
||||||
|
HDCP_V2 = 2;
|
||||||
|
HDCP_V2_1 = 3;
|
||||||
|
HDCP_V2_2 = 4;
|
||||||
|
}
|
||||||
|
optional uint32 ClientToken = 1;
|
||||||
|
optional uint32 SessionToken = 2;
|
||||||
|
optional uint32 VideoResolutionConstraints = 3;
|
||||||
|
optional HdcpVersion MaxHdcpVersion = 4;
|
||||||
|
optional uint32 OemCryptoApiVersion = 5;
|
||||||
|
}
|
||||||
|
required TokenType Type = 1;
|
||||||
|
//optional bytes Token = 2; // by default the client treats this as blob, but it's usually a DeviceCertificate, so for usefulness sake, I'm replacing it with this one:
|
||||||
|
optional SignedDeviceCertificate Token = 2; // use this when parsing, "bytes" when building a client id blob
|
||||||
|
repeated NameValue ClientInfo = 3;
|
||||||
|
optional bytes ProviderClientToken = 4;
|
||||||
|
optional uint32 LicenseCounter = 5;
|
||||||
|
optional ClientCapabilities _ClientCapabilities = 6; // how should we deal with duped names? will have to look at proto docs later
|
||||||
|
optional FileHashes _FileHashes = 7; // vmp blob goes here
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeviceCertificate {
|
||||||
|
enum CertificateType {
|
||||||
|
ROOT = 0;
|
||||||
|
INTERMEDIATE = 1;
|
||||||
|
USER_DEVICE = 2;
|
||||||
|
SERVICE = 3;
|
||||||
|
}
|
||||||
|
required CertificateType Type = 1; // the compiled code reused this as ProvisionedDeviceInfo.WvSecurityLevel, however that is incorrect (compiler aliased it as they're both identical as a structure)
|
||||||
|
optional bytes SerialNumber = 2;
|
||||||
|
optional uint32 CreationTimeSeconds = 3;
|
||||||
|
optional bytes PublicKey = 4;
|
||||||
|
optional uint32 SystemId = 5;
|
||||||
|
optional uint32 TestDeviceDeprecated = 6; // is it bool or int?
|
||||||
|
optional bytes ServiceId = 7; // service URL for service certificates
|
||||||
|
}
|
||||||
|
|
||||||
|
// missing some references,
|
||||||
|
message DeviceCertificateStatus {
|
||||||
|
enum CertificateStatus {
|
||||||
|
VALID = 0;
|
||||||
|
REVOKED = 1;
|
||||||
|
}
|
||||||
|
optional bytes SerialNumber = 1;
|
||||||
|
optional CertificateStatus Status = 2;
|
||||||
|
optional ProvisionedDeviceInfo DeviceInfo = 4; // where is 3? is it deprecated?
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeviceCertificateStatusList {
|
||||||
|
optional uint32 CreationTimeSeconds = 1;
|
||||||
|
repeated DeviceCertificateStatus CertificateStatus = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EncryptedClientIdentification {
|
||||||
|
required string ServiceId = 1;
|
||||||
|
optional bytes ServiceCertificateSerialNumber = 2;
|
||||||
|
required bytes EncryptedClientId = 3;
|
||||||
|
required bytes EncryptedClientIdIv = 4;
|
||||||
|
required bytes EncryptedPrivacyKey = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill (for this top-level type, it might be impossible/difficult)
|
||||||
|
enum LicenseType {
|
||||||
|
ZERO = 0;
|
||||||
|
DEFAULT = 1; // 1 is STREAMING/temporary license; on recent versions may go up to 3 (latest x86); it might be persist/don't persist type, unconfirmed
|
||||||
|
OFFLINE = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill (for this top-level type, it might be impossible/difficult)
|
||||||
|
// this is just a guess because these globals got lost, but really, do we need more?
|
||||||
|
enum ProtocolVersion {
|
||||||
|
CURRENT = 21; // don't have symbols for this
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message LicenseIdentification {
|
||||||
|
optional bytes RequestId = 1;
|
||||||
|
optional bytes SessionId = 2;
|
||||||
|
optional bytes PurchaseId = 3;
|
||||||
|
optional LicenseType Type = 4;
|
||||||
|
optional uint32 Version = 5;
|
||||||
|
optional bytes ProviderSessionToken = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message License {
|
||||||
|
message Policy {
|
||||||
|
optional bool CanPlay = 1; // changed from uint32 to bool
|
||||||
|
optional bool CanPersist = 2;
|
||||||
|
optional bool CanRenew = 3;
|
||||||
|
optional uint32 RentalDurationSeconds = 4;
|
||||||
|
optional uint32 PlaybackDurationSeconds = 5;
|
||||||
|
optional uint32 LicenseDurationSeconds = 6;
|
||||||
|
optional uint32 RenewalRecoveryDurationSeconds = 7;
|
||||||
|
optional string RenewalServerUrl = 8;
|
||||||
|
optional uint32 RenewalDelaySeconds = 9;
|
||||||
|
optional uint32 RenewalRetryIntervalSeconds = 10;
|
||||||
|
optional bool RenewWithUsage = 11; // was uint32
|
||||||
|
}
|
||||||
|
message KeyContainer {
|
||||||
|
enum KeyType {
|
||||||
|
SIGNING = 1;
|
||||||
|
CONTENT = 2;
|
||||||
|
KEY_CONTROL = 3;
|
||||||
|
OPERATOR_SESSION = 4;
|
||||||
|
}
|
||||||
|
enum SecurityLevel {
|
||||||
|
SW_SECURE_CRYPTO = 1;
|
||||||
|
SW_SECURE_DECODE = 2;
|
||||||
|
HW_SECURE_CRYPTO = 3;
|
||||||
|
HW_SECURE_DECODE = 4;
|
||||||
|
HW_SECURE_ALL = 5;
|
||||||
|
}
|
||||||
|
message OutputProtection {
|
||||||
|
enum CGMS {
|
||||||
|
COPY_FREE = 0;
|
||||||
|
COPY_ONCE = 2;
|
||||||
|
COPY_NEVER = 3;
|
||||||
|
CGMS_NONE = 0x2A; // PC default!
|
||||||
|
}
|
||||||
|
optional ClientIdentification.ClientCapabilities.HdcpVersion Hdcp = 1; // it's most likely a copy of Hdcp version available here, but compiler optimized it away
|
||||||
|
optional CGMS CgmsFlags = 2;
|
||||||
|
}
|
||||||
|
message KeyControl {
|
||||||
|
required bytes KeyControlBlock = 1; // what is this?
|
||||||
|
required bytes Iv = 2;
|
||||||
|
}
|
||||||
|
message OperatorSessionKeyPermissions {
|
||||||
|
optional uint32 AllowEncrypt = 1;
|
||||||
|
optional uint32 AllowDecrypt = 2;
|
||||||
|
optional uint32 AllowSign = 3;
|
||||||
|
optional uint32 AllowSignatureVerify = 4;
|
||||||
|
}
|
||||||
|
message VideoResolutionConstraint {
|
||||||
|
optional uint32 MinResolutionPixels = 1;
|
||||||
|
optional uint32 MaxResolutionPixels = 2;
|
||||||
|
optional OutputProtection RequiredProtection = 3;
|
||||||
|
}
|
||||||
|
optional bytes Id = 1;
|
||||||
|
optional bytes Iv = 2;
|
||||||
|
optional bytes Key = 3;
|
||||||
|
optional KeyType Type = 4;
|
||||||
|
optional SecurityLevel Level = 5;
|
||||||
|
optional OutputProtection RequiredProtection = 6;
|
||||||
|
optional OutputProtection RequestedProtection = 7;
|
||||||
|
optional KeyControl _KeyControl = 8; // duped names, etc
|
||||||
|
optional OperatorSessionKeyPermissions _OperatorSessionKeyPermissions = 9; // duped names, etc
|
||||||
|
repeated VideoResolutionConstraint VideoResolutionConstraints = 10;
|
||||||
|
}
|
||||||
|
optional LicenseIdentification Id = 1;
|
||||||
|
optional Policy _Policy = 2; // duped names, etc
|
||||||
|
repeated KeyContainer Key = 3;
|
||||||
|
optional uint32 LicenseStartTime = 4;
|
||||||
|
optional uint32 RemoteAttestationVerified = 5; // bool?
|
||||||
|
optional bytes ProviderClientToken = 6;
|
||||||
|
// there might be more, check with newer versions (I see field 7-8 in a lic)
|
||||||
|
// this appeared in latest x86:
|
||||||
|
optional uint32 ProtectionScheme = 7; // type unconfirmed fully, but it's likely as WidevineCencHeader describesit (fourcc)
|
||||||
|
}
|
||||||
|
|
||||||
|
message LicenseError {
|
||||||
|
enum Error {
|
||||||
|
INVALID_DEVICE_CERTIFICATE = 1;
|
||||||
|
REVOKED_DEVICE_CERTIFICATE = 2;
|
||||||
|
SERVICE_UNAVAILABLE = 3;
|
||||||
|
}
|
||||||
|
//LicenseRequest.RequestType ErrorCode; // clang mismatch
|
||||||
|
optional Error ErrorCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LicenseRequest {
|
||||||
|
message ContentIdentification {
|
||||||
|
message CENC {
|
||||||
|
//optional bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with:
|
||||||
|
optional WidevineCencHeader Pssh = 1;
|
||||||
|
optional LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1 (is this persist/don't persist? look into it!)
|
||||||
|
optional bytes RequestId = 3;
|
||||||
|
}
|
||||||
|
message WebM {
|
||||||
|
optional bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used
|
||||||
|
optional LicenseType LicenseType = 2;
|
||||||
|
optional bytes RequestId = 3;
|
||||||
|
}
|
||||||
|
message ExistingLicense {
|
||||||
|
optional LicenseIdentification LicenseId = 1;
|
||||||
|
optional uint32 SecondsSinceStarted = 2;
|
||||||
|
optional uint32 SecondsSinceLastPlayed = 3;
|
||||||
|
optional bytes SessionUsageTableEntry = 4; // interesting! try to figure out the connection between the usage table blob and KCB!
|
||||||
|
}
|
||||||
|
optional CENC CencId = 1;
|
||||||
|
optional WebM WebmId = 2;
|
||||||
|
optional ExistingLicense License = 3;
|
||||||
|
}
|
||||||
|
enum RequestType {
|
||||||
|
NEW = 1;
|
||||||
|
RENEWAL = 2;
|
||||||
|
RELEASE = 3;
|
||||||
|
}
|
||||||
|
optional ClientIdentification ClientId = 1;
|
||||||
|
optional ContentIdentification ContentId = 2;
|
||||||
|
optional RequestType Type = 3;
|
||||||
|
optional uint32 RequestTime = 4;
|
||||||
|
optional bytes KeyControlNonceDeprecated = 5;
|
||||||
|
optional ProtocolVersion ProtocolVersion = 6; // lacking symbols for this
|
||||||
|
optional uint32 KeyControlNonce = 7;
|
||||||
|
optional EncryptedClientIdentification EncryptedClientId = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw pssh hack
|
||||||
|
message LicenseRequestRaw {
|
||||||
|
message ContentIdentification {
|
||||||
|
message CENC {
|
||||||
|
optional bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with:
|
||||||
|
//optional WidevineCencHeader Pssh = 1;
|
||||||
|
optional LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1 (is this persist/don't persist? look into it!)
|
||||||
|
optional bytes RequestId = 3;
|
||||||
|
}
|
||||||
|
message WebM {
|
||||||
|
optional bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used
|
||||||
|
optional LicenseType LicenseType = 2;
|
||||||
|
optional bytes RequestId = 3;
|
||||||
|
}
|
||||||
|
message ExistingLicense {
|
||||||
|
optional LicenseIdentification LicenseId = 1;
|
||||||
|
optional uint32 SecondsSinceStarted = 2;
|
||||||
|
optional uint32 SecondsSinceLastPlayed = 3;
|
||||||
|
optional bytes SessionUsageTableEntry = 4; // interesting! try to figure out the connection between the usage table blob and KCB!
|
||||||
|
}
|
||||||
|
optional CENC CencId = 1;
|
||||||
|
optional WebM WebmId = 2;
|
||||||
|
optional ExistingLicense License = 3;
|
||||||
|
}
|
||||||
|
enum RequestType {
|
||||||
|
NEW = 1;
|
||||||
|
RENEWAL = 2;
|
||||||
|
RELEASE = 3;
|
||||||
|
}
|
||||||
|
optional ClientIdentification ClientId = 1;
|
||||||
|
optional ContentIdentification ContentId = 2;
|
||||||
|
optional RequestType Type = 3;
|
||||||
|
optional uint32 RequestTime = 4;
|
||||||
|
optional bytes KeyControlNonceDeprecated = 5;
|
||||||
|
optional ProtocolVersion ProtocolVersion = 6; // lacking symbols for this
|
||||||
|
optional uint32 KeyControlNonce = 7;
|
||||||
|
optional EncryptedClientIdentification EncryptedClientId = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message ProvisionedDeviceInfo {
|
||||||
|
enum WvSecurityLevel {
|
||||||
|
LEVEL_UNSPECIFIED = 0;
|
||||||
|
LEVEL_1 = 1;
|
||||||
|
LEVEL_2 = 2;
|
||||||
|
LEVEL_3 = 3;
|
||||||
|
}
|
||||||
|
optional uint32 SystemId = 1;
|
||||||
|
optional string Soc = 2;
|
||||||
|
optional string Manufacturer = 3;
|
||||||
|
optional string Model = 4;
|
||||||
|
optional string DeviceType = 5;
|
||||||
|
optional uint32 ModelYear = 6;
|
||||||
|
optional WvSecurityLevel SecurityLevel = 7;
|
||||||
|
optional uint32 TestDevice = 8; // bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message ProvisioningOptions {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message ProvisioningRequest {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message ProvisioningResponse {
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoteAttestation {
|
||||||
|
optional EncryptedClientIdentification Certificate = 1;
|
||||||
|
optional string Salt = 2;
|
||||||
|
optional string Signature = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SessionInit {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SessionState {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SignedCertificateStatusList {
|
||||||
|
}
|
||||||
|
|
||||||
|
message SignedDeviceCertificate {
|
||||||
|
|
||||||
|
//optional bytes DeviceCertificate = 1; // again, they use a buffer where it's supposed to be a message, so we'll replace it with what it really is:
|
||||||
|
optional DeviceCertificate _DeviceCertificate = 1; // how should we deal with duped names? will have to look at proto docs later
|
||||||
|
optional bytes Signature = 2;
|
||||||
|
optional SignedDeviceCertificate Signer = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SignedProvisioningMessage {
|
||||||
|
}
|
||||||
|
|
||||||
|
// the root of all messages, from either server or client
|
||||||
|
message SignedMessage {
|
||||||
|
enum MessageType {
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
optional bytes Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
optional RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// This message is copied from google's docs, not reversed:
|
||||||
|
message WidevineCencHeader {
|
||||||
|
enum Algorithm {
|
||||||
|
UNENCRYPTED = 0;
|
||||||
|
AESCTR = 1;
|
||||||
|
};
|
||||||
|
optional Algorithm algorithm = 1;
|
||||||
|
repeated bytes key_id = 2;
|
||||||
|
|
||||||
|
// Content provider name.
|
||||||
|
optional string provider = 3;
|
||||||
|
|
||||||
|
// A content identifier, specified by content provider.
|
||||||
|
optional bytes content_id = 4;
|
||||||
|
|
||||||
|
// Track type. Acceptable values are SD, HD and AUDIO. Used to
|
||||||
|
// differentiate content keys used by an asset.
|
||||||
|
optional string track_type_deprecated = 5;
|
||||||
|
|
||||||
|
// The name of a registered policy to be used for this asset.
|
||||||
|
optional string policy = 6;
|
||||||
|
|
||||||
|
// Crypto period index, for media using key rotation.
|
||||||
|
optional uint32 crypto_period_index = 7;
|
||||||
|
|
||||||
|
// Optional protected context for group content. The grouped_license is a
|
||||||
|
// serialized SignedMessage.
|
||||||
|
optional bytes grouped_license = 8;
|
||||||
|
|
||||||
|
// Protection scheme identifying the encryption algorithm.
|
||||||
|
// Represented as one of the following 4CC values:
|
||||||
|
// 'cenc' (AESCTR), 'cbc1' (AESCBC),
|
||||||
|
// 'cens' (AESCTR subsample), 'cbcs' (AESCBC subsample).
|
||||||
|
optional uint32 protection_scheme = 9;
|
||||||
|
|
||||||
|
// Optional. For media using key rotation, this represents the duration
|
||||||
|
// of each crypto period in seconds.
|
||||||
|
optional uint32 crypto_period_seconds = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// remove these when using it outside of protoc:
|
||||||
|
|
||||||
|
// from here on, it's just for testing, these messages don't exist in the binaries, I'm adding them to avoid detecting type programmatically
|
||||||
|
message SignedLicenseRequest {
|
||||||
|
enum MessageType {
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
optional LicenseRequest Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
optional RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hack
|
||||||
|
message SignedLicenseRequestRaw {
|
||||||
|
enum MessageType {
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
optional LicenseRequestRaw Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
optional RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message SignedLicense {
|
||||||
|
enum MessageType {
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
optional License Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
optional RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SignedServiceCertificate {
|
||||||
|
enum MessageType {
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
optional MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
optional SignedDeviceCertificate Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
optional bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
optional bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
optional RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
//vmp support
|
||||||
|
message FileHashes {
|
||||||
|
message Signature {
|
||||||
|
optional string filename = 1;
|
||||||
|
optional bool test_signing = 2; //0 - release, 1 - testing
|
||||||
|
optional bytes SHA512Hash = 3;
|
||||||
|
optional bool main_exe = 4; //0 for dlls, 1 for exe, this is field 3 in file
|
||||||
|
optional bytes signature = 5;
|
||||||
|
}
|
||||||
|
optional bytes signer = 1;
|
||||||
|
repeated Signature signatures = 2;
|
||||||
|
}
|
3324
pywidevine/cdm/formats/wv_proto2_pb2.py
Normal file
3324
pywidevine/cdm/formats/wv_proto2_pb2.py
Normal file
File diff suppressed because one or more lines are too long
389
pywidevine/cdm/formats/wv_proto3.proto
Normal file
389
pywidevine/cdm/formats/wv_proto3.proto
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
// beware proto3 won't show missing fields it seems, need to change to "proto2" and add "optional" before every field, and remove all the dummy enum members I added:
|
||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
// from x86 (partial), most of it from the ARM version:
|
||||||
|
message ClientIdentification {
|
||||||
|
enum TokenType {
|
||||||
|
KEYBOX = 0;
|
||||||
|
DEVICE_CERTIFICATE = 1;
|
||||||
|
REMOTE_ATTESTATION_CERTIFICATE = 2;
|
||||||
|
}
|
||||||
|
message NameValue {
|
||||||
|
string Name = 1;
|
||||||
|
string Value = 2;
|
||||||
|
}
|
||||||
|
message ClientCapabilities {
|
||||||
|
enum HdcpVersion {
|
||||||
|
HDCP_NONE = 0;
|
||||||
|
HDCP_V1 = 1;
|
||||||
|
HDCP_V2 = 2;
|
||||||
|
HDCP_V2_1 = 3;
|
||||||
|
HDCP_V2_2 = 4;
|
||||||
|
}
|
||||||
|
uint32 ClientToken = 1;
|
||||||
|
uint32 SessionToken = 2;
|
||||||
|
uint32 VideoResolutionConstraints = 3;
|
||||||
|
HdcpVersion MaxHdcpVersion = 4;
|
||||||
|
uint32 OemCryptoApiVersion = 5;
|
||||||
|
}
|
||||||
|
TokenType Type = 1;
|
||||||
|
//bytes Token = 2; // by default the client treats this as blob, but it's usually a DeviceCertificate, so for usefulness sake, I'm replacing it with this one:
|
||||||
|
SignedDeviceCertificate Token = 2;
|
||||||
|
repeated NameValue ClientInfo = 3;
|
||||||
|
bytes ProviderClientToken = 4;
|
||||||
|
uint32 LicenseCounter = 5;
|
||||||
|
ClientCapabilities _ClientCapabilities = 6; // how should we deal with duped names? will have to look at proto docs later
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeviceCertificate {
|
||||||
|
enum CertificateType {
|
||||||
|
ROOT = 0;
|
||||||
|
INTERMEDIATE = 1;
|
||||||
|
USER_DEVICE = 2;
|
||||||
|
SERVICE = 3;
|
||||||
|
}
|
||||||
|
//ProvisionedDeviceInfo.WvSecurityLevel Type = 1; // is this how one is supposed to call it? (it's an enum) there might be a bug here, with CertificateType getting confused with WvSecurityLevel, for now renaming it (verify against other binaries)
|
||||||
|
CertificateType Type = 1;
|
||||||
|
bytes SerialNumber = 2;
|
||||||
|
uint32 CreationTimeSeconds = 3;
|
||||||
|
bytes PublicKey = 4;
|
||||||
|
uint32 SystemId = 5;
|
||||||
|
uint32 TestDeviceDeprecated = 6; // is it bool or int?
|
||||||
|
bytes ServiceId = 7; // service URL for service certificates
|
||||||
|
}
|
||||||
|
|
||||||
|
// missing some references,
|
||||||
|
message DeviceCertificateStatus {
|
||||||
|
enum CertificateStatus {
|
||||||
|
VALID = 0;
|
||||||
|
REVOKED = 1;
|
||||||
|
}
|
||||||
|
bytes SerialNumber = 1;
|
||||||
|
CertificateStatus Status = 2;
|
||||||
|
ProvisionedDeviceInfo DeviceInfo = 4; // where is 3? is it deprecated?
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeviceCertificateStatusList {
|
||||||
|
uint32 CreationTimeSeconds = 1;
|
||||||
|
repeated DeviceCertificateStatus CertificateStatus = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message EncryptedClientIdentification {
|
||||||
|
string ServiceId = 1;
|
||||||
|
bytes ServiceCertificateSerialNumber = 2;
|
||||||
|
bytes EncryptedClientId = 3;
|
||||||
|
bytes EncryptedClientIdIv = 4;
|
||||||
|
bytes EncryptedPrivacyKey = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill (for this top-level type, it might be impossible/difficult)
|
||||||
|
enum LicenseType {
|
||||||
|
ZERO = 0;
|
||||||
|
DEFAULT = 1; // do not know what this is either, but should be 1; on recent versions may go up to 3 (latest x86)
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill (for this top-level type, it might be impossible/difficult)
|
||||||
|
// this is just a guess because these globals got lost, but really, do we need more?
|
||||||
|
enum ProtocolVersion {
|
||||||
|
DUMMY = 0;
|
||||||
|
CURRENT = 21; // don't have symbols for this
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message LicenseIdentification {
|
||||||
|
bytes RequestId = 1;
|
||||||
|
bytes SessionId = 2;
|
||||||
|
bytes PurchaseId = 3;
|
||||||
|
LicenseType Type = 4;
|
||||||
|
uint32 Version = 5;
|
||||||
|
bytes ProviderSessionToken = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message License {
|
||||||
|
message Policy {
|
||||||
|
uint32 CanPlay = 1;
|
||||||
|
uint32 CanPersist = 2;
|
||||||
|
uint32 CanRenew = 3;
|
||||||
|
uint32 RentalDurationSeconds = 4;
|
||||||
|
uint32 PlaybackDurationSeconds = 5;
|
||||||
|
uint32 LicenseDurationSeconds = 6;
|
||||||
|
uint32 RenewalRecoveryDurationSeconds = 7;
|
||||||
|
string RenewalServerUrl = 8;
|
||||||
|
uint32 RenewalDelaySeconds = 9;
|
||||||
|
uint32 RenewalRetryIntervalSeconds = 10;
|
||||||
|
uint32 RenewWithUsage = 11;
|
||||||
|
uint32 UnknownPolicy12 = 12;
|
||||||
|
}
|
||||||
|
message KeyContainer {
|
||||||
|
enum KeyType {
|
||||||
|
_NOKEYTYPE = 0; // dummy, added to satisfy proto3, not present in original
|
||||||
|
SIGNING = 1;
|
||||||
|
CONTENT = 2;
|
||||||
|
KEY_CONTROL = 3;
|
||||||
|
OPERATOR_SESSION = 4;
|
||||||
|
}
|
||||||
|
enum SecurityLevel {
|
||||||
|
_NOSECLEVEL = 0; // dummy, added to satisfy proto3, not present in original
|
||||||
|
SW_SECURE_CRYPTO = 1;
|
||||||
|
SW_SECURE_DECODE = 2;
|
||||||
|
HW_SECURE_CRYPTO = 3;
|
||||||
|
HW_SECURE_DECODE = 4;
|
||||||
|
HW_SECURE_ALL = 5;
|
||||||
|
}
|
||||||
|
message OutputProtection {
|
||||||
|
enum CGMS {
|
||||||
|
COPY_FREE = 0;
|
||||||
|
COPY_ONCE = 2;
|
||||||
|
COPY_NEVER = 3;
|
||||||
|
CGMS_NONE = 0x2A; // PC default!
|
||||||
|
}
|
||||||
|
ClientIdentification.ClientCapabilities.HdcpVersion Hdcp = 1; // it's most likely a copy of Hdcp version available here, but compiler optimized it away
|
||||||
|
CGMS CgmsFlags = 2;
|
||||||
|
}
|
||||||
|
message KeyControl {
|
||||||
|
bytes KeyControlBlock = 1; // what is this?
|
||||||
|
bytes Iv = 2;
|
||||||
|
}
|
||||||
|
message OperatorSessionKeyPermissions {
|
||||||
|
uint32 AllowEncrypt = 1;
|
||||||
|
uint32 AllowDecrypt = 2;
|
||||||
|
uint32 AllowSign = 3;
|
||||||
|
uint32 AllowSignatureVerify = 4;
|
||||||
|
}
|
||||||
|
message VideoResolutionConstraint {
|
||||||
|
uint32 MinResolutionPixels = 1;
|
||||||
|
uint32 MaxResolutionPixels = 2;
|
||||||
|
OutputProtection RequiredProtection = 3;
|
||||||
|
}
|
||||||
|
bytes Id = 1;
|
||||||
|
bytes Iv = 2;
|
||||||
|
bytes Key = 3;
|
||||||
|
KeyType Type = 4;
|
||||||
|
SecurityLevel Level = 5;
|
||||||
|
OutputProtection RequiredProtection = 6;
|
||||||
|
OutputProtection RequestedProtection = 7;
|
||||||
|
KeyControl _KeyControl = 8; // duped names, etc
|
||||||
|
OperatorSessionKeyPermissions _OperatorSessionKeyPermissions = 9; // duped names, etc
|
||||||
|
repeated VideoResolutionConstraint VideoResolutionConstraints = 10;
|
||||||
|
}
|
||||||
|
LicenseIdentification Id = 1;
|
||||||
|
Policy _Policy = 2; // duped names, etc
|
||||||
|
repeated KeyContainer Key = 3;
|
||||||
|
uint32 LicenseStartTime = 4;
|
||||||
|
uint32 RemoteAttestationVerified = 5; // bool?
|
||||||
|
bytes ProviderClientToken = 6;
|
||||||
|
// there might be more, check with newer versions (I see field 7-8 in a lic)
|
||||||
|
// this appeared in latest x86:
|
||||||
|
uint32 ProtectionScheme = 7; // type unconfirmed fully, but it's likely as WidevineCencHeader describesit (fourcc)
|
||||||
|
bytes UnknownHdcpDataField = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LicenseError {
|
||||||
|
enum Error {
|
||||||
|
DUMMY_NO_ERROR = 0; // dummy, added to satisfy proto3
|
||||||
|
INVALID_DEVICE_CERTIFICATE = 1;
|
||||||
|
REVOKED_DEVICE_CERTIFICATE = 2;
|
||||||
|
SERVICE_UNAVAILABLE = 3;
|
||||||
|
}
|
||||||
|
//LicenseRequest.RequestType ErrorCode; // clang mismatch
|
||||||
|
Error ErrorCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message LicenseRequest {
|
||||||
|
message ContentIdentification {
|
||||||
|
message CENC {
|
||||||
|
// bytes Pssh = 1; // the client's definition is opaque, it doesn't care about the contents, but the PSSH has a clear definition that is understood and requested by the server, thus I'll replace it with:
|
||||||
|
WidevineCencHeader Pssh = 1;
|
||||||
|
LicenseType LicenseType = 2; // unfortunately the LicenseType symbols are not present, acceptable value seems to only be 1
|
||||||
|
bytes RequestId = 3;
|
||||||
|
}
|
||||||
|
message WebM {
|
||||||
|
bytes Header = 1; // identical to CENC, aside from PSSH and the parent field number used
|
||||||
|
LicenseType LicenseType = 2;
|
||||||
|
bytes RequestId = 3;
|
||||||
|
}
|
||||||
|
message ExistingLicense {
|
||||||
|
LicenseIdentification LicenseId = 1;
|
||||||
|
uint32 SecondsSinceStarted = 2;
|
||||||
|
uint32 SecondsSinceLastPlayed = 3;
|
||||||
|
bytes SessionUsageTableEntry = 4;
|
||||||
|
}
|
||||||
|
CENC CencId = 1;
|
||||||
|
WebM WebmId = 2;
|
||||||
|
ExistingLicense License = 3;
|
||||||
|
}
|
||||||
|
enum RequestType {
|
||||||
|
DUMMY_REQ_TYPE = 0; // dummy, added to satisfy proto3
|
||||||
|
NEW = 1;
|
||||||
|
RENEWAL = 2;
|
||||||
|
RELEASE = 3;
|
||||||
|
}
|
||||||
|
ClientIdentification ClientId = 1;
|
||||||
|
ContentIdentification ContentId = 2;
|
||||||
|
RequestType Type = 3;
|
||||||
|
uint32 RequestTime = 4;
|
||||||
|
bytes KeyControlNonceDeprecated = 5;
|
||||||
|
ProtocolVersion ProtocolVersion = 6; // lacking symbols for this
|
||||||
|
uint32 KeyControlNonce = 7;
|
||||||
|
EncryptedClientIdentification EncryptedClientId = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ProvisionedDeviceInfo {
|
||||||
|
enum WvSecurityLevel {
|
||||||
|
LEVEL_UNSPECIFIED = 0;
|
||||||
|
LEVEL_1 = 1;
|
||||||
|
LEVEL_2 = 2;
|
||||||
|
LEVEL_3 = 3;
|
||||||
|
}
|
||||||
|
uint32 SystemId = 1;
|
||||||
|
string Soc = 2;
|
||||||
|
string Manufacturer = 3;
|
||||||
|
string Model = 4;
|
||||||
|
string DeviceType = 5;
|
||||||
|
uint32 ModelYear = 6;
|
||||||
|
WvSecurityLevel SecurityLevel = 7;
|
||||||
|
uint32 TestDevice = 8; // bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message ProvisioningOptions {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message ProvisioningRequest {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message ProvisioningResponse {
|
||||||
|
}
|
||||||
|
|
||||||
|
message RemoteAttestation {
|
||||||
|
EncryptedClientIdentification Certificate = 1;
|
||||||
|
string Salt = 2;
|
||||||
|
string Signature = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SessionInit {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SessionState {
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SignedCertificateStatusList {
|
||||||
|
}
|
||||||
|
|
||||||
|
message SignedDeviceCertificate {
|
||||||
|
|
||||||
|
//bytes DeviceCertificate = 1; // again, they use a buffer where it's supposed to be a message, so we'll replace it with what it really is:
|
||||||
|
DeviceCertificate _DeviceCertificate = 1; // how should we deal with duped names? will have to look at proto docs later
|
||||||
|
bytes Signature = 2;
|
||||||
|
SignedDeviceCertificate Signer = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// todo: fill
|
||||||
|
message SignedProvisioningMessage {
|
||||||
|
}
|
||||||
|
|
||||||
|
// the root of all messages, from either server or client
|
||||||
|
message SignedMessage {
|
||||||
|
enum MessageType {
|
||||||
|
DUMMY_MSG_TYPE = 0; // dummy, added to satisfy proto3
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
bytes Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// This message is copied from google's docs, not reversed:
|
||||||
|
message WidevineCencHeader {
|
||||||
|
enum Algorithm {
|
||||||
|
UNENCRYPTED = 0;
|
||||||
|
AESCTR = 1;
|
||||||
|
};
|
||||||
|
Algorithm algorithm = 1;
|
||||||
|
repeated bytes key_id = 2;
|
||||||
|
|
||||||
|
// Content provider name.
|
||||||
|
string provider = 3;
|
||||||
|
|
||||||
|
// A content identifier, specified by content provider.
|
||||||
|
bytes content_id = 4;
|
||||||
|
|
||||||
|
// Track type. Acceptable values are SD, HD and AUDIO. Used to
|
||||||
|
// differentiate content keys used by an asset.
|
||||||
|
string track_type_deprecated = 5;
|
||||||
|
|
||||||
|
// The name of a registered policy to be used for this asset.
|
||||||
|
string policy = 6;
|
||||||
|
|
||||||
|
// Crypto period index, for media using key rotation.
|
||||||
|
uint32 crypto_period_index = 7;
|
||||||
|
|
||||||
|
// Optional protected context for group content. The grouped_license is a
|
||||||
|
// serialized SignedMessage.
|
||||||
|
bytes grouped_license = 8;
|
||||||
|
|
||||||
|
// Protection scheme identifying the encryption algorithm.
|
||||||
|
// Represented as one of the following 4CC values:
|
||||||
|
// 'cenc' (AESCTR), 'cbc1' (AESCBC),
|
||||||
|
// 'cens' (AESCTR subsample), 'cbcs' (AESCBC subsample).
|
||||||
|
uint32 protection_scheme = 9;
|
||||||
|
|
||||||
|
// Optional. For media using key rotation, this represents the duration
|
||||||
|
// of each crypto period in seconds.
|
||||||
|
uint32 crypto_period_seconds = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// from here on, it's just for testing, these messages don't exist in the binaries, I'm adding them to avoid detecting type programmatically
|
||||||
|
message SignedLicenseRequest {
|
||||||
|
enum MessageType {
|
||||||
|
DUMMY_MSG_TYPE = 0; // dummy, added to satisfy proto3
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
LicenseRequest Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SignedLicense {
|
||||||
|
enum MessageType {
|
||||||
|
DUMMY_MSG_TYPE = 0; // dummy, added to satisfy proto3
|
||||||
|
LICENSE_REQUEST = 1;
|
||||||
|
LICENSE = 2;
|
||||||
|
ERROR_RESPONSE = 3;
|
||||||
|
SERVICE_CERTIFICATE_REQUEST = 4;
|
||||||
|
SERVICE_CERTIFICATE = 5;
|
||||||
|
}
|
||||||
|
MessageType Type = 1; // has in incorrect overlap with License_KeyContainer_SecurityLevel
|
||||||
|
License Msg = 2; // this has to be casted dynamically, to LicenseRequest, License or LicenseError (? unconfirmed), for Request, no other fields but Type need to be present
|
||||||
|
// for SERVICE_CERTIFICATE, only Type and Msg are present, and it's just a DeviceCertificate with CertificateType set to SERVICE
|
||||||
|
bytes Signature = 3; // might be different type of signatures (ex. RSA vs AES CMAC(??), unconfirmed for now)
|
||||||
|
bytes SessionKey = 4; // often RSA wrapped for licenses
|
||||||
|
RemoteAttestation RemoteAttestation = 5;
|
||||||
|
}
|
2686
pywidevine/cdm/formats/wv_proto3_pb2.py
Normal file
2686
pywidevine/cdm/formats/wv_proto3_pb2.py
Normal file
File diff suppressed because one or more lines are too long
15
pywidevine/cdm/key.py
Normal file
15
pywidevine/cdm/key.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import binascii
|
||||||
|
|
||||||
|
|
||||||
|
class Key:
|
||||||
|
def __init__(self, kid, type, key, permissions=[]):
|
||||||
|
self.kid = kid
|
||||||
|
self.type = type
|
||||||
|
self.key = key
|
||||||
|
self.permissions = permissions
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
if self.type == "OPERATOR_SESSION":
|
||||||
|
return "key(kid={}, type={}, key={}, permissions={})".format(binascii.hexlify(self.kid), self.type, binascii.hexlify(self.key), self.permissions)
|
||||||
|
else:
|
||||||
|
return "key(kid={}, type={}, key={})".format(binascii.hexlify(self.kid), self.type, binascii.hexlify(self.key))
|
18
pywidevine/cdm/session.py
Normal file
18
pywidevine/cdm/session.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
class Session:
|
||||||
|
def __init__(self, session_id, init_data, device_config, offline):
|
||||||
|
self.session_id = session_id
|
||||||
|
self.init_data = init_data
|
||||||
|
self.offline = offline
|
||||||
|
self.device_config = device_config
|
||||||
|
self.device_key = None
|
||||||
|
self.session_key = None
|
||||||
|
self.derived_keys = {
|
||||||
|
'enc': None,
|
||||||
|
'auth_1': None,
|
||||||
|
'auth_2': None
|
||||||
|
}
|
||||||
|
self.license_request = None
|
||||||
|
self.license = None
|
||||||
|
self.service_certificate = None
|
||||||
|
self.privacy_mode = False
|
||||||
|
self.keys = []
|
102
pywidevine/cdm/vmp.py
Normal file
102
pywidevine/cdm/vmp.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
try:
|
||||||
|
from google.protobuf.internal.decoder import _DecodeVarint as _di # this was tested to work with protobuf 3, but it's an internal API (any varint decoder might work)
|
||||||
|
except ImportError:
|
||||||
|
# this is generic and does not depend on pb internals, however it will decode "larger" possible numbers than pb decoder which has them fixed
|
||||||
|
def LEB128_decode(buffer, pos, limit = 64):
|
||||||
|
result = 0
|
||||||
|
shift = 0
|
||||||
|
while True:
|
||||||
|
b = buffer[pos]
|
||||||
|
pos += 1
|
||||||
|
result |= ((b & 0x7F) << shift)
|
||||||
|
if not (b & 0x80):
|
||||||
|
return (result, pos)
|
||||||
|
shift += 7
|
||||||
|
if shift > limit:
|
||||||
|
raise Exception("integer too large, shift: {}".format(shift))
|
||||||
|
_di = LEB128_decode
|
||||||
|
|
||||||
|
|
||||||
|
class FromFileMixin:
|
||||||
|
@classmethod
|
||||||
|
def from_file(cls, filename):
|
||||||
|
"""Load given a filename"""
|
||||||
|
with open(filename,"rb") as f:
|
||||||
|
return cls(f.read())
|
||||||
|
|
||||||
|
# the signatures use a format internally similar to
|
||||||
|
# protobuf's encoding, but without wire types
|
||||||
|
class VariableReader(FromFileMixin):
|
||||||
|
"""Protobuf-like encoding reader"""
|
||||||
|
|
||||||
|
def __init__(self, buf):
|
||||||
|
self.buf = buf
|
||||||
|
self.pos = 0
|
||||||
|
self.size = len(buf)
|
||||||
|
|
||||||
|
def read_int(self):
|
||||||
|
"""Read a variable length integer"""
|
||||||
|
# _DecodeVarint will take care of out of range errors
|
||||||
|
(val, nextpos) = _di(self.buf, self.pos)
|
||||||
|
self.pos = nextpos
|
||||||
|
return val
|
||||||
|
|
||||||
|
def read_bytes_raw(self, size):
|
||||||
|
"""Read size bytes"""
|
||||||
|
b = self.buf[self.pos:self.pos+size]
|
||||||
|
self.pos += size
|
||||||
|
return b
|
||||||
|
|
||||||
|
def read_bytes(self):
|
||||||
|
"""Read a bytes object"""
|
||||||
|
size = self.read_int()
|
||||||
|
return self.read_bytes_raw(size)
|
||||||
|
|
||||||
|
def is_end(self):
|
||||||
|
return (self.size == self.pos)
|
||||||
|
|
||||||
|
|
||||||
|
class TaggedReader(VariableReader):
|
||||||
|
"""Tagged reader, needed for implementing a WideVine signature reader"""
|
||||||
|
|
||||||
|
def read_tag(self):
|
||||||
|
"""Read a tagged buffer"""
|
||||||
|
return (self.read_int(), self.read_bytes())
|
||||||
|
|
||||||
|
def read_all_tags(self, max_tag=3):
|
||||||
|
tags = {}
|
||||||
|
while (not self.is_end()):
|
||||||
|
(tag, bytes) = self.read_tag()
|
||||||
|
if (tag > max_tag):
|
||||||
|
raise IndexError("tag out of bound: got {}, max {}".format(tag, max_tag))
|
||||||
|
|
||||||
|
tags[tag] = bytes
|
||||||
|
return tags
|
||||||
|
|
||||||
|
class WideVineSignatureReader(FromFileMixin):
|
||||||
|
"""Parses a widevine .sig signature file."""
|
||||||
|
|
||||||
|
SIGNER_TAG = 1
|
||||||
|
SIGNATURE_TAG = 2
|
||||||
|
ISMAINEXE_TAG = 3
|
||||||
|
|
||||||
|
def __init__(self, buf):
|
||||||
|
reader = TaggedReader(buf)
|
||||||
|
self.version = reader.read_int()
|
||||||
|
if (self.version != 0):
|
||||||
|
raise Exception("Unsupported signature format version {}".format(self.version))
|
||||||
|
self.tags = reader.read_all_tags()
|
||||||
|
|
||||||
|
self.signer = self.tags[self.SIGNER_TAG]
|
||||||
|
self.signature = self.tags[self.SIGNATURE_TAG]
|
||||||
|
|
||||||
|
extra = self.tags[self.ISMAINEXE_TAG]
|
||||||
|
if (len(extra) != 1 or (extra[0] > 1)):
|
||||||
|
raise Exception("Unexpected 'ismainexe' field value (not '\\x00' or '\\x01'), please check: {0}".format(extra))
|
||||||
|
|
||||||
|
self.mainexe = bool(extra[0])
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_tags(cls, filename):
|
||||||
|
"""Return a dictionary of each tag in the signature file"""
|
||||||
|
return cls.from_file(filename).tags
|
BIN
pywidevine/clients/__pycache__/proxy_config.cpython-39.pyc
Normal file
BIN
pywidevine/clients/__pycache__/proxy_config.cpython-39.pyc
Normal file
Binary file not shown.
44
pywidevine/clients/dictionary.py
Normal file
44
pywidevine/clients/dictionary.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import re
|
||||||
|
from unidecode import unidecode
|
||||||
|
|
||||||
|
def get_release_tag(default_filename, vcodec, video_height, acodec, channels, bitrate, module, tag, isDual):
|
||||||
|
video_codec = ''
|
||||||
|
|
||||||
|
if 'avc' in vcodec:
|
||||||
|
video_codec = 'x264'
|
||||||
|
if 'hvc' in vcodec:
|
||||||
|
video_codec = 'x265'
|
||||||
|
elif 'dvh' in vcodec:
|
||||||
|
video_codec = 'HDR'
|
||||||
|
if isDual==True:
|
||||||
|
video_codec = video_codec + '.DUAL'
|
||||||
|
|
||||||
|
audio_codec = ''
|
||||||
|
if 'mp4a' in acodec:
|
||||||
|
audio_codec = 'AAC'
|
||||||
|
if acodec == 'ac-3':
|
||||||
|
audio_codec = 'DD'
|
||||||
|
if acodec == 'ec-3':
|
||||||
|
audio_codec = 'DDP'
|
||||||
|
elif acodec == 'ec-3' and bitrate > 700000:
|
||||||
|
audio_codec = 'Atmos'
|
||||||
|
|
||||||
|
audio_channels = ''
|
||||||
|
if channels == '2':
|
||||||
|
audio_channels = '2.0'
|
||||||
|
elif channels == '6':
|
||||||
|
audio_channels = '5.1'
|
||||||
|
audio_format = audio_codec + audio_channels
|
||||||
|
|
||||||
|
#if isDual==True:
|
||||||
|
# audio_format = audio_codec + '.DUAL'
|
||||||
|
|
||||||
|
|
||||||
|
default_filename = default_filename.replace('&', '.and.')
|
||||||
|
default_filename = re.sub(r'[]!"#$%\'()*+,:;<=>?@\\^_`{|}~[-]', '', default_filename)
|
||||||
|
default_filename = default_filename.replace(' ', '.')
|
||||||
|
default_filename = re.sub(r'\.{2,}', '.', default_filename)
|
||||||
|
default_filename = unidecode(default_filename)
|
||||||
|
|
||||||
|
output_name = '{}.{}p.{}.WEB-DL.{}.{}-{}'.format(default_filename, video_height, str(module), audio_format, video_codec, tag)
|
||||||
|
return output_name
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
35
pywidevine/clients/paramountplus/config.py
Normal file
35
pywidevine/clients/paramountplus/config.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
from shutil import which
|
||||||
|
from os.path import dirname, realpath, join
|
||||||
|
from os import pathsep, environ
|
||||||
|
|
||||||
|
SCRIPT_PATH = dirname(realpath('paramountplus'))
|
||||||
|
|
||||||
|
BINARIES_FOLDER = join(SCRIPT_PATH, 'binaries')
|
||||||
|
|
||||||
|
MP4DECRYPT_BINARY = 'mp4decrypt'
|
||||||
|
MEDIAINFO_BINARY = 'mediainfo'
|
||||||
|
MP4DUMP_BINARY = 'mp4dump'
|
||||||
|
MKVMERGE_BINARY = 'mkvmerge'
|
||||||
|
FFMPEG_BINARY = 'ffmpeg'
|
||||||
|
FFMPEG_BINARY = 'ffmpeg'
|
||||||
|
ARIA2C_BINARY = 'aria2c'
|
||||||
|
SUBTITLE_EDIT_BINARY = 'subtitleedit'
|
||||||
|
|
||||||
|
# Add binaries folder to PATH as the first item
|
||||||
|
environ['PATH'] = pathsep.join([BINARIES_FOLDER, environ['PATH']])
|
||||||
|
|
||||||
|
MP4DECRYPT = which(MP4DECRYPT_BINARY)
|
||||||
|
MEDIAINFO = which(MEDIAINFO_BINARY)
|
||||||
|
MP4DUMP = which(MP4DUMP_BINARY)
|
||||||
|
MKVMERGE = which(MKVMERGE_BINARY)
|
||||||
|
FFMPEG = which(FFMPEG_BINARY)
|
||||||
|
ARIA2C = which(ARIA2C_BINARY)
|
||||||
|
SUBTITLE_EDIT = which(SUBTITLE_EDIT_BINARY)
|
||||||
|
|
||||||
|
class WvDownloaderConfig(object):
|
||||||
|
def __init__(self, xml, base_url, output_file, track_id, format_id):
|
||||||
|
self.xml = xml
|
||||||
|
self.base_url = base_url
|
||||||
|
self.output_file = output_file
|
||||||
|
self.track_id = track_id
|
||||||
|
self.format_id = format_id
|
116
pywidevine/clients/paramountplus/downloader.py
Normal file
116
pywidevine/clients/paramountplus/downloader.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import requests, pathlib
|
||||||
|
import math, subprocess
|
||||||
|
import os, sys, shutil
|
||||||
|
|
||||||
|
class WvDownloader(object):
|
||||||
|
def __init__(self, config):
|
||||||
|
self.xml = config.xml
|
||||||
|
self.output_file = config.output_file
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
def download_track(self, aria2c_infile, file_name):
|
||||||
|
aria2c_opts = [
|
||||||
|
'aria2c',
|
||||||
|
'--enable-color=false',
|
||||||
|
'--allow-overwrite=true',
|
||||||
|
'--summary-interval=0',
|
||||||
|
'--download-result=hide',
|
||||||
|
'--async-dns=false',
|
||||||
|
'--check-certificate=false',
|
||||||
|
'--auto-file-renaming=false',
|
||||||
|
'--file-allocation=none',
|
||||||
|
'--console-log-level=warn',
|
||||||
|
'-x16', '-s16', '-j16',
|
||||||
|
'-i', aria2c_infile]
|
||||||
|
subprocess.run(aria2c_opts)
|
||||||
|
|
||||||
|
source_files = pathlib.Path(temp_folder).rglob(r'./*.mp4')
|
||||||
|
with open(file_name, mode='wb') as (destination):
|
||||||
|
for file in source_files:
|
||||||
|
with open(file, mode='rb') as (source):
|
||||||
|
shutil.copyfileobj(source, destination)
|
||||||
|
if os.path.exists(temp_folder):
|
||||||
|
shutil.rmtree(temp_folder)
|
||||||
|
os.remove(aria2c_infile)
|
||||||
|
print('\nDone!')
|
||||||
|
|
||||||
|
def process_url_templace(self, template, representation_id, bandwidth, time, number):
|
||||||
|
if representation_id is not None: result = template.replace('$RepresentationID$', representation_id)
|
||||||
|
if number is not None:
|
||||||
|
nstart = result.find('$Number')
|
||||||
|
if nstart >= 0:
|
||||||
|
nend = result.find('$', nstart+1)
|
||||||
|
if nend >= 0:
|
||||||
|
var = result[nstart+1 : nend]
|
||||||
|
if 'Number%' in var:
|
||||||
|
value = var[6:] % (int(number))
|
||||||
|
else:
|
||||||
|
value = number
|
||||||
|
result = result.replace('$'+var+'$', value)
|
||||||
|
if bandwidth is not None: result = result.replace('$Bandwidth$', bandwidth)
|
||||||
|
if time is not None: result = result.replace('$Time$', time)
|
||||||
|
result = result.replace('$$', '$').replace('../', '')
|
||||||
|
return result
|
||||||
|
|
||||||
|
def generate_segments(self):
|
||||||
|
segment_template = self.get_segment_template()
|
||||||
|
return self.get_segments(segment_template)
|
||||||
|
|
||||||
|
def get_segments(self, segment_template):
|
||||||
|
urls = []
|
||||||
|
urls.append(self.config.base_url + segment_template['@initialization'].replace('$RepresentationID$', self.config.format_id))
|
||||||
|
current_number = 1
|
||||||
|
for seg in self.force_segmentimeline(segment_template):
|
||||||
|
if '@t' in seg:
|
||||||
|
current_time = seg['@t']
|
||||||
|
for i in range(int(seg.get('@r', 0)) + 1):
|
||||||
|
urls.append(self.config.base_url + self.process_url_templace(segment_template['@media'],
|
||||||
|
representation_id=self.config.format_id,
|
||||||
|
bandwidth=None, time=str(current_time), number=str(current_number)))
|
||||||
|
current_number += 1
|
||||||
|
current_time += seg['@d']
|
||||||
|
return urls
|
||||||
|
|
||||||
|
def force_segmentimeline(self, segment_timeline):
|
||||||
|
if isinstance(segment_timeline['SegmentTimeline']['S'], list):
|
||||||
|
x16 = segment_timeline['SegmentTimeline']['S']
|
||||||
|
else:
|
||||||
|
x16 = [segment_timeline['SegmentTimeline']['S']]
|
||||||
|
return x16
|
||||||
|
|
||||||
|
def force_instance(self, x):
|
||||||
|
if isinstance(x['Representation'], list):
|
||||||
|
X = x['Representation']
|
||||||
|
else:
|
||||||
|
X = [x['Representation']]
|
||||||
|
return X
|
||||||
|
|
||||||
|
def get_segment_template(self):
|
||||||
|
x = [item for (i, item) in enumerate(self.xml['MPD']['Period']['AdaptationSet']) if self.config.track_id == item["@id"]][0]
|
||||||
|
segment_level = [item['SegmentTemplate'] for (i, item) in enumerate(self.force_instance(x)) if self.config.format_id == item["@id"]][0]
|
||||||
|
return segment_level
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
urls = self.generate_segments()
|
||||||
|
|
||||||
|
print('\n' + self.output_file)
|
||||||
|
global temp_folder
|
||||||
|
aria2c_infile = 'aria2c_infile.txt'
|
||||||
|
if os.path.isfile(aria2c_infile):
|
||||||
|
os.remove(aria2c_infile)
|
||||||
|
temp_folder = self.output_file.replace('.mp4', '')
|
||||||
|
if os.path.exists(temp_folder):
|
||||||
|
shutil.rmtree(temp_folder)
|
||||||
|
if not os.path.exists(temp_folder):
|
||||||
|
os.makedirs(temp_folder)
|
||||||
|
|
||||||
|
if len(urls) > 1:
|
||||||
|
num_segments = int(math.log10(len(urls))) + 1
|
||||||
|
with open(aria2c_infile, 'a', encoding='utf8') as (file):
|
||||||
|
for (i, url) in enumerate(urls):
|
||||||
|
file.write(f'{url}\n')
|
||||||
|
file.write(f'\tout={temp_folder}.{i:0{num_segments}d}.mp4\n')
|
||||||
|
file.write(f'\tdir={temp_folder}\n')
|
||||||
|
file.flush()
|
||||||
|
self.download_track(aria2c_infile, self.output_file)
|
||||||
|
print('Done!')
|
15
pywidevine/clients/proxy_config.py
Normal file
15
pywidevine/clients/proxy_config.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
config = {
|
||||||
|
'proxies': {
|
||||||
|
'none': None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProxyConfig(object):
|
||||||
|
def __init__(self, proxies):
|
||||||
|
self.config = config
|
||||||
|
self.config['proxies'] = proxies
|
||||||
|
|
||||||
|
def get_proxy(self, proxy):
|
||||||
|
return self.config['proxies'].get(proxy)
|
||||||
|
|
53
pywidevine/decrypt/wvdecrypt.py
Normal file
53
pywidevine/decrypt/wvdecrypt.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import logging, subprocess, re, base64
|
||||||
|
from pywidevine.cdm import cdm, deviceconfig
|
||||||
|
|
||||||
|
class WvDecrypt(object):
|
||||||
|
WV_SYSTEM_ID = [
|
||||||
|
237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237]
|
||||||
|
|
||||||
|
def __init__(self, init_data_b64, cert_data_b64):
|
||||||
|
self.init_data_b64 = init_data_b64
|
||||||
|
self.cert_data_b64 = cert_data_b64
|
||||||
|
self.cdm = cdm.Cdm()
|
||||||
|
|
||||||
|
def check_pssh(pssh_b64):
|
||||||
|
pssh = base64.b64decode(pssh_b64)
|
||||||
|
if not pssh[12:28] == bytes(self.WV_SYSTEM_ID):
|
||||||
|
new_pssh = bytearray([0, 0, 0])
|
||||||
|
new_pssh.append(32 + len(pssh))
|
||||||
|
new_pssh[4:] = bytearray(b'pssh')
|
||||||
|
new_pssh[8:] = [0, 0, 0, 0]
|
||||||
|
new_pssh[13:] = self.WV_SYSTEM_ID
|
||||||
|
new_pssh[29:] = [0, 0, 0, 0]
|
||||||
|
new_pssh[31] = len(pssh)
|
||||||
|
new_pssh[32:] = pssh
|
||||||
|
return base64.b64encode(new_pssh)
|
||||||
|
else:
|
||||||
|
return pssh_b64
|
||||||
|
|
||||||
|
self.session = self.cdm.open_session(check_pssh(self.init_data_b64), deviceconfig.DeviceConfig(deviceconfig.device_android_generic))
|
||||||
|
if self.cert_data_b64:
|
||||||
|
self.cdm.set_service_certificate(self.session, self.cert_data_b64)
|
||||||
|
|
||||||
|
def log_message(self, msg):
|
||||||
|
return ('{}').format(msg)
|
||||||
|
|
||||||
|
def start_process(self):
|
||||||
|
keyswvdecrypt = []
|
||||||
|
try:
|
||||||
|
for key in self.cdm.get_keys(self.session):
|
||||||
|
if key.type == 'CONTENT':
|
||||||
|
keyswvdecrypt.append(self.log_message('{}:{}'.format(key.kid.hex(),key.key.hex())))
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
False, keyswvdecrypt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
True, keyswvdecrypt)
|
||||||
|
|
||||||
|
def get_challenge(self):
|
||||||
|
return self.cdm.get_license_request(self.session)
|
||||||
|
|
||||||
|
def update_license(self, license_b64):
|
||||||
|
self.cdm.provide_license(self.session, license_b64)
|
||||||
|
return True
|
59
pywidevine/decrypt/wvdecryptcustom.py
Normal file
59
pywidevine/decrypt/wvdecryptcustom.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# uncompyle6 version 3.7.3
|
||||||
|
# Python bytecode 3.6 (3379)
|
||||||
|
# Decompiled from: Python 3.7.8 (tags/v3.7.8:4b47a5b6ba, Jun 28 2020, 08:53:46) [MSC v.1916 64 bit (AMD64)]
|
||||||
|
# Embedded file name: pywidevine\decrypt\wvdecryptcustom.py
|
||||||
|
import logging, subprocess, re, base64
|
||||||
|
from pywidevine.cdm import cdm, deviceconfig
|
||||||
|
|
||||||
|
class WvDecrypt(object):
|
||||||
|
WV_SYSTEM_ID = [
|
||||||
|
237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237]
|
||||||
|
|
||||||
|
def __init__(self, init_data_b64, cert_data_b64, device):
|
||||||
|
self.init_data_b64 = init_data_b64
|
||||||
|
self.cert_data_b64 = cert_data_b64
|
||||||
|
self.device = device
|
||||||
|
self.cdm = cdm.Cdm()
|
||||||
|
|
||||||
|
def check_pssh(pssh_b64):
|
||||||
|
pssh = base64.b64decode(pssh_b64)
|
||||||
|
if not pssh[12:28] == bytes(self.WV_SYSTEM_ID):
|
||||||
|
new_pssh = bytearray([0, 0, 0])
|
||||||
|
new_pssh.append(32 + len(pssh))
|
||||||
|
new_pssh[4:] = bytearray(b'pssh')
|
||||||
|
new_pssh[8:] = [0, 0, 0, 0]
|
||||||
|
new_pssh[13:] = self.WV_SYSTEM_ID
|
||||||
|
new_pssh[29:] = [0, 0, 0, 0]
|
||||||
|
new_pssh[31] = len(pssh)
|
||||||
|
new_pssh[32:] = pssh
|
||||||
|
return base64.b64encode(new_pssh)
|
||||||
|
else:
|
||||||
|
return pssh_b64
|
||||||
|
|
||||||
|
self.session = self.cdm.open_session(check_pssh(self.init_data_b64), deviceconfig.DeviceConfig(self.device))
|
||||||
|
if self.cert_data_b64:
|
||||||
|
self.cdm.set_service_certificate(self.session, self.cert_data_b64)
|
||||||
|
|
||||||
|
def log_message(self, msg):
|
||||||
|
return '{}'.format(msg)
|
||||||
|
|
||||||
|
def start_process(self):
|
||||||
|
keyswvdecrypt = []
|
||||||
|
try:
|
||||||
|
for key in self.cdm.get_keys(self.session):
|
||||||
|
if key.type == 'CONTENT':
|
||||||
|
keyswvdecrypt.append(self.log_message('{}:{}'.format(key.kid.hex(), key.key.hex())))
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
False, keyswvdecrypt)
|
||||||
|
else:
|
||||||
|
return (
|
||||||
|
True, keyswvdecrypt)
|
||||||
|
|
||||||
|
def get_challenge(self):
|
||||||
|
return self.cdm.get_license_request(self.session)
|
||||||
|
|
||||||
|
def update_license(self, license_b64):
|
||||||
|
self.cdm.provide_license(self.session, license_b64)
|
||||||
|
return True
|
BIN
pywidevine/muxer/__pycache__/muxer.cpython-39.pyc
Normal file
BIN
pywidevine/muxer/__pycache__/muxer.cpython-39.pyc
Normal file
Binary file not shown.
271
pywidevine/muxer/muxer.py
Normal file
271
pywidevine/muxer/muxer.py
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
class Muxer(object):
|
||||||
|
def __init__(self, CurrentName, SeasonFolder, CurrentHeigh, Type, mkvmergeexe):
|
||||||
|
self.CurrentName = CurrentName
|
||||||
|
self.SeasonFolder = SeasonFolder
|
||||||
|
self.CurrentHeigh = CurrentHeigh
|
||||||
|
self.Type = Type
|
||||||
|
self.mkvmergeexe = mkvmergeexe
|
||||||
|
|
||||||
|
def mkvmerge_muxer(self, lang):
|
||||||
|
VideoInputNoExist = False
|
||||||
|
if os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].h264'):
|
||||||
|
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].h264'
|
||||||
|
if self.Type == "show":
|
||||||
|
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].mkv')
|
||||||
|
else:
|
||||||
|
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].mkv'
|
||||||
|
|
||||||
|
if os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p].h264'):
|
||||||
|
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p].h264'
|
||||||
|
if self.Type == "show":
|
||||||
|
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p].mkv')
|
||||||
|
else:
|
||||||
|
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p].mkv'
|
||||||
|
|
||||||
|
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].mp4'):
|
||||||
|
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].mp4'
|
||||||
|
if self.Type == "show":
|
||||||
|
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].mkv')
|
||||||
|
else:
|
||||||
|
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].mkv'
|
||||||
|
|
||||||
|
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].h265'):
|
||||||
|
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].h265'
|
||||||
|
if self.Type == "show":
|
||||||
|
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].mkv')
|
||||||
|
else:
|
||||||
|
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].mkv'
|
||||||
|
|
||||||
|
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].mp4'):
|
||||||
|
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].mp4'
|
||||||
|
if self.Type == "show":
|
||||||
|
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].mkv')
|
||||||
|
else:
|
||||||
|
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].mkv'
|
||||||
|
|
||||||
|
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].vp9'):
|
||||||
|
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].vp9'
|
||||||
|
if self.Type == "show":
|
||||||
|
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].mkv')
|
||||||
|
else:
|
||||||
|
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].mkv'
|
||||||
|
|
||||||
|
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].mp4'):
|
||||||
|
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].mp4'
|
||||||
|
if self.Type == "show":
|
||||||
|
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].mkv')
|
||||||
|
else:
|
||||||
|
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].mkv'
|
||||||
|
|
||||||
|
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].h265'):
|
||||||
|
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].h265'
|
||||||
|
if self.Type == "show":
|
||||||
|
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].mkv')
|
||||||
|
else:
|
||||||
|
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].mkv'
|
||||||
|
|
||||||
|
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].mp4'):
|
||||||
|
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].mp4'
|
||||||
|
if self.Type == "show":
|
||||||
|
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].mkv')
|
||||||
|
else:
|
||||||
|
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].mkv'
|
||||||
|
|
||||||
|
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].h264'):
|
||||||
|
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].h264'
|
||||||
|
if self.Type == "show":
|
||||||
|
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].mkv')
|
||||||
|
else:
|
||||||
|
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].mkv'
|
||||||
|
|
||||||
|
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].mp4'):
|
||||||
|
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].mp4'
|
||||||
|
if self.Type == "show":
|
||||||
|
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].mkv')
|
||||||
|
else:
|
||||||
|
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].mkv'
|
||||||
|
|
||||||
|
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p].mp4'):
|
||||||
|
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p].mp4'
|
||||||
|
if self.Type == "show":
|
||||||
|
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p].mkv')
|
||||||
|
else:
|
||||||
|
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p].mkv'
|
||||||
|
else:
|
||||||
|
VideoInputNoExist = True
|
||||||
|
|
||||||
|
if VideoInputNoExist == False:
|
||||||
|
AudioExtensionsList=[
|
||||||
|
".ac3",
|
||||||
|
".mka",
|
||||||
|
".eac3",
|
||||||
|
".m4a",
|
||||||
|
".dts",
|
||||||
|
".mp3",
|
||||||
|
".aac"
|
||||||
|
]
|
||||||
|
|
||||||
|
SubsExtensionsList= [
|
||||||
|
".srt",
|
||||||
|
".ass",
|
||||||
|
]
|
||||||
|
|
||||||
|
if lang == "English":
|
||||||
|
language_tag = "English"
|
||||||
|
|
||||||
|
if language_tag == "English":
|
||||||
|
subs_forced = 'Forced'
|
||||||
|
subs_sdh = 'SDH'
|
||||||
|
#["en", "en", "eng", "English", "yes", "yes"]
|
||||||
|
#[audio_language, subs_language, language_id, language_name, audio_default, subs_default]
|
||||||
|
LanguageList = [
|
||||||
|
["es-la", "es-la", "spa", "Spanish", "yes", "no"],
|
||||||
|
["es", "es", "spa", "Castilian", "no", "no"],
|
||||||
|
["en", "en", "eng", "English", "no", "no"],
|
||||||
|
["pt-BR", "pt-BR", "por", "Brazilian Portuguese", "no", "no"],
|
||||||
|
["cat", "cat", "cat", "Catalan", "no", "no"],
|
||||||
|
["eu", "eu", "baq", "Basque", "no", "no"],
|
||||||
|
["fr", "fr", "fre", "French", "no", "no"],
|
||||||
|
["fr-bg", "fr-bg", "fre", "French (Belgium)", "no", "no"],
|
||||||
|
["fr-lu", "fr-lu", "fre", "French (Luxembourg)", "no", "no"],
|
||||||
|
["fr-ca", "fr-ca", "fre", "French (Canada)", "no", "no"],
|
||||||
|
["de", "de", "ger", "German", "no", "no"],
|
||||||
|
["it", "it", "ita", "Italian", "no", "no"],
|
||||||
|
["pl", "pl", "pol", "Polish", "no", "no"],
|
||||||
|
["tr", "tr", "tur", "Turkish", "no", "no"],
|
||||||
|
["hy", "hy", "arm", "Armenian", "no", "no"],
|
||||||
|
["sv", "sv", "swe", "Swedish", "no", "no"],
|
||||||
|
["da", "da", "dan", "Danish", "no", "no"],
|
||||||
|
["fi", "fi", "fin", "Finnish", "no", "no"],
|
||||||
|
["nl", "nl", "dut", "Dutch", "no", "no"],
|
||||||
|
["nl-be", "nl-be", "dut", "Flemish", "no", "no"],
|
||||||
|
["no", "no", "nor", "Norwegian", "no", "no"],
|
||||||
|
["lv", "lv", "lav", "Latvian", "no", "no"],
|
||||||
|
["is", "is", "ice", "Icelandic", "no", "no"],
|
||||||
|
["ru", "ru", "rus", "Russian", "no", "no"],
|
||||||
|
["uk", "uk", "ukr", "Ukrainian", "no", "no"],
|
||||||
|
["hu", "hu", "hun", "Hungarian", "no", "no"],
|
||||||
|
["bg", "bg", "bul", "Bulgarian", "no", "no"],
|
||||||
|
["hr", "hr", "hrv", "Croatian", "no", "no"],
|
||||||
|
["lt", "lt", "lit", "Lithuanian", "no", "no"],
|
||||||
|
["et", "et", "est", "Estonian", "no", "no"],
|
||||||
|
["el", "el", "gre", "Greek", "no", "no"],
|
||||||
|
["he", "he", "heb", "Hebrew", "no", "no"],
|
||||||
|
["ar", "ar", "ara", "Arabic", "no", "no"],
|
||||||
|
["fa", "fa", "per", "Persian", "no", "no"],
|
||||||
|
["ro", "ro", "rum", "Romanian", "no", "no"],
|
||||||
|
["sr", "sr", "srp", "Serbian", "no", "no"],
|
||||||
|
["cs", "cs", "cze", "Czech", "no", "no"],
|
||||||
|
["sk", "sk", "slo", "Slovak", "no", "no"],
|
||||||
|
["sl", "sl", "slv", "Slovenian", "no", "no"],
|
||||||
|
["sq", "sq", "alb", "Albanian", "no", "no"],
|
||||||
|
["bs", "bs", "bos", "Bosnian", "no", "no"],
|
||||||
|
["mk", "mk", "mac", "Macedonian", "no", "no"],
|
||||||
|
["hi", "hi", "hin", "Hindi", "no", "no"],
|
||||||
|
["bn", "bn", "ben", "Bengali", "no", "no"],
|
||||||
|
["ur", "ur", "urd", "Urdu", "no", "no"],
|
||||||
|
["pa", "pa", "pan", "Punjabi", "no", "no"],
|
||||||
|
["ta", "ta", "tam", "Tamil", "no", "no"],
|
||||||
|
["te", "te", "tel", "Telugu", "no", "no"],
|
||||||
|
["mr", "mr", "mar", "Marathi", "no", "no"],
|
||||||
|
["kn", "kn", "kan", "Kannada (India)", "no", "no"],
|
||||||
|
["gu", "gu", "guj", "Gujarati", "no", "no"],
|
||||||
|
["ml", "ml", "mal", "Malayalam", "no", "no"],
|
||||||
|
["si", "si", "sin", "Sinhala", "no", "no"],
|
||||||
|
["as", "as", "asm", "Assamese", "no", "no"],
|
||||||
|
["mni", "mni", "mni", "Manipuri", "no", "no"],
|
||||||
|
["tl", "tl", "tgl", "Tagalog", "no", "no"],
|
||||||
|
["id", "id", "ind", "Indonesian", "no", "no"],
|
||||||
|
["ms", "ms", "may", "Malay", "no", "no"],
|
||||||
|
["fil", "fil", "fil", "Filipino", "no", "no"],
|
||||||
|
["vi", "vi", "vie", "Vietnamese", "no", "no"],
|
||||||
|
["th", "th", "tha", "Thai", "no", "no"],
|
||||||
|
["km", "km", "khm", "Khmer", "no", "no"],
|
||||||
|
["ko", "ko", "kor", "Korean", "no", "no"],
|
||||||
|
["zh", "zh", "chi", "Mandarin", "no", "no"],
|
||||||
|
["yue", "yue", "chi", "Cantonese", "no", "no"],
|
||||||
|
["zh-hans", "zh-hans", "chi", "Chinese (Simplified)", "no", "no"],
|
||||||
|
["zh-hant", "zh-hant", "chi", "Chinese (Traditional)", "no", "no"],
|
||||||
|
["zh-hk", "zh-hk", "chi", "Chinese (Simplified)", "no", "no"],
|
||||||
|
["zh-tw", "zh-tw", "chi", "Chinese (Traditional)", "no", "no"],
|
||||||
|
["zh-sg", "zh-sg", "chi", "Chinese (Singapore)", "no", "no"],
|
||||||
|
["ja", "ja", "jpn", "Japanese", "no", "no"],
|
||||||
|
["tlh", "tlh", "tlh", "Klingon", "no", "no"],
|
||||||
|
["zxx", "zxx", "zxx", "No Dialogue", "no", "no"]
|
||||||
|
]
|
||||||
|
|
||||||
|
ALLAUDIOS = []
|
||||||
|
default_active_audio = False
|
||||||
|
for audio_language, subs_language, language_id, language_name, audio_default, subs_default in LanguageList:
|
||||||
|
for AudioExtension in AudioExtensionsList:
|
||||||
|
if os.path.isfile(self.CurrentName + ' (' + audio_language + ')' + AudioExtension):
|
||||||
|
if default_active_audio == True: audio_default = "no"
|
||||||
|
ALLAUDIOS = ALLAUDIOS + ['--language', '0:' + audio_language, '--track-name', '0:' + language_name, '--default-track', '0:' + audio_default, '(', self.CurrentName + ' (' + audio_language + ')' + AudioExtension, ')']
|
||||||
|
if audio_default == "yes": default_active_audio = True
|
||||||
|
|
||||||
|
for audio_language, subs_language, language_id, language_name, audio_default, subs_default in LanguageList:
|
||||||
|
for AudioExtension in AudioExtensionsList:
|
||||||
|
if os.path.isfile(self.CurrentName + ' (' + audio_language + '-ad)' + AudioExtension):
|
||||||
|
if default_active_audio == True: audio_default = "no"
|
||||||
|
ALLAUDIOS = ALLAUDIOS + ['--language', '0:' + audio_language, '--track-name', '0:' + language_name + ' (Audio Description)', '--default-track', '0:no', '(', self.CurrentName + ' (' + audio_language + '-ad)' + AudioExtension, ')']
|
||||||
|
if audio_default == "yes": default_active_audio = True
|
||||||
|
|
||||||
|
OnlyOneLanguage = False
|
||||||
|
if len(ALLAUDIOS) == 9:
|
||||||
|
OnlyOneLanguage = True
|
||||||
|
|
||||||
|
elif len(ALLAUDIOS) == 18:
|
||||||
|
if ALLAUDIOS[1] == ALLAUDIOS[10]:
|
||||||
|
if '-ad' in ALLAUDIOS[7] or '-ad' in ALLAUDIOS[16]:
|
||||||
|
OnlyOneLanguage = True
|
||||||
|
else:
|
||||||
|
OnlyOneLanguage = False
|
||||||
|
|
||||||
|
|
||||||
|
ALLSUBS = []
|
||||||
|
default_active_subs = False
|
||||||
|
for audio_language, subs_language, language_id, language_name, audio_default, subs_default in LanguageList:
|
||||||
|
for SubsExtension in SubsExtensionsList:
|
||||||
|
if os.path.isfile(self.CurrentName + ' (' + subs_language + '-forced)' + SubsExtension):
|
||||||
|
if subs_default == "yes": default_active_subs = True
|
||||||
|
ALLSUBS = ALLSUBS + ['--language', '0:' + language_id, '--track-name', '0:' + subs_forced, '--forced-track', '0:yes', '--default-track', '0:' + subs_default, '--compression', '0:none', '(', self.CurrentName + ' (' + subs_language + '-forced)' + SubsExtension, ')']
|
||||||
|
|
||||||
|
if OnlyOneLanguage == True:
|
||||||
|
if default_active_subs == True: subs_default = "no"
|
||||||
|
if os.path.isfile(self.CurrentName + ' (' + subs_language + ')' + SubsExtension):
|
||||||
|
ALLSUBS = ALLSUBS + ['--language', '0:' + language_id, '--forced-track', '0:no', '--default-track', '0:' + subs_default, '--compression', '0:none', '(', self.CurrentName + ' (' + subs_language + ')' + SubsExtension, ')']
|
||||||
|
|
||||||
|
else:
|
||||||
|
if os.path.isfile(self.CurrentName + ' (' + subs_language + ')' + SubsExtension):
|
||||||
|
ALLSUBS = ALLSUBS + ['--language', '0:' + language_id, '--forced-track', '0:no', '--default-track', '0:no', '--compression', '0:none', '(', self.CurrentName + ' (' + subs_language + ')' + SubsExtension, ')']
|
||||||
|
|
||||||
|
if os.path.isfile(self.CurrentName + ' (' + subs_language + '-sdh)' + SubsExtension):
|
||||||
|
ALLSUBS = ALLSUBS + ['--language', '0:' + language_id, '--track-name', '0:' + subs_sdh, '--forced-track', '0:no', '--default-track', '0:no', '--compression', '0:none', '(', self.CurrentName + ' (' + subs_language + '-sdh)' + SubsExtension, ')']
|
||||||
|
|
||||||
|
#(Chapters)
|
||||||
|
if os.path.isfile(self.CurrentName+' Chapters.txt'):
|
||||||
|
CHAPTERS=['--chapter-charset', 'UTF-8', '--chapters', self.CurrentName + ' Chapters.txt']
|
||||||
|
else:
|
||||||
|
CHAPTERS=[]
|
||||||
|
|
||||||
|
|
||||||
|
mkvmerge_command_video = [self.mkvmergeexe,
|
||||||
|
'-q',
|
||||||
|
'--output',
|
||||||
|
VideoOutputName,
|
||||||
|
'--language',
|
||||||
|
'0:und',
|
||||||
|
'--default-track',
|
||||||
|
'0:yes',
|
||||||
|
'(',
|
||||||
|
VideoInputName,
|
||||||
|
')']
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
mkvmerge_command = mkvmerge_command_video + ALLAUDIOS + ALLSUBS + CHAPTERS
|
||||||
|
mkvmerge_process = subprocess.run(mkvmerge_command)
|
16
requirements.txt
Normal file
16
requirements.txt
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
pymediainfo==5.1.0
|
||||||
|
future==0.18.2
|
||||||
|
xmltodict==0.12.0
|
||||||
|
m3u8==0.9.0
|
||||||
|
requests==2.25.1
|
||||||
|
asgiref==3.4.1
|
||||||
|
titlecase==2.2.0
|
||||||
|
graphql-core==2.3.2
|
||||||
|
streamlink==2.2.0
|
||||||
|
Unidecode==1.2.0
|
||||||
|
tqdm==4.61.1
|
||||||
|
isodate==0.6.0
|
||||||
|
graphql-relay==2.0.1
|
||||||
|
prettytable==2.1.0
|
||||||
|
urllib3==1.26.5
|
||||||
|
PyJWT==2.1.0
|
Reference in New Issue
Block a user