770 lines
34 KiB
Python
770 lines
34 KiB
Python
import base64
|
|
from datetime import datetime
|
|
import json
|
|
from math import e
|
|
|
|
import random
|
|
import sys
|
|
import time
|
|
import typing
|
|
from uuid import UUID
|
|
import click
|
|
import re
|
|
from typing import List, Literal, Optional, Set, Union, Tuple
|
|
from http.cookiejar import CookieJar
|
|
from itertools import zip_longest
|
|
from Crypto.Random import get_random_bytes
|
|
|
|
import jsonpickle
|
|
from pymp4.parser import Box
|
|
from pywidevine import PSSH, Cdm
|
|
import requests
|
|
from langcodes import Language
|
|
|
|
from unshackle.core.constants import AnyTrack
|
|
from unshackle.core.credential import Credential
|
|
from unshackle.core.drm.widevine import Widevine
|
|
from unshackle.core.service import Service
|
|
from unshackle.core.titles import Titles_T, Title_T
|
|
from unshackle.core.titles.episode import Episode, Series
|
|
from unshackle.core.titles.movie import Movie, Movies
|
|
from unshackle.core.titles.title import Title
|
|
from unshackle.core.tracks import Tracks, Chapters
|
|
from unshackle.core.tracks.audio import Audio
|
|
from unshackle.core.tracks.chapter import Chapter
|
|
from unshackle.core.tracks.subtitle import Subtitle
|
|
from unshackle.core.tracks.track import Track
|
|
from unshackle.core.tracks.video import Video
|
|
from unshackle.core.utils.collections import flatten, as_list
|
|
|
|
from unshackle.core.tracks.attachment import Attachment
|
|
from unshackle.core.drm.playready import PlayReady
|
|
from unshackle.core.titles.song import Song
|
|
from unshackle.utils.base62 import decode
|
|
from .MSL import MSL, KeyExchangeSchemes
|
|
from .MSL.schemes.UserAuthentication import UserAuthentication
|
|
|
|
class NF(Service):
|
|
"""
|
|
Service for https://netflix.com
|
|
Version: 1.0.0
|
|
|
|
Authorization: Cookies
|
|
Security: UHD@SL3000/L1 FHD@SL3000/L1
|
|
"""
|
|
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+)",
|
|
]
|
|
ALIASES= ("NF", "Netflix")
|
|
NF_LANG_MAP = {
|
|
"es": "es-419",
|
|
"pt": "pt-PT",
|
|
}
|
|
|
|
@staticmethod
|
|
@click.command(name="NF", short_help="https://netflix.com")
|
|
@click.argument("title", type=str)
|
|
@click.option("-drm", "--drm-system", type=click.Choice(["widevine", "playready"], case_sensitive=False),
|
|
default="widevine",
|
|
help="which drm system to use")
|
|
@click.option("-p", "--profile", type=click.Choice(["MPL", "HPL", "QC", "MPL+HPL", "MPL+HPL+QC", "MPL+QC"], case_sensitive=False),
|
|
default=None,
|
|
help="H.264 profile to use. Default is best available.")
|
|
@click.option("--meta-lang", type=str, help="Language to use for metadata")
|
|
@click.option("-ht","--hydrate-track", is_flag=True, default=False, help="Hydrate missing audio and subtitle.")
|
|
@click.option("-hb", "--high-bitrate", is_flag=True, default=False, help="Get more video bitrate")
|
|
@click.pass_context
|
|
def cli(ctx, **kwargs):
|
|
return NF(ctx, **kwargs)
|
|
|
|
def __init__(self, ctx: click.Context, title: str, drm_system: Literal["widevine", "playready"], profile: str, meta_lang: str, hydrate_track: bool, high_bitrate: bool):
|
|
super().__init__(ctx)
|
|
# General
|
|
self.title = title
|
|
self.profile = profile
|
|
self.meta_lang = meta_lang
|
|
self.hydrate_track = hydrate_track
|
|
self.drm_system = drm_system
|
|
self.profiles: List[str] = []
|
|
self.requested_profiles: List[str] = []
|
|
self.high_bitrate = high_bitrate
|
|
|
|
# MSL
|
|
self.esn = self.cache.get("ESN")
|
|
self.msl: Optional[MSL] = None
|
|
self.userauthdata = None
|
|
|
|
# Download options
|
|
self.range = ctx.parent.params.get("range_") or [Video.Range.SDR]
|
|
self.vcodec = ctx.parent.params.get("vcodec") or Video.Codec.AVC # Defaults to H264
|
|
self.acodec : Audio.Codec = ctx.parent.params.get("acodec") or Audio.Codec.EC3
|
|
self.quality: List[int] = ctx.parent.params.get("quality")
|
|
self.audio_only = ctx.parent.params.get("audio_only")
|
|
self.subs_only = ctx.parent.params.get("subs_only")
|
|
self.chapters_only = ctx.parent.params.get("chapters_only")
|
|
|
|
self.vcodec = self.vcodec[0] if isinstance(self.vcodec,list) else self.vcodec
|
|
|
|
|
|
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
|
# Configure first before download
|
|
self.log.debug("Authenticating Netflix service")
|
|
auth = super().authenticate(cookies, credential)
|
|
if not cookies:
|
|
raise EnvironmentError("Service requires Cookies for Authentication.")
|
|
self.configure()
|
|
return auth
|
|
|
|
def get_titles(self) -> Titles_T:
|
|
metadata = self.get_metadata(self.title)
|
|
if "video" not in metadata:
|
|
self.log.error(f"Failed to get metadata: {metadata}")
|
|
sys.exit(1)
|
|
titles: Titles_T | None = None
|
|
if metadata["video"]["type"] == "movie":
|
|
movie = Movie(
|
|
id_=self.title,
|
|
name=metadata["video"]["title"],
|
|
year=metadata["video"]["year"],
|
|
service=self.__class__,
|
|
data=metadata["video"],
|
|
description=metadata["video"]["synopsis"]
|
|
)
|
|
titles = Movies([
|
|
movie
|
|
])
|
|
else:
|
|
episode_list: List[Episode] = []
|
|
for season in metadata["video"]["seasons"]:
|
|
for episode in season["episodes"]:
|
|
episode_list.append(
|
|
Episode(
|
|
id_=self.title,
|
|
title=metadata["video"]["title"],
|
|
year=season["year"],
|
|
service=self.__class__,
|
|
season=season["seq"],
|
|
number=episode["seq"],
|
|
name=episode["title"],
|
|
data=episode,
|
|
description=episode["synopsis"],
|
|
)
|
|
)
|
|
|
|
titles = Series(episode_list)
|
|
|
|
return titles
|
|
|
|
|
|
def get_tracks(self, title: Title_T) -> Tracks:
|
|
|
|
tracks = Tracks()
|
|
|
|
# If Video Codec is H.264 is selected but `self.profile is none` profile QC has to be requested separately
|
|
if self.vcodec == Video.Codec.AVC:
|
|
try:
|
|
manifest = self.get_manifest(title, self.profiles)
|
|
movie_track = self.manifest_as_tracks(manifest, title, self.hydrate_track)
|
|
tracks.add(movie_track)
|
|
|
|
if self.profile is not None:
|
|
self.log.info(f"Requested profiles: {self.profile}")
|
|
else:
|
|
qc_720_profile = [x for x in self.config["profiles"]["video"][self.vcodec.extension.upper()]["QC"] if "l40" not in x and 720 in self.quality]
|
|
qc_manifest = self.get_manifest(title, qc_720_profile if 720 in self.quality else self.config["profiles"]["video"][self.vcodec.extension.upper()]["QC"])
|
|
qc_tracks = self.manifest_as_tracks(qc_manifest, title, False)
|
|
tracks.add(qc_tracks.videos)
|
|
|
|
mpl_manifest = self.get_manifest(title, [x for x in self.config["profiles"]["video"][self.vcodec.extension.upper()]["MPL"] if "l40" not in x])
|
|
mpl_tracks = self.manifest_as_tracks(mpl_manifest, title, False)
|
|
tracks.add(mpl_tracks.videos)
|
|
except Exception as e:
|
|
self.log.error(e)
|
|
else:
|
|
if self.high_bitrate:
|
|
splitted_profiles = self.split_profiles(self.profiles)
|
|
for index, profile_list in enumerate(splitted_profiles):
|
|
try:
|
|
self.log.debug(f"Index: {index}. Getting profiles: {profile_list}")
|
|
manifest = self.get_manifest(title, profile_list)
|
|
manifest_tracks = self.manifest_as_tracks(manifest, title, self.hydrate_track if index == 0 else False)
|
|
tracks.add(manifest_tracks if index == 0 else manifest_tracks.videos)
|
|
except Exception:
|
|
self.log.error(f"Error getting profile: {profile_list}. Skipping")
|
|
continue
|
|
else:
|
|
try:
|
|
manifest = self.get_manifest(title, self.profiles)
|
|
manifest_tracks = self.manifest_as_tracks(manifest, title, self.hydrate_track)
|
|
tracks.add(manifest_tracks)
|
|
except Exception as e:
|
|
self.log.error(e)
|
|
|
|
|
|
# Add Attachments for profile picture
|
|
if isinstance(title, Movie):
|
|
tracks.add(
|
|
Attachment.from_url(
|
|
url=title.data["boxart"][0]["url"]
|
|
)
|
|
)
|
|
else:
|
|
tracks.add(
|
|
Attachment.from_url(title.data["stills"][0]["url"])
|
|
)
|
|
|
|
return tracks
|
|
|
|
def split_profiles(self, profiles: List[str]) -> List[List[str]]:
|
|
"""
|
|
Split profiles with names containing specific patterns based on video codec
|
|
For H264: uses patterns "l30", "l31", "l40" (lowercase)
|
|
For non-H264: uses patterns "L30", "L31", "L40", "L41", "L50", "L51" (uppercase)
|
|
Returns List[List[str]] type with profiles grouped by pattern
|
|
"""
|
|
if self.vcodec == Video.Codec.AVC: # H264
|
|
patterns = ["l30", "l31", "l40"]
|
|
else:
|
|
patterns = ["L30", "L31", "L40", "L41", "L50", "L51"]
|
|
|
|
result: List[List[str]] = []
|
|
for pattern in patterns:
|
|
pattern_group = []
|
|
for profile in profiles:
|
|
if pattern in profile:
|
|
pattern_group.append(profile)
|
|
if pattern_group:
|
|
result.append(pattern_group)
|
|
|
|
return result
|
|
|
|
|
|
def get_chapters(self, title: Title_T) -> Chapters:
|
|
chapters: Chapters = Chapters()
|
|
credits = title.data["skipMarkers"]["credit"]
|
|
if credits["start"] > 0 and credits["end"] > 0:
|
|
chapters.add(Chapter(
|
|
timestamp=credits["start"], # Milliseconds
|
|
name="Intro"
|
|
))
|
|
chapters.add(
|
|
Chapter(
|
|
timestamp=credits["end"], # Milliseconds
|
|
name="Part 01"
|
|
)
|
|
)
|
|
|
|
chapters.add(Chapter(
|
|
timestamp=float(title.data["creditsOffset"]), # this is seconds, needed to assign to float
|
|
name="Outro"
|
|
))
|
|
|
|
return chapters
|
|
|
|
def get_widevine_license(self, *, challenge: bytes, title, track: AnyTrack, session_id=None):
|
|
if not self.msl:
|
|
self.log.error(f"MSL Client is not initialized!")
|
|
sys.exit(1)
|
|
application_data = {
|
|
"version": 2,
|
|
"url": track.data["license_url"],
|
|
"id": int(time.time() * 10000),
|
|
"esn": self.esn.data,
|
|
"languages": ["en-US"],
|
|
"clientVersion": "6.0026.291.011",
|
|
"params": [{
|
|
"sessionId": base64.b64encode(get_random_bytes(16)).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"
|
|
}
|
|
header, payload_data = self.msl.send_message(
|
|
endpoint=self.config["endpoints"]["license"],
|
|
params={
|
|
"reqAttempt": 1,
|
|
"reqName": "license",
|
|
},
|
|
application_data=application_data,
|
|
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"]
|
|
|
|
def get_playready_license(self, *, challenge: bytes, title, track: AnyTrack, session_id=None):
|
|
return None # PlayReady is not implemented
|
|
|
|
def configure(self):
|
|
if self.profile is None:
|
|
self.profiles = self.config["profiles"]["video"][self.vcodec.extension.upper()]
|
|
|
|
if self.profile is not None:
|
|
self.requested_profiles = self.profile.split('+')
|
|
self.log.info(f"Requested profile: {self.requested_profiles}")
|
|
else:
|
|
self.requested_profiles = self.config["profiles"]["video"][self.vcodec.extension.upper()]
|
|
|
|
# Make sure video codec is supported by Netflix
|
|
if self.vcodec.extension.upper() not in self.config["profiles"]["video"]:
|
|
raise ValueError(f"Video Codec {self.vcodec} is not supported by Netflix")
|
|
|
|
if self.range[0].name not in list(self.config["profiles"]["video"][self.vcodec.extension.upper()].keys()) and self.vcodec != Video.Codec.AVC and self.vcodec != Video.Codec.VP9:
|
|
self.log.error(f"Video range {self.range[0].name} is not supported by Video Codec: {self.vcodec}")
|
|
sys.exit(1)
|
|
|
|
if len(self.range) > 1:
|
|
self.log.error(f"Multiple video range is not supported right now.")
|
|
sys.exit(1)
|
|
|
|
if self.vcodec == Video.Codec.AVC and self.range[0] != Video.Range.SDR:
|
|
self.log.error(f"H.264 Video Codec only supports SDR")
|
|
sys.exit(1)
|
|
|
|
self.profiles = self.get_profiles()
|
|
self.log.info("Initializing a MSL client")
|
|
self.get_esn()
|
|
scheme = KeyExchangeSchemes.AsymmetricWrapped
|
|
self.log.info(f"Scheme: {scheme}")
|
|
|
|
self.msl = MSL.handshake(
|
|
scheme=scheme,
|
|
session=self.session,
|
|
endpoint=self.config["endpoints"]["manifest"],
|
|
sender=self.esn.data,
|
|
cache=self.cache.get("MSL")
|
|
)
|
|
cookie = self.session.cookies.get_dict()
|
|
self.userauthdata = UserAuthentication.NetflixIDCookies(
|
|
netflixid=cookie["NetflixId"],
|
|
securenetflixid=cookie["SecureNetflixId"]
|
|
)
|
|
|
|
|
|
def get_profiles(self):
|
|
result_profiles = []
|
|
|
|
if self.vcodec == Video.Codec.AVC:
|
|
if self.requested_profiles is not None:
|
|
for requested_profiles in self.requested_profiles:
|
|
result_profiles.extend(flatten(list(self.config["profiles"]["video"][self.vcodec.extension.upper()][requested_profiles])))
|
|
return result_profiles
|
|
|
|
result_profiles.extend(flatten(list(self.config["profiles"]["video"][self.vcodec.extension.upper()].values())))
|
|
return result_profiles
|
|
|
|
# Handle case for codec VP9
|
|
if self.vcodec == Video.Codec.VP9 and self.range[0] != Video.Range.HDR10:
|
|
result_profiles.extend(self.config["profiles"]["video"][self.vcodec.extension.upper()].values())
|
|
return result_profiles
|
|
|
|
for profiles in self.config["profiles"]["video"][self.vcodec.extension.upper()]:
|
|
for range in self.range:
|
|
if range in profiles:
|
|
result_profiles.extend(self.config["profiles"]["video"][self.vcodec.extension.upper()][range.name])
|
|
|
|
self.log.debug(f"Result_profiles: {result_profiles}")
|
|
return result_profiles
|
|
|
|
def get_esn(self):
|
|
ESN_GEN = "".join(random.choice("0123456789ABCDEF") for _ in range(30))
|
|
esn_value = f"NFCDIE-03-{ESN_GEN}"
|
|
if self.esn.data is None or self.esn.data == {} or (hasattr(self.esn, 'expired') and self.esn.expired):
|
|
self.esn.set(esn_value, 1 * 60 * 60) # 1 hour in seconds
|
|
self.log.info(f"Generated new ESN with 1-hour expiration")
|
|
else:
|
|
self.log.info(f"Using cached ESN.")
|
|
self.log.info(f"ESN: {self.esn.data}")
|
|
|
|
|
|
def get_metadata(self, title_id: str):
|
|
"""
|
|
Obtain Metadata information about a title by it's ID.
|
|
:param title_id: Title's ID.
|
|
:returns: Title Metadata.
|
|
"""
|
|
try:
|
|
metadata = self.session.get(
|
|
self.config["endpoints"]["metadata"].format(build_id="release"),
|
|
params={
|
|
"movieid": title_id,
|
|
"drmSystem": self.config["configuration"]["drm_system"],
|
|
"isWatchlistEnabled": False,
|
|
"isShortformEnabled": False,
|
|
"languages": self.meta_lang
|
|
}
|
|
).json()
|
|
except requests.HTTPError as e:
|
|
if e.response.status_code == 500:
|
|
self.log.warning(
|
|
" - Received a HTTP 500 error while getting metadata, deleting cached reactContext data"
|
|
)
|
|
raise Exception(f"Error getting metadata: {e}")
|
|
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: Title_T, video_profiles: List[str], required_text_track_id: Optional[str] = None, required_audio_track_id: Optional[str] = None):
|
|
audio_profiles = self.config["profiles"]["audio"].values()
|
|
video_profiles = sorted(set(flatten(as_list(
|
|
video_profiles,
|
|
audio_profiles,
|
|
self.config["profiles"]["video"]["H264"]["BPL"] if self.vcodec == Video.Codec.AVC else [],
|
|
self.config["profiles"]["subtitles"],
|
|
))))
|
|
|
|
self.log.debug("Profiles:\n\t" + "\n\t".join(video_profiles))
|
|
|
|
if not self.msl:
|
|
raise Exception("MSL Client is not initialized.")
|
|
|
|
params = {
|
|
"reqAttempt": 1,
|
|
"reqPriority": 10,
|
|
"reqName": "manifest",
|
|
}
|
|
_, 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.data,
|
|
"languages": ["en-US"],
|
|
"clientVersion": "6.0026.291.011",
|
|
"params": {
|
|
"clientVersion": "6.0051.090.911",
|
|
"challenge": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"],
|
|
"challanges": {
|
|
"default": self.config["payload_challenge_pr"] if self.drm_system == 'playready' else self.config["payload_challenge"]
|
|
},
|
|
"contentPlaygraph": ["v2"],
|
|
"deviceSecurityLevel": "3000",
|
|
"drmVersion": 25,
|
|
"desiredVmaf": "plus_lts",
|
|
"desiredSegmentVmaf": "plus_lts",
|
|
"flavor": "STANDARD",
|
|
"drmType": self.drm_system,
|
|
"imageSubtitleHeight": 1080,
|
|
"isBranching": False,
|
|
"isNonMember": False,
|
|
"isUIAutoPlay": False,
|
|
"licenseType": "standard",
|
|
"liveAdsCapability": "remove",
|
|
"liveMetadataFormat": "INDEXED_SEGMENT_TEMPLATE",
|
|
"manifestVersion": "v2",
|
|
"osName": "windows",
|
|
"osVersion": "10.0",
|
|
"platform": "138.0.0.0",
|
|
"profilesGroups": [{
|
|
"name": "default",
|
|
"profiles": video_profiles
|
|
}],
|
|
"profiles": video_profiles,
|
|
"preferAssistiveAudio": False,
|
|
"requestSegmentVmaf": False,
|
|
"requiredAudioTrackId": required_audio_track_id,
|
|
"requiredTextTrackId": required_text_track_id,
|
|
"supportsAdBreakHydration": False, #
|
|
"supportsNetflixMediaEvents": True,
|
|
"supportsPartialHydration": True,
|
|
"supportsPreReleasePin": True,
|
|
"supportsUnequalizedDownloadables": True,
|
|
"supportsWatermark": True,
|
|
"titleSpecificData": {
|
|
title.data.get("episodeId", title.data["id"]): {"unletterboxed": False}
|
|
},
|
|
"type": "standard",
|
|
"uiPlatform": "SHAKTI",
|
|
"uiVersion": "shakti-v49577320",
|
|
"useBetterTextUrls": True,
|
|
"useHttpsStreams": True,
|
|
"usePsshBox": True,
|
|
"videoOutputInfo": [{
|
|
"type": "DigitalVideoOutputDescriptor",
|
|
"outputType": "unknown",
|
|
"supportedHdcpVersions": self.config["configuration"]["supported_hdcp_versions"],
|
|
"isHdcpEngaged": self.config["configuration"]["is_hdcp_engaged"]
|
|
}],
|
|
"viewableId": title.data.get("episodeId", title.data["id"]),
|
|
"xid": str(int((int(time.time()) + 0.1612) * 1000)),
|
|
"showAllSubDubTracks": True,
|
|
}
|
|
},
|
|
userauthdata=self.userauthdata
|
|
)
|
|
if "errorDetails" in payload_chunks:
|
|
raise Exception(f"Manifest call failed: {payload_chunks['errorDetails']}")
|
|
return payload_chunks
|
|
|
|
@staticmethod
|
|
def get_original_language(manifest) -> Language:
|
|
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])
|
|
|
|
def get_widevine_service_certificate(self, *, challenge: bytes, title, track: AnyTrack):
|
|
return self.config["certificate"]
|
|
|
|
def manifest_as_tracks(self, manifest, title: Title_T, hydrate_tracks: bool = False) -> Tracks:
|
|
|
|
tracks = Tracks()
|
|
original_language = self.get_original_language(manifest)
|
|
self.log.debug(f"Original language: {original_language}")
|
|
license_url = manifest["links"]["license"]["href"]
|
|
|
|
for video in reversed(manifest["video_tracks"][0]["streams"]):
|
|
tracks.add(
|
|
Video(
|
|
id_=video["downloadable_id"],
|
|
url=video["urls"][0]["url"],
|
|
codec=Video.Codec.from_netflix_profile(video["content_profile"]),
|
|
bitrate=video["bitrate"] * 1000,
|
|
width=video["res_w"],
|
|
height=video["res_h"],
|
|
fps=(float(video["framerate_value"]) / video["framerate_scale"]) if "framerate_value" in video else None,
|
|
language=Language.get(original_language),
|
|
edition=video["content_profile"],
|
|
range_=self.parse_video_range_from_profile(video["content_profile"]),
|
|
drm=[Widevine(
|
|
pssh=PSSH(
|
|
manifest["video_tracks"][0]["drmHeader"]["bytes"]
|
|
),
|
|
kid=video["drmHeaderId"]
|
|
)],
|
|
data={
|
|
'license_url': license_url
|
|
}
|
|
)
|
|
)
|
|
|
|
# Audio
|
|
# store unavailable tracks for hydrating later
|
|
unavailable_audio_tracks: List[Tuple[str, str]] = []
|
|
for index, audio in enumerate(manifest["audio_tracks"]):
|
|
if len(audio["streams"]) < 1:
|
|
unavailable_audio_tracks.append((audio["new_track_id"], audio["id"]))
|
|
continue
|
|
is_original_lang = audio["language"] == original_language.language
|
|
for stream in audio["streams"]:
|
|
tracks.add(
|
|
Audio(
|
|
id_=stream["downloadable_id"],
|
|
url=stream["urls"][0]["url"],
|
|
codec=Audio.Codec.from_netflix_profile(stream["content_profile"]),
|
|
language=Language.get(self.NF_LANG_MAP.get(audio["language"]) or audio["language"]),
|
|
is_original_lang=is_original_lang,
|
|
bitrate=stream["bitrate"] * 1000,
|
|
channels=stream["channels"],
|
|
descriptive=audio.get("rawTrackType", "").lower() == "assistive",
|
|
name="[Original]" if Language.get(audio["language"]).language == original_language.language else None,
|
|
joc=6 if "atmos" in stream["content_profile"] else None
|
|
)
|
|
)
|
|
|
|
# Subtitle
|
|
unavailable_subtitle: List[Tuple[str, str]] = []
|
|
for index, subtitle in enumerate(manifest["timedtexttracks"]):
|
|
if "isNoneTrack" in subtitle and subtitle["isNoneTrack"] == True:
|
|
continue
|
|
if subtitle["hydrated"] == False:
|
|
unavailable_subtitle.append((subtitle["new_track_id"], subtitle["id"]))
|
|
continue
|
|
|
|
if subtitle["languageDescription"] == 'Off':
|
|
continue
|
|
|
|
id = list(subtitle["downloadableIds"].values())
|
|
language = Language.get(subtitle["language"])
|
|
profile = next(iter(subtitle["ttDownloadables"].keys()))
|
|
tt_downloadables = next(iter(subtitle["ttDownloadables"].values()))
|
|
is_original_lang = subtitle["language"] == original_language.language
|
|
tracks.add(
|
|
Subtitle(
|
|
id_=id[0],
|
|
url=tt_downloadables["urls"][0]["url"],
|
|
codec=Subtitle.Codec.from_netflix_profile(profile),
|
|
language=language,
|
|
forced=subtitle["isForcedNarrative"],
|
|
cc=subtitle["rawTrackType"] == "closedcaptions",
|
|
sdh=subtitle["trackVariant"] == 'STRIPPED_SDH' if "trackVariant" in subtitle else False,
|
|
is_original_lang=is_original_lang,
|
|
name=("[Original]" if language.language == original_language.language else None or "[Dubbing]" if "trackVariant" in subtitle and subtitle["trackVariant"] == "DUBTITLE" else None),
|
|
)
|
|
)
|
|
|
|
# FIX 2: Return early if hydration not requested
|
|
if not hydrate_tracks:
|
|
return tracks
|
|
|
|
# Hydrate missing tracks
|
|
self.log.info(f"Getting all missing audio and subtitle tracks")
|
|
|
|
# Netflix API (playapi-459) requires BOTH requiredAudioTrackId AND requiredTextTrackId
|
|
# to be present together when using partial hydration — you cannot send just one.
|
|
# When one list is shorter, we fill the missing side with the new_track_id of any
|
|
# already-hydrated track of that type as a harmless "dummy". The API will simply
|
|
# return that track hydrated again (already in our tracks), which we skip below.
|
|
fallback_audio_id: Optional[str] = next(
|
|
(a["new_track_id"] for a in manifest["audio_tracks"] if a.get("streams")),
|
|
None
|
|
)
|
|
fallback_subtitle_id: Optional[str] = next(
|
|
(s["new_track_id"] for s in manifest["timedtexttracks"]
|
|
if s.get("hydrated") and not s.get("isNoneTrack") and s.get("languageDescription") != "Off"),
|
|
None
|
|
)
|
|
|
|
for audio_hydration, subtitle_hydration in zip_longest(unavailable_audio_tracks, unavailable_subtitle, fillvalue=("N/A", "N/A")):
|
|
|
|
is_audio_real = audio_hydration[0] != "N/A"
|
|
is_subtitle_real = subtitle_hydration[0] != "N/A"
|
|
|
|
# Skip entirely if both are exhausted (safety guard)
|
|
if not is_audio_real and not is_subtitle_real:
|
|
continue
|
|
|
|
# Resolve the actual IDs to send — always send both.
|
|
# If one side is N/A, substitute the fallback so the API doesn't reject the request.
|
|
audio_track_id_param = audio_hydration[0] if is_audio_real else fallback_audio_id
|
|
subtitle_track_id_param = subtitle_hydration[0] if is_subtitle_real else fallback_subtitle_id
|
|
|
|
# If we have no fallback at all for a side, skip rather than send None
|
|
if audio_track_id_param is None or subtitle_track_id_param is None:
|
|
self.log.warning(
|
|
f"Cannot hydrate pair (audio={audio_hydration[0]}, sub={subtitle_hydration[0]}): "
|
|
f"no fallback ID available for the missing side. Skipping."
|
|
)
|
|
continue
|
|
|
|
manifest_hydrated = self.get_manifest(
|
|
title,
|
|
self.profiles,
|
|
subtitle_track_id_param,
|
|
audio_track_id_param
|
|
)
|
|
|
|
# Only add audio if this iteration was actually fetching a real audio track
|
|
if is_audio_real:
|
|
audios = next(
|
|
(item for item in manifest_hydrated["audio_tracks"] if 'id' in item and item["id"] == audio_hydration[1]),
|
|
None
|
|
)
|
|
if audios and audios.get("streams"):
|
|
for stream in audios["streams"]:
|
|
tracks.add(
|
|
Audio(
|
|
id_=stream["downloadable_id"],
|
|
url=stream["urls"][0]["url"],
|
|
codec=Audio.Codec.from_netflix_profile(stream["content_profile"]),
|
|
language=Language.get(self.NF_LANG_MAP.get(audios["language"]) or audios["language"]),
|
|
is_original_lang=Language.get(audios["language"]).language == original_language.language,
|
|
bitrate=stream["bitrate"] * 1000,
|
|
channels=stream["channels"],
|
|
descriptive=audios.get("rawTrackType", "").lower() == "assistive",
|
|
name="[Original]" if Language.get(audios["language"]).language == original_language.language else None,
|
|
joc=6 if "atmos" in stream["content_profile"] else None
|
|
)
|
|
)
|
|
|
|
# Only add subtitle if this iteration was actually fetching a real subtitle track
|
|
if not is_subtitle_real:
|
|
continue
|
|
|
|
subtitles = next(
|
|
(item for item in manifest_hydrated["timedtexttracks"] if 'id' in item and item["id"] == subtitle_hydration[1]),
|
|
None
|
|
)
|
|
|
|
if not subtitles:
|
|
self.log.warning(f"Could not find hydrated subtitle track {subtitle_hydration[1]} in manifest")
|
|
continue
|
|
|
|
# Make sure ttDownloadables is present and has URLs
|
|
if "ttDownloadables" not in subtitles or not subtitles["ttDownloadables"]:
|
|
self.log.warning(f"Hydrated subtitle track {subtitle_hydration[1]} has no ttDownloadables")
|
|
continue
|
|
|
|
sub_tt_downloadables = next(iter(subtitles["ttDownloadables"].values()))
|
|
if "urls" not in sub_tt_downloadables or not sub_tt_downloadables["urls"]:
|
|
self.log.warning(f"Hydrated subtitle track {subtitle_hydration[1]} has no URLs")
|
|
continue
|
|
|
|
id = list(subtitles["downloadableIds"].values())
|
|
language = Language.get(subtitles["language"])
|
|
profile = next(iter(subtitles["ttDownloadables"].keys()))
|
|
tt_downloadables = next(iter(subtitles["ttDownloadables"].values()))
|
|
tracks.add(
|
|
Subtitle(
|
|
id_=id[0],
|
|
url=tt_downloadables["urls"][0]["url"],
|
|
codec=Subtitle.Codec.from_netflix_profile(profile),
|
|
language=language,
|
|
forced=subtitles["isForcedNarrative"],
|
|
cc=subtitles["rawTrackType"] == "closedcaptions",
|
|
sdh=subtitles["trackVariant"] == 'STRIPPED_SDH' if "trackVariant" in subtitles else False,
|
|
is_original_lang=subtitles["language"] == original_language.language,
|
|
# FIX 6: was `subtitle` (loop var from outer scope) — now correctly uses `subtitles`
|
|
name=("[Original]" if language.language == original_language.language else None or "[Dubbing]" if "trackVariant" in subtitles and subtitles["trackVariant"] == "DUBTITLE" else None),
|
|
)
|
|
)
|
|
|
|
return tracks
|
|
|
|
|
|
def parse_video_range_from_profile(self, profile: str) -> Video.Range:
|
|
"""
|
|
Parse the video range from a Netflix profile string.
|
|
|
|
Args:
|
|
profile (str): The Netflix profile string (e.g., "hevc-main10-L30-dash-cenc")
|
|
|
|
Returns:
|
|
Video.Range: The corresponding Video.Range enum value
|
|
|
|
Examples:
|
|
>>> parse_video_range_from_profile("hevc-main10-L30-dash-cenc")
|
|
<Video.Range.SDR: 'SDR'>
|
|
>>> parse_video_range_from_profile("hevc-dv5-main10-L30-dash-cenc")
|
|
<Video.Range.DV: 'DV'>
|
|
"""
|
|
|
|
video_profiles = self.config.get("profiles", {}).get("video", {})
|
|
|
|
for codec, ranges in video_profiles.items():
|
|
for range_name, profiles in ranges.items():
|
|
if profile in profiles:
|
|
try:
|
|
return Video.Range(range_name)
|
|
except ValueError:
|
|
self.log.debug(f"Video range is not valid {range_name}")
|
|
return Video.Range.SDR
|
|
|
|
return Video.Range.SDR |