from __future__ import annotations import re import sys import uuid from collections.abc import Generator from http.cookiejar import CookieJar from typing import Any, Optional, Union import click from click import Context from requests import Request from devine.core.credential import Credential from devine.core.manifests import HLS from devine.core.search_result import SearchResult from devine.core.service import Service from devine.core.titles import Episode, Movie, Movies, Series from devine.core.tracks import Chapters, Tracks, Video, Chapter from devine.core.utils.collections import as_list from . import queries class DSNP(Service): """ \b Service code for DisneyPlus streaming service (https://www.disneyplus.com). \b Authorization: Credentials Robustness: Widevine: L1: 2160p, 1080p L3: 720p PlayReady: SL3: 2160p, 1080p \b Tips: - Input should be only the entity ID for both series and movies: MOVIE: entity-99e15d53-926e-4074-b9f4-6524d10c8bed SERIES: entity-30429ad6-dd12-41bf-924e-19131fa66bb5 - Use the --lang LANG_RANGE option to request non-english tracks - CDM level dictates playback quality (L3 == 720p, L1 == 1080p, 2160p) \b Notes: - On first run, the program will look for the first account profile that doesn't have kids mode or pin protection enabled. If none are found, the program will exit. - The profile will be cached and re-used until cache is cleared. """ @staticmethod @click.command(name="DSNP", short_help="https://www.disneyplus.com", help=__doc__) @click.argument("title", type=str) @click.pass_context def cli(ctx: Context, **kwargs: Any) -> DSNP: return DSNP(ctx, **kwargs) def __init__(self, ctx: Context, title: str): self.title = title super().__init__(ctx) self.cdm = ctx.obj.cdm self.playback_data = {} vcodec = ctx.parent.params.get("vcodec") range = ctx.parent.params.get("range_") self.range = range[0].name if range else "SDR" self.vcodec = "H265" if vcodec and vcodec == Video.Codec.HEVC else "H264" if self.range != "SDR" and self.vcodec != "H265": self.vcodec = "H265" def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: super().authenticate(cookies, credential) if not credential: raise EnvironmentError("Service requires Credentials for Authentication.") self.session.headers.update(self.config["HEADERS"]) self.session.headers.update({"x-bamsdk-transaction-id": str(uuid.uuid4())}) self.prd_config = self.session.get(self.config["CONFIG_URL"]).json() self._cache = self.cache.get(f"tokens_{credential.sha1}") if self._cache: self.log.info(" + Refreshing Tokens") profile = self.refresh_token(self._cache.data["token"]["refreshToken"]) self._cache.set(profile, expiration=profile["token"]["expiresIn"] - 30) token = self._cache.data["token"]["accessToken"] self.session.headers.update({"Authorization": "Bearer {}".format(token)}) self.active_session = self.account()["activeSession"] else: self.log.info(" + Setting up new profile...") token = self.register_device() status = self.check_email(credential.username, token) if status.lower() == "register": raise ValueError("Account is not registered. Please register first.") elif status.lower() == "otp": self.log.error(" - Account requires passcode for login.") sys.exit(1) else: tokens = self.login(credential.username, credential.password, token) self.session.headers.update({"Authorization": "Bearer {}".format(tokens["accessToken"])}) account = self.account() profile_id = next( ( x.get("id") for x in account["account"]["profiles"] if not x["attributes"]["kidsModeEnabled"] and not x["attributes"]["parentalControls"]["isPinProtected"] ), None, ) if not profile_id: self.log.error( " - Missing profile - you need at least one profile with kids mode and pin protection disabled" ) sys.exit(1) set_profile = self.switch_profile(profile_id) profile = self.refresh_token(set_profile["token"]["refreshToken"]) self._cache.set(profile, expiration=profile["token"]["expiresIn"] - 30) token = self._cache.data["token"]["accessToken"] self.session.headers.update({"Authorization": "Bearer {}".format(token)}) self.active_session = self.account()["activeSession"] self.log.info(" + Acquired tokens...") def search(self) -> Generator[SearchResult, None, None]: params = { "query": self.title, } endpoint = self.href( self.prd_config["services"]["explore"]["client"]["endpoints"]["search"]["href"], version=self.config["EXPLORE_VERSION"], ) data = self._request("GET", endpoint, params=params)["data"]["page"] if not data.get("containers"): return results = data["containers"][0]["items"] for result in results: entity = "entity-" + result.get("id") yield SearchResult( id_=entity, title=result["visuals"].get("title"), description=result["visuals"]["description"].get("brief"), label=result["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"), url=f"https://www.disneyplus.com/browse/{entity}", ) def get_titles(self) -> Union[Movies, Series]: if not self.title.startswith("entity"): raise ValueError("Invalid input - Use only entity IDs.") content = self.get_deeplink(self.title) _type = content["data"]["deeplink"]["actions"][0]["contentType"] if _type == "movie": movie = self._movie(self.title) return Movies(movie) elif _type == "series": episodes = self._show(self.title) return Series(episodes) def get_tracks(self, title: Union[Movie, Episode]) -> Tracks: resource_id = title.data.get("resourceId") content_id = title.data["partnerFeed"].get("dmcContentId") content = self.get_video(content_id) playback = content["video"]["mediaMetadata"]["playbackUrls"][0]["href"] token = self._refresh() headers = { "accept": "application/vnd.media-service+json; version=5", "authorization": token, "x-dss-feature-filtering": "true", } payload = { "playbackId": resource_id, "playback": { "attributes": { "codecs": { "supportsMultiCodecMaster": False, }, "protocol": "HTTPS", # "ads": "", "frameRates": [60], "assetInsertionStrategy": "SGAI", "playbackInitializationContext": "ONLINE", }, }, } video_ranges = [] audio_types = [] audio_types.append("ATMOS") audio_types.append("DTS_X") if not self.cdm.security_level == 3 and self.range == "DV": video_ranges.append("DOLBY_VISION") if not self.cdm.security_level == 3 and self.range == "HDR10": video_ranges.append("HDR10") if self.vcodec == "H265": payload["playback"]["attributes"]["codecs"] = {"video": ["h264", "h265"]} if audio_types: payload["playback"]["attributes"]["audioTypes"] = audio_types if video_ranges: payload["playback"]["attributes"]["videoRanges"] = video_ranges if self.cdm.security_level == 3: payload["playback"]["attributes"]["resolution"] = {"max": ["1280x720"]} scenario = "ctr-regular" if self.cdm.security_level == 3 else "ctr-high" endpoint = playback.format(scenario=scenario) res = self._request("POST", endpoint, payload=payload, headers=headers) self.playback_data[title.id] = self._request( "POST", f"https://disney.playback.edge.bamgrid.com/v7/playback/{scenario}", payload=payload, headers=headers ) manifest = res["stream"]["complete"][0]["url"] tracks = HLS.from_url(url=manifest, session=self.session).to_tracks(language="en-US") for audio in tracks.audio: bitrate = re.search( r"(?<=r/composite_)\d+|\d+(?=_complete.m3u8)", as_list(audio.url)[0], ) audio.bitrate = int(bitrate.group()) * 1000 if audio.bitrate == 1000_000: # DSNP lies about the Atmos bitrate audio.bitrate = 768_000 return tracks def get_chapters(self, title: Union[Movie, Episode]) -> Chapters: """ Extract chapter information from the title data if available. Returns chapter markers for intro, credits, and scenes. """ chapters = Chapters() try: # First try to get chapters from the new API via playback data if title.id in self.playback_data and "stream" in self.playback_data[title.id]: playback_res = self.playback_data[title.id] # Check for editorial markers in playback data if "editorial" in playback_res.get("stream", {}): editorial = playback_res["stream"]["editorial"] # Add "Start" chapter if not already present if not any(item.get("offsetMillis") == 0 for item in editorial): chapters.add(Chapter(timestamp=0, name="Start")) # Map editorial labels to chapter names mapping = { "recap_start": "Recap", "FFER": "Recap", # First Frame Episode Recap "recap_end": "Scene", "LFER": "Scene", # Last Frame Episode Recap "intro_start": "Title Sequence", "intro_end": "Scene", "FFEI": "Title Sequence", # First Frame Episode Intro "LFEI": "Scene", # Last Frame Episode Intro "FFCB": None, # First Frame Credits Bumper "LFCB": "Scene", # Last Frame Credits Bumper "FFEC": "End Credits", # First Frame End Credits "LFEC": None, # Last Frame End Credits "up_next": None, } # Sort by timestamp to ensure proper scene numbering editorial.sort(key=lambda x: x.get("offsetMillis", 0)) # Track chapters we've already added by timestamp to avoid duplicates seen_timestamps = set() scene_count = 0 for marker in editorial: if "label" in marker and "offsetMillis" in marker: timestamp = marker["offsetMillis"] name = mapping.get(marker["label"]) # Skip if no mapping or already processed timestamp if not name or timestamp in seen_timestamps: continue # Mark this timestamp as seen seen_timestamps.add(timestamp) if name == "Scene": scene_count += 1 name = f"Scene {scene_count}" chapters.add(Chapter(timestamp=timestamp, name=name)) # If we found chapters in the playback data, return them if chapters: return chapters # If no chapters found in playback data, try the original method content_id = title.data["partnerFeed"].get("dmcContentId") content = self.get_video(content_id) # Check for chapter/milestone data video_info = content.get("video", {}).get("milestone", {}) if not video_info: return chapters # Mapping of milestone types to chapter names mapping = { "recap_start": "Recap", "recap_end": "Scene", "intro_start": "Title Sequence", "intro_end": "Scene", "FFEI": "Title Sequence", # First Frame Episode Intro "LFEI": "Scene", # Last Frame Episode Intro "FFCB": None, # First Frame Credits Bumper "LFCB": "Scene", # Last Frame Credits Bumper "FFEC": "End Credits", # First Frame End Credits "LFEC": None, # Last Frame End Credits "up_next": None, } # Flatten the milestone data and sort by start time flattened = [] for chapter_type, items in video_info.items(): for entry in items: if "milestoneTime" in entry and entry["milestoneTime"]: start = entry["milestoneTime"][0]["startMillis"] flattened.append({"type": chapter_type, "start": start}) flattened.sort(key=lambda x: x["start"]) # Create chapters chapter_list = [] scene_count = 0 for f in flattened: name = mapping.get(f["type"]) if not name: continue if name == "Scene": scene_count += 1 name = f"Scene {scene_count}" chapter_list.append(Chapter(timestamp=f["start"], name=name)) # Add a "Start" chapter at 0 if we have end credits if "FFEC" in video_info and not any(ch.timestamp == 0 for ch in chapter_list): chapter_list.insert(0, Chapter(timestamp=0, name="Start")) # Remove duplicates (same time and name) prev_time, prev_name = None, None for ch in chapter_list: # Convert timestamp to milliseconds for comparison if isinstance(ch.timestamp, str): ts_parts = ch.timestamp.split(":") hour, minute, second = int(ts_parts[0]), int(ts_parts[1]), float(ts_parts[2]) ts_ms = (hour * 3600 + minute * 60 + second) * 1000 else: ts_ms = ch.timestamp if prev_time is None or (ts_ms != prev_time and ch.name != prev_name): chapters.add(ch) prev_time, prev_name = ts_ms, ch.name return chapters except Exception as e: self.log.warning(f"Failed to extract chapters: {e}") return chapters def get_widevine_service_certificate(self, **_: Any) -> str: return None def get_widevine_license(self, *, challenge: bytes, title, track) -> None: headers = { "Authorization": f"Bearer {self._cache.data['token']['accessToken']}", "Content-Type": "application/octet-stream", } r = self.session.post(url=self.config["LICENSE"], headers=headers, data=challenge) if r.status_code != 200: raise ConnectionError(r.text) return r.content # Service specific functions def _show(self, title: str) -> Episode: page = self.get_page(title) container = next(x for x in page["containers"] if x.get("type") == "episodes") season_ids = [x.get("id") for x in container["seasons"] if x.get("type") == "season"] episodes = [] for season in season_ids: endpoint = self.href( self.prd_config["services"]["explore"]["client"]["endpoints"]["getSeason"]["href"], version=self.config["EXPLORE_VERSION"], seasonId=season, ) data = self.session.get(endpoint).json()["data"]["season"]["items"] episodes.extend(data) return [ Episode( id_=episode.get("id"), service=self.__class__, title=episode["visuals"].get("title"), year=episode["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"), season=int(episode["visuals"].get("seasonNumber", 0)), number=int(episode["visuals"].get("episodeNumber", 0)), name=episode["visuals"].get("episodeTitle"), data=next(x for x in episode["actions"] if x.get("type") == "playback"), ) for episode in episodes if episode.get("type") == "view" ] def _movie(self, title: str) -> Movie: movie = self.get_page(title) return [ Movie( id_=movie.get("id"), service=self.__class__, name=movie["visuals"].get("title"), year=movie["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"), data=next(x for x in movie["actions"] if x.get("type") == "playback"), ) ] def _request( self, method: str, endpoint: str, params: dict = None, headers: dict = None, payload: dict = None, ) -> Any[dict | str]: _headers = headers if headers else self.session.headers prep = self.session.prepare_request(Request(method, endpoint, headers=_headers, params=params, json=payload)) response = self.session.send(prep) try: data = response.json() if data.get("errors"): code = data["errors"][0]["extensions"].get("code") if "token.service.unauthorized.client" in code: raise ConnectionError("Unauthorized Client/IP: " + code) if "idp.error.identity.bad-credentials" in code: raise ConnectionError("Bad Credentials: " + code) else: raise ConnectionError(data["errors"]) return data except Exception as e: raise ConnectionError("Request failed: {}".format(response.content)) def get_page(self, title): params = { "disableSmartFocus": "true", "limit": 999, "enhancedContainersLimit": 0, } endpoint = self.href( self.prd_config["services"]["explore"]["client"]["endpoints"]["getPage"]["href"], version=self.config["EXPLORE_VERSION"], pageId=title, ) return self._request("GET", endpoint, params=params)["data"]["page"] def get_video(self, content_id: str) -> dict: endpoint = self.href( self.prd_config["services"]["content"]["client"]["endpoints"]["getDmcVideo"]["href"], contentId=content_id ) return self._request("GET", endpoint)["data"]["DmcVideo"] def get_deeplink(self, ref_id: str) -> str: params = { "refId": ref_id, "refIdType": "deeplinkId", } endpoint = "https://disney.content.edge.bamgrid.com/explore/v1.0/deeplink" return self._request("GET", endpoint, params=params) def series_bundle(self, series_id: str) -> dict: endpoint = self.href( self.prd_config["services"]["content"]["client"]["endpoints"]["getDmcSeriesBundle"]["href"], encodedSeriesId=series_id, ) return self.session.get(endpoint).json()["data"]["DmcSeriesBundle"] def refresh_token(self, refresh_token: str): payload = { "operationName": "refreshToken", "variables": { "input": { "refreshToken": refresh_token, }, }, "query": queries.REFRESH_TOKEN, } endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["refreshToken"]["href"] data = self._request("POST", endpoint, payload=payload, headers={"authorization": self.config["API_KEY"]}) return data["extensions"]["sdk"] def _refresh(self): if not self._cache.expired: return self._cache.data["token"]["accessToken"] profile = self.refresh_token(self._cache.data["token"]["refreshToken"]) self._cache.set(profile, expiration=profile["token"]["expiresIn"] - 30) return self._cache.data["token"]["accessToken"] def register_device(self) -> dict: payload = { "variables": { "registerDevice": { "applicationRuntime": "android", "attributes": { "operatingSystem": "Android", "operatingSystemVersion": "8.1.0", }, "deviceFamily": "android", "deviceLanguage": "en", "deviceProfile": "tv", } }, "query": queries.REGISTER_DEVICE, } endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["registerDevice"]["href"] data = self._request("POST", endpoint, payload=payload, headers={"authorization": self.config["API_KEY"]}) return data["extensions"]["sdk"]["token"]["accessToken"] def login(self, email: str, password: str, token: str) -> dict: payload = { "operationName": "loginTv", "variables": { "input": { "email": email, "password": password, }, }, "query": queries.LOGIN, } endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] data = self._request("POST", endpoint, payload=payload, headers={"authorization": token}) return data["extensions"]["sdk"]["token"] def href(self, href, **kwargs) -> str: _args = { "apiVersion": "{apiVersion}", "region": self.active_session["location"]["countryCode"], "impliedMaturityRating": 1850, "kidsModeEnabled": "false", "appLanguage": "en-US", "partner": "disney", } _args.update(**kwargs) href = href.format(**_args) # [3.0, 3.1, 3.2, 5.0, 3.3, 5.1, 6.0, 5.2, 6.1] api_version = "6.1" if "/search/" in href: api_version = "5.1" return href.format(apiVersion=api_version) def check_email(self, email: str, token: str) -> str: payload = { "operationName": "Check", "variables": { "email": email, }, "query": queries.CHECK_EMAIL, } endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] data = self._request("POST", endpoint, payload=payload, headers={"authorization": token}) return data["data"]["check"]["operations"][0] def account(self) -> dict: endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] payload = { "operationName": "EntitledGraphMeQuery", "variables": {}, "query": queries.ENTITLEMENTS, } data = self._request("POST", endpoint, payload=payload) return data["data"]["me"] def switch_profile(self, profile_id: str) -> dict: payload = { "operationName": "switchProfile", "variables": { "input": { "profileId": profile_id, }, }, "query": queries.SWITCH_PROFILE, } endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] data = self._request("POST", endpoint, payload=payload) return data["extensions"]["sdk"]