Start support for Hotstar

Hotstar is currently throwing errors when getting tracks, will be fixed with next commit.
Fixed error when loading Widevine Device.
This commit is contained in:
Aswin 2025-04-01 02:41:59 +05:30
parent ddcb82a853
commit ecb26968da
14 changed files with 1626 additions and 82 deletions

Binary file not shown.

View File

@ -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"

16
sonyliv.yml Normal file
View File

@ -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"}'

View File

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

View File

@ -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'

Binary file not shown.

View File

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

View File

@ -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<id>\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"]

343
vinetrimmer/services/jio.py Normal file
View File

@ -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',
})

View File

@ -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<id>.+)"
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}",
}
)

View File

@ -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/(?P<type>movies|shows)/[a-z0-9-]+-)?(?P<id>\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.")

View File

@ -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'

View File

@ -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()