782 lines
33 KiB
Python
782 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import sys
|
|
import time
|
|
from datetime import timedelta
|
|
from http.cookiejar import CookieJar
|
|
from typing import Any, Optional, Union
|
|
from uuid import UUID
|
|
|
|
import click
|
|
import requests
|
|
from langcodes import Language
|
|
from pymp4.parser import Box
|
|
from pywidevine.cdm import Cdm
|
|
from pywidevine.device import DeviceTypes
|
|
from pywidevine.pssh import PSSH
|
|
|
|
from devine.core.credential import Credential
|
|
from devine.core.service import Service
|
|
from devine.core.drm import Widevine
|
|
from devine.core.titles import Episode, Movie, Movies, Series, Title_T
|
|
from devine.core.tracks import Audio, Chapter, Chapters, Subtitle, Track, Tracks, Video
|
|
from devine.core.utils.collections import as_list, flatten
|
|
|
|
from .MSL import MSL
|
|
from .MSL.schemes import KeyExchangeSchemes
|
|
from .MSL.schemes.UserAuthentication import UserAuthentication
|
|
|
|
|
|
class NF(Service):
|
|
"""
|
|
Service code for the Netflix streaming service (https://netflix.com).
|
|
|
|
\b
|
|
Authorization: Cookies
|
|
Robustness:
|
|
Widevine:
|
|
L1: 2160p
|
|
L3 Chrome: 720p, 1080p
|
|
L3 Android: 540p
|
|
PlayReady:
|
|
SL3: 2160p
|
|
SL2: 1080p
|
|
|
|
*MPL: FHD with Android L3, sporadically available with ChromeCDM
|
|
HPL: 1080p with ChromeCDM, 720p/1080p with other L3 (varies per title)
|
|
|
|
\b
|
|
Tips:
|
|
- Input can be either just title ID or URL:
|
|
devine dl -w s01e01 NF 80057281
|
|
devine dl -w s01e01 NF https://www.netflix.com/title/80057281
|
|
|
|
\b
|
|
Notes:
|
|
- Android CDM is currently not supported as the MSL Widevine KeyExchange is broken.
|
|
- The library of contents as well as regional availability is available at https://unogs.com
|
|
However, Do note that Netflix locked everyone out of being able to automate the available data
|
|
meaning the reliability and amount of information may be reduced.
|
|
- You could combine the information from https://unogs.com with https://justwatch.com for further data
|
|
|
|
TODO: Fix Widevine KeyExchange scheme
|
|
"""
|
|
|
|
ALIASES = ("netflix",)
|
|
TITLE_RE = [
|
|
r"^(?:https?://(?:www\.)?netflix\.com(?:/[a-z0-9]{2})?/(?:title/|watch/|.+jbv=))?(?P<id>\d+)",
|
|
r"^https?://(?:www\.)?unogs\.com/title/(?P<id>\d+)",
|
|
]
|
|
|
|
NF_LANG_MAP = {
|
|
"es": "es-419",
|
|
"pt": "pt-PT",
|
|
}
|
|
|
|
@staticmethod
|
|
@click.command(name="NF", short_help="https://netflix.com")
|
|
@click.argument("title", type=str, required=False)
|
|
@click.option(
|
|
"-p",
|
|
"--profile",
|
|
type=click.Choice(["MPL", "HPL", "MPL+HPL"], case_sensitive=False),
|
|
default="MPL+HPL",
|
|
help="H.264 profile to use. Default is best available.",
|
|
)
|
|
@click.option("--meta-lang", type=str, help="Language to use for metadata")
|
|
@click.pass_context
|
|
def cli(ctx, **kwargs):
|
|
return NF(ctx, **kwargs)
|
|
|
|
def __init__(self, ctx, title, profile, meta_lang):
|
|
super().__init__(ctx)
|
|
self.parse_title(ctx, title)
|
|
self.profile = profile
|
|
self.meta_lang = meta_lang
|
|
|
|
if ctx.parent.params["proxy"] and len("".join(i for i in ctx.parent.params["proxy"] if not i.isdigit())) == 2:
|
|
self.GEOFENCE.append(ctx.parent.params["proxy"])
|
|
|
|
vcodec = ctx.parent.params.get("vcodec")
|
|
self.vcodec = "H265" if vcodec and vcodec == Video.Codec.HEVC else "H264"
|
|
self.acodec = ctx.parent.params["acodec"]
|
|
self.range = ctx.parent.params["range_"][0].name
|
|
self.quality = ctx.parent.params["quality"]
|
|
self.audio_only = ctx.parent.params["audio_only"]
|
|
self.subs_only = ctx.parent.params["subs_only"]
|
|
self.chapters_only = ctx.parent.params["chapters_only"]
|
|
self.profiles = []
|
|
|
|
self.cdm = ctx.obj.cdm
|
|
if self.cdm.device_type == DeviceTypes.ANDROID:
|
|
self.log.error(
|
|
" - Android CDMs are currently not supported as the Widevine KeyExchange scheme is broken.")
|
|
sys.exit(1)
|
|
|
|
self.user_profile = ctx.parent.params.get("profile")
|
|
if not self.user_profile:
|
|
self.user_profile = "default"
|
|
|
|
def authenticate(
|
|
self,
|
|
cookies: Optional[CookieJar] = None,
|
|
credential: Optional[Credential] = None,
|
|
) -> None:
|
|
super().authenticate(cookies, credential)
|
|
if not cookies:
|
|
raise EnvironmentError("Service requires Cookies for Authentication.")
|
|
|
|
self.session.cookies.update(cookies)
|
|
self.log.info(f" + User profile: '{self.user_profile}'")
|
|
self.configure()
|
|
|
|
def get_titles(self) -> Union[Movies, Series]:
|
|
metadata = self.get_metadata(self.title)["video"]
|
|
if metadata["type"] == "movie":
|
|
movie = [
|
|
Movie(
|
|
id_=self.title,
|
|
name=metadata["title"],
|
|
year=metadata["year"],
|
|
service=self.__class__,
|
|
data=metadata
|
|
)
|
|
]
|
|
return Movies(movie)
|
|
else:
|
|
episodes = [
|
|
episode
|
|
for season in [
|
|
[dict(x, **{"season": season["seq"]})
|
|
for x in season["episodes"]]
|
|
for season in metadata["seasons"]
|
|
]
|
|
for episode in season
|
|
]
|
|
titles = [
|
|
Episode(
|
|
id_=self.title,
|
|
title=metadata["title"],
|
|
year=metadata["seasons"][0].get("year"),
|
|
season=episode.get("season"),
|
|
number=episode.get("seq"),
|
|
name=episode.get("title"),
|
|
service=self.__class__,
|
|
data=episode,
|
|
)
|
|
for episode in episodes
|
|
]
|
|
|
|
return Series(titles)
|
|
|
|
# TODO: Get original language without making an extra manifest request
|
|
# manifest = self.get_manifest(titles[0], self.profiles)
|
|
# original_language = self.get_original_language(manifest)
|
|
|
|
# for title in titles:
|
|
# title.original_lang = original_language
|
|
|
|
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
|
if self.vcodec == "H264":
|
|
# If H.264, get both MPL and HPL tracks as they alternate in terms of bitrate
|
|
tracks = Tracks()
|
|
|
|
self.config["profiles"]["video"]["H264"]["MPL+HPL+QC"] = (
|
|
self.config["profiles"]["video"]["H264"]["MPL"]
|
|
+ self.config["profiles"]["video"]["H264"]["HPL"]
|
|
+ self.config["profiles"]["video"]["H264"]["QC"]
|
|
)
|
|
|
|
if self.audio_only or self.subs_only or self.chapters_only:
|
|
profiles = ["MPL+HPL+QC"]
|
|
else:
|
|
profiles = self.profile.split("+")
|
|
|
|
for profile in profiles:
|
|
try:
|
|
manifest = self.get_manifest(title, self.config["profiles"]["video"]["H264"][profile])
|
|
except Exception:
|
|
manifest = self.get_manifest(
|
|
title,
|
|
self.config["profiles"]["video"]["H264"]["MPL"]
|
|
+ self.config["profiles"]["video"]["H264"]["HPL"]
|
|
)
|
|
manifest_tracks = self.manifest_as_tracks(manifest)
|
|
license_url = manifest["links"]["license"]["href"]
|
|
|
|
if self.cdm.security_level == 3 and self.cdm.device_type == DeviceTypes.ANDROID:
|
|
max_quality = max(x.height for x in manifest_tracks.videos)
|
|
if profile == "MPL" and max_quality >= 720:
|
|
manifest_sd = self.get_manifest(title, self.config["profiles"]["video"]["H264"]["BPL"])
|
|
license_url_sd = manifest_sd["links"]["license"]["href"]
|
|
if "SD_LADDER" in manifest_sd["video_tracks"][0]["streams"][0]["tags"]:
|
|
# SD manifest is new encode encrypted with different keys that won't work for HD
|
|
continue
|
|
license_url = license_url_sd
|
|
if profile == "HPL" and max_quality >= 1080:
|
|
if "SEGMENT_MAP_2KEY" in manifest["video_tracks"][0]["streams"][0]["tags"]:
|
|
# 1080p license restricted from Android L3, 720p license will work for 1080p
|
|
manifest_720 = self.get_manifest(
|
|
title, [x for x in self.config["profiles"]["video"]["H264"]["HPL"] if "l40" not in x]
|
|
)
|
|
license_url = manifest_720["links"]["license"]["href"]
|
|
else:
|
|
# Older encode, can't use 720p keys for 1080p
|
|
continue
|
|
|
|
for track in manifest_tracks:
|
|
if track.drm:
|
|
track.data["license_url"] = license_url
|
|
tracks.add(manifest_tracks, warn_only=True)
|
|
return tracks
|
|
else:
|
|
manifest = self.get_manifest(title, self.profiles)
|
|
manifest_tracks = self.manifest_as_tracks(manifest)
|
|
license_url = manifest["links"]["license"]["href"]
|
|
for track in manifest_tracks:
|
|
if track.drm:
|
|
track.data["license_url"] = license_url
|
|
# if isinstance(track, Video):
|
|
# # TODO: Needs something better than this
|
|
# track.hdr10 = track.codec.split("-")[1] == "hdr" # hevc-hdr, vp9-hdr
|
|
# track.dv = track.codec.startswith("hevc-dv")
|
|
return manifest_tracks
|
|
|
|
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
|
|
def _convert(total_seconds):
|
|
hours = total_seconds // 3600
|
|
minutes = (total_seconds % 3600) // 60
|
|
seconds = total_seconds % 60
|
|
# milliseconds = (total_seconds % 1) * 1000
|
|
return f"{int(hours):02}:{int(minutes):02}:{int(seconds):02}"
|
|
|
|
metadata = self.get_metadata(title.id)["video"]
|
|
|
|
if metadata["type"] == "movie":
|
|
episode = metadata
|
|
else:
|
|
season = next(x for x in metadata["seasons"] if x["seq"] == title.season)
|
|
episode = next(x for x in season["episodes"] if x["seq"] == title.number)
|
|
|
|
if not (episode.get("skipMarkers") and episode.get("creditsOffset")):
|
|
return []
|
|
|
|
chapters = {}
|
|
for item in episode["skipMarkers"]:
|
|
chapters[item] = {"start": 0, "end": 0}
|
|
if not episode["skipMarkers"][item]:
|
|
continue
|
|
if episode["skipMarkers"][item]["start"] is None:
|
|
chapters[item]["start"] = 0
|
|
else:
|
|
chapters[item]["start"] = episode["skipMarkers"][item]["start"] / 1000
|
|
if episode["skipMarkers"][item]["end"] is None:
|
|
chapters[item]["end"] = 0
|
|
else:
|
|
chapters[item]["end"] = episode["skipMarkers"][item]["end"] / 1000
|
|
|
|
cc, intro = 1, 0
|
|
chaps = [Chapter(timestamp="00:00:00.000")]
|
|
|
|
for item in chapters:
|
|
if chapters[item]["start"] != 0:
|
|
if intro == 0:
|
|
cc += 1
|
|
chaps.append(Chapter(name="Intro", timestamp=_convert(chapters[item]["start"])))
|
|
cc += 1
|
|
chaps.append(Chapter(timestamp=_convert(chapters[item]["end"])))
|
|
else:
|
|
cc += 1
|
|
chaps.append(Chapter(timestamp=_convert(chapters[item]["start"])))
|
|
cc += 1
|
|
chaps.append(Chapter(timestamp=_convert(chapters[item]["end"])))
|
|
cc += 1
|
|
|
|
if cc == 1:
|
|
chaps.append(Chapter(name="Credits", timestamp=_convert(episode["creditsOffset"])))
|
|
else:
|
|
chaps.append(Chapter(name="Credits", timestamp=_convert(episode["creditsOffset"])))
|
|
|
|
return chaps
|
|
|
|
def get_widevine_service_certificate(self, challenge: bytes, **_: Any) -> str:
|
|
return self.config["certificate"]
|
|
|
|
def get_widevine_license(self, *, challenge: bytes, session_id: bytes, title: Title_T, track) -> None:
|
|
if not self.msl:
|
|
self.log.error(" - Cannot get license, MSL client has not been created yet.")
|
|
sys.exit(1)
|
|
header, payload_data = self.msl.send_message(
|
|
endpoint=self.config["endpoints"]["licence"],
|
|
params={},
|
|
application_data={
|
|
"version": 2,
|
|
"url": track.data["license_url"],
|
|
"id": int(time.time() * 10000),
|
|
"esn": self.esn,
|
|
"languages": ["en-US"],
|
|
"uiVersion": self.react_context["serverDefs"]["data"]["uiVersion"],
|
|
"clientVersion": "6.0026.291.011",
|
|
"params": [
|
|
{
|
|
"sessionId": base64.b64encode(session_id).decode("utf-8"),
|
|
"clientTime": int(time.time()),
|
|
"challengeBase64": base64.b64encode(challenge).decode("utf-8"),
|
|
"xid": str(int((int(time.time()) + 0.1612) * 1000)),
|
|
}
|
|
],
|
|
"echo": "sessionId",
|
|
},
|
|
userauthdata=self.userauthdata,
|
|
)
|
|
if not payload_data:
|
|
self.log.error(f" - Failed to get license: {header['message']} [{header['code']}]")
|
|
sys.exit(1)
|
|
if "error" in payload_data[0]:
|
|
error = payload_data[0]["error"]
|
|
error_display = error.get("display")
|
|
error_detail = re.sub(r" \(E3-[^)]+\)", "", error.get("detail", ""))
|
|
|
|
if error_display:
|
|
self.log.critical(f" - {error_display}")
|
|
if error_detail:
|
|
self.log.critical(f" - {error_detail}")
|
|
|
|
if not (error_display or error_detail):
|
|
self.log.critical(f" - {error}")
|
|
|
|
sys.exit(1)
|
|
|
|
return payload_data[0]["licenseResponseBase64"]
|
|
|
|
# Service specific functions
|
|
|
|
def configure(self):
|
|
self.session.headers.update({"Origin": "https://netflix.com"})
|
|
self.profiles = self.get_profiles()
|
|
self.esn = None
|
|
self.msl = None
|
|
self.userauthdata = None
|
|
self.log.info("Initializing a Netflix MSL client")
|
|
|
|
if self.cdm.device_type == DeviceTypes.CHROME:
|
|
self.esn = self.chrome_esn_generator()
|
|
else:
|
|
esn_map = self.config.get("esn_map", {})
|
|
self.esn = esn_map.get(self.cdm.system_id) or esn_map.get(str(self.cdm.system_id))
|
|
if not self.esn:
|
|
self.log.error(" - No ESN specified")
|
|
sys.exit(1)
|
|
self.log.info(f" + ESN: {self.esn}")
|
|
scheme = {
|
|
DeviceTypes.CHROME: KeyExchangeSchemes.AsymmetricWrapped,
|
|
DeviceTypes.ANDROID: KeyExchangeSchemes.Widevine,
|
|
}[self.cdm.device_type]
|
|
self.log.info(f" + Scheme: {scheme}")
|
|
self.msl = MSL.handshake(
|
|
scheme=scheme,
|
|
session=self.session,
|
|
endpoint=self.config["endpoints"]["manifest"],
|
|
sender=self.esn,
|
|
cdm=self.cdm,
|
|
msl_keys_path=self.cache.get(
|
|
"msl_{id}_{esn}_{scheme}_{profile}".format(
|
|
id=self.cdm.system_id,
|
|
esn=self.esn,
|
|
scheme=scheme,
|
|
profile=self.user_profile
|
|
)
|
|
),
|
|
)
|
|
if not self.session.cookies:
|
|
self.log.error(" - No cookies provided, cannot log in.")
|
|
sys.exit(1)
|
|
if self.cdm.device_type == DeviceTypes.CHROME:
|
|
self.userauthdata = UserAuthentication.NetflixIDCookies(
|
|
netflixid=self.session.cookies.get_dict()["NetflixId"],
|
|
securenetflixid=self.session.cookies.get_dict()["SecureNetflixId"],
|
|
)
|
|
else:
|
|
if not self.credentials:
|
|
self.log.error(" - Credentials are required for Android CDMs, and none were provided.")
|
|
sys.exit(1)
|
|
# need to get cookies via an android-like way
|
|
# outdated
|
|
# self.android_login(credentials.username, credentials.password)
|
|
# need to use EmailPassword for userauthdata, it specifically checks for this
|
|
self.userauthdata = UserAuthentication.EmailPassword(
|
|
email=self.credentials.username, password=self.credentials.password
|
|
)
|
|
self.react_context = self.get_react_context()
|
|
|
|
def get_profiles(self):
|
|
if self.range in ("HDR10", "DV") and self.vcodec not in ("H265", "VP9"):
|
|
self.vcodec = "H265"
|
|
profiles = self.config["profiles"]["video"][self.vcodec]
|
|
if self.range and self.range in profiles:
|
|
return profiles[self.range]
|
|
return profiles
|
|
|
|
def get_react_context(self):
|
|
"""Netflix uses a "BUILD_IDENTIFIER" value on some API's, e.g. the Shakti (metadata) API.
|
|
This value isn't given to the user through normal means so REGEX is needed.
|
|
It's obtained by grabbing the body of a logged-in netflix homepage.
|
|
The value changes often but doesn't often matter if it's only a bit out of date.
|
|
|
|
It also uses a Client Version for various MPL calls.
|
|
|
|
:returns: reactContext parsed json-loaded dictionary
|
|
"""
|
|
cached_context = self.cache.get(f"data_{self.user_profile}")
|
|
|
|
if not cached_context:
|
|
src = self.session.get("https://www.netflix.com/browse").text
|
|
match = re.search(r"netflix\.reactContext = ({.+});</script><script>window\.", src, re.MULTILINE)
|
|
if not match:
|
|
self.log.error(" - Failed to retrieve reactContext data, cookies might be outdated.")
|
|
sys.exit(1)
|
|
react_context_raw = match.group(1)
|
|
react_context = json.loads(re.sub(r"\\x", r"\\u00", react_context_raw))["models"]
|
|
react_context["requestHeaders"]["data"] = {
|
|
re.sub(r"\B([A-Z])", r"-\1", k): str(v) for k, v in react_context["requestHeaders"]["data"].items()
|
|
}
|
|
react_context["abContext"]["data"]["headers"] = {
|
|
k: str(v) for k, v in react_context["abContext"]["data"]["headers"].items()
|
|
}
|
|
react_context["requestHeaders"]["data"] = {
|
|
k: str(v) for k, v in react_context["requestHeaders"]["data"].items()
|
|
}
|
|
# react_context["playerModel"]["data"]["config"]["core"]["initParams"]["clientVersion"] = (
|
|
# react_context["playerModel"]["data"]["config"]["core"]["assets"]["core"].split("-")[-1][:-3]
|
|
# )
|
|
cached_context.set(react_context)
|
|
return cached_context.data
|
|
|
|
return cached_context.data
|
|
|
|
def get_metadata(self, title_id):
|
|
"""
|
|
Obtain Metadata information about a title by it's ID.
|
|
:param title_id: Title's ID.
|
|
:returns: Title Metadata.
|
|
"""
|
|
|
|
"""
|
|
# Wip non-working code for the newer shakti metadata replacement
|
|
metadata = self.session.post(
|
|
url=self.config["endpoints"]["website"].format(
|
|
build_id=self.react_context["serverDefs"]["data"]["BUILD_IDENTIFIER"]
|
|
),
|
|
params={
|
|
# features
|
|
"webp": self.react_context["browserInfo"]["data"]["features"]["webp"],
|
|
"drmSystem": self.config["configuration"]["drm_system"],
|
|
# truths
|
|
"isVolatileBillboardsEnabled": self.react_context["truths"]["data"]["volatileBillboardsEnabled"],
|
|
"routeAPIRequestsThroughFTL": self.react_context["truths"]["data"]["routeAPIRequestsThroughFTL"],
|
|
"isTop10Supported": self.react_context["truths"]["data"]["isTop10Supported"],
|
|
"categoryCraversEnabled": self.react_context["truths"]["data"]["categoryCraversEnabled"],
|
|
"hasVideoMerchInBob": self.react_context["truths"]["data"]["hasVideoMerchInBob"],
|
|
"persoInfoDensity": self.react_context["truths"]["data"]["enablePersoInfoDensityToggle"],
|
|
"contextAwareImages": self.react_context["truths"]["data"]["contextAwareImages"],
|
|
# ?
|
|
"falcor_server": "0.1.0",
|
|
"withSize": True,
|
|
"materialize": True,
|
|
"original_path": quote_plus(
|
|
f"/shakti/{self.react_context['serverDefs']['data']['BUILD_IDENTIFIER']}/pathEvaluator"
|
|
)
|
|
},
|
|
headers=dict(
|
|
**self.react_context["abContext"]["data"]["headers"],
|
|
**{
|
|
"X-Netflix.Client.Request.Name": "ui/falcorUnclassified",
|
|
"X-Netflix.esn": self.react_context["esnGeneratorModel"]["data"]["esn"],
|
|
"x-netflix.nq.stack": self.react_context["serverDefs"]["data"]["stack"],
|
|
"x-netflix.request.client.user.guid": (
|
|
self.react_context["memberContext"]["data"]["userInfo"]["guid"]
|
|
)
|
|
},
|
|
**self.react_context["requestHeaders"]["data"]
|
|
),
|
|
data={
|
|
"path": json.dumps([
|
|
[
|
|
"videos",
|
|
70155547,
|
|
[
|
|
"bobSupplementalMessage",
|
|
"bobSupplementalMessageIcon",
|
|
"bookmarkPosition",
|
|
"delivery",
|
|
"displayRuntime",
|
|
"evidence",
|
|
"hasSensitiveMetadata",
|
|
"interactiveBookmark",
|
|
"maturity",
|
|
"numSeasonsLabel",
|
|
"promoVideo",
|
|
"releaseYear",
|
|
"seasonCount",
|
|
"title",
|
|
"userRating",
|
|
"userRatingRequestId",
|
|
"watched"
|
|
]
|
|
],
|
|
[
|
|
"videos",
|
|
70155547,
|
|
"seasonList",
|
|
"current",
|
|
"summary"
|
|
]
|
|
]),
|
|
"authURL": self.react_context["memberContext"]["data"]["userInfo"]["authURL"]
|
|
}
|
|
)
|
|
|
|
print(metadata.headers)
|
|
print(metadata.text)
|
|
exit()
|
|
"""
|
|
|
|
try:
|
|
metadata = self.session.get(
|
|
self.config["endpoints"]["metadata"].format(
|
|
build_id=self.react_context["serverDefs"]["data"]["BUILD_IDENTIFIER"]
|
|
),
|
|
params={
|
|
"movieid": title_id,
|
|
"drmSystem": self.config["configuration"]["drm_system"],
|
|
"isWatchlistEnabled": False,
|
|
"isShortformEnabled": False,
|
|
"isVolatileBillboardsEnabled": self.react_context["truths"]["data"]["volatileBillboardsEnabled"],
|
|
"languages": self.meta_lang,
|
|
},
|
|
).json()
|
|
except requests.HTTPError as e:
|
|
if e.response.status_code == 500:
|
|
self.log.warning(
|
|
" - Recieved a HTTP 500 error while getting metadata, deleting cached reactContext data"
|
|
)
|
|
os.unlink(self.cache.get("web_data.json"))
|
|
return self.get_metadata(self, title_id)
|
|
raise
|
|
except json.JSONDecodeError:
|
|
self.log.error(" - Failed to get metadata, title might not be available in your region.")
|
|
sys.exit(1)
|
|
else:
|
|
if "status" in metadata and metadata["status"] == "error":
|
|
self.log.error(f" - Failed to get metadata, cookies might be expired. ({metadata['message']})")
|
|
sys.exit(1)
|
|
return metadata
|
|
|
|
def get_manifest(self, title, video_profiles):
|
|
if isinstance(video_profiles, dict):
|
|
video_profiles = list(video_profiles.values())
|
|
if self.quality == 720:
|
|
# NF only returns lower quality 720p streams if 1080p is also requested
|
|
video_profiles = [x for x in video_profiles if "l40" not in x]
|
|
audio_profiles = self.config["profiles"]["audio"]
|
|
if self.acodec:
|
|
audio_profiles = audio_profiles[self.acodec]
|
|
if isinstance(audio_profiles, dict):
|
|
audio_profiles = list(audio_profiles.values())
|
|
profiles = sorted(set(flatten(as_list(
|
|
# as list then flatten in case any of these profiles are a list of lists
|
|
# list(set()) used to remove any potential duplicates
|
|
self.config["profiles"]["video"]["H264"]["BPL"], # always required for some reason
|
|
video_profiles,
|
|
audio_profiles,
|
|
self.config["profiles"]["subtitles"],
|
|
))))
|
|
self.log.debug("Profiles:\n\t" + "\n\t".join(profiles))
|
|
|
|
params = {}
|
|
if self.cdm.device_type == DeviceTypes.CHROME:
|
|
params = {
|
|
"reqAttempt": 1,
|
|
"reqPriority": 10,
|
|
"reqName": "manifest",
|
|
"clienttype": self.react_context["playerModel"]["data"]["config"]["ui"]["initParams"]["uimode"],
|
|
"uiversion": self.react_context["serverDefs"]["data"]["BUILD_IDENTIFIER"],
|
|
# "browsername": self.react_context["playerModel"]["data"]["config"]["core"]["initParams"]["browserInfo"][
|
|
# "name"],
|
|
# "browserversion":
|
|
# self.react_context["playerModel"]["data"]["config"]["core"]["initParams"]["browserInfo"]["version"],
|
|
# "osname":
|
|
# self.react_context["playerModel"]["data"]["config"]["core"]["initParams"]["browserInfo"]["os"][
|
|
# "name"],
|
|
# "osversion":
|
|
# self.react_context["playerModel"]["data"]["config"]["core"]["initParams"]["browserInfo"]["os"][
|
|
# "version"]
|
|
}
|
|
|
|
_, payload_chunks = self.msl.send_message(
|
|
endpoint=self.config["endpoints"]["manifest"],
|
|
params=params,
|
|
application_data={
|
|
"version": 2,
|
|
"url": "/manifest",
|
|
"id": int(time.time()),
|
|
"esn": self.esn,
|
|
"languages": ["en-US"],
|
|
"uiVersion": self.react_context["playerModel"]["data"]["config"]["ui"]["initParams"]["uiVersion"],
|
|
"clientVersion": "6.0026.291.011",
|
|
"params": {
|
|
"type": "standard", # ? PREPARE
|
|
"viewableId": title.data.get("episodeId", title.data["id"]),
|
|
"profiles": profiles,
|
|
"flavor": "STANDARD", # ? PRE_FETCH, SUPPLEMENTAL
|
|
"drmType": self.config["configuration"]["drm_system"],
|
|
"drmVersion": self.config["configuration"]["drm_version"],
|
|
"usePsshBox": True,
|
|
"isBranching": False, # ? possibly for interactive titles like Minecraft Story
|
|
"useHttpsStreams": True,
|
|
"supportsUnequalizedDownloadables": True, # ?
|
|
"imageSubtitleHeight": 1080,
|
|
"uiVersion": self.react_context["playerModel"]["data"]["config"]["ui"]["initParams"]["uiVersion"],
|
|
"uiPlatform": self.react_context["playerModel"]["data"]["config"]["ui"]["initParams"]["uiPlatform"],
|
|
"clientVersion": "6.0026.291.011",
|
|
"supportsPreReleasePin": True, # ?
|
|
"supportsWatermark": True, # ?
|
|
"showAllSubDubTracks": True,
|
|
"videoOutputInfo": [
|
|
{
|
|
# todo ; make this return valid, but "secure" values, maybe it helps
|
|
"type": "DigitalVideoOutputDescriptor",
|
|
"outputType": "unknown",
|
|
"supportedHdcpVersions": self.config["configuration"]["supported_hdcp_versions"],
|
|
"isHdcpEngaged": self.config["configuration"]["is_hdcp_engaged"],
|
|
}
|
|
],
|
|
"titleSpecificData": {title.data.get("episodeId", title.data["id"]): {"unletterboxed": True}},
|
|
"preferAssistiveAudio": False,
|
|
"isUIAutoPlay": False,
|
|
"isNonMember": False,
|
|
"challenge": self.config["payload_challenge"],
|
|
# "desiredVmaf": "plus_lts", # ?
|
|
# "maxSupportedLanguages": 2, # ?
|
|
},
|
|
},
|
|
userauthdata=self.userauthdata,
|
|
)
|
|
if "errorDetails" in payload_chunks:
|
|
raise Exception(f"Manifest call failed: {payload_chunks['errorDetails']}")
|
|
return payload_chunks
|
|
|
|
def manifest_as_tracks(self, manifest):
|
|
# filter audio_tracks so that each stream is an entry instead of each track
|
|
manifest["audio_tracks"] = [
|
|
x for y in [[dict(t, **d)for d in t["streams"]] for t in manifest["audio_tracks"]] for x in y
|
|
]
|
|
|
|
tracks = Tracks()
|
|
for x in manifest["video_tracks"][0]["streams"]:
|
|
_pssh = Box.parse(Box.build(dict(
|
|
type=b"pssh",
|
|
version=0,
|
|
flags=0,
|
|
system_ID=Cdm.uuid,
|
|
init_data=b"\x12\x10" + UUID(hex=x["drmHeaderId"]).bytes
|
|
))) if x.get("drmHeaderId") else None
|
|
|
|
tracks.add(
|
|
Video(
|
|
id_=x["downloadable_id"],
|
|
url=x["urls"][0]["url"],
|
|
codec=Video.Codec.from_netflix_profile(x["content_profile"]),
|
|
bitrate=x["bitrate"] * 1000,
|
|
width=x["res_w"],
|
|
height=x["res_h"],
|
|
fps=(float(x["framerate_value"]) / x["framerate_scale"]) if "framerate_value" in x else None,
|
|
language=self.get_original_language(manifest),
|
|
needs_repack=False,
|
|
drm=[Widevine(pssh=PSSH(_pssh))] if _pssh else None,
|
|
descriptor=Track.Descriptor.URL,
|
|
)
|
|
)
|
|
|
|
for x in manifest["audio_tracks"]:
|
|
_pssh = Box.parse(base64.b64decode(x["drmHeader"]["bytes"])) if x.get("drmHeader") else None
|
|
|
|
tracks.add(
|
|
Audio(
|
|
id_=x["downloadable_id"],
|
|
url=x["urls"][0]["url"],
|
|
codec=Audio.Codec.from_netflix_profile(x["content_profile"]),
|
|
language=self.NF_LANG_MAP.get(x["language"], x["language"]),
|
|
bitrate=x["bitrate"] * 1000,
|
|
channels=x["channels"],
|
|
descriptive=x.get("rawTrackType", "").lower() == "assistive",
|
|
needs_repack=False,
|
|
drm=[Widevine(pssh=PSSH(_pssh))] if _pssh else None,
|
|
descriptor=Track.Descriptor.URL,
|
|
)
|
|
)
|
|
|
|
|
|
for x in manifest["timedtexttracks"]:
|
|
if not x["isNoneTrack"]:
|
|
tracks.add(
|
|
Subtitle(
|
|
id_=list(x["downloadableIds"].values())[0],
|
|
url=next(iter(next(iter(x["ttDownloadables"].values()))["downloadUrls"].values())),
|
|
codec=Subtitle.Codec.from_netflix_profile(next(iter(x["ttDownloadables"].keys()))),
|
|
language=self.NF_LANG_MAP.get(x["language"], x["language"]),
|
|
forced=x["isForcedNarrative"],
|
|
sdh=x["rawTrackType"] == "closedcaptions",
|
|
)
|
|
)
|
|
|
|
return tracks
|
|
|
|
def chrome_esn_generator(self):
|
|
esn_gen = "NFCDCH-02-" + "".join(random.choice("0123456789ABCDEF") for _ in range(30))
|
|
|
|
esn_cache = self.cache.get(f"chrome_esn_{self.user_profile}")
|
|
|
|
if esn_cache and not esn_cache.expired:
|
|
self.log.info("ESN found in cache")
|
|
esn = esn_cache.data.get("esn")
|
|
elif esn_cache and esn_cache.expired:
|
|
self.log.info("ESN expired, Generating a new Chrome ESN")
|
|
esn_cache.set({"esn": esn_gen}, expiration=int(timedelta(hours=6).total_seconds()))
|
|
esn = esn_cache.data.get("esn")
|
|
else:
|
|
self.log.info("Generating a new Chrome ESN")
|
|
esn_cache.set({"esn": esn_gen}, expiration=int(timedelta(hours=6).total_seconds()))
|
|
esn = esn_cache.data.get("esn")
|
|
|
|
return esn
|
|
|
|
def parse_title(self, ctx, title) -> dict | None:
|
|
title = title or ctx.parent.params.get("title")
|
|
if not title:
|
|
self.log.error(" - No title ID specified")
|
|
sys.exit(1)
|
|
if not getattr(self, "TITLE_RE"):
|
|
self.title = title
|
|
return {}
|
|
for regex in as_list(self.TITLE_RE):
|
|
m = re.search(regex, title)
|
|
if m:
|
|
self.title = m.group("id")
|
|
return m.groupdict()
|
|
self.log.warning(f" - Unable to parse title ID {title!r}, using as-is")
|
|
self.title = title
|
|
|
|
@staticmethod
|
|
def get_original_language(manifest):
|
|
for language in manifest["audio_tracks"]:
|
|
if language["languageDescription"].endswith(" [Original]"):
|
|
return Language.get(language["language"])
|
|
# e.g. get `en` from "A:1:1;2;en;0;|V:2:1;[...]"
|
|
return Language.get(manifest["defaultTrackOrderList"][0]["mediaId"].split(";")[2])
|