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\d+)", r"^https?://(?:www\.)?unogs\.com/title/(?P\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") >>> parse_video_range_from_profile("hevc-dv5-main10-L30-dash-cenc") """ 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