Fixes for HULU 4K and HS AVC 4K
This commit is contained in:
parent
3efc534b10
commit
3b1bbdb7fd
Binary file not shown.
Binary file not shown.
@ -222,7 +222,9 @@ class Hulu(BaseService):
|
|||||||
))
|
))
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
for track in tracks:
|
||||||
|
track.needs_proxy = False
|
||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
def get_chapters(self, title):
|
def get_chapters(self, title):
|
||||||
|
|||||||
@ -273,7 +273,7 @@ async def m3u8dl(uri, out, track, headers=None, proxy=None):
|
|||||||
arguments.extend(["--http-request-timeout", "8"])
|
arguments.extend(["--http-request-timeout", "8"])
|
||||||
if track.__class__.__name__ == "VideoTrack":
|
if track.__class__.__name__ == "VideoTrack":
|
||||||
from vinetrimmer.objects.tracks import Track
|
from vinetrimmer.objects.tracks import Track
|
||||||
if track.height and not (track.descriptor == Track.Descriptor.M3U):
|
if track.height and not (track.descriptor == Track.Descriptor.M3U) and track.source != "HS":
|
||||||
arguments.extend([
|
arguments.extend([
|
||||||
"-sv", f"res='{track.height}*':codec='{track.codec}':for=best"
|
"-sv", f"res='{track.height}*':codec='{track.codec}':for=best"
|
||||||
])
|
])
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
|
|
||||||
import pyhulu
|
from vinetrimmer.vendor.pyhulu.client import HuluClient
|
||||||
|
|
||||||
|
|
||||||
class Device: # pylint: disable=too-few-public-methods
|
class Device: # pylint: disable=too-few-public-methods
|
||||||
@ -30,7 +30,7 @@ class Device: # pylint: disable=too-few-public-methods
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class HuluClient(pyhulu.HuluClient):
|
class HuluClient(HuluClient):
|
||||||
def __init__(self, device, session, version=1, **kwargs):
|
def __init__(self, device, session, version=1, **kwargs):
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
self.device = device
|
self.device = device
|
||||||
|
|||||||
3
vinetrimmer/vendor/pyhulu/__init__.py
vendored
Normal file
3
vinetrimmer/vendor/pyhulu/__init__.py
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""pyhulu - Python library for interacting with the E2E encrypted Hulu API"""
|
||||||
|
|
||||||
|
from vinetrimmer.vendor.pyhulu.client import HuluClient
|
||||||
176
vinetrimmer/vendor/pyhulu/client.py
vendored
Normal file
176
vinetrimmer/vendor/pyhulu/client.py
vendored
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
"""
|
||||||
|
Client module
|
||||||
|
|
||||||
|
Main module for Hulu API requests
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from Cryptodome.Cipher import AES
|
||||||
|
from Cryptodome.Util import Padding
|
||||||
|
|
||||||
|
from vinetrimmer.vendor.pyhulu.device import Device
|
||||||
|
|
||||||
|
|
||||||
|
class HuluClient(object):
|
||||||
|
"""
|
||||||
|
HuluClient class
|
||||||
|
|
||||||
|
Main class for Hulu API requests
|
||||||
|
|
||||||
|
__init__:
|
||||||
|
|
||||||
|
@param device_code: Three-digit string or integer (doesn't matter)
|
||||||
|
denoting the device you will make requests as
|
||||||
|
|
||||||
|
@param device_key: 16-byte AES key that corresponds to the device
|
||||||
|
code you're using. This is used to decrypt the
|
||||||
|
device config response.
|
||||||
|
|
||||||
|
@param cookies: Either a cookie jar object or a dict of cookie
|
||||||
|
key / value pairs. This is passed to the requests library,
|
||||||
|
so whatever it takes will work. Examples here:
|
||||||
|
http://docs.python-requests.org/en/master/user/quickstart/#cookies
|
||||||
|
|
||||||
|
@return: HuluClient object
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, device_code, device_key, cookies, version=1, extra_playlist_params={}):
|
||||||
|
self.logger = logging.getLogger(__name__)
|
||||||
|
self.device = Device(device_code, device_key)
|
||||||
|
self.cookies = cookies
|
||||||
|
self.version = version
|
||||||
|
self.extra_playlist_params = extra_playlist_params
|
||||||
|
|
||||||
|
self.session_key, self.server_key = self.get_session_key()
|
||||||
|
|
||||||
|
def load_playlist(self, video_id):
|
||||||
|
"""
|
||||||
|
load_playlist()
|
||||||
|
|
||||||
|
Method to get a playlist containing the MPD
|
||||||
|
and license URL for the provided video ID and return it
|
||||||
|
|
||||||
|
@param video_id: String of the video ID to get a playlist for
|
||||||
|
|
||||||
|
@return: Dict of decrypted playlist response
|
||||||
|
"""
|
||||||
|
|
||||||
|
base_url = 'https://play.hulu.com/v4/playlist'
|
||||||
|
params = {
|
||||||
|
'device_identifier': hashlib.md5().hexdigest().upper(),
|
||||||
|
'deejay_device_id': int(self.device.device_code),
|
||||||
|
'version': self.version,
|
||||||
|
'content_eab_id': video_id,
|
||||||
|
'rv': random.randrange(1E5, 1E6),
|
||||||
|
'kv': self.server_key,
|
||||||
|
}
|
||||||
|
params.update(self.extra_playlist_params)
|
||||||
|
|
||||||
|
resp = requests.post(url=base_url, json=params, cookies=self.cookies)
|
||||||
|
ciphertext = self.__get_ciphertext(resp.text, params)
|
||||||
|
|
||||||
|
return self.decrypt_response(self.session_key, ciphertext)
|
||||||
|
|
||||||
|
def decrypt_response(self, key, ciphertext):
|
||||||
|
"""
|
||||||
|
decrypt_response()
|
||||||
|
|
||||||
|
Method to decrypt an encrypted response with provided key
|
||||||
|
|
||||||
|
@param key: Key in bytes
|
||||||
|
@param ciphertext: Ciphertext to decrypt in bytes
|
||||||
|
|
||||||
|
@return: Decrypted response as a dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
aes_cbc_ctx = AES.new(key, AES.MODE_CBC, iv=b'\0'*16)
|
||||||
|
|
||||||
|
try:
|
||||||
|
plaintext = Padding.unpad(aes_cbc_ctx.decrypt(ciphertext), 16)
|
||||||
|
except ValueError:
|
||||||
|
self.logger.error('Error decrypting response')
|
||||||
|
self.logger.error('Ciphertext:')
|
||||||
|
self.logger.error(base64.b64encode(ciphertext).decode('utf8'))
|
||||||
|
self.logger.error(
|
||||||
|
'Tried decrypting with key %s',
|
||||||
|
base64.b64encode(key).decode('utf8')
|
||||||
|
)
|
||||||
|
|
||||||
|
raise ValueError('Error decrypting response')
|
||||||
|
|
||||||
|
return json.loads(plaintext.decode('latin-1'))
|
||||||
|
|
||||||
|
def get_session_key(self):
|
||||||
|
"""
|
||||||
|
get_session_key()
|
||||||
|
|
||||||
|
Method to do a Hulu config request and calculate
|
||||||
|
the session key against device key and current server key
|
||||||
|
|
||||||
|
@return: Session key in bytes
|
||||||
|
"""
|
||||||
|
|
||||||
|
random_value = random.randrange(1E5, 1E6)
|
||||||
|
|
||||||
|
base = '{device_key},{device},{version},{random_value}'.format(
|
||||||
|
device_key=binascii.hexlify(self.device.device_key).decode('utf8'),
|
||||||
|
device=self.device.device_code,
|
||||||
|
version=self.version,
|
||||||
|
random_value=random_value
|
||||||
|
).encode('utf8')
|
||||||
|
|
||||||
|
nonce = hashlib.md5(base).hexdigest()
|
||||||
|
|
||||||
|
url = 'https://play.hulu.com/config'
|
||||||
|
payload = {
|
||||||
|
'rv': random_value,
|
||||||
|
'mozart_version': '1',
|
||||||
|
'region': 'US',
|
||||||
|
'version': self.version,
|
||||||
|
'device': self.device.device_code,
|
||||||
|
'encrypted_nonce': nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = requests.post(url=url, data=payload)
|
||||||
|
ciphertext = self.__get_ciphertext(resp.text, payload)
|
||||||
|
|
||||||
|
config_dict = self.decrypt_response(
|
||||||
|
self.device.device_key,
|
||||||
|
ciphertext
|
||||||
|
)
|
||||||
|
|
||||||
|
derived_key_array = bytearray()
|
||||||
|
for device_byte, server_byte in zip(self.device.device_key,
|
||||||
|
bytes.fromhex(config_dict['key'])):
|
||||||
|
derived_key_array.append(device_byte ^ server_byte)
|
||||||
|
|
||||||
|
return bytes(derived_key_array), config_dict['key_id']
|
||||||
|
|
||||||
|
def __get_ciphertext(self, text, request):
|
||||||
|
try:
|
||||||
|
ciphertext = bytes.fromhex(text)
|
||||||
|
except ValueError:
|
||||||
|
self.logger.error('Error decoding response hex')
|
||||||
|
self.logger.error('Request:')
|
||||||
|
for line in json.dumps(request, indent=4).splitlines():
|
||||||
|
self.logger.error(line)
|
||||||
|
|
||||||
|
self.logger.error('Response:')
|
||||||
|
for line in text.splitlines():
|
||||||
|
self.logger.error(line)
|
||||||
|
|
||||||
|
raise ValueError('Error decoding response hex')
|
||||||
|
|
||||||
|
return ciphertext
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<HuluClient session_key=%s>' % base64.b64encode(
|
||||||
|
self.session_key
|
||||||
|
).decode('utf8')
|
||||||
31
vinetrimmer/vendor/pyhulu/device.py
vendored
Normal file
31
vinetrimmer/vendor/pyhulu/device.py
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Device module
|
||||||
|
|
||||||
|
Module containing Device data class
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
|
class Device(object): # pylint: disable=too-few-public-methods
|
||||||
|
"""
|
||||||
|
Device()
|
||||||
|
|
||||||
|
Data class used for containing device attributes
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, device_code, device_key):
|
||||||
|
self.device_code = str(device_code)
|
||||||
|
self.device_key = device_key
|
||||||
|
|
||||||
|
if len(self.device_code) != 3:
|
||||||
|
raise ValueError('Invalid device code length')
|
||||||
|
|
||||||
|
if len(self.device_key) != 16:
|
||||||
|
raise ValueError('Invalid device key length')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<Device device_code=%s, device_key=%s>' % (
|
||||||
|
self.device_code,
|
||||||
|
base64.b64encode(self.device_key).decode('utf8')
|
||||||
|
)
|
||||||
@ -54,7 +54,9 @@ def main(debug):
|
|||||||
|
|
||||||
log = logging.getLogger("vt")
|
log = logging.getLogger("vt")
|
||||||
|
|
||||||
log.info("vinetrimmer - Widevine DRM downloader and decrypter")
|
log.debug(sys.argv)
|
||||||
|
|
||||||
|
log.info("vinetrimmer - Widevine and Playready DRM downloader and decrypter")
|
||||||
log.info(f"[Root Config] : {filenames.user_root_config}")
|
log.info(f"[Root Config] : {filenames.user_root_config}")
|
||||||
log.info(f"[Service Configs] : {directories.service_configs}")
|
log.info(f"[Service Configs] : {directories.service_configs}")
|
||||||
log.info(f"[Cookies] : {directories.cookies}")
|
log.info(f"[Cookies] : {directories.cookies}")
|
||||||
@ -71,8 +73,5 @@ def main(debug):
|
|||||||
|
|
||||||
dl()
|
dl()
|
||||||
|
|
||||||
# D:\PlayReady-Amazon-Tool-main\.venv\Scripts\python.exe -X pycache_prefix=C:\Users\Aswin\AppData\Local\JetBrains\PyCharm2024.3\cpython-cache "C:/Program Files (x86)/JetBrains/PyCharm 2024.2.4/plugins/python-ce/helpers/pydev/pydevd.py" --port 42000 --module --multiprocess --save-signatures --qt-support=auto --file poetry run vt dl --no-cache --keys AMZN 0H7LY5ZKKBM1MIW0244WE9O2C4
|
|
||||||
# Above seems to work
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
#sys.argv = ["vinetrimmer", "dl", "--no-cache", "--keys", "AMZN", "0H7LY5ZKKBM1MIW0244WE9O2C4"]
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
7
vt.py
7
vt.py
@ -54,7 +54,9 @@ def main(debug):
|
|||||||
|
|
||||||
log = logging.getLogger("vt")
|
log = logging.getLogger("vt")
|
||||||
|
|
||||||
log.info("vinetrimmer - Widevine DRM downloader and decrypter")
|
log.debug(sys.argv)
|
||||||
|
|
||||||
|
log.info("vinetrimmer - Widevine and Playready DRM downloader and decrypter")
|
||||||
log.info(f"[Root Config] : {filenames.user_root_config}")
|
log.info(f"[Root Config] : {filenames.user_root_config}")
|
||||||
log.info(f"[Service Configs] : {directories.service_configs}")
|
log.info(f"[Service Configs] : {directories.service_configs}")
|
||||||
log.info(f"[Cookies] : {directories.cookies}")
|
log.info(f"[Cookies] : {directories.cookies}")
|
||||||
@ -71,8 +73,5 @@ def main(debug):
|
|||||||
|
|
||||||
dl()
|
dl()
|
||||||
|
|
||||||
# D:\PlayReady-Amazon-Tool-main\.venv\Scripts\python.exe -X pycache_prefix=C:\Users\Aswin\AppData\Local\JetBrains\PyCharm2024.3\cpython-cache "C:/Program Files (x86)/JetBrains/PyCharm 2024.2.4/plugins/python-ce/helpers/pydev/pydevd.py" --port 42000 --module --multiprocess --save-signatures --qt-support=auto --file poetry run vt dl --no-cache --keys AMZN 0H7LY5ZKKBM1MIW0244WE9O2C4
|
|
||||||
# Above seems to work
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
#sys.argv = ["vinetrimmer", "dl", "--no-cache", "--keys", "AMZN", "0H7LY5ZKKBM1MIW0244WE9O2C4"]
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user