import base64 import hashlib import hmac import json import os import time import uuid import re import requests from datetime import datetime from urllib.parse import urlparse, parse_qs from urllib.request import urlopen, Request import http.cookiejar as cookiejar import click from vinetrimmer.objects import Title, Tracks from vinetrimmer.services.BaseService import BaseService from vinetrimmer.config import config, directories class Hotstar(BaseService): """ Service code for Star India's Hotstar (aka Disney+ Hotstar) streaming service (https://hotstar.com). \b Authorization: Credentials Security: UHD@L3, doesn't seem to care about releases. \b Tips: - The library of contents can be viewed without logging in at https://hotstar.com - The homepage hosts domestic programming; Disney+ content is at https://hotstar.com/in/disneyplus """ ALIASES = ["HS", "hotstar"] #GEOFENCE = ["in"] TITLE_RE = r"^(?:https?://(?:www\.)?hotstar\.com/[a-z0-9/-]+/)(?P\d+)" @staticmethod @click.command(name="Hotstar", short_help="https://hotstar.com") @click.argument("title", type=str, required=False) @click.option("-q", "--quality", default="hd", type=click.Choice(["4k", "fhd", "hd", "sd"], case_sensitive=False), help="Manifest quality to request.") @click.option("-c", "--channels", default="5.1", type=click.Choice(["5.1", "2.0", "atmos"], case_sensitive=False), help="Audio Codec") @click.option("-rg", "--region", default="in", type=click.Choice(["in", "id", "th"], case_sensitive=False), help="Account region") @click.pass_context def cli(ctx, **kwargs): return Hotstar(ctx, **kwargs) def __init__(self, ctx, title, quality, channels, region): super().__init__(ctx) self.parse_title(ctx, title) self.quality = quality self.channels = channels self.region = region.lower() assert ctx.parent is not None self.vcodec = ctx.parent.params["vcodec"] self.acodec = ctx.parent.params["acodec"] or "EC3" self.range = ctx.parent.params["range_"] self.profile = ctx.obj.profile self.device_id = None self.hotstar_auth = None self.token = None self.license_api = None self.configure() def get_titles(self): headers = { "Accept": "*/*", "Accept-Language": "en-GB,en;q=0.5", "hotstarauth": self.hotstar_auth, "X-HS-UserToken": self.token, "X-HS-Platform": self.config["device"]["platform"]["name"], "X-HS-AppVersion": self.config["device"]["platform"]["version"], "X-Country-Code": self.region, "x-platform-code": "PCTV" } try: r = self.session.get( url=self.config["endpoints"]["movie_title"], headers=headers, params={"contentId": self.title} ) try: res = r.json()["body"]["results"]["item"] except json.JSONDecodeError: raise ValueError(f"Failed to load title manifest: {res.text}") except: r = self.session.get( url=self.config["endpoints"]["tv_title"], headers=headers, params={"contentId": self.title} ) try: res = r.json()["body"]["results"]["item"] except json.JSONDecodeError: raise ValueError(f"Failed to load title manifest: {res.text}") if res["assetType"] == "MOVIE": return Title( id_=self.title, type_=Title.Types.MOVIE, name=res["title"], year=res["year"], original_lang=res["langObjs"][0]["iso3code"], source=self.ALIASES[0], service_data=res, ) else: r = self.session.get( url=self.config["endpoints"]["tv_episodes"], headers=headers, params={ "eid": res["id"], "etid": "2", "tao": "0", "tas": "1000" } ) try: res = r.json()["body"]["results"]["assets"]["items"] except json.JSONDecodeError: raise ValueError(f"Failed to load episodes list: {r.text}") return [Title( id_=self.title, type_=Title.Types.TV, name=x.get("showShortTitle"), year=x.get("year"), season=x.get("seasonNo"), episode=x.get("episodeNo"), episode_name=x.get("title"), original_lang=x["langObjs"][0]["iso3code"], source=self.ALIASES[0], service_data=x ) for x in res] def get_tracks(self, title): if title.service_data.get("parentalRating", 0) > 2: body = json.dumps({ "devices": [{ "id": self.device_id, "name": "Chrome Browser on Windows", "consentProvided": True }] }) self.session.post( url="https://api.hotstar.com/play/v1/consent/content/{id}?".format(id=title.service_data["contentId"]), headers={ "Accept": "*/*", "Content-Type": "application/json", "hotstarauth": self.hotstar_auth, "X-HS-UserToken": self.token, "X-HS-Platform": self.config["device"]["platform"]["name"], "X-HS-AppVersion": self.config["device"]["platform"]["version"], "X-HS-Request-Id": str(uuid.uuid4()), "X-Country-Code": self.region }, data=body ).json() akamai_cdn=True count = 1 while akamai_cdn: r = self.session.post( url=self.config["endpoints"]["manifest"].format(id=title.service_data["contentId"]), params={ # TODO: Perhaps set up desired-config to actual desired playback set values? "desired-config": "|".join([ "audio_channel:stereo", "container:fmp4", "dynamic_range:sdr", "encryption:widevine", "ladder:tv", "package:dash", "resolution:fhd", "video_codec:h264" ]), "device-id": self.device_id, "type": "paid", }, headers={ "Accept": "*/*", "hotstarauth": self.hotstar_auth, "x-hs-usertoken": self.token, "x-hs-request-id": self.device_id, "x-country-code": self.region }, json={ "os_name": "Windows", "os_version": "10", "app_name": "web", "app_version": "7.34.1", "platform": "Chrome", "platform_version": "99.0.4844.82", "client_capabilities": { "ads": ["non_ssai"], "audio_channel": ["stereo"], "dvr": ["short"], "package": ["dash", "hls"], "dynamic_range": ["sdr"], "video_codec": ["h264"], "encryption": ["widevine"], "ladder": ["tv"], "container": ["fmp4", "ts"], "resolution": ["hd"] }, "drm_parameters": { "widevine_security_level": ["SW_SECURE_DECODE", "SW_SECURE_CRYPTO"], "hdcp_version": ["HDCP_V2_2", "HDCP_V2_1", "HDCP_V2", "HDCP_V1"] }, "resolution": "auto", "type": "paid", } ) try: playback_sets = r.json()["data"]["playback_sets"] except json.JSONDecodeError: raise ValueError(f"Manifest fetch failed: {r.text}") # transform tagsCombination into `tags` key-value dictionary for easier usage playback_sets = [dict( **x, tags=dict(y.split(":") for y in x["tags_combination"].lower().split(";")) ) for x in playback_sets] #self.log.debug(playback_sets) playback_set = next(( x for x in playback_sets if x["tags"].get("encryption") == "widevine" or x["tags"].get("encryption") == "plain" # widevine, fairplay, playready if x["tags"].get("package") == "dash" # dash, hls if x["tags"].get("container") == "fmp4br" # fmp4, fmp4br, ts if x["tags"].get("ladder") == "tv" # tv, phone if x["tags"].get("video_codec").endswith(self.vcodec.lower()) # dvh265, h265, h264 - vp9? # user defined, may not be available in the tags list: if x["tags"].get("resolution") in [self.quality, None] # max is fine, -q can choose lower if wanted if x["tags"].get("dynamic_range") in [self.range.lower(), None] # dv, hdr10, sdr - hdr10+? if x["tags"].get("audio_codec") in [self.acodec.lower(), None] # ec3, aac - atmos? if x["tags"].get("audio_channel") in [{"5.1": "dolby51", "2.0": "stereo", "atmos": "atmos"}[self.channels], None] ), None) if not playback_set: playback_set = next(( x for x in playback_sets if x["tags"].get("encryption") == "widevine" or x["tags"].get("encryption") == "plain" # widevine, fairplay, playready if x["tags"].get("package") == "dash" # dash, hls if x["tags"].get("ladder") == "tv" # tv, phone if x["tags"].get("resolution") in [self.quality, None] ), None) if not playback_set: raise ValueError("Wanted playback set is unavailable for this title...") if "licence_url" in playback_set: self.license_api = playback_set["licence_url"] if playback_set['token_algorithm'] == 'airtel-qwilt-vod' or playback_set['token_algorithm'] == 'AKAMAI-HMAC': self.log.info(f'Gotcha!') akamai_cdn = False else: self.log.info(f'Finding MPD... {count}') count += 1 r = Request(playback_set["playback_url"]) r.add_header("user-agent", "Hotstar;in.startv.hotstar/3.3.0 (Android/8.1.0)") data = urlopen(r).read() mpd_url = playback_set["playback_url"] # .replace(".hotstar.com", ".akamaized.net") self.log.debug(mpd_url) try: self.session.headers.update({ "Cookie": self.hdntl, }) except: pass tracks = Tracks.from_mpd( url=mpd_url, data=data, session=self.session, source=self.ALIASES[0] ) for track in tracks: track.needs_proxy = True return tracks def get_chapters(self, title): return [] def certificate(self, **_): return None # will use common privacy cert def license(self, challenge, **_): return self.session.post( url=self.license_api, data=challenge # expects bytes ).content # Service specific functions def configure(self): self.session.headers.update({ "Origin": "https://www.hotstar.com", "Referer": f'"https://www.hotstar.com/{self.region}"' }) self.log.info("Logging into Hotstar") self.log.info(f'Setting region to "{self.region}"') self.hotstar_auth = self.get_akamai() self.log.info(f" + Calculated HotstarAuth: {self.hotstar_auth}") try: if self.cookies: hdntl_cookies = [cookie for cookie in self.session.cookies if cookie.name == 'hdntl'] self.hdntl = f"hdntl={hdntl_cookies[-1].value}" self.device_id = self.session.cookies.get("deviceId") self.log.info(f" + Using Device ID: {self.device_id}") except: self.device_id = str(uuid.uuid4()) self.log.info(f" + Created Device ID: {self.device_id}") self.session.headers.update({ "dnt": "1" }) self.token = self.get_token() self.log.info(" + Obtained tokens") @staticmethod def get_akamai(): enc_key = b"\x05\xfc\x1a\x01\xca\xc9\x4b\xc4\x12\xfc\x53\x12\x07\x75\xf9\xee" st = int(time.time()) exp = st + 6000 res = f"st={st}~exp={exp}~acl=/*" res += "~hmac=" + hmac.new(enc_key, res.encode(), hashlib.sha256).hexdigest() return res def get_token(self): token_cache_path = self.get_cache("token_{profile}.json".format(profile=self.profile)) if os.path.isfile(token_cache_path): with open(token_cache_path, encoding="utf-8") as fd: token = json.load(fd) if token.get("exp", 0) > int(time.time()): # not expired, lets use self.log.info(" + Using cached auth tokens...") return token["uid"] else: # expired, refresh self.log.info(" + Refreshing and using cached auth tokens...") return self.save_token(self.refresh(token["uid"], token["sub"]["deviceId"]), token_cache_path) # get new token if self.cookies: token = self.session.cookies.get("sessionUserUP", None, 'www.hotstar.com', '/' + self.region) else: raise self.log.exit(f" - Please add cookies") # token = self.login() return self.save_token(token, token_cache_path) @staticmethod def save_token(token, to): # Decode the JWT data component data = json.loads(base64.b64decode(token.split(".")[1] + "===").decode("utf-8")) data["uid"] = token data["sub"] = json.loads(data["sub"]) os.makedirs(os.path.dirname(to), exist_ok=True) with open(to, "w", encoding="utf-8") as fd: json.dump(data, fd) return token def refresh(self, user_id_token, device_id): json_data = { 'deeplink_url': f'/{self.region}?client_capabilities=%7B%22ads%22%3A%5B%22non_ssai%22%5D%2C%22audio_channel%22%3A%5B%22stereo%22%5D%2C%22container%22%3A%5B%22fmp4%22%2C%22ts%22%5D%2C%22dvr%22%3A%5B%22short%22%5D%2C%22dynamic_range%22%3A%5B%22sdr%22%5D%2C%22encryption%22%3A%5B%22widevine%22%2C%22plain%22%5D%2C%22ladder%22%3A%5B%22web%22%2C%22tv%22%2C%22phone%22%5D%2C%22package%22%3A%5B%22dash%22%2C%22hls%22%5D%2C%22resolution%22%3A%5B%22sd%22%2C%22hd%22%5D%2C%22video_codec%22%3A%5B%22h264%22%5D%2C%22true_resolution%22%3A%5B%22sd%22%2C%22hd%22%2C%22fhd%22%5D%7D&drm_parameters=%7B%22hdcp_version%22%3A%5B%22HDCP_V2_2%22%5D%2C%22widevine_security_level%22%3A%5B%22SW_SECURE_DECODE%22%5D%2C%22playready_security_level%22%3A%5B%5D%7D', 'app_launch_count': 1, } r = self.session.post( url=self.config["endpoints"]["refresh"], headers={ 'x-hs-usertoken': user_id_token, 'X-HS-Platform': self.config["device"]["platform"]["name"], 'X-Country-Code': self.region, 'X-HS-Accept-language': 'eng', 'X-Request-Id': str(uuid.uuid4()), 'x-hs-device-id': device_id, 'X-HS-Client-Targeting': f'ad_id:{device_id};user_lat:false', 'x-hs-request-id': str(uuid.uuid4()), 'X-HS-Client': 'platform:web;app_version:23.06.23.3;browser:Firefox;schema_version:0.0.911', 'Origin': 'https://www.hotstar.com', 'Referer': f'https://www.hotstar.com/{self.region}', }, json=json_data ) for cookie in self.cookies: if cookie.name == 'sessionUserUP' and cookie.path == f"/{self.region}" and cookie.domain == 'www.hotstar.com': cookie.value = r.headers["x-hs-usertoken"] for x in self.ALIASES: cookie_file = os.path.join(directories.cookies, x.lower(), f"{self.profile}.txt") if not os.path.isfile(cookie_file): cookie_file = os.path.join(directories.cookies, x, f"{self.profile}.txt") if os.path.isfile(cookie_file): self.cookies.save(cookie_file, ignore_discard=True, ignore_expires=True) break return r.headers["x-hs-usertoken"] def login(self): """ Log in to HOTSTAR and return a JWT User Identity token. :returns: JWT User Identity token. """ if self.credentials.username == "username" and self.credentials.password == "password": logincode_url = f"https://api.hotstar.com/{self.region}/aadhar/v2/firetv/{self.region}/users/logincode/" logincode_headers = { "Content-Length": "0", "User-Agent": "Hotstar;in.startv.hotstar/3.3.0 (Android/8.1.0)" } logincode = self.session.post( url = logincode_url, headers = logincode_headers ).json()["description"]["code"] print(f"Go to tv.hotstar.com and put {logincode}") logincode_choice = input('Did you put as informed above? (y/n): ') if logincode_choice.lower() == 'y': res = self.session.get( url = logincode_url+logincode, headers = logincode_headers ) else: self.log.exit(" - Exited.") raise else: res = self.session.post( url=self.config["endpoints"]["login"], json={ "isProfileRequired": "false", "userData": { "deviceId": self.device_id, "password": self.credentials.password, "username": self.credentials.username, "usertype": "email" }, "verification": {} }, headers={ "hotstarauth": self.hotstar_auth, "content-type": "application/json" } ) try: data = res.json() except json.JSONDecodeError: self.log.exit(f" - Failed to get auth token, response was not JSON: {res.text}") raise if "errorCode" in data: self.log.exit(f" - Login failed: {data['description']} [{data['errorCode']}]") raise return data["description"]["userIdentity"]