Upload
This commit is contained in:
0
pywidevine/cdm/__init__.py
Normal file
0
pywidevine/cdm/__init__.py
Normal file
364
pywidevine/cdm/cdm.py
Normal file
364
pywidevine/cdm/cdm.py
Normal file
@@ -0,0 +1,364 @@
|
||||
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, 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 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())
|
||||
session.device_key = key
|
||||
else:
|
||||
self.logger.error("need device private key, other methods unimplemented")
|
||||
return 1
|
||||
|
||||
self.logger.debug("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
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
print(permissions)
|
||||
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
|
||||
74
pywidevine/cdm/deviceconfig.py
Normal file
74
pywidevine/cdm/deviceconfig.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import os
|
||||
|
||||
device_android_generic_lvl3 = {
|
||||
'name': 'android_generic_lvl3',
|
||||
'description': 'android studio cdm',
|
||||
'security_level': 3,
|
||||
'session_id_type': 'android',
|
||||
'private_key_available': True,
|
||||
'vmp': False,
|
||||
'send_key_control_nonce': True
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
device_chromecdm_2209 = {
|
||||
'name': 'chromecdm_2209',
|
||||
'description': 'chrome cdm windows 2209',
|
||||
'security_level': 3,
|
||||
'session_id_type': 'chrome',
|
||||
'private_key_available': False,
|
||||
'vmp': True,
|
||||
'send_key_control_nonce': False
|
||||
}
|
||||
|
||||
devices_available = [device_chromecdm_2209, device_android_generic_lvl3]
|
||||
|
||||
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)
|
||||
BIN
pywidevine/cdm/devices/android_generic.zip
Normal file
BIN
pywidevine/cdm/devices/android_generic.zip
Normal file
Binary file not shown.
BIN
pywidevine/cdm/devices/android_generic/device_client_id_blob
Normal file
BIN
pywidevine/cdm/devices/android_generic/device_client_id_blob
Normal file
Binary file not shown.
27
pywidevine/cdm/devices/android_generic/device_private_key
Normal file
27
pywidevine/cdm/devices/android_generic/device_private_key
Normal file
@@ -0,0 +1,27 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpQIBAAKCAQEA4sUKDpvMG/idF8oCH5AVSwFd5Mk+rEwOBsLZMYdliXWe1hn9
|
||||
mdE6u9pjsr+bLrZjlKxMFqPPxbIUcC1Ii7BFSje2Fd8kxnaIprQWxDPgK+NSSx7v
|
||||
Un452TyB1L9lx39ZBt0PlRfwjkCodX+I9y+oBga73NRh7hPbtLzXe/r/ubFBaEu+
|
||||
aRkDZBwYPqHgH1RoFLuyFNMjfqGcPosGxceDtvPysmBxB93Hk2evml5fjdYGg6tx
|
||||
z510g+XFPDFv7GSy1KuWqit83MqzPls9qAQMkwUc05ggjDhGCKW4/p97fn23WDFE
|
||||
3TzSSsQvyJLKA3s9oJbtJCD/gOHYqDvnWn8zPwIDAQABAoIBAQDCWe1Mp+o+7sx0
|
||||
XwWC15HoPruiIXg9YtGCqexLrqcvMEd5Z70Z32BfL8TSpbTyTA78lM6BeNPRs9Yg
|
||||
bi8GyYQZH7ZG+IAkN+LWPPJmJa+y7ZjSGSkzoksiC+GZ3I/2cwZyA3Qfa+0XfgLi
|
||||
8PMKJyXyREMt+DgWO57JQC/OakhRdCR19mM6NKd+ynd/IEz/NIbjMLDVKwW8HEPx
|
||||
N3r5CU9O96nr62DI68KVj3jwUR3cDi/5xfhosYhCQjHJuobNbeFR18dY2nQNLWYd
|
||||
S0wtskla1fl9eYHwYAzwru4wHT4WJC7+V4pscfCI0YZB6PslxDKrv73l5H1tz4cf
|
||||
Vy58NRSBAoGBAPSmjoVtQzTvQ6PZIs81SF1ulJI9kUpyFaBoSSgt+2ZkeNtF6Hih
|
||||
Zm7OVJ9wg9sfjpB3SFBUjuhXz/ts/t6dkA2PgCbrvhBMRKSGbfyhhtM2gRf002I4
|
||||
bJ7Y0C/ont4WzC/XbXEkAmh+fG2/JRvbdVQaIdyS6MmVHtCtRsHEQZS5AoGBAO1K
|
||||
IXOKAFA+320+Hkbqskfevmxrv+JHIdetliaREZwQH+VYUUM8u5/Kt3oyMat+mH90
|
||||
rZOKQK2zM8cz4tKclTUT54nrtICxeo6UHVc56FqXZ6sVvVgm8Cnvt1md4XwG4FwQ
|
||||
r/OlaM6Hr5HRf8dkzuzqm4ZQYRHGzZ6AMphj8Xu3AoGAdmo7p5dIJVH98kuCDrsi
|
||||
iJ6iaNpF/buUfiyb5EfFXD0bRj7jE6hDdTSHPxjtqVzv2zrxFHipJwqBz5dlEYlA
|
||||
FWA0ziHiv+66dsveZp4kLQ0/lMHaorre0E/vDJFSe/qa4DksbsvYIo2+WjxfkMk7
|
||||
U/bGFwZAiHmWDbkg+16rw3kCgYEAyyodWf9eJVavlakJ404vNrnP8KSQtfyRTUii
|
||||
toKewTBNHuBvM1JckoPOdCFlxZ+ukfIka56DojU8r+IM4qaOWdOg+sWE1mses9S9
|
||||
CmHaPzZC3IjQhRlRp5ZHNcOnu7lnf2wKOmH1Sl+CQydMcDwvr0lvv6AyfDXq9zps
|
||||
F2365CECgYEAmYgs/qwnh9m0aGDw/ZGrASoE0TxlpizPvsVDGx9t9UGC2Z+5QvAE
|
||||
ZcQeKoLCbktr0BnRLI+W1g+KpXQGcnSF9VX/qwUlf72XA6C6kobQvW+Yd/H/IN5d
|
||||
jPqoL/m41rRzm+J+9/Tfc8Aiy1kkllUYnVJdC5QLAIswuhI8lkaFTN4=
|
||||
-----END RSA PRIVATE KEY-----
|
||||
BIN
pywidevine/cdm/devices/android_generic_lvl3.zip
Normal file
BIN
pywidevine/cdm/devices/android_generic_lvl3.zip
Normal file
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpQIBAAKCAQEA4sUKDpvMG/idF8oCH5AVSwFd5Mk+rEwOBsLZMYdliXWe1hn9
|
||||
mdE6u9pjsr+bLrZjlKxMFqPPxbIUcC1Ii7BFSje2Fd8kxnaIprQWxDPgK+NSSx7v
|
||||
Un452TyB1L9lx39ZBt0PlRfwjkCodX+I9y+oBga73NRh7hPbtLzXe/r/ubFBaEu+
|
||||
aRkDZBwYPqHgH1RoFLuyFNMjfqGcPosGxceDtvPysmBxB93Hk2evml5fjdYGg6tx
|
||||
z510g+XFPDFv7GSy1KuWqit83MqzPls9qAQMkwUc05ggjDhGCKW4/p97fn23WDFE
|
||||
3TzSSsQvyJLKA3s9oJbtJCD/gOHYqDvnWn8zPwIDAQABAoIBAQDCWe1Mp+o+7sx0
|
||||
XwWC15HoPruiIXg9YtGCqexLrqcvMEd5Z70Z32BfL8TSpbTyTA78lM6BeNPRs9Yg
|
||||
bi8GyYQZH7ZG+IAkN+LWPPJmJa+y7ZjSGSkzoksiC+GZ3I/2cwZyA3Qfa+0XfgLi
|
||||
8PMKJyXyREMt+DgWO57JQC/OakhRdCR19mM6NKd+ynd/IEz/NIbjMLDVKwW8HEPx
|
||||
N3r5CU9O96nr62DI68KVj3jwUR3cDi/5xfhosYhCQjHJuobNbeFR18dY2nQNLWYd
|
||||
S0wtskla1fl9eYHwYAzwru4wHT4WJC7+V4pscfCI0YZB6PslxDKrv73l5H1tz4cf
|
||||
Vy58NRSBAoGBAPSmjoVtQzTvQ6PZIs81SF1ulJI9kUpyFaBoSSgt+2ZkeNtF6Hih
|
||||
Zm7OVJ9wg9sfjpB3SFBUjuhXz/ts/t6dkA2PgCbrvhBMRKSGbfyhhtM2gRf002I4
|
||||
bJ7Y0C/ont4WzC/XbXEkAmh+fG2/JRvbdVQaIdyS6MmVHtCtRsHEQZS5AoGBAO1K
|
||||
IXOKAFA+320+Hkbqskfevmxrv+JHIdetliaREZwQH+VYUUM8u5/Kt3oyMat+mH90
|
||||
rZOKQK2zM8cz4tKclTUT54nrtICxeo6UHVc56FqXZ6sVvVgm8Cnvt1md4XwG4FwQ
|
||||
r/OlaM6Hr5HRf8dkzuzqm4ZQYRHGzZ6AMphj8Xu3AoGAdmo7p5dIJVH98kuCDrsi
|
||||
iJ6iaNpF/buUfiyb5EfFXD0bRj7jE6hDdTSHPxjtqVzv2zrxFHipJwqBz5dlEYlA
|
||||
FWA0ziHiv+66dsveZp4kLQ0/lMHaorre0E/vDJFSe/qa4DksbsvYIo2+WjxfkMk7
|
||||
U/bGFwZAiHmWDbkg+16rw3kCgYEAyyodWf9eJVavlakJ404vNrnP8KSQtfyRTUii
|
||||
toKewTBNHuBvM1JckoPOdCFlxZ+ukfIka56DojU8r+IM4qaOWdOg+sWE1mses9S9
|
||||
CmHaPzZC3IjQhRlRp5ZHNcOnu7lnf2wKOmH1Sl+CQydMcDwvr0lvv6AyfDXq9zps
|
||||
F2365CECgYEAmYgs/qwnh9m0aGDw/ZGrASoE0TxlpizPvsVDGx9t9UGC2Z+5QvAE
|
||||
ZcQeKoLCbktr0BnRLI+W1g+KpXQGcnSF9VX/qwUlf72XA6C6kobQvW+Yd/H/IN5d
|
||||
jPqoL/m41rRzm+J+9/Tfc8Aiy1kkllUYnVJdC5QLAIswuhI8lkaFTN4=
|
||||
-----END RSA PRIVATE KEY-----
|
||||
BIN
pywidevine/cdm/devices/asus_x00dd/device_client_id_blob
Normal file
BIN
pywidevine/cdm/devices/asus_x00dd/device_client_id_blob
Normal file
Binary file not shown.
28
pywidevine/cdm/devices/asus_x00dd/device_private_key
Normal file
28
pywidevine/cdm/devices/asus_x00dd/device_private_key
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkEEu74m+vIBU/
|
||||
fz4YvhcwmQyUsngcd+ikFT5DTsV0Fy5mczmcEJtnsouRBTK8/aJe9I/Blo5sjG8t
|
||||
PZGStA+Nx2DWxMuLVo+w9gkdarJlRVZmvtMMRMtUW5+5HnvUlXT9v8BA7lCi5jq9
|
||||
oVb5FtNIkFov6Eeor6bZipW2keNMYNA8G1gv3vAzWVxLkyJq+4lkonKSHH+nho0/
|
||||
zjS5tA/j0zsgt3BY3WUkDGR4EUUAQW6dnYskdknCz2LebG0ijWRMWWhvKrqXPD3w
|
||||
0c6eHd5sMM79iht2+eZq5uQhvkY6TUA5v1+YkCRN/tYcLI85CFSbP5bwnTAeF/FA
|
||||
TLCf7NyjAgMBAAECggEAMpHFTkHS6/dnJT3KvaSmEDg0nQbPp/K71guoN+XpTcmx
|
||||
ZynACZ9Cz3lEtKwWAhPpvvwB7YlDGClHQXoXS1FN9AUPD9Pz61yAiOqlT5hqKXXw
|
||||
VlYeRktUIwnk1xIS2ClDOM2obDsoxrVRSM8YuEl1UYoRCnA92jxeIPKrB+260nkM
|
||||
EhdA37yY0hzrSM4FkjDkDCsT6H0bT47L4xN4WsIX+QO5eMqFv6O6niJGyWZbAd0t
|
||||
Rskb7/uX54TCcLE14ed6wZZfP2Dk68TjH4WjP5ZsU5+zmwzG5OxlvNExalc0bQ+h
|
||||
HDxfnllo2ZeAvP2qo4iVUI/UhdB94itCUgMolh8SuQKBgQDfzU4+txR7DWlZEeRV
|
||||
rt5AUJ9x1ILDB/oKR/DR7kkTxmTPqmqJZze4IE1IyxHj3VuC0vYwdtjcOdaFODj5
|
||||
Pd+gHg1D9bVrFwtYeEMeUODJJYPVx9ZJmWx4Zc07wcY71xCK91AQpn8IOknxv/Zx
|
||||
CeKtHk2xMe5QdlWxINZOj5BkGQKBgQC7qs9RVZeTMW9qRCpx6vECbPsJdxGreyG8
|
||||
f6uDK81+vxQk574O9Oxk3Oe+tGpxFRuNHMng/Ce6r8MQNWWpeRstHk8ScApadfXE
|
||||
BzUJakHrPuGm6800VO2Qk4uL7hT2lTF3B9qxyrKstQDk1eRYElnwPd0LA6FPo0iO
|
||||
/vTiG55+GwKBgQCZH7dxYYI5zytNLdi0ZzjZeFAfae8t0b9aWW6DtC17wjone8n4
|
||||
yQ5uhGtcatSKu2FpWT3oWuh1hX0pGRaiMZtXyjf5T5wY8WNp3J7Plza1aV2SX3BW
|
||||
WwrENaL+R86CEtvWuO+m9MPltTvylAzhuonFOP6fRjJwCwcGchkvFzsLwQKBgAwf
|
||||
Dz74oU6vjbu2ZWtwOlrmwRwuZaTG0joDoQda5+3814c+tKbwqOgm4I0PyCQ/scTe
|
||||
jCeNAd/sBFRirotQuDGk2ISfWfvXzKT5tjJCe7l/GUIXzuxHZATHkubZ3N7Eaip/
|
||||
BmAfcIT5N/KP4izSGp8Kz+ErPdsP99rMmvKTyBvrAoGAVe1Uw9Cu+pqmagX1QT5U
|
||||
nERJ3YZCQNfCApYb2iDyN7XNtR6Izz6EvymK5UGu5MgjMUlN60vg0ZSShtTaV9j5
|
||||
mXfiExU+arzcR+j2ZR92VaV69Nx1KNuhBX2VXPqhUeVvsES2Y+yuTumnUYfm4tP9
|
||||
A0co0TS6lIU2JYdtOsjWTFg=
|
||||
-----END PRIVATE KEY-----
|
||||
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-38.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/__init__.cpython-38.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-38.pyc
Normal file
BIN
pywidevine/cdm/formats/__pycache__/wv_proto2_pb2.cpython-38.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
14
pywidevine/cdm/key.py
Normal file
14
pywidevine/cdm/key.py
Normal file
@@ -0,0 +1,14 @@
|
||||
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(self.kid, self.type, binascii.hexlify(self.key), self.permissions)
|
||||
else:
|
||||
return "key(kid={}, type={}, key={})".format(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
|
||||
45
pywidevine/clients/appletv/client.py
Normal file
45
pywidevine/clients/appletv/client.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import requests, json, os, re
|
||||
import pywidevine.clients.appletv.config as aptv_cfg
|
||||
import urllib.parse
|
||||
|
||||
currentFile = 'appletv'
|
||||
realPath = os.path.realpath(currentFile)
|
||||
dirPath = os.path.dirname(realPath)
|
||||
cookies_file = dirPath + '/cookies/' + 'cookies_aptv.txt'
|
||||
|
||||
def get_auth_headers(content_url):
|
||||
|
||||
def urldecode(str):
|
||||
return urllib.parse.unquote(str)
|
||||
|
||||
def parseCookieFile(cookiefile):
|
||||
cookies = {}
|
||||
with open (cookies_file, 'r') as fp:
|
||||
for line in fp:
|
||||
if not re.match(r'^\#', line):
|
||||
lineFields = line.strip().split('\t')
|
||||
cookies[lineFields[5]] = lineFields[6]
|
||||
return cookies
|
||||
|
||||
COOKIES = parseCookieFile(dirPath + '/cookies/' + cookies_file)
|
||||
COMMOM_HEADERS = aptv_cfg.COMMOM_HEADERS
|
||||
COMMOM_HEADERS["media-user-token"] = COOKIES["media-user-token"]
|
||||
|
||||
while 1:
|
||||
html_data = requests.get(url=content_url, headers=COMMOM_HEADERS)
|
||||
if html_data.ok:
|
||||
break
|
||||
|
||||
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:
|
||||
rg = re.compile('(<meta name="web-tv-app/config/environment" content=")(.*)("><!-- EMBER_CLI_FASTBOOT_TITLE --)')
|
||||
m = rg.search(div)
|
||||
if m:
|
||||
AUTH_TOKEN = json.loads(urldecode(m[2]))["MEDIA_API"]["token"]
|
||||
|
||||
COMMOM_HEADERS["authorization"] = "Bearer %s" % (AUTH_TOKEN)
|
||||
|
||||
return {"wvHeaders": COMMOM_HEADERS}, COOKIES
|
||||
49
pywidevine/clients/appletv/config.py
Normal file
49
pywidevine/clients/appletv/config.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from shutil import which
|
||||
from os.path import dirname, realpath, join
|
||||
from os import pathsep, environ
|
||||
|
||||
APTV_ENDPOINTS = {
|
||||
'SERIES': 'https://tv.apple.com/api/uts/v2/view/show/umc.cmc.%s/episodes',
|
||||
'PRODUCT': 'https://tv.apple.com/api/uts/v2/view/product/%s',
|
||||
'CONTENT_DATA': 'https://tv.apple.com/api/uts/v2/view/product/%s/personalized',
|
||||
'WV_LICENSE': 'https://play.itunes.apple.com/WebObjects/MZPlay.woa/web/video/subscription/license',
|
||||
'WV_CERT': 'https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/widevineCert'
|
||||
}
|
||||
|
||||
APTV_PARAMS = {'utscf': 'OjAAAAAAAAA~', 'utsk': '6e3013c6d6fae3c2::::::9724ac949afa4bb2', 'caller': 'web', 'sf': '143468', 'v': '40', 'pfm': 'web', 'locale': 'en-US'}
|
||||
|
||||
SCRIPT_PATH = dirname(realpath('appletv'))
|
||||
|
||||
BINARIES_FOLDER = join(SCRIPT_PATH, 'binaries')
|
||||
|
||||
SHAKA_PACKAGER_BINARY = 'packager-win'
|
||||
MEDIAINFO_BINARY = 'mediainfo'
|
||||
MP4DUMP_BINARY = 'mp4dump'
|
||||
MKVMERGE_BINARY = 'mkvmerge'
|
||||
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']])
|
||||
|
||||
SHAKA_PACKAGER = which(SHAKA_PACKAGER_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, m3u8, output_file):
|
||||
self.m3u8 = m3u8
|
||||
self.output_file = output_file
|
||||
|
||||
COMMOM_HEADERS = {
|
||||
'Connection': 'keep-alive',
|
||||
'User-Agent': 'AppleCoreMedia/1.0.0.12B466 (Apple TV; U; CPU OS 8_1_3 like Mac OS X; en_us)',
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'https://tv.apple.com',
|
||||
'Referer': 'https://tv.apple.com/',
|
||||
}
|
||||
162
pywidevine/clients/appletv/downloader.py
Normal file
162
pywidevine/clients/appletv/downloader.py
Normal file
@@ -0,0 +1,162 @@
|
||||
|
||||
|
||||
import sys, os, shutil, requests, re
|
||||
import subprocess, math, pathlib
|
||||
|
||||
from m3u8 import parse as m3u8parser
|
||||
|
||||
dlthreads = 24
|
||||
|
||||
class WvDownloader(object):
|
||||
def __init__(self, config):
|
||||
self.m3u8_url = config.m3u8
|
||||
self.output_file = config.output_file
|
||||
self.config = config
|
||||
|
||||
def downloadM3u8(self, Link_List, Folder, file_name):
|
||||
TempFolder = file_name.replace('.mp4', '')
|
||||
if os.path.exists(TempFolder):
|
||||
shutil.rmtree(TempFolder)
|
||||
if not os.path.exists(TempFolder):
|
||||
os.makedirs(TempFolder)
|
||||
LinkList_txt = file_name.replace('.mp4', '_LinkList.txt')
|
||||
if os.path.isfile(LinkList_txt):
|
||||
os.remove(LinkList_txt)
|
||||
|
||||
counter = 0
|
||||
with open(LinkList_txt, 'a', encoding='ansi') as (file):
|
||||
for link in Link_List:
|
||||
mp4_segment = re.split('(/)(?i)', link)[(-1)]
|
||||
seg_num = str(counter).zfill(5)
|
||||
seg_num = f"{seg_num}.mp4"
|
||||
file.write(f"{link}\n\tdir={TempFolder}\n\tout={seg_num}\n")
|
||||
counter = counter + 1
|
||||
|
||||
aria_command = [
|
||||
'aria2c', '-i', LinkList_txt,
|
||||
'--user-agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"',
|
||||
'--async-dns=false',
|
||||
'--enable-color=false',
|
||||
'--allow-overwrite=true',
|
||||
'--download-result=hide',
|
||||
'--auto-file-renaming=false',
|
||||
'--file-allocation=none',
|
||||
'--summary-interval=0',
|
||||
'--retry-wait=5',
|
||||
'--uri-selector=inorder',
|
||||
'--console-log-level=warn',
|
||||
'-x16', '-j16', '-s16']
|
||||
if sys.version_info >= (3, 5):
|
||||
aria_out = subprocess.run(aria_command)
|
||||
aria_out.check_returncode()
|
||||
if os.path.isfile(LinkList_txt):
|
||||
os.remove(LinkList_txt)
|
||||
source_files = pathlib.Path(TempFolder).rglob('./*.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(TempFolder):
|
||||
shutil.rmtree(TempFolder)
|
||||
|
||||
if os.path.exists(TempFolder):
|
||||
shutil.rmtree(TempFolder)
|
||||
|
||||
|
||||
|
||||
def get_intro_m3u8(self):
|
||||
m3u8_json = m3u8parser(requests.get(self.m3u8_url).text)
|
||||
url_dict_list = []
|
||||
init_url_list = []
|
||||
|
||||
for segment in m3u8_json['segments']:
|
||||
if not 'key' in segment:
|
||||
init_url_list.append(segment['init_section']['uri'])
|
||||
|
||||
for init_mp4 in init_url_list:
|
||||
url_list = []
|
||||
|
||||
for segment in m3u8_json['segments']:
|
||||
if not 'key' in segment:
|
||||
init_id = 'INTRO'
|
||||
url_list.append(init_mp4)
|
||||
url_list.append(segment['uri'])
|
||||
|
||||
url_dict_list.append({'id':init_id, 'url_list':url_list})
|
||||
|
||||
return url_dict_list
|
||||
|
||||
def get_segs_m3u8(self):
|
||||
m3u8_json = m3u8parser(requests.get(self.m3u8_url).text)
|
||||
url_dict_list = []
|
||||
init_url_list = []
|
||||
for segment in m3u8_json['segments']:
|
||||
if 'key' in segment:
|
||||
init_url_list.append(segment['init_section']['uri'])
|
||||
|
||||
for init_mp4 in init_url_list:
|
||||
url_list = []
|
||||
|
||||
for segment in m3u8_json['segments']:
|
||||
if 'key' in segment:
|
||||
url_list.append(segment['init_section']['uri'])
|
||||
|
||||
init = list(url_list)[0]
|
||||
seg_list = []
|
||||
for segment in m3u8_json['segments']:
|
||||
if init == segment['init_section']['uri']:
|
||||
init_id = 'MAIN'
|
||||
seg_list.append(segment['uri'])
|
||||
seg_list = [init] + seg_list
|
||||
|
||||
url_dict_list.append({'id':init_id, 'url_list':seg_list})
|
||||
|
||||
|
||||
return url_dict_list
|
||||
|
||||
def get_decon_m3u8(self):
|
||||
m3u8_json = m3u8parser(requests.get(self.m3u8_url).text)
|
||||
url_dict_list = []
|
||||
init_url_list = []
|
||||
for segment in m3u8_json['segments']:
|
||||
if 'key' in segment:
|
||||
init_url_list.append(segment['init_section']['uri'])
|
||||
|
||||
for init_mp4 in init_url_list:
|
||||
url_list = []
|
||||
|
||||
for segment in m3u8_json['segments']:
|
||||
if 'key' in segment:
|
||||
url_list.append(segment['init_section']['uri'])
|
||||
|
||||
init = list(url_list)[-1]
|
||||
seg_list = []
|
||||
for segment in m3u8_json['segments']:
|
||||
if init == segment['init_section']['uri']:
|
||||
init_id = 'EXTRAS'
|
||||
seg_list.append(segment['uri'])
|
||||
seg_list = [init] + seg_list
|
||||
|
||||
url_dict_list.append({'id':init_id, 'url_list':seg_list})
|
||||
|
||||
return url_dict_list
|
||||
|
||||
def run(self):
|
||||
segment_list = []
|
||||
segment_list = self.get_segs_m3u8()
|
||||
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', '')
|
||||
counter = 0
|
||||
for url_dic in segment_list:
|
||||
url_id = url_dic['id']
|
||||
count_id = str(counter).zfill(4)
|
||||
fileName = temp_folder + f'_{count_id}_{url_id}.mp4'
|
||||
self.downloadM3u8(Link_List=url_dic['url_list'], Folder=temp_folder, file_name=fileName)
|
||||
counter = counter + 1
|
||||
return segment_list
|
||||
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
|
||||
0
pywidevine/downloader/__init__.py
Normal file
0
pywidevine/downloader/__init__.py
Normal file
72
pywidevine/downloader/tracks.py
Normal file
72
pywidevine/downloader/tracks.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import pywidevine.downloader.wvdownloaderconfig as wvdl_cfg
|
||||
|
||||
class VideoTrack(object):
|
||||
def __init__(self, encrypted, size, id, url, codec, bitrate, width, height):
|
||||
self.encrypted = encrypted
|
||||
self.size = size
|
||||
self.id = id
|
||||
self.url = url
|
||||
self.codec = codec
|
||||
self.bitrate = bitrate
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
def get_type(self):
|
||||
return "video"
|
||||
|
||||
def __repr__(self):
|
||||
return "(encrypted={}, size={}, id={}, url={}, codec={}, bitrate={}, width={}, height={})"\
|
||||
.format(self.encrypted, self.size, self.id, self.url, self.codec, self.bitrate, self.width, self.height)
|
||||
|
||||
def get_filename(self, filename, decrypted=False, fixed=False):
|
||||
if not self.encrypted or decrypted:
|
||||
fn = wvdl_cfg.DECRYPTED_FILENAME
|
||||
else:
|
||||
fn = wvdl_cfg.ENCRYPTED_FILENAME
|
||||
if fixed:
|
||||
fn = fn + '_fixed.mkv'
|
||||
return fn.format(filename=filename, track_type="video", track_no=self.id)
|
||||
|
||||
|
||||
|
||||
class AudioTrack(object):
|
||||
def __init__(self, encrypted, size, id, url, codec, bitrate, language):
|
||||
self.encrypted = encrypted
|
||||
self.size = size
|
||||
self.id = id
|
||||
self.url = url
|
||||
self.codec = codec
|
||||
self.bitrate = bitrate
|
||||
self.language = language
|
||||
|
||||
def get_type(self):
|
||||
return "audio"
|
||||
|
||||
def __repr__(self):
|
||||
return "(encrypted={}, size={}, id={}, url={}, codec={}, bitrate={})"\
|
||||
.format(self.encrypted, self.size, self.id, self.url, self.codec, self.bitrate)
|
||||
|
||||
def get_filename(self, filename, decrypted=False, fixed=False):
|
||||
if not self.encrypted or decrypted:
|
||||
fn = wvdl_cfg.DECRYPTED_FILENAME
|
||||
else:
|
||||
fn = wvdl_cfg.ENCRYPTED_FILENAME
|
||||
if fixed:
|
||||
fn = fn + '_fixed.mka'
|
||||
return fn.format(filename=filename, track_type="audio", track_no=self.id)
|
||||
|
||||
|
||||
class SubtitleTrack(object):
|
||||
def __init__(self, id, name, language_code, default, url, type):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.language_code = language_code
|
||||
self.url = url
|
||||
self.type = type
|
||||
self.default = default
|
||||
|
||||
def __repr__(self):
|
||||
return "(id={}, name={}, language_code={}, url={}, type={})".format(self.id, self.name, self.language_code, self.url, self.type)
|
||||
|
||||
def get_filename(self, filename, subtitle_format):
|
||||
return wvdl_cfg.SUBTITLES_FILENAME.format(filename=filename, language_code=self.language_code, id=self.id, subtitle_type=subtitle_format)
|
||||
279
pywidevine/downloader/wvdownloader.py
Normal file
279
pywidevine/downloader/wvdownloader.py
Normal file
@@ -0,0 +1,279 @@
|
||||
|
||||
import threading
|
||||
import os, re
|
||||
import requests
|
||||
import math
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import shutil
|
||||
import isodate
|
||||
import pathlib, sys, subprocess
|
||||
|
||||
from urllib.error import HTTPError
|
||||
from m3u8 import parse as m3u8parser
|
||||
from tqdm import tqdm
|
||||
from queue import Queue
|
||||
|
||||
dlthreads = 24
|
||||
|
||||
class WvDownloader(object):
|
||||
def __init__(self, config):
|
||||
self.xml = config.xml
|
||||
self.output_file = config.output_file
|
||||
self.tqdm_mode = config.tqdm_mode
|
||||
self.cookies = config.cookies
|
||||
self.config = config
|
||||
|
||||
def download_track(self, aria_input, file_name):
|
||||
aria_command = ['aria2c', '-i', aria_input,
|
||||
'--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', '-j16', '-s16']
|
||||
if sys.version_info >= (3, 5):
|
||||
aria_out = subprocess.run(aria_command)
|
||||
aria_out.check_returncode()
|
||||
else:
|
||||
aria_out = subprocess.call(aria_command)
|
||||
if aria_out != 0:
|
||||
raise ValueError('aria failed with exit code {}'.format(aria_out))
|
||||
|
||||
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(aria_input)
|
||||
print('\nDone!')
|
||||
|
||||
## MPD
|
||||
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):
|
||||
segs = self.get_representation_number()
|
||||
if "$Time$" in segs.get('@media'):
|
||||
return self.alt_get_segments(segs)
|
||||
else:
|
||||
return self.get_segments(segs)
|
||||
|
||||
def get_init_and_info(self):
|
||||
init_lv = self.get_representation_number()
|
||||
# get init segment url
|
||||
init = init_lv['@initialization']
|
||||
init = self.process_url_templace(init, representation_id=self.config.format_id, bandwidth=self.config.bandwidth, time=None, number=None)
|
||||
return init
|
||||
|
||||
def get_segments(self, segment_level):
|
||||
try:
|
||||
media = segment_level['@media']
|
||||
current_number = 1
|
||||
current_time = 0
|
||||
for seg in segment_level['SegmentTimeline']['S']:
|
||||
if '@t' in seg:
|
||||
current_time = seg['@t']
|
||||
for _ in range(int(seg.get('@r', 0)) + 1):
|
||||
url = self.process_url_templace(media, representation_id=self.config.format_id, bandwidth=self.config.bandwidth, time=str(current_time), number=str(current_number))
|
||||
current_number += 1
|
||||
current_time += seg['@d']
|
||||
yield url
|
||||
|
||||
except KeyError:
|
||||
current_number = int(segment_level.get("@startNumber", 0))
|
||||
period_duration = self.get_duration()
|
||||
segment_duration = int(segment_level["@duration"]) / int(segment_level["@timescale"])
|
||||
total_segments = math.ceil(period_duration / segment_duration)
|
||||
media = segment_level['@media']
|
||||
for _ in range(current_number, current_number + total_segments):
|
||||
url = self.process_url_templace(media, representation_id=self.config.format_id, bandwidth=self.config.bandwidth, time="0", number=str(current_number))
|
||||
current_number += 1
|
||||
yield url
|
||||
|
||||
def get_duration(self):
|
||||
media_duration = self.xml["MPD"]["@mediaPresentationDuration"]
|
||||
return isodate.parse_duration(media_duration).total_seconds()
|
||||
|
||||
def alt_get_segments(self, segment_level):
|
||||
media = segment_level['@media']
|
||||
t = 0
|
||||
if isinstance(segment_level['SegmentTimeline']['S'], list):
|
||||
segment_level = segment_level['SegmentTimeline']['S']
|
||||
else:
|
||||
segment_level = [segment_level['SegmentTimeline']['S']]
|
||||
|
||||
for seg in segment_level:
|
||||
if '@t' in seg:
|
||||
t = int(seg['@t'])
|
||||
for _ in range(int(seg.get('@r', 0)) + 1):
|
||||
url = self.process_url_templace(media, representation_id=self.config.format_id, bandwidth=self.config.bandwidth, time=str(t), number=None)
|
||||
t += int(seg['@d'])
|
||||
yield url
|
||||
|
||||
def get_representation_number(self):
|
||||
x = []
|
||||
for [idx, item] in enumerate(self.xml['MPD']['Period']['AdaptationSet']):
|
||||
try:
|
||||
if self.config.file_type in item.get('@mimeType'):
|
||||
x = idx
|
||||
except TypeError:
|
||||
if self.config.file_type in item.get('@contentType'):
|
||||
x = idx
|
||||
|
||||
y = []
|
||||
if 'video' in self.config.file_type:
|
||||
for [number, rep] in enumerate(self.xml['MPD']['Period']['AdaptationSet'][x]['Representation']):
|
||||
if self.config.format_id == rep.get('@id'):
|
||||
y = number
|
||||
|
||||
mpd = self.xml['MPD']['Period']
|
||||
try:
|
||||
if 'video' in self.config.file_type:
|
||||
stream_index = mpd['AdaptationSet'][x]['Representation'][y]
|
||||
else:
|
||||
stream_index = mpd['AdaptationSet'][x]['Representation']
|
||||
|
||||
segment_level = stream_index['SegmentTemplate']
|
||||
|
||||
except KeyError:
|
||||
segment_level = mpd['AdaptationSet'][x]['SegmentTemplate']
|
||||
|
||||
return segment_level
|
||||
|
||||
def open_url(self, url):
|
||||
return urllib.request.urlopen(url), url
|
||||
|
||||
## M3U8
|
||||
def get_m3u8_segments(self):
|
||||
base_url = re.split('(/)(?i)', self.xml)
|
||||
del base_url[-1]
|
||||
base_url = ''.join(base_url)
|
||||
m3u8_request = requests.get(self.xml).text
|
||||
m3u8_json = m3u8parser(m3u8_request)
|
||||
segment_urls = []
|
||||
|
||||
for segment in m3u8_json['segments']:
|
||||
if 'https://' not in segment['uri']:
|
||||
segment_url = base_url + segment['uri']
|
||||
segment_urls.append(segment_url)
|
||||
|
||||
seg_url = list(filter(lambda k: 'MAIN' in k, segment_urls))
|
||||
init_url = seg_url[0].replace('00/00/00_000', 'map')
|
||||
segment_urls = []
|
||||
segment_urls = [init_url] + seg_url
|
||||
|
||||
return segment_urls
|
||||
|
||||
def run(self):
|
||||
if 'm3u8' in self.xml:
|
||||
urls = self.get_m3u8_segments()
|
||||
if 'MPD' in self.xml:
|
||||
segment_list = self.generate_segments()
|
||||
init = self.get_init_and_info()
|
||||
urls = []
|
||||
url = self.config.base_url + '/' + init
|
||||
urls.append(url)
|
||||
for seg_url in segment_list:
|
||||
url = self.config.base_url + '/' + seg_url
|
||||
urls.append(url)
|
||||
|
||||
print('\n' + self.output_file)
|
||||
# download por aria2c
|
||||
if not self.tqdm_mode:
|
||||
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[:-1]):
|
||||
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)
|
||||
else:
|
||||
# download por thread
|
||||
work_q = Queue()
|
||||
result_q = Queue()
|
||||
|
||||
pool = [WorkerThread(work_q=work_q, result_q=result_q, cookies=self.cookies) for i in range(dlthreads)]
|
||||
for thread in pool:
|
||||
thread.start()
|
||||
|
||||
work_count = 0
|
||||
for seg_url in urls:
|
||||
url = seg_url
|
||||
work_q.put((work_count, url, self.cookies))
|
||||
work_count += 1
|
||||
results = []
|
||||
|
||||
for _ in tqdm(range(work_count)):
|
||||
results.append(result_q.get())
|
||||
outfile = open(self.output_file , 'wb+')
|
||||
sortedr = sorted(results, key=lambda v: v[0])
|
||||
for r in sortedr:
|
||||
outfile.write(r[1])
|
||||
outfile.close()
|
||||
del results
|
||||
print('Done!')
|
||||
|
||||
class Downloader:
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
|
||||
def DownloadSegment(self, url, cookies):
|
||||
resp = self.session.get(url, cookies=cookies, stream=True)
|
||||
resp.raw.decode_content = True
|
||||
data = resp.raw.read()
|
||||
return data
|
||||
|
||||
class WorkerThread(threading.Thread):
|
||||
def __init__(self, work_q, result_q, cookies):
|
||||
super(WorkerThread, self).__init__()
|
||||
self.work_q = work_q
|
||||
self.result_q = result_q
|
||||
self.cookies = cookies
|
||||
self.stoprequest = threading.Event()
|
||||
self.downloader = Downloader()
|
||||
|
||||
def run(self):
|
||||
while not self.stoprequest.isSet():
|
||||
try:
|
||||
(seq, url, cookies) = self.work_q.get(True, 0.05)
|
||||
self.result_q.put((seq, self.downloader.DownloadSegment(url, cookies)))
|
||||
except:
|
||||
continue
|
||||
|
||||
def join(self, timeout=None):
|
||||
self.stoprequest.set()
|
||||
super(WorkerThread, self).join(timeout)
|
||||
14
pywidevine/downloader/wvdownloaderconfig.py
Normal file
14
pywidevine/downloader/wvdownloaderconfig.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import re
|
||||
import os
|
||||
import platform
|
||||
|
||||
class WvDownloaderConfig(object):
|
||||
def __init__(self, xml, base_url, output_file, format_id, bandwidth, cookies, file_type, tqdm_mode):
|
||||
self.xml = xml
|
||||
self.output_file = output_file
|
||||
self.base_url = base_url
|
||||
self.format_id = format_id
|
||||
self.bandwidth = bandwidth
|
||||
self.cookies = cookies
|
||||
self.file_type = file_type
|
||||
self.tqdm_mode = tqdm_mode
|
||||
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 = [
|
||||
["pt-BR", "pt-BR", "por", "Brazilian Portuguese", "yes", "no"],
|
||||
["en", "en", "eng", "English", "no", "no"],
|
||||
["es", "es", "spa", "Castilian", "no", "no"],
|
||||
["es-la", "es-la", "spa", "Spanish", "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)
|
||||
Reference in New Issue
Block a user