diff --git a/vinetrimmer/devices/hisense_smarttv_he55a7000euwts_sl3000.prd b/vinetrimmer/devices/hisense_smarttv_he55a7000euwts_sl3000.prd index cee30dc..ee3ee0d 100644 Binary files a/vinetrimmer/devices/hisense_smarttv_he55a7000euwts_sl3000.prd and b/vinetrimmer/devices/hisense_smarttv_he55a7000euwts_sl3000.prd differ diff --git a/vinetrimmer/key_store.db b/vinetrimmer/key_store.db index 56272c7..8ccaeac 100644 Binary files a/vinetrimmer/key_store.db and b/vinetrimmer/key_store.db differ diff --git a/vinetrimmer/services/hulu.py b/vinetrimmer/services/hulu.py index 71cea62..30f2a80 100644 --- a/vinetrimmer/services/hulu.py +++ b/vinetrimmer/services/hulu.py @@ -222,7 +222,9 @@ class Hulu(BaseService): )) except KeyError: pass - + + for track in tracks: + track.needs_proxy = False return tracks def get_chapters(self, title): diff --git a/vinetrimmer/utils/io.py b/vinetrimmer/utils/io.py index 6bc7721..96a76ff 100644 --- a/vinetrimmer/utils/io.py +++ b/vinetrimmer/utils/io.py @@ -273,7 +273,7 @@ async def m3u8dl(uri, out, track, headers=None, proxy=None): arguments.extend(["--http-request-timeout", "8"]) if track.__class__.__name__ == "VideoTrack": 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([ "-sv", f"res='{track.height}*':codec='{track.codec}':for=best" ]) diff --git a/vinetrimmer/utils/pyhulu.py b/vinetrimmer/utils/pyhulu.py index 330e44c..2df34fe 100644 --- a/vinetrimmer/utils/pyhulu.py +++ b/vinetrimmer/utils/pyhulu.py @@ -3,7 +3,7 @@ import hashlib import logging import random -import pyhulu +from vinetrimmer.vendor.pyhulu.client import HuluClient 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): self.logger = logging.getLogger(__name__) self.device = device diff --git a/vinetrimmer/vendor/pyhulu/__init__.py b/vinetrimmer/vendor/pyhulu/__init__.py new file mode 100644 index 0000000..a9957a1 --- /dev/null +++ b/vinetrimmer/vendor/pyhulu/__init__.py @@ -0,0 +1,3 @@ +"""pyhulu - Python library for interacting with the E2E encrypted Hulu API""" + +from vinetrimmer.vendor.pyhulu.client import HuluClient diff --git a/vinetrimmer/vendor/pyhulu/client.py b/vinetrimmer/vendor/pyhulu/client.py new file mode 100644 index 0000000..ea50d42 --- /dev/null +++ b/vinetrimmer/vendor/pyhulu/client.py @@ -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 '' % base64.b64encode( + self.session_key + ).decode('utf8') diff --git a/vinetrimmer/vendor/pyhulu/device.py b/vinetrimmer/vendor/pyhulu/device.py new file mode 100644 index 0000000..dbf2655 --- /dev/null +++ b/vinetrimmer/vendor/pyhulu/device.py @@ -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 '' % ( + self.device_code, + base64.b64encode(self.device_key).decode('utf8') + ) diff --git a/vinetrimmer/vinetrimmer.py b/vinetrimmer/vinetrimmer.py index d31f166..54ddec0 100644 --- a/vinetrimmer/vinetrimmer.py +++ b/vinetrimmer/vinetrimmer.py @@ -54,7 +54,9 @@ def main(debug): 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"[Service Configs] : {directories.service_configs}") log.info(f"[Cookies] : {directories.cookies}") @@ -71,8 +73,5 @@ def main(debug): 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__": - #sys.argv = ["vinetrimmer", "dl", "--no-cache", "--keys", "AMZN", "0H7LY5ZKKBM1MIW0244WE9O2C4"] main() diff --git a/vt.py b/vt.py index d31f166..54ddec0 100644 --- a/vt.py +++ b/vt.py @@ -54,7 +54,9 @@ def main(debug): 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"[Service Configs] : {directories.service_configs}") log.info(f"[Cookies] : {directories.cookies}") @@ -71,8 +73,5 @@ def main(debug): 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__": - #sys.argv = ["vinetrimmer", "dl", "--no-cache", "--keys", "AMZN", "0H7LY5ZKKBM1MIW0244WE9O2C4"] main()