diff --git a/Vinetrimmer - CHROME CDM version.zip b/Vinetrimmer - CHROME CDM version.zip new file mode 100644 index 0000000..168e0b3 Binary files /dev/null and b/Vinetrimmer - CHROME CDM version.zip differ diff --git a/pyproject.toml b/pyproject.toml index 8857f7b..01c2607 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,9 +48,9 @@ Unidecode = "^1.2.0" validators = "^0.18.2" websocket-client = "^1.1.0" xmltodict = "^0.14.0" -yt-dlp = "^2022.11.11" +yt-dlp = "^2024.11.11" -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] flake8 = "^3.8.4" isort = "^5.9.2" pyinstaller = "^4.4" diff --git a/sonyliv.yml b/sonyliv.yml new file mode 100644 index 0000000..44f9f6c --- /dev/null +++ b/sonyliv.yml @@ -0,0 +1,16 @@ +endpoints: + refresh: 'https://apiv2.sonyliv.com/AGL/2.9/A/ENG/WEB/IN/TN/GETPROFILE?channelPartnerID=MSMIND' + hash: 'https://apiv2.sonyliv.com/AGL/2.0/SR/ENG/WEB/IN/TN/GETHASHVALUE' + title: 'https://apiv2.sonyliv.com/AGL/2.6/R/ENG/WEB/IN/MH/DETAIL-V2/{id}' + season: 'https://apiv2.sonyliv.com/AGL/2.6/SR/ENG/WEB/IN/MH/CONTENT/DETAIL/BUNDLE/{id}?from={ep_start}&to={ep_end}&orderBy=episodeNumber&sortOrder=asc&kids_safe=false' + # manifest: 'https://apiv2.sonyliv.com/AGL/3.8/SR/ENG/WEB/IN/MH/CONTENT/VIDEOURL/VOD/{id}?contactId={bid}' + manifest: 'https://apiv2.sonyliv.com/AGL/3.8/SR/ENG/SONY_ANDROID_TV/IN/MH/CONTENT/VIDEOURL/VOD/{id}?contactId={bid}' + license: 'https://apiv2.sonyliv.com/AGL/1.4/SR/ENG/WEB/IN/CONTENT/GETLAURL' + +device: + chrome: + device_id: '577f872ae8554c14a16ac1ef3d11c0e9-1712427232539' + app_version: '3.5.63' + platform: 'web' + user-agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' + td_client: '{"os_name":"Mac OS","os_version":"10.15.7","device_make":"none","device_model":"none","display_res":"1470","viewport_res":"894","conn_type":"4g","supp_codec":"H264,AV1,AAC","client_throughput":"16000","td_user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36","hdr_decoder":"UNKNOWN","audio_decoder":"STEREO"}' diff --git a/vinetrimmer/commands/dl.py b/vinetrimmer/commands/dl.py index 93877e6..d144dd4 100644 --- a/vinetrimmer/commands/dl.py +++ b/vinetrimmer/commands/dl.py @@ -278,9 +278,9 @@ def dl(ctx, profile, cdm, *_, **__): except ValueError as e: raise log.exit(f" - {e}") - device_name = device.system_id if "set_service_certificate" in dir(device) else device.get_name() + device_name = device.system_id if "vmp" in dir(device) else device.get_name() log.info(f" + Loaded {device.__class__.__name__}: {device_name} (L{device.security_level})") - cdm = Cdm(device) if "set_service_certificate" in dir(device) else CdmPr.from_device(device) + cdm = Cdm.from_device(device) if "vmp" in dir(device) else CdmPr.from_device(device) if profile: cookies = get_cookie_jar(service, profile) diff --git a/vinetrimmer/config/Services/hotstar.yml b/vinetrimmer/config/Services/hotstar.yml new file mode 100644 index 0000000..63bd101 --- /dev/null +++ b/vinetrimmer/config/Services/hotstar.yml @@ -0,0 +1,16 @@ +endpoints: + login: 'https://api.hotstar.com/in/aadhar/v2/web/th/user/login' + refresh: 'https://www.hotstar.com/api/internal/bff/v2/start' + tv_title: 'https://api.hotstar.com/o/v1/show/detail' + tv_episodes: 'https://api.hotstar.com/o/v1/tray/g/1/detail' + movie_title: 'https://api.hotstar.com/o/v1/movie/detail' + manifest: 'https://api.hotstar.com/play/v4/playback/content/{id}' + +device: + os: + name: 'Windows' + version: 10 + + platform: + name: 'web' + version: '7.35.0' diff --git a/vinetrimmer/devices/xiaomi_mi_a1_15.0.0_60ceee88_8159_l3.wvd b/vinetrimmer/devices/xiaomi_mi_a1_15.0.0_60ceee88_8159_l3.wvd new file mode 100644 index 0000000..e6d024f Binary files /dev/null and b/vinetrimmer/devices/xiaomi_mi_a1_15.0.0_60ceee88_8159_l3.wvd differ diff --git a/vinetrimmer/key_store.db b/vinetrimmer/key_store.db index 73b804f..9ab4f96 100644 Binary files a/vinetrimmer/key_store.db and b/vinetrimmer/key_store.db differ diff --git a/vinetrimmer/services/__init__.py b/vinetrimmer/services/__init__.py index a71bca8..9b934f8 100644 --- a/vinetrimmer/services/__init__.py +++ b/vinetrimmer/services/__init__.py @@ -11,6 +11,11 @@ from vinetrimmer.services.appletvplus import AppleTVPlus from vinetrimmer.services.max import Max from vinetrimmer.services.netflix import Netflix from vinetrimmer.services.peacock import Peacock +from vinetrimmer.services.hotstar import Hotstar +from vinetrimmer.services.jio import Jio +from vinetrimmer.services.moviesanywhere import MoviesAnywhere +from vinetrimmer.services.sonyliv import Sonyliv + # Above is necessary since dynamic imports like below fuck up nuitak diff --git a/vinetrimmer/services/hotstar.py b/vinetrimmer/services/hotstar.py new file mode 100644 index 0000000..10a7a6f --- /dev/null +++ b/vinetrimmer/services/hotstar.py @@ -0,0 +1,449 @@ +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="fhd", + 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] + + 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.session.headers.update({ + "Cookie": self.hdntl + }) + + 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.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"] diff --git a/vinetrimmer/services/jio.py b/vinetrimmer/services/jio.py new file mode 100644 index 0000000..454ca8b --- /dev/null +++ b/vinetrimmer/services/jio.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import base64 +import json +import re as rex +import os +import requests +from langcodes import Language +#from typing import Any, Optional, Union +import datetime +import click + +from vinetrimmer.objects import MenuTrack, TextTrack, Title, Tracks, Track +from vinetrimmer.services.BaseService import BaseService + + +class Jio(BaseService): + """ + Service code for Viacom18's JioCinema streaming service (https://www.jiocinema.com/). + + \b + Authorization: Token + Security: UHD@L3 FHD@L3 + + """ + + PHONE_NUMBER = "" # Add number with country code + + ALIASES = ["JIO", "JioCinema"] + #GEOFENCE = ["in2"] + + @staticmethod + @click.command(name="Jio", short_help="https://www.jiocinema.com") + @click.argument("title", type=str) + @click.option("-m", "--movie", is_flag=True, default=False, help="Title is a Movie.") + @click.pass_context + def cli(ctx, **kwargs): + return Jio(ctx, **kwargs) + + def __init__(self, ctx, title: str, movie: bool): + self.title = title + self.movie = movie + super().__init__(ctx) + + assert ctx.parent is not None + + self.vcodec = ctx.parent.params["vcodec"] + self.acodec = ctx.parent.params["acodec"] + self.range = ctx.parent.params["range_"] + + self.profile = ctx.obj.profile + + self.token: str + self.refresh_token: str + self.license_api = None + + self.configure() + + def get_titles(self): + titles = [] + if self.movie: + res = self.session.get( + url='https://content-jiovoot.voot.com/psapi/voot/v1/voot-tv/content/query/asset-details?ids=include%3A{id}&devicePlatformType=androidtv&responseType=common&page=1'.format(id=self.title) + ) + try: + data = res.json()['result'][0] + self.log.debug(json.dumps(data, indent=4)) + except json.JSONDecodeError: + raise ValueError(f"Failed to load title manifest: {res.text}") + + titles.append(Title( + id_=data['id'], + type_=Title.Types.MOVIE, + name=rex.sub(r'\([^)]*\)', '', data["fullTitle"]).strip(), + year=data.get("releaseYear"), + original_lang=Language.find(data['languages'][0]), + source=self.ALIASES[0], + service_data=data + )) + + else: + def get_recursive_episodes(season_id): + total_attempts = 1 + recursive_episodes = [] + season_params = { + 'sort': 'episode:asc', + 'responseType': 'common', + 'id': season_id, + 'page': 1 + } + while True: + episode = self.session.get(url='https://content-jiovoot.voot.com/psapi/voot/v1/voot-web/content/generic/series-wise-episode', params=season_params).json() + if any(episode["result"]): + total_attempts += 1 + recursive_episodes.extend(episode["result"]) + season_params.update({'page': total_attempts}) + else: + break + return recursive_episodes + # params = { + # 'sort': 'season:asc', + # 'id': self.title, + # 'responseType': 'common' + # } + re = self.session.get(url='https://content-jiovoot.voot.com/psapi/voot/v1/voot-tv/view/show/{id}?devicePlatformType=androidtv&responseType=common&page=1'.format(id=self.title)).json()['trays'][1] + self.log.debug(json.dumps(re, indent=4)) + for season in re['trayTabs']: + season_id = season["id"] + recursive_episodes = get_recursive_episodes(season_id) + self.log.debug(json.dumps(recursive_episodes, indent=4)) + for episodes in recursive_episodes: + titles.append(Title( + id_=episodes["id"], + type_=Title.Types.TV, + name=rex.sub(r'\([^)]*\)', '', episodes["showName"]).strip(), + season=int(float(episodes["season"])), + episode=int(float(episodes["episode"])), + episode_name=episodes["fullTitle"], + original_lang=Language.find(episodes['languages'][0]), + source=self.ALIASES[0], + service_data=episodes + )) + + return titles + + def get_tracks(self, title: Title) -> Tracks: + #self.log.debug(json.dumps(title.service_data, indent=4)) + json_data = { + '4k': True, + 'ageGroup': '18+', + 'appVersion': '4.0.9', + 'bitrateProfile': 'xxxhdpi', + 'capability': { + 'drmCapability': { + 'aesSupport': 'yes', + 'fairPlayDrmSupport': 'yes', + 'playreadyDrmSupport': 'yes', + 'widevineDRMSupport': 'L1', + }, + 'frameRateCapability': [ + { + 'frameRateSupport': '60fps', + 'videoQuality': '2160p', + }, + ], + }, + 'continueWatchingRequired': False, + 'dolby': True, + 'downloadRequest': False, + 'hevc': False, + 'kidsSafe': False, + 'manufacturer': 'NVIDIA', + 'model': 'SHIELDTV', + 'multiAudioRequired': True, + 'osVersion': '12', + 'parentalPinValid': True, + 'x-apisignatures': 'o668nxgzwff', + } + + try: + res = self.session.post( + url = f'https://apis-jiovoot.voot.com/playbackjv/v3/{title.id}', + json=json_data, + ) + except requests.exceptions.RequestException: + self.refresh() + try: + res = self.session.post( + url = f'https://apis-jiovoot.voot.com/playbackjv/v3/{title.id}', + json=json_data + ) + except requests.exceptions.RequestException: + self.log.exit("Unable to retrive manifest") + + res = res.json() + self.log.debug(json.dumps(res, indent=4)) + self.license_api = res['data']['playbackUrls'][0].get('licenseurl') + vid_url = res['data']['playbackUrls'][0].get('url') + + if "mpd" in vid_url: + tracks = Tracks.from_mpd( + url=vid_url, + session=self.session, + #lang=title.original_lang, + source=self.ALIASES[0] + ) + else: + self.log.exit('No mpd found') + + self.log.info(f"Getting audio from Various manifests for potential higher bitrate or better codec") + for device in ['androidtablet']: #'androidmobile', 'androidweb' ==> what more devices ? + self.session.headers.update({'x-platform': device}) + audio_mpd_url = self.session.post(url=f'https://apis-jiovoot.voot.com/playbackjv/v3/{title.id}', json=json_data) + if audio_mpd_url.status_code != 200: + self.log.warning("Unable to retrive manifest") + else: + audio_mpd_url = audio_mpd_url.json()['data']['playbackUrls'][0].get('url') + if "mpd" in audio_mpd_url: + audio_mpd = Tracks([ + x for x in iter(Tracks.from_mpd( + url=audio_mpd_url, + session=self.session, + source=self.ALIASES[0], + #lang=title.original_lang, + )) + ]) + tracks.add(audio_mpd.audios) + else: + self.log.warning('No mpd found') + + for track in tracks: + #track.language = Language.get('ta') + track.needs_proxy = True + + return tracks + + def get_chapters(self, title: Title) -> list[MenuTrack]: + return [] + + def certificate(self, **kwargs) -> None: + return self.license(**kwargs) + + def license(self, challenge: bytes, **_) -> bytes: + assert self.license_api is not None + self.session.headers.update({ + 'x-playbackid': '5ec82c75-6fda-4b47-b2a5-84b8d9079675', + 'x-feature-code': 'ytvjywxwkn', + 'origin': 'https://www.jiocinema.com', + 'referer': 'https://www.jiocinema.com/', + }) + return self.session.post( + url=self.license_api, + data=challenge, # expects bytes + ).content + + def refresh(self) -> None: + self.log.info(" + Refreshing auth tokens...") + res = self.session.post( + url="https://auth-jiocinema.voot.com/tokenservice/apis/v4/refreshtoken", + json={ + 'appName': 'RJIL_JioCinema', + 'deviceId': '332536276', + 'refreshToken': self.refresh_token, + 'appVersion': '5.6.0' + } + ) + if res.status_code != 200: + return self.log.warning('Tokens cannot be Refreshed. Something went wrong..') + + self.token = res.json()["authToken"] + self.refresh_token = res.json()["refreshTokenId"] + self.session.headers.update({'accesstoken': self.token}) + token_cache_path = self.get_cache("token_{profile}.json".format(profile=self.profile)) + old_data = json.load(open(token_cache_path, "r", encoding="utf-8")) + old_data.update({ + 'authToken': self.token, + 'refreshToken': self.refresh_token + }) + json.dump(old_data, open(token_cache_path, "w", encoding="utf-8"), indent=4) + + def login(self): + self.log.info(' + Logging into JioCinema') + if not self.PHONE_NUMBER: + self.log.exit('Please provide Jiocinema registered Phone number....') + guest = self.session.post( + url="https://auth-jiocinema.voot.com/tokenservice/apis/v4/guest", + json={ + 'appName': 'RJIL_JioCinema', + 'deviceType': 'phone', + 'os': 'ios', + 'deviceId': '332536276', + 'freshLaunch': False, + 'adId': '332536276', + 'appVersion': '5.6.0', + } + ) + headers = { + 'accesstoken': guest.json()["authToken"], + 'appname': 'RJIL_JioCinema', + 'devicetype': 'phone', + 'os': 'ios' + } + send = self.session.post( + url="https://auth-jiocinema.voot.com/userservice/apis/v4/loginotp/send", + headers=headers, + json={ + 'number': '{}'.format(base64.b64encode(self.PHONE_NUMBER.encode("utf-8")).decode("utf-8")), + 'appVersion': '5.6.0' + } + ) + if send.status_code != 200: + self.log.exit("OTP Send Failed!") + else: + self.log.info("OTP has been sent. Please write it down below and press Enter") + otp = input() + verify_data = { + 'deviceInfo': { + 'consumptionDeviceName': 'iPhone', + 'info': { + 'platform': { + 'name': 'iPhone OS', + }, + 'androidId': '332536276', + 'type': 'iOS', + }, + }, + 'appVersion': '5.6.0', + 'number': '{}'.format(base64.b64encode(self.PHONE_NUMBER.encode("utf-8")).decode("utf-8")), + 'otp': '{}'.format(otp) + } + verify = self.session.post( + url="https://auth-jiocinema.voot.com/userservice/apis/v4/loginotp/verify", + headers=headers, + json=verify_data + ) + if verify.status_code != 200: + self.log.exit("Cannot be verified") + self.log.info(" + Verified!") + + return verify.json() + + def configure(self) -> None: + token_cache_path = self.get_cache("token_{profile}.json".format(profile=self.profile)) + if os.path.isfile(token_cache_path): + tokens = json.load(open(token_cache_path, "r", encoding="utf-8")) + self.log.info(" + Using cached auth tokens...") + else: + tokens = self.login() + os.makedirs(os.path.dirname(token_cache_path), exist_ok=True) + with open(token_cache_path, "w", encoding="utf-8") as file: + json.dump(tokens, file, indent=4) + self.token = tokens["authToken"] + self.refresh_token = tokens["refreshToken"] + self.session.headers.update({ + 'deviceid': '332536276', + 'accesstoken': self.token, + 'appname': 'RJIL_JioCinema', + 'uniqueid': 'be277ebe-e50b-441e-bc37-bd803286f3d5', + 'user-agent': 'Dalvik/2.1.0 (Linux; U; Android 9; SHIELD Android TV Build/PPR1.180610.011)', + 'x-apisignatures': 'o668nxgzwff', + 'x-platform': 'androidtv', # base device + 'x-platform-token': 'android', + }) \ No newline at end of file diff --git a/vinetrimmer/services/moviesanywhere.py b/vinetrimmer/services/moviesanywhere.py new file mode 100644 index 0000000..28c9b7b --- /dev/null +++ b/vinetrimmer/services/moviesanywhere.py @@ -0,0 +1,248 @@ +import base64 +import json +import click +import re +from requests import JSONDecodeError +from httpx import URL +import uuid +import xmltodict + +import time +from datetime import datetime +from langcodes import Language +from vinetrimmer.objects import AudioTrack, TextTrack, Title, Tracks, VideoTrack +from vinetrimmer.services.BaseService import BaseService +from vinetrimmer.vendor.pymp4.parser import Box + + +class MoviesAnywhere(BaseService): + """ + Service code for US' streaming service MoviesAnywhere (https://moviesanywhere.com). + + \b + Authorization: Cookies + Security: SD-HD@L3, FHD SDR@L1 (any active device), FHD-UHD HDR-DV@L1 (whitelisted devices). + + NOTE: Can be accessed from any region, it does not seem to care. + Accounts can only mount services when its US based though. + + """ + ALIASES = ["MA", "moviesanywhere"] + + TITLE_RE = r"https://moviesanywhere\.com(?P.+)" + + VIDEO_CODEC_MAP = { + "H264": ["avc"], + "H265": ["hvc", "hev", "dvh"] + } + AUDIO_CODEC_MAP = { + "AAC": ["mp4a", "HE", "stereo"], + "AC3": ["ac3"], + "EC3": ["ec3", "atmos"] + } + + @staticmethod + @click.command(name="MoviesAnywhere", short_help="moviesanywhere.com") + @click.argument("title", type=str) + + @click.pass_context + def cli(ctx, **kwargs): + return MoviesAnywhere(ctx, **kwargs) + + def __init__(self, ctx, title): + super().__init__(ctx) + self.parse_title(ctx, title) + self.configure() + + self.atmos = ctx.parent.params["atmos"] + self.vcodec = ctx.parent.params["vcodec"] + self.acodec = ctx.parent.params["acodec"] + + def get_titles(self): + self.headers={ + "authorization": f"Bearer {self.access_token}", + "install-id": self.install_id, + } + res = self.session.post( + url="https://gateway.moviesanywhere.com/graphql", + json={ + "platform": "web", + "variables": {"slug": self.title}, # Does not seem to care which platform will be used to give the best tracks available + "extensions": '{"persistedQuery":{"sha256Hash":"5cb001491262214406acf8237ea2b8b46ca6dbcf37e70e791761402f4f74336e","version":1}}', # ONE_GRAPH_PERSIST_QUERY_TOKEN + }, + headers={ + "authorization": f"Bearer {self.access_token}", + "install-id": self.install_id, + } + ) + + try: + self.content = res.json() + except JSONDecodeError: + self.log.exit(" - Not able to return title information") + + title_data = self.content["data"]["page"] + + title_info = [ + x + for x in title_data["components"] + if x["__typename"] == "MovieMarqueeComponent" + ][0] + + title_info["title"] = re.sub(r" \(.+?\)", "", title_info["title"]) + + title_data = self.content["data"]["page"] + try: + Id = title_data["components"][0]["mainAction"]["playerData"]["playable"]["id"] + except KeyError: + self.log.exit(" - Account does not seem to own this title") + + return Title( + id_=Id, + type_=Title.Types.MOVIE, + name=title_info["title"], + year=title_info["year"], + original_lang="en", + source=self.ALIASES[0], + service_data=title_data, + ) + + def get_pssh_init(self, url): + import os, yt_dlp + from pathlib import Path + init = 'init.mp4' + + files_to_delete = [init] + for file_name in files_to_delete: + if os.path.exists(file_name): + os.remove(file_name) + + def read_pssh(path: str): + raw = Path(path).read_bytes() + wv = raw.rfind(bytes.fromhex('edef8ba979d64acea3c827dcd51d21ed')) + if wv == -1: return None + return base64.b64encode(raw[wv-12:wv-12+raw[wv-9]]).decode('utf-8') + + ydl_opts = { + 'format': 'bestvideo[ext=mp4]/bestaudio[ext=m4a]/best', + 'allow_unplayable_formats': True, + 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3', + 'no_warnings': True, + 'quiet': True, + 'outtmpl': init, + 'no_merge': True, + 'test': True, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info_dict = ydl.extract_info(url, download=True) + url = info_dict.get("url", None) + if url is None: + raise ValueError("Failed to download the video") + video_file_name = ydl.prepare_filename(info_dict) + + pssh = read_pssh(init) + + for file_name in files_to_delete: + if os.path.exists(file_name): + os.remove(file_name) + return pssh + + def get_tracks(self, title): + player_data = self.content["data"]["page"]["components"][0]["mainAction"]["playerData"]["playable"] + + videos = [] + audios = [] + for cr in player_data["videoAssets"]["dash"].values(): + if not cr: + continue + for manifest in cr: + tracks = Tracks.from_mpd( + url=manifest["url"], + source=self.ALIASES[0], + session=self.session, + ) + + for video in tracks.videos: + pssh = self.get_pssh_init(manifest["url"]) + video_pssh = Box.parse(base64.b64decode(pssh)) + video.pssh = video_pssh + video.license_url = manifest["widevineLaUrl"] + video.contentId = URL(video.license_url).params._dict["ContentId"][ + 0 + ] + videos += [video] + # Extract Atmos audio track if available. + for audio in tracks.audios: + audio.pssh = video_pssh + audio.license_url = manifest["widevineLaUrl"] + audio.contentId = URL(audio.license_url).params._dict["ContentId"][ + 0 + ] + if "atmos" in audio.url: + audio.atmos = True + audios += [audio] + + corrected_video_list = [] + for res in ("uhd", "hdp", "hd", "sd"): + for video in videos: + if f"_{res}_video" not in video.url or not video.url.endswith( + f"&r={res}" + ): + continue + + if corrected_video_list and any( + video.id == vid.id for vid in corrected_video_list + ): + continue + + if "dash_hevc_hdr" in video.url: + video.hdr10 = True + if "dash_hevc_dolbyvision" in video.url: + video.dv = True + + corrected_video_list += [video] + + tracks.add(corrected_video_list) + tracks.audios = audios + tracks.videos = [x for x in tracks.videos if (x.codec or "")[:3] in self.VIDEO_CODEC_MAP[self.vcodec]] + + return tracks + + def get_chapters(self, title): + return [] + + def certificate(self, **_): + return None # will use common privacy cert + + def license(self, challenge: bytes, track: Tracks, **_) -> bytes: + license_message = self.session.post( + url=track.license_url, + data=challenge, # expects bytes + ) + + if "errorCode" in license_message.text: + self.log.exit(f" - Cannot complete license request: {license_message.text}") + + return license_message.content + + + def configure(self): + access_token = None + install_id = None + for cookie in self.cookies: + if cookie.name == "secure_access_token": + access_token = cookie.value + elif cookie.name == "install_id": + install_id = cookie.value + + self.access_token = access_token + self.install_id = install_id + + self.session.headers.update( + { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Origin": "https://moviesanywhere.com", + "Authorization": f"Bearer {self.access_token}", + } + ) diff --git a/vinetrimmer/services/sonyliv.py b/vinetrimmer/services/sonyliv.py new file mode 100644 index 0000000..40d87d2 --- /dev/null +++ b/vinetrimmer/services/sonyliv.py @@ -0,0 +1,544 @@ +import os +import re +import time +import json +import m3u8 +import base64 +import requests + +import click + +from vinetrimmer.objects import TextTrack, Title, Tracks +from vinetrimmer.services.BaseService import BaseService + +class Sonyliv(BaseService): + """ + SonyLiv India streaming service (https://sonyliv.com). + \b + Authorization: Cookies + accessToken(from Browser Local Storage) + Security: UHD@L3, doesn't seem to care about releases. + + Script By https://telegram.me/divine_404 + """ + + ALIASES = ["SL", "sonyliv"] + + TITLE_RE = r"^(?:https?://(?:www\.)?sonyliv.com/(?Pmovies|shows)/[a-z0-9-]+-)?(?P\d+)" + + @staticmethod + @click.command(name="Sonyliv", short_help="https://sonyliv.com") + @click.argument("title", type=str, required=False) + @click.option("-d", "--device", default="chrome", + type=click.Choice(["chrome", "android", "safari"], case_sensitive=False), + help="Device to use for requesting manifest.") + @click.pass_context + def cli(ctx, **kwargs): + return Sonyliv(ctx, **kwargs) + + def __init__(self, ctx, title, device): + super().__init__(ctx) + self.m = self.parse_title(ctx, title) + self.manifestDevice = device + + self.vcodec = ctx.parent.params["vcodec"] or "H264" + self.acodec = ctx.parent.params["acodec"] or "EC3" + self.range = ctx.parent.params["range_"] or "SDR" + self.quality = ctx.parent.params.get("quality") or 1080 + + self.profile = ctx.obj.profile + + self.device_id = None + self.app_version = None + self.accessToken = None + self.securityToken = None + self.license_api = None + self.cacheData = None + + self.configure() + + def get_titles(self): + tempHeaders = self.session.headers.copy() + tempHeaders.update({ + "Host": "apiv2.sonyliv.com", + "Security_token": self.securityToken, + "Session_id": self.session.cookies.get('sessionId', None, 'apiv2.sonyliv.com'), + "Device_id": self.device_id, + "App_version": self.app_version, + }) + + r = requests.get( + url = self.config['endpoints']['title'].format(id=self.m['id']), + headers = tempHeaders, + cookies = self.reqCookies, + proxies=self.session.proxies + ) + try: + titleRes = json.loads(r.content.decode()) + except json.JSONDecodeError: + raise ValueError(f"Received Irrelevant Title API Response: {r.text}") + + for correct in titleRes['resultObj']['containers']: + if int(correct['id']) == int(self.m['id']): + titleRes = correct.copy() + + if titleRes['metadata']['objectSubtype'] == 'MOVIE_BUNDLE' or titleRes['metadata']['objectSubtype'] == 'MOVIE': + return Title( + id_=self.m['id'], + type_=Title.Types.MOVIE, + name=titleRes['metadata']['title'], + year=titleRes['metadata']['emfAttributes']['release_year'] if 'release_year' in titleRes['metadata']['emfAttributes'] else titleRes['metadata']['emfAttributes']['release_date'].split('-')[0], + original_lang=titleRes['metadata']['language'], + source=self.ALIASES[0], + service_data=titleRes, + ) + + elif (titleRes['layout'] == "BUNDLE_ITEM"): + bucket = [] + + if (titleRes['metadata']['objectSubtype'] == "EPISODIC_SHOW"): + ep_count = titleRes['episodeCount'] + r = requests.get( + url = self.config['endpoints']['season'].format(id=titleRes['id'], ep_start=0, ep_end=ep_count-1), + headers = tempHeaders, + cookies = self.reqCookies, + proxies=self.session.proxies + ) + + try: + seasonRes = json.loads(r.content.decode()) + except json.JSONDecodeError: + raise ValueError(f"Received Irrelevant Season API Response: {r.text}") + + for episode in seasonRes['resultObj']['containers'][0]['containers']: + bucket.append({ + "episode_id": episode['id'], + "series_name": titleRes['metadata']['title'], + "season_number": titleRes['metadata']['season'] if ('season' in titleRes['metadata'].keys()) else "1", + "episode_number": episode['metadata']['episodeNumber'], + "episode_name": episode['metadata']['episodeTitle'], + "episode_org_lang": episode['metadata']['language'], + "service_data": episode + }) + + if (titleRes['metadata']['objectSubtype'] == "SHOW"): + for season in titleRes['containers']: + if season['metadata']['objectSubtype'] == "SEASON" and int(season['parents'][0]['parentId']) == int(self.m['id']): + ep_count = season['episodeCount'] + r = requests.get( + url = self.config['endpoints']['season'].format(id=season['id'], ep_start=0, ep_end=ep_count-1), + headers = tempHeaders, + cookies = self.reqCookies, + proxies=self.session.proxies + ) + try: + seasonRes = json.loads(r.content.decode()) + except json.JSONDecodeError: + raise ValueError(f"Received Irrelevant Season API Response: {r.text}") + + for episode in seasonRes['resultObj']['containers'][0]['containers']: + bucket.append({ + "episode_id": episode['id'], + "series_name": titleRes['metadata']['title'], + "season_number": season['metadata']['season'], + "episode_number": episode['metadata']['episodeNumber'], + "episode_name": episode['metadata']['episodeTitle'], + "episode_org_lang": episode['metadata']['language'], + "service_data": episode + }) + + elif season['metadata']['objectSubtype'] == "EPISODE_RANGE" and int(season['parents'][0]['parentId']) == int(self.m['id']): + ep_count = season['episodeCount'] + r = requests.get( + url = self.config['endpoints']['season'].format(id=season['id'], ep_start=0, ep_end=ep_count-1), + headers = tempHeaders, + cookies = self.reqCookies, + proxies=self.session.proxies + ) + + try: + seasonRes = json.loads(r.content.decode()) + except json.JSONDecodeError: + raise ValueError(f"Received Irrelevant Season API Response: {r.text}") + + for episode in seasonRes['resultObj']['containers'][0]['containers']: + bucket.append({ + "episode_id": episode['id'], + "series_name": titleRes['metadata']['title'], + "season_number": season['metadata']['season'], + "episode_number": episode['metadata']['episodeNumber'], + "episode_name": episode['metadata']['episodeTitle'], + "episode_org_lang": episode['metadata']['language'], + "service_data": episode + }) + + if not bucket == []: + return [Title( + id_=b['episode_id'], + type_=Title.Types.TV, + name=b['series_name'], + season=b['season_number'], + episode=b['episode_number'], + episode_name=b['episode_name'], + original_lang=b['episode_org_lang'], + source=self.ALIASES[0], + service_data=b['service_data'] + ) for b in bucket] + else: + self.log.exit(" - Title unsupported.") + + def get_tracks(self, title): + + if self.vcodec == 'H265': + if self.range == 'DV': + client = '{"device_make":"Amazon","device_model":"AFTMM","display_res":"2160","viewport_res":"2160","supp_codec":"HEVC,H264,AAC,EAC3,AC3,ATMOS","audio_decoder":"EAC3,AAC,AC3,ATMOS","hdr_decoder":"DOLBY_VISION","td_user_useragent":"com.onemainstream.sonyliv.android\/8.95 (Android 7.1.2; en_IN; AFTMM; Build\/NS6281 )"}' + elif self.range == 'HDR10': + client = '{"device_make":"Amazon","device_model":"AFTMM","display_res":"2160","viewport_res":"2160","supp_codec":"HEVC,H264,AAC,EAC3,AC3,ATMOS","audio_decoder":"EAC3,AAC,AC3,ATMOS","hdr_decoder":"HDR10","td_user_useragent":"com.onemainstream.sonyliv.android\/8.95 (Android 7.1.2; en_IN; AFTMM; Build\/NS6281 )"}' + elif self.range == 'SDR': + client = '{"device_make":"Amazon","device_model":"AFTMM","display_res":"2160","viewport_res":"2160","supp_codec":"HEVC,H264,AAC,EAC3,AC3,ATMOS","audio_decoder":"EAC3,AAC,AC3,ATMOS","hdr_decoder":"HLG","td_user_useragent":"com.onemainstream.sonyliv.android\/8.95 (Android 7.1.2; en_IN; AFTMM; Build\/NS6281 )"}' + else: + client = '{"os_name":"Mac OS","os_version":"10.15.7","device_make":"none","device_model":"none","display_res":"1470","viewport_res":"894","conn_type":"4g","supp_codec":"H264,AV1,AAC","client_throughput":"16000","td_user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36","hdr_decoder":"UNKNOWN","audio_decoder":"STEREO"}' + + tempHeaders = self.session.headers.copy() + if "X-Playback-Session-Id" in tempHeaders.keys(): + tempHeaders.pop("X-Playback-Session-Id") + tempHeaders.update({ + "Host": "apiv2.sonyliv.com", + "Content-Type": "application/json", + "X-Via-Device": "true", + "Security_token": self.securityToken, + "App_version": self.app_version, + "Device_id": self.device_id, + "Session_id": self.session.cookies.get('sessionId', None, 'apiv2.sonyliv.com'), + "Authorization": "Bearer " + self.accessToken, + "Td_client_hints": client, + }) + + if str(title.type) == "Types.MOVIE": + if 'containers' not in title.service_data.keys(): + _id_ = title.service_data['metadata']['contentId'] + else: + for mov in title.service_data['containers']: + if mov['metadata']['contentSubtype'] == "MOVIE": + _id_ = mov['id'] + else: + _id_ = title.service_data['metadata']['contentId'] + + r = requests.post( + url = self.config['endpoints']['manifest'].format(id=_id_, bid=self.cacheData['contactId']['id']), + headers = tempHeaders, + cookies = self.reqCookies, + json = { + "actionType": "play", + "browser": 'chrome', + "deviceId": self.device_id, + "hasLAURLEnabled": True, + "os": "Mac OS", + "platform": "web", + "adsParams":{ + "idtype": "uuid", + "rdid": self.device_id, + "is_lat": 0, + "ppid": self.cacheData['contactId']['PPID'] + } + }, + proxies=self.session.proxies + ) + try: + manifestRes = json.loads(r.content.decode()) + self.log.debug(f"\n{json.dumps(manifestRes, indent=4)}") + if manifestRes['resultCode'] != "OK": + self.log.exit(manifestRes['message']) + except json.JSONDecodeError: + raise ValueError(f"Received Irrelevant Manifest API Response: {r.text}") + + mpd_url = manifestRes['resultObj']['videoURL'] + + try: + self.license_api = manifestRes['resultObj']['LA_Details']['laURL'] + except Exception as e: + pass + + self.session.headers.update({ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate", + "Referer": "https://www.sonyliv.com/", + "X-Playback-Session-Id": self.device_id, + "Origin": "https://www.sonyliv.com", + }) + + r = self.session.get(mpd_url) + + if not '.m3u8' in str(mpd_url): + tracks = Tracks.from_mpd( + url = mpd_url, + data = r.content.decode(), + session = self.session, + source = self.ALIASES[0], + ) + + else: + tracks = Tracks.from_m3u8( + m3u8.loads(str(r.content.decode())), + source = self.ALIASES[0], + ) + + # Checking SDR/HDR/DV + for video in tracks.videos: + video.hdr10 = False + video.dv = False + video.hlg = False + av_range_ = manifestRes['resultObj']['additionalDataJson']['video_quality'] + if av_range_ == "HDR": + for video in tracks.videos: + video.hdr10 = True + if av_range_ == "DOLBY_VISION": + for video in tracks.videos: + video.dv = True + if av_range_ == "HLG": + for video in tracks.videos: + video.hlg = True + + # Adding subtitle tracks + for sub in manifestRes['resultObj']['subtitle']: + tracks.add(TextTrack( + id_= sub['subtitleId'], + source = self.ALIASES[0], + url = sub['subtitleUrl'], + codec = "vtt", #hardcoded + language = sub['subtitleLanguageName'], + ), warn_only=True) + + for track in tracks: + track.needs_proxy = True + return tracks + + def get_chapters(self, title): + return [] + + def certificate(self, **_): + return None + + def license(self, challenge: bytes, title: Title, **_): + + if self.license_api == None: + + licHeaders = self.session.headers.copy() + if "X-Playback-Session-Id" in licHeaders.keys(): + licHeaders.pop("X-Playback-Session-Id") + licHeaders.update({ + "Host": "apiv2.sonyliv.com", + "Content-Type": "application/json", + "Security_token": self.securityToken, + "Device_id": self.device_id, + "X-Via-Device": "true", + "Authorization": "Bearer " + self.accessToken + }) + r = requests.post( + url = self.config['endpoints']['license'], + headers = licHeaders, + json = { + "platform": self.config['device'][self.manifestDevice]['platform'], + "deviceId": self.device_id, + "actionType": "play", + "browser": self.manifestDevice, + "assetId": title.service_data['metadata']['contentId'], + "os": self.manifestDevice + } + ) + + try: + licRes = json.loads(r.content.decode()) + except json.JSONDecodeError: + raise ValueError(f"Irrelevant License API Response: {r.text}") + + self.license_api = licRes['resultObj']['laURL'] + + return requests.post( + url = self.license_api, + data=challenge, + # proxies=self.session.proxies, + ).content + + else: + return requests.post( + url = self.license_api, + data=challenge, + # proxies=self.session.proxies, + ).content + + + def configure(self): + self.session.headers.update({ + "User-Agent": self.config['device'][self.manifestDevice]['user-agent'], + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate", + "Referer": "https://www.sonyliv.com/", + "Origin": "https://www.sonyliv.com", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "same-site", + }) + + if not self.cookies: + raise self.log.exit(" - Please add cookies") + self.reqCookies = { + "_abck": self.session.cookies.get('_abck', None, '.sonyliv.com'), + "ak_bmsc": self.session.cookies.get('ak_bmsc', None, '.sonyliv.com'), + "bm_sz": self.session.cookies.get('bm_sz', None, '.sonyliv.com'), + } + + self.device_id = self.config['device'][self.manifestDevice]['device_id'] + self.app_version = self.config['device'][self.manifestDevice]['app_version'] + self.prepToken() + + + def prepToken(self): + cache_path = self.get_cache("{profile}_ProfileCache.json".format(profile=self.profile)) + + if not os.path.isfile(cache_path): + self.cacheData = {"vt_profile": self.profile} + self.log.info(" + Generating Cache...") + self.log.info("Enter your access_token from Browser (Dev Tools -> Application -> Local Storage -> 'https://www.sonyliv.com' -> accessToken):") + self.accessToken = str(input(">")) + self.cacheData["accessToken"] = { + "rawToken": self.accessToken, + "data": json.loads(base64.b64decode(f"{str(self.accessToken.split('.')[1]) + '=='}")) + } + if int(self.cacheData['accessToken']['data']['exp']) < int(time.time()): + raise self.log.exit(f" - Provided access_token is expired.") + + self.log.info("Getting security_token...") + self.securityToken = self.getSecurityToken() + userData = self.refresh() + + self.cacheData["securityToken"] = { + "rawToken": self.securityToken, + "data": json.loads(base64.b64decode(f"{str(self.accessToken.split('.')[1]) + '=='}")) + } + self.cacheData["contactId"] = { + "id": userData['resultObj']['contactMessage'][0]['contactID'], + "PPID": self.getHash(userData['resultObj']['contactMessage'][0]['contactID']) + } + + os.makedirs(os.path.dirname(cache_path), exist_ok=True) + with open(cache_path, "w", encoding="utf-8") as fd: + json.dump(self.cacheData, fd, indent = 2) + + else: + with open(cache_path, "r+", encoding="utf-8") as fd: + self.cacheData = json.loads(fd.read()) + if int(self.cacheData['accessToken']['data']['exp']) < int(time.time()): + raise self.log.exit("- access_token expired. Delete cache file and update cookies.") + else: + self.accessToken = self.cacheData['accessToken']['rawToken'] + + if int(self.cacheData['securityToken']['data']['exp']) < int(time.time()): + self.log.info("security_token expired, Getting a new one...") + self.securityToken = self.getSecurityToken() + userData = self.refresh() + + self.cacheData["securityToken"] = { + "rawToken": self.securityToken, + "data": json.loads(base64.b64decode(f"{str(self.securityToken.split('.')[1]) + '=='}")) + } + self.cacheData["contactId"] = { + "id": userData['resultObj']['contactMessage'][0]['contactID'], + "PPID": self.getHash(userData['resultObj']['contactMessage'][0]['contactID']) + } + else: + self.securityToken = self.cacheData['securityToken']['rawToken'] + userData = self.refresh() + self.cacheData["contactId"] = { + "id": userData['resultObj']['contactMessage'][0]['contactID'], + "PPID": self.getHash(userData['resultObj']['contactMessage'][0]['contactID']) + } + self.log.info("Using Account Tokens from Cache.") + fd.seek(0) + fd.truncate() + json.dump(self.cacheData, fd, indent = 2) + return + + def getSecurityToken(self): + tempHeaders = self.session.headers.copy() + tempHeaders.update({ + "Host": "www.sonyliv.com", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Site": "none", + }) + + try: + resp = requests.get( + url="https://sonyliv.com", + headers = tempHeaders, + cookies = self.reqCookies, + proxies=self.session.proxies + ) + resp = str(resp.content.decode()) + scToken = resp.split('securityToken:{resultCode:"OK",message:"",errorDescription:"200-10000",resultObj:"')[1].split('",systemTime')[0] + json.loads(base64.b64decode(f"{scToken.split('.')[1] + '.'}")) + return scToken + except Exception as e: + self.log.exit(e) + + def refresh(self): + tempHeaders = self.session.headers.copy() + tempHeaders.update({ + "Host": "apiv2.sonyliv.com", + "X-Via-Device": "true", + "Security_token": self.securityToken, + "App_version": self.app_version, + "Device_id": self.device_id, + "Session_id": self.session.cookies.get('sessionId', None, 'apiv2.sonyliv.com'), + "Authorization": self.accessToken, + }) + + self.reqCookies.update({ + "sessionId": self.session.cookies.get('sessionId', None, 'apiv2.sonyliv.com'), + "bm_sv": self.session.cookies.get('bm_sv', None, '.sonyliv.com'), + "AKA_A2": self.session.cookies.get('AKA_A2', None, '.sonyliv.com'), + "bm_mi": self.session.cookies.get('bm_mi', None, '.sonyliv.com'), + }) + + userData = requests.get( + url = self.config['endpoints']['refresh'], + headers = tempHeaders, + cookies = self.reqCookies, + proxies=self.session.proxies + ) + userData = json.loads(userData.content.decode()) + if userData['resultCode'] == "OK" and userData['message'] == "SUCCESS": + return userData + else: + self.log.error(userData) + self.log.exit("Unintended API Response.") + + def getHash(self, contactId): + tempHeaders = self.session.headers.copy() + tempHeaders.update({ + "Host": "apiv2.sonyliv.com", + "Content-Type": "application/json", + "X-Via-Device": "true", + "Security_token": self.securityToken, + "App_version": self.app_version, + "Device_id": self.device_id, + "Session_id": self.session.cookies.get('sessionId', None, 'apiv2.sonyliv.com'), + "Authorization": self.accessToken, + }) + hashData = requests.post( + url = self.config['endpoints']['hash'], + headers = tempHeaders, + json = { + "baseId": contactId + }, + cookies = self.reqCookies, + proxies=self.session.proxies + ) + hashData = json.loads(hashData.content.decode()) + if hashData['resultCode'] == "OK": + return hashData['resultObj']['ppId'] + else: + self.log.error(hashData) + self.log.exit("Unintended API Response.") \ No newline at end of file diff --git a/vinetrimmer/vinetrimmer.yml b/vinetrimmer/vinetrimmer.yml index 38918e5..b35e6d2 100644 --- a/vinetrimmer/vinetrimmer.yml +++ b/vinetrimmer/vinetrimmer.yml @@ -9,6 +9,7 @@ aria2c: cdm: default: 'hisense_smarttv_he55a7000euwts_sl3000' Amazon: 'hisense_smarttv_he55a7000euwts_sl3000' + Hotstar: 'xiaomi_mi_a1_15.0.0_60ceee88_8159_l3' cdm_api: - name: 'playready' diff --git a/vinetrimmer1.py b/vinetrimmer1.py deleted file mode 100644 index d31f166..0000000 --- a/vinetrimmer1.py +++ /dev/null @@ -1,78 +0,0 @@ -import logging -import os -import sys -from datetime import datetime - -import click -import coloredlogs - -from vinetrimmer.config import directories, filenames # isort: split -from vinetrimmer.commands import dl - - -@click.command(context_settings=dict( - allow_extra_args=True, - ignore_unknown_options=True, - max_content_width=116, # max PEP8 line-width, -4 to adjust for initial indent -)) -@click.option("--debug", is_flag=True, default=False, - help="Enable DEBUG level logs on the console. This is always enabled for log files.") -def main(debug): - """ - vinetrimmer is the most convenient command-line program to - download videos from Widevine DRM-protected video platforms. - """ - LOG_FORMAT = "{asctime} [{levelname[0]}] {name} : {message}" - LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" - LOG_STYLE = "{" - - def log_exit(self, msg, *args, **kwargs): - self.critical(msg, *args, **kwargs) - sys.exit(1) - - logging.Logger.exit = log_exit - - os.makedirs(directories.logs, exist_ok=True) - logging.basicConfig( - level=logging.DEBUG, - format=LOG_FORMAT, - datefmt=LOG_DATE_FORMAT, - style=LOG_STYLE, - handlers=[logging.FileHandler( - os.path.join(directories.logs, filenames.log.format(time=datetime.now().strftime("%Y%m%d-%H%M%S"))), - encoding='utf-8' - )] - ) - - coloredlogs.install( - level=logging.DEBUG if debug else logging.INFO, - fmt=LOG_FORMAT, - datefmt=LOG_DATE_FORMAT, - style=LOG_STYLE, - handlers=[logging.StreamHandler()], - ) - - log = logging.getLogger("vt") - - log.info("vinetrimmer - Widevine 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}") - log.info(f"[CDM Devices] : {directories.devices}") - log.info(f"[Cache] : {directories.cache}") - log.info(f"[Logs] : {directories.logs}") - log.info(f"[Temp Files] : {directories.temp}") - log.info(f"[Downloads] : {directories.downloads}") - - os.environ['PATH'] = os.path.abspath('./binaries') - - if len(sys.argv) > 1 and sys.argv[1].lower() == "dl": - sys.argv.pop(1) - - 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()