initial services
This commit is contained in:
770
NF/__init__.py
Normal file
770
NF/__init__.py
Normal file
@@ -0,0 +1,770 @@
|
||||
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
|
||||
Reference in New Issue
Block a user