This commit is contained in:
widevinedump
2021-12-25 14:41:36 +05:30
parent 9692d1d509
commit ec8ddd995a
68 changed files with 13111 additions and 2 deletions

0
pywidevine/__init__.py Normal file
View File

View File

364
pywidevine/cdm/cdm.py Normal file
View 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

View File

@@ -0,0 +1,53 @@
import os
device_android_generic = {
'name': 'android_generic',
'description': 'android generic l3',
'security_level': 3,
'session_id_type': 'android',
'private_key_available': True,
'vmp': False,
'send_key_control_nonce': True
}
devices_available = [device_android_generic]
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)

View 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-----

View File

View 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;
}

File diff suppressed because one or more lines are too long

View 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;
}

File diff suppressed because one or more lines are too long

14
pywidevine/cdm/key.py Normal file
View 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
View 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
View 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

View File

@@ -0,0 +1,27 @@
import json, sys, time
import pywidevine.clients.blim.config as blim_cfg
from os.path import join
BLIMLOGINDATA_FILE = join(blim_cfg.COOKIES_FOLDER, 'blim_login_data.json')
login_cfg = {
'email': 'teste@blim.com',
'password': 'teste1234'
}
def login(SESSION, save_login=False):
post_data = {"email": login_cfg['email'], "password": login_cfg['password'], "remember": True, "clientId":5}
login_resp = SESSION.post(url=blim_cfg.ENDPOINTS['login'], json=post_data)
if login_resp.json()['data'] == []:
print(login_resp.json()['messages'][0]['value'])
sys.exit(1)
costumer_key = login_resp.json()['data']['sessionId']
access_key_secret = login_resp.json()['data']['accessToken']
login_data = {'COSTUMER_KEY': costumer_key, 'SECRET_KEY': access_key_secret}
if save_login:
with open(BLIMLOGINDATA_FILE, 'w', encoding='utf-8') as f:
f.write(json.dumps(login_data, indent=4))
f.close()
return SESSION, costumer_key, access_key_secret

View File

@@ -0,0 +1,59 @@
from shutil import which
from os.path import dirname, realpath, join
from os import pathsep, environ
ENDPOINTS = {
'login': 'https://api.blim.com/account/login',
'seasons': 'https://api.blim.com/asset/',
'content': 'https://api.blim.com/play/resume/',
'config': 'https://www.blim.com/secure/play/resume/configuration?config_token=portal-config'
}
protection_keys = {
'094af042a17556c5b28a176deffdd4a7:14319c175eb145071fe189d2b1da8634',
'4ae10c2357e250e088bb8a5ab044dd50:e7f47e2b948e9222cf4d24b51881ec04',
'b6e16839eebd4ff6ab768d482d8d2b6a:ad6c675e0810741538f7f2f0b4099d9e'
}
init_files = {
'1080p': 'https://cdn.discordapp.com/attachments/686581369249333291/857062526856200252/video_init_1920x1080.bin',
'480p': 'https://cdn.discordapp.com/attachments/686581369249333291/857062525421092944/video_640x480.bin',
'audio': 'https://cdn.discordapp.com/attachments/686581369249333291/857104327742193735/audio_init.bin'
}
SCRIPT_PATH = dirname(realpath('blimtv'))
BINARIES_FOLDER = join(SCRIPT_PATH, 'binaries')
COOKIES_FOLDER = join(SCRIPT_PATH, 'cookies')
MP4DECRYPT_BINARY = 'mp4decrypt'
MP4DUMP_BINARY = 'mp4dump'
MKVMERGE_BINARY = 'mkvmerge'
FFMPEG_BINARY = 'ffmpeg'
ARIA2C_BINARY = 'aria2c'
# Add binaries folder to PATH as the first item
environ['PATH'] = pathsep.join([BINARIES_FOLDER, environ['PATH']])
MP4DECRYPT = which(MP4DECRYPT_BINARY)
MP4DUMP = which(MP4DUMP_BINARY)
MKVMERGE = which(MKVMERGE_BINARY)
FFMPEG = which(FFMPEG_BINARY)
ARIA2C = which(ARIA2C_BINARY)
class PrDownloaderConfig(object):
def __init__(self, ism, base_url, output_file, bitrate, init_url, file_type):
self.ism = ism
self.base_url = base_url
self.output_file = output_file
self.bitrate = bitrate
self.init_url = init_url
self.file_type = file_type
class WvDownloaderConfig(object):
def __init__(self, mpd, base_url, output_file, format_id, file_type):
self.mpd = mpd
self.base_url = base_url
self.output_file = output_file
self.format_id = format_id
self.file_type = file_type

View File

@@ -0,0 +1,117 @@
import threading, isodate
import requests
import math
import urllib.parse
from requests.sessions import session
from tqdm import tqdm
from queue import Queue
dlthreads = 24
class PrDownloader(object):
def __init__(self, config):
self.ism = config.ism
self.output_file = config.output_file
self.bitrate = config.bitrate
self.base_url = config.base_url
self.init_url = config.init_url
self.config = config
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):
quality_level = self.get_quality_level()
return self.get_segments(quality_level)
def get_segments(self, stream_index):
urls = []
urls.append(self.init_url)
t = 0
for seg in stream_index["c"]:
if '@t' in seg:
t = seg['@t']
for i in range(int(seg.get('@r', 0)) + 1):
path = stream_index['@Url'].format(**{
'bitrate': self.bitrate,
'start time': t})
url = urllib.parse.urljoin(self.base_url, path)
urls.append(url)
t += int(seg['@d'])
return urls
def get_quality_level(self):
X = [item for (i, item) in enumerate(self.ism['SmoothStreamingMedia']['StreamIndex']) if self.config.file_type in item.get('@Type')][0]
return X
def run(self):
urls = self.generate_segments()
work_q = Queue()
result_q = Queue()
print('\n' + self.output_file)
pool = [WorkerThread(work_q=work_q, result_q=result_q) for i in range(dlthreads)]
for thread in pool:
thread.start()
work_count = 0
for seg_url in urls:
work_q.put((work_count, seg_url))
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):
resp = self.session.get(url, stream=True)
resp.raw.decode_content = True
data = resp.raw.read()
return data
class WorkerThread(threading.Thread):
def __init__(self, work_q, result_q):
super(WorkerThread, self).__init__()
self.work_q = work_q
self.result_q = result_q
self.stoprequest = threading.Event()
self.downloader = Downloader()
def run(self):
while not self.stoprequest.isSet():
try:
(seq, url) = self.work_q.get(True, 0.05)
self.result_q.put((seq, self.downloader.DownloadSegment(url)))
except:
continue
def join(self, timeout=None):
self.stoprequest.set()
super(WorkerThread, self).join(timeout)

View File

@@ -0,0 +1,155 @@
import threading, isodate
import requests
import math
from requests.sessions import session
from tqdm import tqdm
from queue import Queue
dlthreads = 24
class WvDownloader(object):
def __init__(self, config):
self.mpd = config.mpd
self.output_file = config.output_file
self.mimetype = config.file_type
self.formatId = config.format_id
self.config = config
def process_url_templace(self, template, representation_id, bandwidth, time, number):
if representation_id is not None: result = template.replace('$RepresentationID$', representation_id)
if number is not None:
nstart = result.find('$Number')
if nstart >= 0:
nend = result.find('$', nstart+1)
if nend >= 0:
var = result[nstart+1 : nend]
if 'Number%' in var:
value = var[6:] % (int(number))
else:
value = number
result = result.replace('$'+var+'$', value)
if bandwidth is not None: result = result.replace('$Bandwidth$', bandwidth)
if time is not None: result = result.replace('$Time$', time)
result = result.replace('$$', '$').replace('../', '')
return result
def generate_segments(self):
segment_template = self.get_segment_template()
return self.get_segments(segment_template)
def get_segments(self, segment_template):
urls = []
urls.append(self.config.base_url + segment_template['@initialization'].replace('$RepresentationID$', self.config.format_id))
print(urls)
try:
current_number = int(segment_template.get("@startNumber", 0))
period_duration = self.get_duration()
segment_duration = int(segment_template["@duration"]) / int(segment_template["@timescale"])
total_segments = math.ceil(period_duration / segment_duration)
for _ in range(current_number, current_number + total_segments):
urls.append(self.config.base_url + self.process_url_templace(segment_template['@media'],
representation_id=self.config.format_id,
bandwidth=None, time="0", number=str(current_number)))
current_number += 1
except KeyError:
current_number = 0
current_time = 0
for seg in segment_template["SegmentTimeline"]["S"]:
if '@t' in seg:
current_time = seg['@t']
for i in range(int(seg.get('@r', 0)) + 1):
urls.append(self.config.base_url + self.process_url_templace(segment_template['@media'],
representation_id=self.config.format_id,
bandwidth=None, time=str(current_time), number=str(current_number)))
current_number += 1
current_time += seg['@d']
return urls
def get_duration(self):
media_duration = self.mpd["MPD"]["@mediaPresentationDuration"]
return isodate.parse_duration(media_duration).total_seconds()
def get_segment_template(self):
tracks = self.mpd['MPD']['Period']['AdaptationSet']
segment_template = []
if self.mimetype == "video/mp4":
for video_track in tracks:
if video_track["@mimeType"] == self.mimetype:
for v in video_track["Representation"]:
segment_template = v["SegmentTemplate"]
if self.mimetype == "audio/mp4":
for audio_track in tracks:
if audio_track["@mimeType"] == self.mimetype:
try:
segment_template = audio_track["SegmentTemplate"]
except (KeyError, TypeError):
for a in self.list_representation(audio_track):
segment_template = a["SegmentTemplate"]
return segment_template
def list_representation(self, x):
if isinstance(x['Representation'], list):
X = x['Representation']
else:
X = [x['Representation']]
return X
def run(self):
urls = self.generate_segments()
work_q = Queue()
result_q = Queue()
print('\n' + self.output_file)
pool = [WorkerThread(work_q=work_q, result_q=result_q) for i in range(dlthreads)]
for thread in pool:
thread.start()
work_count = 0
for seg_url in urls:
work_q.put((work_count, seg_url))
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):
resp = self.session.get(url, stream=True)
resp.raw.decode_content = True
data = resp.raw.read()
return data
class WorkerThread(threading.Thread):
def __init__(self, work_q, result_q):
super(WorkerThread, self).__init__()
self.work_q = work_q
self.result_q = result_q
self.stoprequest = threading.Event()
self.downloader = Downloader()
def run(self):
while not self.stoprequest.isSet():
try:
(seq, url) = self.work_q.get(True, 0.05)
self.result_q.put((seq, self.downloader.DownloadSegment(url)))
except:
continue
def join(self, timeout=None):
self.stoprequest.set()
super(WorkerThread, self).join(timeout)

View File

@@ -0,0 +1,104 @@
import isodate
def get_mpd_list(mpd):
def get_height(width, height):
if width == '1920':
return '1080'
elif width in ('1280', '1248'):
return '720'
else:
return height
length = isodate.parse_duration(mpd['MPD']['@mediaPresentationDuration']).total_seconds()
period = mpd['MPD']['Period']
base_url = period['BaseURL']
tracks = period['AdaptationSet']
video_list = []
for video_tracks in tracks:
if video_tracks['@mimeType'] == 'video/mp4':
for x in video_tracks['Representation']:
try:
codecs = x['@codecs']
except KeyError:
codecs = video_tracks['@codecs']
videoDict = {
'Height':get_height(x['@width'], x['@height']),
'Width':x['@width'],
'Bandwidth':x['@bandwidth'],
'ID':x['@id'],
'Codec':codecs}
video_list.append(videoDict)
def list_representation(x):
if isinstance(x['Representation'], list):
X = x['Representation']
else:
X = [x['Representation']]
return X
def replace_code_lang(x):
X = x.replace('es', 'es-la').replace('en', 'es-la')
return X
audio_list = []
for audio_tracks in tracks:
if audio_tracks['@mimeType'] == 'audio/mp4':
for x in list_representation(audio_tracks):
try:
codecs = x['@codecs']
except KeyError:
codecs = audio_tracks['@codecs']
audio_dict = {
'Bandwidth':x['@bandwidth'],
'ID':x['@id'],
'Language':audio_tracks["@lang"],
'Codec':codecs}
audio_list.append(audio_dict)
subs_list = []
for subs_tracks in tracks:
if subs_tracks['@mimeType'] == 'text/vtt':
for x in list_representation(subs_tracks):
subs_dict = {
'ID':x['@id'],
'Language':replace_code_lang(subs_tracks["@lang"]),
'Codec':subs_tracks['@mimeType'],
'File_URL':base_url + x['BaseURL'].replace('../', '')}
subs_list.append(subs_dict)
return length, video_list, audio_list, subs_list
def get_ism_list(ism):
length = float(ism['SmoothStreamingMedia']['@Duration'][:-7])
tracks = ism['SmoothStreamingMedia']["StreamIndex"]
video_list = []
for video_tracks in tracks:
if video_tracks['@Type'] == 'video':
for x in video_tracks['QualityLevel']:
videoDict = {
'Height':x['@MaxHeight'],
'Width':x['@MaxWidth'],
'ID':'0',
'Bandwidth':x['@Bitrate'],
'Codec':x["@FourCC"]}
video_list.append(videoDict)
def replace_code_lang(x):
X = x.replace('255', 'es-la')
return X
audio_list = []
for audio_tracks in tracks:
if audio_tracks['@Type'] == 'audio':
for x in audio_tracks["QualityLevel"]:
audio_dict = {
'Bandwidth':x['@Bitrate'],
'ID':'0',
'Language':replace_code_lang(x["@AudioTag"]),
'Codec':x["@FourCC"]}
audio_list.append(audio_dict)
return length, video_list, audio_list, []

View File

@@ -0,0 +1,42 @@
import re
from unidecode import unidecode
def get_release_tag(default_filename, vcodec, video_height, acodec, channels, bitrate, module, tag, isDual):
video_codec = ''
if 'avc' in vcodec:
video_codec = 'H.264'
if 'hvc' in vcodec:
video_codec = 'H.265'
elif 'dvh' in vcodec:
video_codec = 'HDR'
if isDual==False:
audio_codec = ''
if 'mp4a' in acodec:
audio_codec = 'AAC'
if acodec == 'ac-3':
audio_codec = 'DD'
if acodec == 'ec-3':
audio_codec = 'DDP'
elif acodec == 'ec-3' and bitrate > 700000:
audio_codec = 'Atmos'
audio_channels = ''
if channels == '2':
audio_channels = '2.0'
elif channels == '6':
audio_channels = '5.1'
audio_format = audio_codec + audio_channels
else:
audio_format = 'DUAL'
default_filename = default_filename.replace('&', '.and.')
default_filename = re.sub(r'[]!"#$%\'()*+,:;<=>?@\\^_`{|}~[-]', '', default_filename)
default_filename = default_filename.replace(' ', '.')
default_filename = re.sub(r'\.{2,}', '.', default_filename)
default_filename = unidecode(default_filename)
output_name = '{}.{}p.{}.WEB-DL.{}.{}-{}'.format(default_filename, video_height, str(module), audio_format, video_codec, tag)
return output_name

View File

@@ -0,0 +1,106 @@
import base64, time, requests, os, json
import pywidevine.clients.hbomax.config as hmaxcfg
from os.path import join
SESSION = requests.Session()
HMAXTOKEN_FILE = join(hmaxcfg.COOKIES_FOLDER, 'hmax_login_data.json')
login_config = {
'username': 'rivas909@me.com',
'password': 'NoCambieselPass.12345'
}
def login(SESSION, login_endpoint, content_url, save_login=True):
def get_free_token(token_url):
token_data = hmaxcfg.get_token_info()
free_token = requests.post(url=token_url, headers=token_data['headers'], json=token_data['data'])
if int(free_token.status_code) != 200:
print(free_token.json()['message'])
exit(1)
return free_token.json()['access_token']
free_access_tk = get_free_token(login_endpoint)
auth_data = hmaxcfg.get_auth_token_info(login_config)
headers = auth_data['headers']
headers['authorization'] = "Bearer {}".format(free_access_tk)
auth_rep = SESSION.post(url=login_endpoint, headers=headers, json=auth_data['data'])
if int(auth_rep.status_code) != 200:
print(auth_rep.json()['message'])
exit(1)
access_token_js = auth_rep.json()
login_grant_access = [
{
"id": "urn:hbo:privacy-settings:mined",
"id": "urn:hbo:profiles:mined",
"id": "urn:hbo:query:lastplayed",
"id": "urn:hbo:user:me"}
]
user_grant_access = {
"accept": "application/vnd.hbo.v9.full+json",
"accept-encoding": "gzip, deflate, br",
"accept-language": hmaxcfg.metadata_language,
"user-agent": hmaxcfg.UA,
"x-hbo-client-version": "Hadron/50.40.0.111 desktop (DESKTOP)",
"x-hbo-device-name": "desktop",
"x-hbo-device-os-version": "undefined",
"Authorization": f"Bearer {access_token_js['refresh_token']}"
}
user_grant_req = SESSION.post(content_url, json=login_grant_access, headers=user_grant_access)
if int(user_grant_req.status_code) != 207:
print("failed to list profiles")
user_grant_js = user_grant_req.json()
user_grant_id = ""
for profile in user_grant_js:
if profile['id'] == "urn:hbo:profiles:mine":
if len(profile['body']['profiles']) > 0:
user_grant_id = profile['body']['profiles'][0]['profileId']
else:
print("no profiles found, create one on hbomax and try again")
exit(1)
profile_headers = {
"accept": "application/vnd.hbo.v9.full+json",
"accept-encoding": "gzip, deflate, br",
"accept-language": hmaxcfg.metadata_language,
"user-agent": hmaxcfg.UA,
"x-hbo-client-version": "Hadron/50.40.0.111 desktop (DESKTOP)",
"x-hbo-device-name": "desktop",
"x-hbo-device-os-version": "undefined",
"referer": "https://play.hbomax.com/profileSelect",
"Authorization": f"Bearer {free_access_tk}" #~ free token
}
user_profile = {
"grant_type": "user_refresh_profile",
"profile_id": user_grant_id,
"refresh_token": f"{access_token_js['refresh_token']}",
}
user_profile_req = SESSION.post(login_endpoint, json=user_profile, headers=profile_headers)
if int(user_profile_req.status_code) != 200:
error_msg = "failed to obatin the final token"
print(error_msg)
user_profile_js = user_profile_req.json()
refresh_token = user_profile_js['refresh_token']
login_data = {'ACCESS_TOKEN': refresh_token, 'EXPIRATION_TIME': int(time.time())}
if save_login:
with open(HMAXTOKEN_FILE, 'w', encoding='utf-8') as f:
f.write(json.dumps(login_data, indent=4))
f.close()
return auth_rep.json()['access_token']
def get_video_payload(urn):
headers = hmaxcfg.generate_payload()
payload = []
payload.append({"id":urn, "headers": headers['headers']})
return payload

View File

@@ -0,0 +1,125 @@
import uuid, sys
import configparser
from shutil import which
from os.path import dirname, realpath, join
from os import pathsep, environ
def generate_device():
return str(uuid.uuid4())
_uuid = generate_device() #traceid
user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36'
config = {}
config['la'] = {
'tokens': 'https://gateway-latam.api.hbo.com/auth/tokens',
'content': 'https://comet-latam.api.hbo.com/content',
'license_wv': 'https://comet-latam.api.hbo.com/drm/license/widevine?keygen=playready&drmKeyVersion=2'
}
config['us'] = {
'tokens': 'https://gateway.api.hbo.com/auth/tokens',
'content': 'https://comet.api.hbo.com/content',
'license_wv': 'https://comet.api.hbo.com/drm/license/widevine?keygen=playready&drmKeyVersion=2'
}
metadata_language = 'en-US'
UA = 'Mozilla/5.0 (Linux; Android 7.1.1; SHIELD Android TV Build/LMY47D) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/84.0.4147.135 Safari/537.36'
login_headers = {
"accept": "application/vnd.hbo.v9.full+json",
"accept-encoding": "gzip, deflate, br",
"accept-language": metadata_language,
"user-agent": UA,
"x-hbo-client-version": "Hadron/50.40.0.111 desktop (DESKTOP)",
"x-hbo-device-name": "desktop",
"x-hbo-device-os-version": "undefined",
}
login_json = {
"client_id": '24fa5e36-3dc4-4ed0-b3f1-29909271b63d',
"client_secret": '24fa5e36-3dc4-4ed0-b3f1-29909271b63d',
"scope":"browse video_playback_free",
"grant_type":"client_credentials",
"deviceSerialNumber": 'b394a2da-b3a7-429d-8f70-5c4eae50a678',
"clientDeviceData":{
"paymentProviderCode":"apple"
}
}
payload = {
'x-hbo-device-model':user_agent,
'x-hbo-video-features':'server-stitched-playlist,mlp',
'x-hbo-session-id':_uuid,
'x-hbo-video-player-version':'QUANTUM_BROWSER/50.30.0.249',
'x-hbo-device-code-override':'ANDROIDTV',
'x-hbo-video-mlp':True,
}
SCRIPT_PATH = dirname(realpath('hbomax'))
BINARIES_FOLDER = join(SCRIPT_PATH, 'binaries')
COOKIES_FOLDER = join(SCRIPT_PATH, 'cookies')
MP4DECRYPT_BINARY = 'mp4decrypt'
MEDIAINFO_BINARY = 'mediainfo'
MP4DUMP_BINARY = 'mp4dump'
MKVMERGE_BINARY = 'mkvmerge'
FFMPEG_BINARY = 'ffmpeg'
FFMPEG_BINARY = 'ffmpeg'
ARIA2C_BINARY = 'aria2c'
SUBTITLE_EDIT_BINARY = 'subtitleedit'
# Add binaries folder to PATH as the first item
environ['PATH'] = pathsep.join([BINARIES_FOLDER, environ['PATH']])
MP4DECRYPT = which(MP4DECRYPT_BINARY)
MEDIAINFO = which(MEDIAINFO_BINARY)
MP4DUMP = which(MP4DUMP_BINARY)
MKVMERGE = which(MKVMERGE_BINARY)
FFMPEG = which(FFMPEG_BINARY)
ARIA2C = which(ARIA2C_BINARY)
SUBTITLE_EDIT = which(SUBTITLE_EDIT_BINARY)
def get_token_info():
return {'headers': login_headers, 'data': login_json}
def get_user_headers():
headers = {
'origin': 'https://play.hbomax.com',
'referer': 'https://play.hbomax.com/',
'x-b3-traceid': f'{_uuid}-{_uuid}',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.61 Safari/537.36',
'accept': 'application/vnd.hbo.v9.full+json',
'content-type': 'application/json; charset=utf-8',
'x-hbo-client-version': 'Hadron/50.50.0.85 desktop (DESKTOP)',
'x-hbo-device-name': 'desktop',
'x-hbo-device-os-version': 'undefined'}
return {'headers': headers}
def get_auth_token_info(cfg):
data = {
"grant_type": "user_name_password",
"scope": "browse video_playback device elevated_account_management",
"username": cfg['username'],
"password": cfg['password'],
}
return {'headers': login_headers, 'data': data, 'device_id': _uuid}
def generate_payload():
return {"headers": payload}
class HMAXRegion(object):
def configHBOMaxLatam():
tokens = config['la']['tokens']
content = config['la']['content']
license_wv = config['la']['license_wv']
return tokens, content, license_wv
def configHBOMaxUS():
tokens = config['us']['tokens']
content = config['us']['content']
license_wv = config['us']['license_wv']
return tokens, content, license_wv

View File

@@ -0,0 +1,30 @@
from shutil import which
from os.path import dirname, realpath, join
from os import pathsep, environ
SCRIPT_PATH = dirname(realpath('paramountplus'))
BINARIES_FOLDER = join(SCRIPT_PATH, 'binaries')
MP4DECRYPT_BINARY = 'mp4decrypt'
MP4DUMP_BINARY = 'mp4dump'
MKVMERGE_BINARY = 'mkvmerge'
FFMPEG_BINARY = 'ffmpeg'
ARIA2C_BINARY = 'aria2c'
# Add binaries folder to PATH as the first item
environ['PATH'] = pathsep.join([BINARIES_FOLDER, environ['PATH']])
MP4DECRYPT = which(MP4DECRYPT_BINARY)
MP4DUMP = which(MP4DUMP_BINARY)
MKVMERGE = which(MKVMERGE_BINARY)
FFMPEG = which(FFMPEG_BINARY)
ARIA2C = which(ARIA2C_BINARY)
class WvDownloaderConfig(object):
def __init__(self, xml, base_url, output_file, track_id, format_id):
self.xml = xml
self.base_url = base_url
self.output_file = output_file
self.track_id = track_id
self.format_id = format_id

View File

@@ -0,0 +1,9 @@
class WvDownloaderConfig(object):
def __init__(self, xml, base_url, output_file, track_id, format_id, file_type):
self.xml = xml
self.base_url = base_url
self.output_file = output_file
self.track_id = track_id
self.format_id = format_id
self.file_type = file_type

View File

@@ -0,0 +1,116 @@
import requests, pathlib
import math, subprocess
import os, sys, shutil
class WvDownloader(object):
def __init__(self, config):
self.xml = config.xml
self.output_file = config.output_file
self.config = config
def download_track(self, aria2c_infile, file_name):
aria2c_opts = [
'aria2c',
'--enable-color=false',
'--allow-overwrite=true',
'--summary-interval=0',
'--download-result=hide',
'--async-dns=false',
'--check-certificate=false',
'--auto-file-renaming=false',
'--file-allocation=none',
'--console-log-level=warn',
'-x16', '-s16', '-j16',
'-i', aria2c_infile]
subprocess.run(aria2c_opts, check=True)
source_files = pathlib.Path(temp_folder).rglob(r'./*.mp4')
with open(file_name, mode='wb') as (destination):
for file in source_files:
with open(file, mode='rb') as (source):
shutil.copyfileobj(source, destination)
if os.path.exists(temp_folder):
shutil.rmtree(temp_folder)
os.remove(aria2c_infile)
print('\nDone!')
def process_url_templace(self, template, representation_id, bandwidth, time, number):
if representation_id is not None: result = template.replace('$RepresentationID$', representation_id)
if number is not None:
nstart = result.find('$Number')
if nstart >= 0:
nend = result.find('$', nstart+1)
if nend >= 0:
var = result[nstart+1 : nend]
if 'Number%' in var:
value = var[6:] % (int(number))
else:
value = number
result = result.replace('$'+var+'$', value)
if bandwidth is not None: result = result.replace('$Bandwidth$', bandwidth)
if time is not None: result = result.replace('$Time$', time)
result = result.replace('$$', '$').replace('../', '')
return result
def generate_segments(self):
segment_template = self.get_segment_template()
return self.get_segments(segment_template)
def get_segments(self, segment_template):
urls = []
urls.append(self.config.base_url + segment_template['@initialization'].replace('$RepresentationID$', self.config.format_id))
current_number = 1
for seg in self.force_segmentimeline(segment_template):
if '@t' in seg:
current_time = seg['@t']
for i in range(int(seg.get('@r', 0)) + 1):
urls.append(self.config.base_url + self.process_url_templace(segment_template['@media'],
representation_id=self.config.format_id,
bandwidth=None, time=str(current_time), number=str(current_number)))
current_number += 1
current_time += seg['@d']
return urls
def force_segmentimeline(self, segment_timeline):
if isinstance(segment_timeline['SegmentTimeline']['S'], list):
x16 = segment_timeline['SegmentTimeline']['S']
else:
x16 = [segment_timeline['SegmentTimeline']['S']]
return x16
def force_instance(self, x):
if isinstance(x['Representation'], list):
X = x['Representation']
else:
X = [x['Representation']]
return X
def get_segment_template(self):
x = [item for (i, item) in enumerate(self.xml['MPD']['Period']['AdaptationSet']) if self.config.track_id == item["@id"]][0]
segment_level = [item['SegmentTemplate'] for (i, item) in enumerate(self.force_instance(x)) if self.config.format_id == item["@id"]][0]
return segment_level
def run(self):
urls = self.generate_segments()
print('\n' + self.output_file)
global temp_folder
aria2c_infile = 'aria2c_infile.txt'
if os.path.isfile(aria2c_infile):
os.remove(aria2c_infile)
temp_folder = self.output_file.replace('.mp4', '')
if os.path.exists(temp_folder):
shutil.rmtree(temp_folder)
if not os.path.exists(temp_folder):
os.makedirs(temp_folder)
if len(urls) > 1:
num_segments = int(math.log10(len(urls))) + 1
with open(aria2c_infile, 'a', encoding='utf8') as (file):
for (i, url) in enumerate(urls):
file.write(f'{url}\n')
file.write(f'\tout={temp_folder}.{i:0{num_segments}d}.mp4\n')
file.write(f'\tdir={temp_folder}\n')
file.flush()
self.download_track(aria2c_infile, self.output_file)
print('Done!')

View File

@@ -0,0 +1,110 @@
import requests, pathlib
import math, subprocess
import os, sys, shutil
class WvDownloader(object):
def __init__(self, config):
self.xml = config.xml
self.output_file = config.output_file
self.config = config
def download_track(self, aria2c_infile, file_name):
aria2c_opts = [
'aria2c',
'--allow-overwrite=true',
'--download-result=hide',
'--console-log-level=warn',
'-x16', '-s16', '-j16',
'-i', aria2c_infile]
subprocess.run(aria2c_opts, check=True)
source_files = pathlib.Path(temp_folder).rglob(r'./*.mp4')
with open(file_name, mode='wb') as (destination):
for file in source_files:
with open(file, mode='rb') as (source):
shutil.copyfileobj(source, destination)
if os.path.exists(temp_folder):
shutil.rmtree(temp_folder)
os.remove(aria2c_infile)
print('\nDone!')
def process_url_templace(self, template, representation_id, bandwidth, time, number):
if representation_id is not None: result = template.replace('$RepresentationID$', representation_id)
if number is not None:
nstart = result.find('$Number')
if nstart >= 0:
nend = result.find('$', nstart+1)
if nend >= 0:
var = result[nstart+1 : nend]
if 'Number%' in var:
value = var[6:] % (int(number))
else:
value = number
result = result.replace('$'+var+'$', value)
if bandwidth is not None: result = result.replace('$Bandwidth$', bandwidth)
if time is not None: result = result.replace('$Time$', time)
result = result.replace('$$', '$').replace('../', '')
return result
def generate_segments(self):
segment_template = self.get_segment_template()
return self.get_segments(segment_template)
def get_segments(self, segment_template):
urls = []
urls.append(self.config.base_url + segment_template['@initialization'].replace('$RepresentationID$', self.config.format_id))
current_number = 1
for seg in self.force_segmentimeline(segment_template):
if '@t' in seg:
current_time = seg['@t']
for i in range(int(seg.get('@r', 0)) + 1):
urls.append(self.config.base_url + self.process_url_templace(segment_template['@media'],
representation_id=self.config.format_id,
bandwidth=None, time=str(current_time), number=str(current_number)))
current_number += 1
current_time += seg['@d']
return urls
def force_segmentimeline(self, segment_timeline):
if isinstance(segment_timeline['SegmentTimeline']['S'], list):
x16 = segment_timeline['SegmentTimeline']['S']
else:
x16 = [segment_timeline['SegmentTimeline']['S']]
return x16
def force_instance(self, x):
if isinstance(x['Representation'], list):
X = x['Representation']
else:
X = [x['Representation']]
return X
def get_segment_template(self):
x = [item for (i, item) in enumerate(self.xml['MPD']['Period']['AdaptationSet']) if self.config.track_id in item["@id"]][0]
segment_level = [item['SegmentTemplate'] for (i, item) in enumerate(self.force_instance(x)) if self.config.format_id in item["@id"]][0]
return segment_level
def run(self):
urls = self.generate_segments()
print('\n' + self.output_file)
global temp_folder
aria2c_infile = 'aria2c_infile.txt'
if os.path.isfile(aria2c_infile):
os.remove(aria2c_infile)
temp_folder = self.output_file.replace('.mp4', '')
if os.path.exists(temp_folder):
shutil.rmtree(temp_folder)
if not os.path.exists(temp_folder):
os.makedirs(temp_folder)
if len(urls) > 1:
num_segments = int(math.log10(len(urls))) + 1
with open(aria2c_infile, 'a', encoding='utf8') as (file):
for (i, url) in enumerate(urls):
file.write(f'{url}\n')
file.write(f'\tout={temp_folder}.{i:0{num_segments}d}.mp4\n')
file.write(f'\tdir={temp_folder}\n')
file.flush()
self.download_track(aria2c_infile, self.output_file)
print('Done!')

Binary file not shown.

View 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)

View File

@@ -0,0 +1,55 @@
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

View 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

View File

View 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)

View File

@@ -0,0 +1,199 @@
import threading
import os
import requests
import math
import shutil
import pathlib, sys, subprocess
from requests.sessions import session
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()
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!')
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()
return self.get_segments(segs)
def get_segments(self, segment_level):
media = segment_level['@media']
current_number = 1
current_time = 0
for seg in self.force_segment_level(segment_level):
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 += int(seg['@d'])
yield url
def force_segment_level(self, segment_level):
if isinstance(segment_level['SegmentTimeline']['S'], list):
segment_level = segment_level['SegmentTimeline']['S']
else:
segment_level = [segment_level['SegmentTimeline']['S']]
return segment_level
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:
segment_level = mpd['AdaptationSet'][x]['SegmentTemplate']
except TypeError:
segment_level = mpd['AdaptationSet'][x]['SegmentTemplate']
return segment_level
def run(self):
segment_list = self.generate_segments()
urls = []
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):
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)

View 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
View File

@@ -0,0 +1,271 @@
# -*- coding: utf-8 -*-
import os
import subprocess
class Muxer(object):
def __init__(self, CurrentName, SeasonFolder, CurrentHeigh, Type, mkvmergeexe):
self.CurrentName = CurrentName
self.SeasonFolder = SeasonFolder
self.CurrentHeigh = CurrentHeigh
self.Type = Type
self.mkvmergeexe = mkvmergeexe
def mkvmerge_muxer(self, lang):
VideoInputNoExist = False
if os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].h264'):
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].h264'
if self.Type == "show":
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].mkv')
else:
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].mkv'
if os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p].h264'):
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p].h264'
if self.Type == "show":
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p].mkv')
else:
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p].mkv'
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].mp4'):
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].mp4'
if self.Type == "show":
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].mkv')
else:
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].mkv'
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].h265'):
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].h265'
if self.Type == "show":
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].mkv')
else:
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HEVC].mkv'
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].mp4'):
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].mp4'
if self.Type == "show":
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].mkv')
else:
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].mkv'
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].vp9'):
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].vp9'
if self.Type == "show":
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].mkv')
else:
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [VP9].mkv'
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].mp4'):
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].mp4'
if self.Type == "show":
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].mkv')
else:
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].mkv'
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].h265'):
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].h265'
if self.Type == "show":
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].mkv')
else:
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [HDR].mkv'
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].mp4'):
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].mp4'
if self.Type == "show":
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].mkv')
else:
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].mkv'
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].h264'):
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].h264'
if self.Type == "show":
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].mkv')
else:
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [AVC HIGH].mkv'
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].mp4'):
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].mp4'
if self.Type == "show":
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].mkv')
else:
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p] [CBR].mkv'
elif os.path.isfile(self.CurrentName + ' [' + self.CurrentHeigh + 'p].mp4'):
VideoInputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p].mp4'
if self.Type == "show":
VideoOutputName = os.path.join(self.SeasonFolder, self.CurrentName + ' [' + self.CurrentHeigh + 'p].mkv')
else:
VideoOutputName = self.CurrentName + ' [' + self.CurrentHeigh + 'p].mkv'
else:
VideoInputNoExist = True
if VideoInputNoExist == False:
AudioExtensionsList=[
".ac3",
".mka",
".eac3",
".m4a",
".dts",
".mp3",
".aac"
]
SubsExtensionsList= [
".srt",
".ass",
]
if lang == "English":
language_tag = "English"
if language_tag == "English":
subs_forced = 'Forced'
subs_sdh = 'SDH'
#["en", "en", "eng", "English", "yes", "yes"]
#[audio_language, subs_language, language_id, language_name, audio_default, subs_default]
LanguageList = [
["es-la", "es-la", "spa", "Spanish", "yes", "no"],
["en", "en", "eng", "English", "no", "no"],
["pt-br", "pt-br", "por", "Brazilian Portuguese", "no", "no"],
["es", "es", "spa", "Castilian", "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:' + language_id, '--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:' + language_id, '--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)