From 898b765d7e0cda82d73ccfd99a3850173e7e6d34 Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 25 Jan 2026 15:45:14 +0200 Subject: [PATCH] Update Services DSNP and CF --- CR-FIX/__init__.py | 777 +++++++++++++++++++++++++++++ CR-FIX/config.yaml | 47 ++ DSNP-FIX/__init__.py | 1116 ++++++++++++++++++++++++++++++++++++++++++ DSNP-FIX/config.yaml | 54 ++ DSNP-FIX/queries.py | 13 + 5 files changed, 2007 insertions(+) create mode 100644 CR-FIX/__init__.py create mode 100644 CR-FIX/config.yaml create mode 100644 DSNP-FIX/__init__.py create mode 100644 DSNP-FIX/config.yaml create mode 100644 DSNP-FIX/queries.py diff --git a/CR-FIX/__init__.py b/CR-FIX/__init__.py new file mode 100644 index 0000000..fbcd320 --- /dev/null +++ b/CR-FIX/__init__.py @@ -0,0 +1,777 @@ +import re +import time +import uuid +from threading import Lock +from typing import Generator, Optional, Union + +import click +import jwt +from langcodes import Language + +from unshackle.core.manifests import DASH +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.session import session +from unshackle.core.titles import Episode, Series +from unshackle.core.tracks import Attachment, Chapters, Tracks +from unshackle.core.tracks.chapter import Chapter +from unshackle.core.tracks.subtitle import Subtitle + + +class CR(Service): + """ + Service code for Crunchyroll streaming service (https://www.crunchyroll.com). + + \b + Version: 2.0.0 + Author: sp4rk.y + Date: 2025-11-01 + Authorization: Credentials + Robustness: + Widevine: + L3: 1080p, AAC2.0 + + \b + Tips: + - Input should be complete URL or series ID + https://www.crunchyroll.com/series/GRMG8ZQZR/series-name OR GRMG8ZQZR + - Supports multiple audio and subtitle languages + - Device ID is cached for consistent authentication across runs + + \b + Notes: + - Uses password-based authentication with token caching + - Manages concurrent stream limits automatically + """ + + TITLE_RE = r"^(?:https?://(?:www\.)?crunchyroll\.com/(?:series|watch)/)?(?P[A-Z0-9]+)" + LICENSE_LOCK = Lock() + MAX_CONCURRENT_STREAMS = 3 + ACTIVE_STREAMS: list[tuple[str, str]] = [] + + @staticmethod + def get_session(): + return session("okhttp4") + + @staticmethod + @click.command(name="CR", short_help="https://crunchyroll.com") + @click.argument("title", type=str, required=True) + @click.pass_context + def cli(ctx, **kwargs) -> "CR": + return CR(ctx, **kwargs) + + def __init__(self, ctx, title: str): + self.title = title + self.account_id: Optional[str] = None + self.access_token: Optional[str] = None + self.token_expiration: Optional[int] = None + self.anonymous_id = str(uuid.uuid4()) + + super().__init__(ctx) + + device_cache_key = "cr_device_id" + cached_device = self.cache.get(device_cache_key) + + if cached_device and not cached_device.expired: + self.device_id = cached_device.data["device_id"] + else: + self.device_id = str(uuid.uuid4()) + cached_device.set( + data={"device_id": self.device_id}, + expiration=60 * 60 * 24 * 365 * 10, + ) + + self.device_name = self.config.get("device", {}).get("name", "SHIELD Android TV") + self.device_type = self.config.get("device", {}).get("type", "ANDROIDTV") + + self.session.headers.update(self.config.get("headers", {})) + self.session.headers["etp-anonymous-id"] = self.anonymous_id + + @property + def auth_header(self) -> dict: + """Return authorization header dict.""" + return {"authorization": f"Bearer {self.access_token}"} + + def ensure_authenticated(self) -> None: + """Check if token is expired and re-authenticate if needed.""" + if not self.token_expiration: + cache_key = f"cr_auth_token_{self.credential.sha1 if self.credential else 'default'}" + cached = self.cache.get(cache_key) + + if cached and not cached.expired: + self.access_token = cached.data["access_token"] + self.account_id = cached.data.get("account_id") + self.token_expiration = cached.data.get("token_expiration") + self.session.headers.update(self.auth_header) + self.log.debug("Loaded authentication from cache") + else: + self.log.debug("No valid cached token, authenticating") + self.authenticate(credential=self.credential) + return + + current_time = int(time.time()) + if current_time >= (self.token_expiration - 60): + self.log.debug("Authentication token expired or expiring soon, re-authenticating") + self.authenticate(credential=self.credential) + + def authenticate(self, cookies=None, credential=None) -> None: + """Authenticate using username and password credentials.""" + super().authenticate(cookies, credential) + + cache_key = f"cr_auth_token_{credential.sha1 if credential else 'default'}" + cached = self.cache.get(cache_key) + + if cached and not cached.expired: + self.access_token = cached.data["access_token"] + self.account_id = cached.data.get("account_id") + self.token_expiration = cached.data.get("token_expiration") + else: + if not credential: + class HardcodedCreds: + username = "akjrtx@gmail.com" + password = "Ariyan@45" + sha1 = "dummy_hash" + credential = HardcodedCreds() + + response = self.session.post( + url=self.config["endpoints"]["token"], + headers={ + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "request-type": "SignIn", + }, + data={ + "grant_type": "password", + "username": credential.username, + "password": credential.password, + "scope": "offline_access", + "client_id": self.config["client"]["id"], + "client_secret": self.config["client"]["secret"], + "device_type": self.device_type, + "device_id": self.device_id, + "device_name": self.device_name, + }, + ) + + if response.status_code != 200: + self.log.error(f"Login failed: {response.status_code}") + try: + error_data = response.json() + error_msg = error_data.get("error", "Unknown error") + error_code = error_data.get("code", "") + self.log.error(f"Error: {error_msg} ({error_code})") + except Exception: + self.log.error(f"Response: {response.text}") + response.raise_for_status() + + token_data = response.json() + self.access_token = token_data["access_token"] + self.account_id = self.get_account_id() + + try: + decoded_token = jwt.decode(self.access_token, options={"verify_signature": False}) + self.token_expiration = decoded_token.get("exp") + except Exception: + self.token_expiration = int(time.time()) + token_data.get("expires_in", 3600) + + cached.set( + data={ + "access_token": self.access_token, + "account_id": self.account_id, + "token_expiration": self.token_expiration, + }, + expiration=self.token_expiration + if isinstance(self.token_expiration, int) and self.token_expiration > int(time.time()) + else 3600, + ) + + self.session.headers.update(self.auth_header) + + if self.ACTIVE_STREAMS: + self.ACTIVE_STREAMS.clear() + + try: + self.clear_all_sessions() + except Exception as e: + self.log.warning(f"Failed to clear previous sessions: {e}") + + def get_titles(self) -> Union[Series]: + """Fetch series and episode information.""" + series_id = self.parse_series_id(self.title) + + series_response = self.session.get( + url=self.config["endpoints"]["series"].format(series_id=series_id), + params={"locale": self.config["params"]["locale"]}, + ).json() + + if "error" in series_response: + raise ValueError(f"Series not found: {series_id}") + + series_data = ( + series_response.get("data", [{}])[0] if isinstance(series_response.get("data"), list) else series_response + ) + series_title = series_data.get("title", "Unknown Series") + + seasons_response = self.session.get( + url=self.config["endpoints"]["seasons"].format(series_id=series_id), + params={"locale": self.config["params"]["locale"]}, + ).json() + + seasons_data = seasons_response.get("data", []) + + if not seasons_data: + raise ValueError(f"No seasons found for series: {series_id}") + + all_episode_data = [] + special_episodes = [] + + for season in seasons_data: + season_id = season["id"] + season_number = season.get("season_number", 0) + + episodes_response = self.session.get( + url=self.config["endpoints"]["season_episodes"].format(season_id=season_id), + params={"locale": self.config["params"]["locale"]}, + ).json() + + episodes_data = episodes_response.get("data", []) + + for episode_data in episodes_data: + episode_number = episode_data.get("episode_number") + + if episode_number is None or isinstance(episode_number, float): + special_episodes.append(episode_data) + + all_episode_data.append((episode_data, season_number)) + + if not all_episode_data: + raise ValueError(f"No episodes found for series: {series_id}") + + series_year = None + if all_episode_data: + first_episode_data = all_episode_data[0][0] + first_air_date = first_episode_data.get("episode_air_date") + if first_air_date: + series_year = int(first_air_date[:4]) + + special_episodes.sort(key=lambda x: x.get("episode_air_date", "")) + special_episode_numbers = {ep["id"]: idx + 1 for idx, ep in enumerate(special_episodes)} + episodes = [] + season_episode_counts = {} + + for episode_data, season_number in all_episode_data: + episode_number = episode_data.get("episode_number") + + if episode_number is None or isinstance(episode_number, float): + final_season = 0 + final_number = special_episode_numbers[episode_data["id"]] + else: + final_season = season_number + if final_season not in season_episode_counts: + season_episode_counts[final_season] = 0 + + season_episode_counts[final_season] += 1 + final_number = season_episode_counts[final_season] + + original_language = None + versions = episode_data.get("versions", []) + for version in versions: + if "main" in version.get("roles", []): + original_language = version.get("audio_locale") + break + + episode = Episode( + id_=episode_data["id"], + service=self.__class__, + title=series_title, + season=final_season, + number=final_number, + name=episode_data.get("title"), + year=series_year, + language=original_language, + description=episode_data.get("description"), + data=episode_data, + ) + episodes.append(episode) + + return Series(episodes) + + def set_track_metadata(self, tracks: Tracks, episode_id: str, is_original: bool) -> None: + """Set metadata for video and audio tracks.""" + for video in tracks.videos: + video.needs_repack = True + video.data["episode_id"] = episode_id + video.is_original_lang = is_original + for audio in tracks.audio: + audio.data["episode_id"] = episode_id + audio.is_original_lang = is_original + + def get_tracks(self, title: Episode) -> Tracks: + """Fetch video, audio, and subtitle tracks for an episode.""" + self.ensure_authenticated() + + episode_id = title.id + + if self.ACTIVE_STREAMS: + self.ACTIVE_STREAMS.clear() + + self.clear_all_sessions() + + initial_response = self.get_playback_data(episode_id, track_stream=False) + versions = initial_response.get("versions", []) + + if not versions: + self.log.warning("No versions found in playback response, using single version") + versions = [{"audio_locale": initial_response.get("audioLocale", "ja-JP")}] + + tracks = None + + for idx, version in enumerate(versions): + audio_locale = version.get("audio_locale") + version_guid = version.get("guid") + is_original = version.get("original", False) + + if not audio_locale: + continue + + request_episode_id = version_guid if version_guid else episode_id + + if idx == 0 and not version_guid: + version_response = initial_response + version_token = version_response.get("token") + else: + if idx == 1 and not versions[0].get("guid"): + initial_token = initial_response.get("token") + if initial_token: + self.close_stream(episode_id, initial_token) + + try: + version_response = self.get_playback_data(request_episode_id, track_stream=False) + except ValueError as e: + self.log.warning(f"Could not get playback info for audio {audio_locale}: {e}") + continue + + version_token = version_response.get("token") + + hard_subs = version_response.get("hardSubs", {}) + dash_url = None + + if "none" in hard_subs: + dash_url = hard_subs["none"].get("url") + elif hard_subs: + first_key = list(hard_subs.keys())[0] + dash_url = hard_subs[first_key].get("url") + + if not dash_url: + self.log.warning(f"No DASH manifest found for audio {audio_locale}, skipping") + if version_token: + self.close_stream(request_episode_id, version_token) + continue + + try: + version_tracks = DASH.from_url( + url=dash_url, + session=self.session, + ).to_tracks(language=audio_locale) + + if tracks is None: + tracks = version_tracks + self.set_track_metadata(tracks, request_episode_id, is_original) + else: + self.set_track_metadata(version_tracks, request_episode_id, is_original) + for video in version_tracks.videos: + tracks.add(video) + for audio in version_tracks.audio: + tracks.add(audio) + + except Exception as e: + self.log.warning(f"Failed to parse DASH manifest for audio {audio_locale}: {e}") + if version_token: + self.close_stream(request_episode_id, version_token) + continue + + if is_original: + captions = version_response.get("captions", {}) + subtitles_data = version_response.get("subtitles", {}) + all_subs = {**captions, **subtitles_data} + + for lang_code, sub_data in all_subs.items(): + if lang_code == "none": + continue + + if isinstance(sub_data, dict) and "url" in sub_data: + try: + lang = Language.get(lang_code) + except (ValueError, LookupError): + lang = Language.get("en") + + subtitle_format = sub_data.get("format", "vtt").lower() + if subtitle_format == "ass" or subtitle_format == "ssa": + codec = Subtitle.Codec.SubStationAlphav4 + else: + codec = Subtitle.Codec.WebVTT + + tracks.add( + Subtitle( + id_=f"subtitle-{audio_locale}-{lang_code}", + url=sub_data["url"], + codec=codec, + language=lang, + forced=False, + sdh=False, + ), + warn_only=True, + ) + + if version_token: + self.close_stream(request_episode_id, version_token) + + if versions and versions[0].get("guid"): + initial_token = initial_response.get("token") + if initial_token: + self.close_stream(episode_id, initial_token) + + if tracks is None: + raise ValueError(f"Failed to fetch any tracks for episode: {episode_id}") + + for track in tracks.audio + tracks.subtitles: + if track.language: + try: + lang_obj = Language.get(str(track.language)) + base_lang = Language.get(lang_obj.language) + lang_display = base_lang.language_name() + track.name = lang_display + except (ValueError, LookupError): + pass + + images = title.data.get("images", {}) + thumbnails = images.get("thumbnail", []) + if thumbnails: + thumb_variants = thumbnails[0] if isinstance(thumbnails[0], list) else [thumbnails[0]] + if thumb_variants: + thumb_index = min(7, len(thumb_variants) - 1) + thumb = thumb_variants[thumb_index] + if isinstance(thumb, dict) and "source" in thumb: + thumbnail_name = f"{title.name or title.title} - S{title.season:02d}E{title.number:02d}" + + return tracks + + def get_widevine_license(self, challenge: bytes, title: Episode, track) -> bytes: + """ + Get Widevine license for decryption. + + Creates a fresh playback session for each track, gets the license, then immediately + closes the stream. This prevents hitting the 3 concurrent stream limit. + CDN authorization is embedded in the manifest URLs, not tied to active sessions. + """ + self.ensure_authenticated() + + track_episode_id = track.data.get("episode_id", title.id) + + with self.LICENSE_LOCK: + playback_token = None + try: + playback_data = self.get_playback_data(track_episode_id, track_stream=True) + playback_token = playback_data.get("token") + + if not playback_token: + raise ValueError(f"No playback token in response for {track_episode_id}") + + track.data["playback_token"] = playback_token + + license_response = self.session.post( + url=self.config["endpoints"]["license_widevine"], + params={"specConform": "true"}, + data=challenge, + headers={ + **self.auth_header, + "content-type": "application/octet-stream", + "accept": "application/octet-stream", + "x-cr-content-id": track_episode_id, + "x-cr-video-token": playback_token, + }, + ) + + if license_response.status_code != 200: + self.log.error(f"License request failed with status {license_response.status_code}") + self.log.error(f"Response: {license_response.text[:500]}") + self.close_stream(track_episode_id, playback_token) + raise ValueError(f"License request failed: {license_response.status_code}") + + self.close_stream(track_episode_id, playback_token) + return license_response.content + + except Exception: + if playback_token: + try: + self.close_stream(track_episode_id, playback_token) + except Exception: + pass + raise + + def cleanup_active_streams(self) -> None: + """ + Close all remaining active streams. + Called to ensure no streams are left open. + """ + if self.ACTIVE_STREAMS: + try: + self.authenticate() + except Exception as e: + self.log.warning(f"Failed to re-authenticate during cleanup: {e}") + + for episode_id, token in list(self.ACTIVE_STREAMS): + try: + self.close_stream(episode_id, token) + except Exception as e: + self.log.warning(f"Failed to close stream {episode_id}: {e}") + if (episode_id, token) in self.ACTIVE_STREAMS: + self.ACTIVE_STREAMS.remove((episode_id, token)) + + def __del__(self) -> None: + """Cleanup any remaining streams when service is destroyed.""" + try: + self.cleanup_active_streams() + except Exception: + pass + + def get_chapters(self, title: Episode) -> Chapters: + """Get chapters/skip events for an episode.""" + chapters = Chapters() + + chapter_response = self.session.get( + url=self.config["endpoints"]["skip_events"].format(episode_id=title.id), + ) + + if chapter_response.status_code == 200: + try: + chapter_data = chapter_response.json() + except Exception as e: + self.log.warning(f"Failed to parse chapter data: {e}") + return chapters + + for chapter_type in ["intro", "recap", "credits", "preview"]: + if chapter_info := chapter_data.get(chapter_type): + try: + chapters.add( + Chapter( + timestamp=int(chapter_info["start"] * 1000), + name=chapter_info["type"].capitalize(), + ) + ) + except Exception as e: + self.log.debug(f"Failed to add {chapter_type} chapter: {e}") + + return chapters + + def search(self) -> Generator[SearchResult, None, None]: + """Search for content on Crunchyroll.""" + try: + response = self.session.get( + url=self.config["endpoints"]["search"], + params={ + "q": self.title, + "type": "series", + "start": 0, + "n": 20, + "locale": self.config["params"]["locale"], + }, + ) + + if response.status_code != 200: + self.log.error(f"Search request failed with status {response.status_code}") + return + + search_data = response.json() + for result_group in search_data.get("data", []): + for series in result_group.get("items", []): + series_id = series.get("id") + + if not series_id: + continue + + title = series.get("title", "Unknown") + description = series.get("description", "") + year = series.get("series_launch_year") + if len(description) > 300: + description = description[:300] + "..." + + url = f"https://www.crunchyroll.com/series/{series_id}" + label = f"SERIES ({year})" if year else "SERIES" + + yield SearchResult( + id_=series_id, + title=title, + label=label, + description=description, + url=url, + ) + + except Exception as e: + self.log.error(f"Search failed: {e}") + return + + def get_account_id(self) -> str: + """Fetch and return the account ID.""" + response = self.session.get(url=self.config["endpoints"]["account_me"], headers=self.auth_header) + + if response.status_code != 200: + self.log.error(f"Failed to get account info: {response.status_code}") + self.log.error(f"Response: {response.text}") + response.raise_for_status() + + data = response.json() + return data["account_id"] + + def close_stream(self, episode_id: str, token: str) -> None: + """Close an active playback stream to free up concurrent stream slots.""" + should_remove = False + try: + response = self.session.delete( + url=self.config["endpoints"]["playback_delete"].format(episode_id=episode_id, token=token), + headers=self.auth_header, + ) + if response.status_code in (200, 204, 403): + should_remove = True + else: + self.log.error( + f"Failed to close stream for {episode_id} (status {response.status_code}): {response.text[:200]}" + ) + except Exception as e: + self.log.error(f"Error closing stream for {episode_id}: {e}") + finally: + if should_remove and (episode_id, token) in self.ACTIVE_STREAMS: + self.ACTIVE_STREAMS.remove((episode_id, token)) + + def get_active_sessions(self) -> list: + """Get all active streaming sessions for the account.""" + try: + response = self.session.get( + url=self.config["endpoints"]["playback_sessions"], + headers=self.auth_header, + ) + if response.status_code == 200: + data = response.json() + return data.get("items", []) + else: + self.log.warning(f"Failed to get active sessions (status {response.status_code})") + return [] + except Exception as e: + self.log.warning(f"Error getting active sessions: {e}") + return [] + + def clear_all_sessions(self) -> int: + """ + Clear all active streaming sessions created during this or previous runs. + + Tries multiple approaches to ensure all streams are closed: + 1. Clear tracked streams with known tokens + 2. Query active sessions API and close all found streams + 3. Try alternate token formats if needed + """ + cleared = 0 + + if self.ACTIVE_STREAMS: + streams_to_close = self.ACTIVE_STREAMS[:] + for episode_id, playback_token in streams_to_close: + try: + self.close_stream(episode_id, playback_token) + cleared += 1 + except Exception: + if (episode_id, playback_token) in self.ACTIVE_STREAMS: + self.ACTIVE_STREAMS.remove((episode_id, playback_token)) + + sessions = self.get_active_sessions() + if sessions: + for session_data in sessions: + content_id = session_data.get("contentId") + session_token = session_data.get("token") + + if content_id and session_token: + tokens_to_try = ( + ["11-" + session_token[3:], session_token] + if session_token.startswith("08-") + else [session_token] + ) + + session_closed = False + for token in tokens_to_try: + try: + response = self.session.delete( + url=self.config["endpoints"]["playback_delete"].format( + episode_id=content_id, token=token + ), + headers=self.auth_header, + ) + if response.status_code in (200, 204): + cleared += 1 + session_closed = True + break + elif response.status_code == 403: + session_closed = True + break + except Exception: + pass + + if not session_closed: + self.log.warning(f"Unable to close session {content_id} with any token format") + + return cleared + + def get_playback_data(self, episode_id: str, track_stream: bool = True) -> dict: + """ + Get playback data for an episode with automatic retry on stream limits. + + Args: + episode_id: The episode ID to get playback data for + track_stream: Whether to track this stream in active_streams (False for temporary streams) + + Returns: + dict: The playback response data + + Raises: + ValueError: If playback request fails after retry + """ + self.ensure_authenticated() + + max_retries = 2 + for attempt in range(max_retries + 1): + response = self.session.get( + url=self.config["endpoints"]["playback"].format(episode_id=episode_id), + params={"queue": "false"}, + ).json() + + if "error" in response: + error_code = response.get("code", "") + error_msg = response.get("message", response.get("error", "Unknown error")) + + if error_code == "TOO_MANY_ACTIVE_STREAMS" and attempt < max_retries: + self.log.warning(f"Hit stream limit: {error_msg}") + cleared = self.clear_all_sessions() + + if cleared == 0 and attempt == 0: + wait_time = 30 + self.log.warning( + f"Found orphaned sessions from previous run. Waiting {wait_time}s for them to expire..." + ) + time.sleep(wait_time) + + continue + + self.log.error(f"Playback API error: {error_msg}") + self.log.debug(f"Full response: {response}") + raise ValueError(f"Could not get playback info for episode: {episode_id} - {error_msg}") + + playback_token = response.get("token") + if playback_token and track_stream: + self.ACTIVE_STREAMS.append((episode_id, playback_token)) + + return response + + raise ValueError(f"Failed to get playback data for episode: {episode_id}") + + def parse_series_id(self, title_input: str) -> str: + """Parse series ID from URL or direct ID input.""" + match = re.match(self.TITLE_RE, title_input, re.IGNORECASE) + if not match: + raise ValueError(f"Could not parse series ID from: {title_input}") + return match.group("id") diff --git a/CR-FIX/config.yaml b/CR-FIX/config.yaml new file mode 100644 index 0000000..6651db7 --- /dev/null +++ b/CR-FIX/config.yaml @@ -0,0 +1,47 @@ +# Crunchyroll API Configuration +client: + id: "lkesi7snsy9oojmi2r9h" + secret: "-aGDXFFNTluZMLYXERngNYnEjvgH5odv" + +# API Endpoints +endpoints: + # Authentication + token: "https://www.crunchyroll.com/auth/v1/token" + + # Account + account_me: "https://www.crunchyroll.com/accounts/v1/me" + multiprofile: "https://www.crunchyroll.com/accounts/v1/{account_id}/multiprofile" + + # Content Metadata + series: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}" + seasons: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}/seasons" + season_episodes: "https://www.crunchyroll.com/content/v2/cms/seasons/{season_id}/episodes" + skip_events: "https://static.crunchyroll.com/skip-events/production/{episode_id}.json" + + # Playback + playback: "https://www.crunchyroll.com/playback/v2/{episode_id}/tv/android_tv/play" + playback_delete: "https://www.crunchyroll.com/playback/v1/token/{episode_id}/{token}" + playback_sessions: "https://www.crunchyroll.com/playback/v1/sessions/streaming" + license_widevine: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/widevine" + + # Discovery + search: "https://www.crunchyroll.com/content/v2/discover/search" + +# Headers for Android TV client +headers: + user-agent: "Crunchyroll/ANDROIDTV/3.49.1_22281 (Android 11; en-US; SHIELD Android TV)" + accept: "application/json" + accept-charset: "UTF-8" + accept-encoding: "gzip" + connection: "Keep-Alive" + content-type: "application/x-www-form-urlencoded; charset=UTF-8" + +# Query parameters +params: + locale: "en-US" + +# Device parameters for authentication +device: + type: "ANDROIDTV" + name: "SHIELD Android TV" + model: "SHIELD Android TV" diff --git a/DSNP-FIX/__init__.py b/DSNP-FIX/__init__.py new file mode 100644 index 0000000..db9dd7b --- /dev/null +++ b/DSNP-FIX/__init__.py @@ -0,0 +1,1116 @@ +from __future__ import annotations + +import base64 +import click +import re +import secrets +import sys +import uuid + +from click import Context +from collections.abc import Generator +from datetime import datetime +from http.cookiejar import CookieJar +from langcodes import Language +from pyplayready.cdm import Cdm as PlayReadyCdm +from requests import Request +from typing import Any, Optional, Union, List + +from unshackle.core.constants import AnyTrack +from unshackle.core.credential import Credential +from unshackle.core.manifests import HLS +from unshackle.core.search_result import SearchResult +from unshackle.core.service import Service +from unshackle.core.titles import Title_T, Titles_T, Episode, Movie, Movies, Series +from unshackle.core.tracks import Chapter, Chapters, Tracks, Attachment, Video, Audio, Subtitle +from unshackle.core.utilities import get_ip_info +from unshackle.core.utils.collections import as_list + +from . import queries + +class DSNP(Service): + """ + Service code for Disney+ Streaming Service (https://disneyplus.com). + + Author: Made by CodeName393 with Special Thanks to narakama\n + Authorization: Credentials\n + Security: UHD@L1/SL3000 FHD@L1/SL3000 HD@L3/SL2000 + """ + + ALIASES = ("DSNP", "disneyplus", "disney+") + TITLE_RE = ( + r"^(?:https?://(?:www\.)?disneyplus\.com(?:/(?!browse)[a-z0-9-]+)?(?:/(?!browse)[a-z0-9-]+)?/(browse)/(?Pentity-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))(?:\?.*)?$", + r"^(?:https?://(?:www\.)?disneyplus\.com(?:/[a-z0-9-]+)?(?:/[a-z0-9-]+)?/(movies|series)/[a-z0-9-]+/)?(?P[a-zA-Z0-9-]+)(?:\?.*)?$", + ) + + @staticmethod + @click.command(name="DisneyPlus", short_help="https://disneyplus.com", help=__doc__) + @click.argument("title", type=str) + @click.option("--imax", is_flag=True, default=False, help="Prefer IMAX Enhanced version if available.") + @click.option("--remastered-ar", is_flag=True, default=False, help="Prefer Remastered Aspect Ratio if available.") + @click.option("-ext", "--extras", is_flag=True, default=False, help="Select a extras video if available.") + @click.pass_context + def cli(ctx: Context, **kwargs: Any) -> DSNP: + return DSNP(ctx, **kwargs) + + def __init__(self, ctx: Context, title: str, imax: bool, remastered_ar: bool, extras: bool): + self.title = title + super().__init__(ctx) + + self.title_id = self.title + for pattern in self.TITLE_RE: + match = re.match(pattern, self.title) + if match: + self.title_id = match.group("id") + break + + self.prefer_imax = imax + self.prefer_remastered_ar = remastered_ar + self.extras = extras + + self.vcodec = ctx.parent.params.get("vcodec") or Video.Codec.AVC + self.acodec : Audio.Codec = ctx.parent.params.get("acodec") + self.range = ctx.parent.params.get("range_") or [Video.Range.SDR] + self.quality: List[int] = ctx.parent.params.get("quality") or [1080] + self.wanted = ctx.parent.params.get("wanted") + 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.cdm = ctx.obj.cdm + self.playready = isinstance(self.cdm, PlayReadyCdm) if self.cdm else False + self.is_l3 = (self.cdm.security_level < 3000) if self.playready else (self.cdm.security_level == 3) if self.cdm else False + + self.region = None + self.prod_config = {} + self.account_tokens = {} + self.active_session = {} + self.playback_data = {} + + self.log.info("Preparing...") + + if self.is_l3: + self.vcodec = Video.Codec.AVC + self.range = [Video.Range.SDR] + self.quality = [720] + self.log.warning(" + Switched video to HD. This CDM only support HD.") + else: + if self.quality > [1080] and self.range[0] == [Video.Range.SDR]: + self.range = [Video.Range.HDR10] + self.log.info(" + Switched range to HDR10. 4K resolution requires HDR.") + + if (self.range != [Video.Range.SDR] or self.quality > [1080]) and self.vcodec != Video.Codec.HEVC: + self.vcodec = Video.Codec.HEVC + self.log.info(f" + Switched video codec to H265 to be able to get {self.range[0]} dynamic range.") + + if self.acodec == Audio.Codec.DTS and not self.prefer_imax: + self.prefer_imax = True + self.log.info(" + Switched IMAX prefer. DTS audio can only be get from IMAX prefer.") + + self.session.headers.update({ + "User-Agent": self.config["bamsdk"]["user_agent"], + "Accept-Encoding": "gzip", + "Accept": "application/json", + "Content-Type": "application/json" + }) + + ip_info = get_ip_info(self.session) + country_key = None + possible_keys = ["countryCode", "country", "country_code", "country-code"] + for key in possible_keys: + if key in ip_info: + country_key = key + break + if country_key: + self.region = str(ip_info[country_key]).upper() + self.log.info(f" + IP Region: {self.region}") + else: + self.log.warning(f" - The region could not be determined from IP information: {ip_info}") + self.region = "US" + self.log.info(f" + IP Region: {self.region} (By Default)") + + self.prod_config = self.session.get(self.config["endpoints"]["config"]).json() + + self.session.headers.update({ + "X-Application-Version": self.config["bamsdk"]["application_version"], + "X-BAMSDK-Client-ID": self.config["bamsdk"]["client"], + "X-BAMSDK-Platform": self.config["device"]["platform"], + "X-BAMSDK-Version": self.config["bamsdk"]["sdk_version"], + "X-DSS-Edge-Accept": "vnd.dss.edge+json; version=2", + "X-Request-Yp-Id": self.config["bamsdk"]["yp_service_id"] + }) + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + self.credentials = credential + if not credential: + raise EnvironmentError("Service requires Credentials for Authentication.") + + self.log.info("Logging into Disney+...") + self._login() + + if self.config.get("profile") and "index" in self.config["profile"]: + try: + target_profile_index = int(self.config["profile"]["index"]) + except (ValueError, TypeError, KeyError): + self.log.error(" - Profile index in configuration is invalid.", exc_info=False) + sys.exit(1) + + profiles = self.active_session['account']['profiles'] + if not 0 <= target_profile_index < len(profiles): + self.log.error(f" - Invalid profile index: {target_profile_index}. Please choose between 0 and {len(profiles) - 1}.", exc_info=False) + sys.exit(1) + + target_profile = profiles[target_profile_index] + active_profile_id = self.active_session['account']['activeProfile']['id'] + + if target_profile['id'] != active_profile_id: + self._perform_switch_profile(target_profile, self.session.headers) + + self.log.info(" + Refreshing session data after profile switch...") + full_account_info = self._get_account_info() + self.active_session = full_account_info["activeSession"] + self.active_session['account'] = full_account_info['account'] + self.log.info("Session data updated successfully.") + + self.log.debug(self.active_session) + + if not self.active_session['isSubscriber']: + self.log.error(" - Cannot continue, account is not subscribed to Disney+", exc_info=False) + sys.exit(1) + if not self.active_session['inSupportedLocation']: + self.log.error(" - Cannot continue, Not available in your Region.", exc_info=False) + sys.exit(1) + + self.log.info(f" + Account ID: {self.active_session['account']['id']}") + self.log.info(f" + Profile ID: {self.active_session['account']['activeProfile']['id']}") + self.log.info(f" + Subscribed: {self.active_session['isSubscriber']}") + self.log.debug(f" + Account Region: {self.active_session['homeLocation']['countryCode']}") + self.log.debug(f" + Detected Location: {self.active_session['location']['countryCode']}") + self.log.debug(f" + Supported Location: {self.active_session['inSupportedLocation']}") + + active_profile_id = self.active_session['account']['activeProfile']['id'] + full_profile_object = next( + p for p in self.active_session['account']['profiles'] if p['id'] == active_profile_id + ) + + current_imax_setting = full_profile_object["attributes"]["playbackSettings"]["preferImaxEnhancedVersion"] + self.log.info(f" + IMAX Enhanced: {current_imax_setting}") + if current_imax_setting is not self.prefer_imax: + update_tokens = self._set_imax_preference(self.prefer_imax) + self._apply_new_tokens(update_tokens["token"]) + + current_133_setting = full_profile_object["attributes"]["playbackSettings"]["prefer133"] # Original Aspect Ratio + self.log.info(f" + Remastered Aspect Ratio: {not current_133_setting}") + if not current_133_setting is not self.prefer_remastered_ar: + update_tokens = self._set_remastered_ar_preference(self.prefer_remastered_ar) + self._apply_new_tokens(update_tokens["token"]) + + def _login(self) -> None: + cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}") + + if cache: + try: + self.log.info(" + Using cached tokens...") + self.account_tokens = cache.data + + bearer = self.account_tokens["accessToken"] + if not bearer: + raise ValueError("accessToken not found in cache") + self.session.headers.update({'Authorization': f'Bearer {bearer}'}) + + except (KeyError, ValueError, TypeError) as e: + self.log.warning(f" - Cached token data is invalid or corrupted ({e}). Getting new tokens...") + self._perform_full_login() + + try: + self._refresh() + except Exception as e: + self.log.warning(f" - Failed to refresh token from cache ({e}). Getting new tokens...") + self._perform_full_login() + + # No problem if don't use it + # self._update_device() + + else: + self.log.info(" + Getting new tokens...") + self._perform_full_login() + + self.log.info(" + Fetching session data...") + full_account_info = self._get_account_info() + self.active_session = full_account_info["activeSession"] + self.active_session['account'] = full_account_info['account'] + self.log.info("Session data setup successfully.") + + def _perform_full_login(self) -> None: + android_id = secrets.token_bytes(8).hex() + drm_id = f"{base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8')}\n" + device_token = self._register_device(android_id, drm_id) + + email_status = self._check_email(self.credentials.username, device_token) + + if email_status.lower() != "login": + if email_status.lower() == "otp": + self.log.warning(" - Account requires OTP code login.") + self._request_otp(self.credentials.username, device_token) + + otp_code = None + try: + otp_code = input("Enter a OTP code (Check email): ") + if not otp_code: + self.log.error(" - OTP code is required, but no value was entered.", exc_info=False) + sys.exit(1) + if not otp_code.isdigit(): + self.log.error(" - Invalid OTP code. Please enter only numbers.", exc_info=False) + sys.exit(1) + if len(otp_code) < 6: + self.log.error(" - OTP code is too short. Please enter at least 6 digits.", exc_info=False) + sys.exit(1) + if len(otp_code) > 6: + self.log.warning(" - OTP code is longer than 6 digits. Using the first 6 digits.") + otp_code = otp_code[:6] + except KeyboardInterrupt: + self.log.error("\n - OTP code input cancelled by user.", exc_info=False) + sys.exit(1) + + auth_action = self._auth_action_with_otp(self.credentials.username, otp_code, device_token) + login_tokens = self._login_with_auth_action(auth_action, device_token) + + elif email_status.lower() == "register": + self.log.error(" - Account is not registered. Please register first.", exc_info=False) + sys.exit(1) + else: + self.log.error(f" - Email status is '{email_status}'. Account status verification required.", exc_info=False) + sys.exit(1) + + else: + login_tokens = self._login_with_password(self.credentials.username, self.credentials.password, device_token) + + temp_auth_header = {"Authorization": f'Bearer {login_tokens["token"]["accessToken"]}'} + account_info = self._get_account_info(temp_auth_header) + profiles = account_info["account"]["profiles"] + + selected_profile = None + if self.config.get("profile") and "index" in self.config["profile"]: + try: + profile_index = int(self.config["profile"]["index"]) + if not 0 <= profile_index < len(profiles): + raise ValueError(f"Index out of range (0-{len(profiles)-1})") + + selected_profile = profiles[profile_index] + except (ValueError, TypeError): + self.log.error(" - Profile index in configuration is invalid.", exc_info=False) + sys.exit(1) + else: + selected_profile = next( + (p for p in profiles if not p["attributes"]["kidsModeEnabled"] and not p["attributes"]["parentalControls"]["isPinProtected"]), + None + ) + if not selected_profile: + self.log.error(" - Auto-selection failed: No suitable profile found (non-kids, no PIN). Please configure a specific profile.", exc_info=False) + sys.exit(1) + + if selected_profile: + self._perform_switch_profile(selected_profile, temp_auth_header) + + def _perform_switch_profile(self, target_profile: dict, auth_headers: dict) -> None: + self.log.info(f" + Switching to profile: {target_profile['name']}({target_profile['id']})") + + if target_profile['attributes']['kidsModeEnabled']: + self.log.error(" - Kids Profile and cannot be used.", exc_info=False) + sys.exit(1) + + profile_pin = None + if target_profile['attributes']['parentalControls']['isPinProtected']: + self.log.warning(" - This profile is PIN protected.") + try: + profile_pin = input("Enter a profile pin: ") + if not profile_pin: + self.log.error(" - PIN is required, but no value was entered.", exc_info=False) + sys.exit(1) + if not profile_pin.isdigit(): + self.log.error(" - Invalid PIN. Please enter only numbers.", exc_info=False) + sys.exit(1) + if len(profile_pin) < 4: + self.log.error(" - PIN is too short. Please enter at least 4 digits.", exc_info=False) + sys.exit(1) + if len(profile_pin) > 4: + self.log.warning(" - PIN is longer than 4 digits. Using the first 4 digits.") + profile_pin = profile_pin[:4] + except KeyboardInterrupt: + self.log.error("\n - PIN input cancelled by user.", exc_info=False) + sys.exit(1) + + switch_profile_data = self._switch_profile(target_profile['id'], auth_headers, profile_pin) + self._apply_new_tokens(switch_profile_data["token"]) + + def _refresh(self) -> str: + cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}") + if not cache.expired: + self.log.debug(f" + Token is valid until: {datetime.fromtimestamp(cache.expiration.timestamp()).strftime('%Y-%m-%d %H:%M:%S')}") + return self.session.headers.get('Authorization', 'Bearer ').split(' ')[1] + + self.log.warning(" + Token expired. Refreshing...") + try: + refreshed_data = self._refresh_token(self.account_tokens["refreshToken"]) + self._apply_new_tokens(refreshed_data["token"]) + except Exception as _: + raise Exception("Refresh Token Expired") + + def _apply_new_tokens(self, token_data: dict) -> str: + self.account_tokens = token_data + + bearer = self.account_tokens["accessToken"] + if not bearer: + self.log.error("Invalid token data: accessToken not found.", exc_info=False) + sys.exit(1) + self.session.headers.update({'Authorization': f'Bearer {bearer}'}) + + expires_in = self.account_tokens["expiresIn"] or 3600 + cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}") + cache.set(self.account_tokens, expires_in - 60) + self.log.debug(f" + New Token is valid until: {datetime.fromtimestamp(cache.expiration.timestamp()).strftime('%Y-%m-%d %H:%M:%S')}") + + return bearer + + def search(self) -> Generator[SearchResult, None, None]: + params = {"query": self.title} + endpoint = self._href(self.prod_config["services"]["explore"]["client"]["endpoints"]["search"]["href"]) + 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["id"] + yield SearchResult( + id_=entity, + title=result["visuals"]["title"], + description=result["visuals"]["description"]["brief"], + label=result["visuals"]["metastringParts"]["releaseYearRange"]["startYear"], + url=f"https://www.disneyplus.com/browse/{entity}", + ) + + def get_titles(self) -> Titles_T: + if not self.extras: + try: + content_info = self._get_deeplink(self.title_id) + content_type = content_info["data"]["deeplink"]["actions"][0]["contentType"] + except Exception as e: + try: + actions_info = self._get_deeplink_last(self.title_id) + if actions_info["data"]["deeplink"]["actions"][0]["type"] == "browse": + info_block = base64.b64decode(actions_info["data"]["deeplink"]["actions"][0]["infoBlock"]) + if b"movie" in info_block: + content_type = "movie" + elif b"series" in info_block: + content_type = "series" + else: + content_type = "other" + self.log.warning(" - The content is not standard. however, it tries to look up the data.") + except Exception as e: + self.log.error(f" - Failed to determine content type via deeplink ({e}).", exc_info=False) + sys.exit(1) + else: + content_type = "extras" + self.log.debug(f" + Content Type: {content_type.upper()}") + + page = self._get_page(self.title_id) + + year = None + if year_data := page["visuals"]["metastringParts"].get("releaseYearRange"): + year = year_data.get("startYear") + + if content_type != "extras": + playback_action = next( + (x for x in page["actions"] if x["type"] == "playback"), + None + ) + if not playback_action: + self.log.error(f" - No content is available. (Playback action not found)", exc_info=False) + sys.exit(1) + lang_data = self._get_original_lang(playback_action["availId"]) + player_exp = lang_data["data"]["playerExperience"] + orig_lang = player_exp.get("originalLanguage") or player_exp.get("targetLanguage") or "en" + self.log.debug(f' + Original Language: {orig_lang}') + + if content_type in ("movie", "other"): + return Movies([ + Movie( + id_=page["id"], + service=self.__class__, + name=page["visuals"]["title"], + year=year, + language=Language.get(orig_lang), + data=page + ) + ]) + + elif content_type == "series": + return Series(self._get_series(page, year, orig_lang)) + + elif content_type == "extras": + return Series(self._get_extras(page, year)) + + else: + self.log.error(f" - Unsupported content type: {content_type}", exc_info=False) + sys.exit(1) + + def _get_series(self, page: dict, year: int, orig_lang: str) -> Series: + container = next(x for x in page["containers"] if x["type"] == "episodes") + season_ids = [s["id"] for s in container["seasons"]] + + episodes : List[Episode] = [] + for season_id in season_ids: + episodes_data = self._get_episodes_data(season_id) + + for ep in episodes_data: + if ep["type"] != "view": + continue + + episodes.append( + Episode( + id_=ep["id"], + service=self.__class__, + title=page["visuals"]["title"], + season=int(ep["visuals"]["seasonNumber"]), + number=int(ep["visuals"]["episodeNumber"]), + name=ep["visuals"]["episodeTitle"], + year=year, + language=Language.get(orig_lang), + data=ep + ) + ) + + return episodes + + def _get_extras(self, page: dict, year: int) -> Series: + extras_containers = [ + x for x in page["containers"] + if x["type"] == "set" and x["style"]["name"] == "standard_compact_list" + ] + + if not extras_containers: + self.log.error(" - No extras found.", exc_info=False) + sys.exit(1) + + extras_episodes : List[Episode] = [] + ep_count = 1 + + first_item = extras_containers[0]["items"][0] + first_action = next((x for x in first_item["actions"] if x["type"] in ("playback", "trailer")), None) + if first_action: + lang_data = self._get_original_lang(first_action["availId"]) + player_exp = lang_data["data"]["playerExperience"] + orig_lang = player_exp.get("originalLanguage") or player_exp.get("targetLanguage") or "en" + self.log.debug(f' + Original Language: {orig_lang}') + + for container in extras_containers: + items = container["items"] + for item in items: + if item["type"] == "view": + action = next((x for x in item["actions"] if x["type"] in ("playback", "trailer")), None) + + if action: + extras_episodes.append( + Episode( + id_=item["id"], + service=self.__class__, + title=page["visuals"]["title"], + season=0, # Special + number=ep_count, + name=item["visuals"]["title"], + year=year, + language=Language.get(orig_lang), + data=item + ) + ) + ep_count += 1 + + if not extras_episodes: + self.log.error(" - No playable extras found.", exc_info=False) + sys.exit(1) + + return extras_episodes + + def get_tracks(self, title: Title_T) -> Tracks: + playback = next(x for x in title.data["actions"] if x.get("type") == "playback") + media_id = playback["resourceId"] or None + if not media_id: + self.log.error(" - Failed to get media ID for playback info", exc_info=False) + sys.exit(1) + + scenario = "ctr-regular" if self.is_l3 else "ctr-high" # cbcs-high + + self.log.debug(f"Playback Scenario: {scenario}") + self.log.debug(f"Media ID: {media_id}") + + self._refresh() # Safe Access + + if Video.Range.HYBRID in self.range[0] and not self.is_l3: + self.log.warning("DV+HDR Multi-range requested.") + + self.log.info(" + Fetching Dolby Vision tracks...") + tracks = self._fetch_manifest_tracks(title, media_id, scenario, ["DOLBY_VISION"]) + + self.log.info(" + Fetching HDR10 tracks...") + hdr_tracks_temp = self._fetch_manifest_tracks(title, media_id, scenario, ["HDR10"]) # HDR10PLUS + + tracks.add(hdr_tracks_temp, warn_only=True) + else: + video_ranges = [] + if not self.is_l3: + if Video.Range.DV in self.range[0]: + video_ranges = ["DOLBY_VISION"] + elif Video.Range.HDR10 in self.range[0] or Video.Range.HDR10P in self.range[0]: + video_ranges = ["HDR10"] # HDR10PLUS + + tracks = self._fetch_manifest_tracks(title, media_id, scenario, video_ranges or None) + + tracks.add(self._get_thumbnail(title)) + return self._post_process_tracks(tracks) + + def _fetch_manifest_tracks(self, title: Title_T, media_id: str, scenario: str, video_ranges: List[str] = None) -> Tracks: + attributes = { + "codecs": { + "supportsMultiCodecMaster": False, + "video": ["h.264"] + }, + "protocol": "HTTPS", + "frameRates": [60], + "assetInsertionStrategies": { + "point": "SGAI", # Server-Guided Ad Insertion + "range": "SGAI" # Server-Guided Ad Insertion + }, + "playbackInitiationContext": "ONLINE", + "slugDuration": "SLUG_500_MS", # SLUG_1000_MS, SLUG_750_MS ? + "maxSlideDuration": "4_HOUR" # 15_MIN ? + } + + if self.is_l3: + attributes["resolution"] = {"max": ["1280x720"]} + else: + attributes["resolution"] = {"max": ["3840x2160"]} + + if self.vcodec == Video.Codec.HEVC: + attributes["codecs"]["video"] = ["h.264", "h.265"] + + attributes["audioTypes"] = ["ATMOS", "DTS_X"] + + if video_ranges: + attributes["videoRanges"] = video_ranges + + payload = { + "playbackId": media_id, + "playback": { + "attributes": attributes + } + } + self.playback_data[title.id] = self._get_playback(scenario, payload) + manifest_url = self.playback_data[title.id]["sources"][0]['complete']['url'] + self.log.debug(f" + Manifest URL: {manifest_url}") + return HLS.from_url(url=manifest_url, session=self.session).to_tracks(title.language) + + def _get_thumbnail(self, title: Title_T) -> Attachment: + if type(title) == Movie: + thumbnail_id = title.data["visuals"]["artwork"]["standard"]["background"]["1.78"]["imageId"] + elif type(title) == Episode: + thumbnail_id = title.data["visuals"]["artwork"]["standard"]["thumbnail"]["1.78"]["imageId"] + thumbnail_url = self._href( + self.prod_config["services"]["ripcut"]["client"]["endpoints"]["mainCompose"]["href"], + version="v2", + partnerId="disney", + imageId=thumbnail_id + ) + return Attachment.from_url(url=thumbnail_url, name=thumbnail_id, mime_type="image/png") + + def _post_process_tracks(self, tracks: Tracks) -> Tracks: + for track in tracks: + if isinstance(track, (Audio, Subtitle)): + track.name = "[Original]" if track.is_original_lang else None + + for audio in tracks.audio: + bitrate_match = re.search(r"(?<=composite_)\d+|\d+(?=_(?:hdri|complete))|(?<=-)\d+(?=K/)", as_list(audio.url)[0]) + if bitrate_match: + audio.bitrate = int(bitrate_match.group()) * 1000 + if audio.bitrate == 1_000_000: + audio.bitrate = 768_000 # DSNP lies about the Atmos bitrate + if audio.channels == 6.0: + audio.channels = 5.1 + if audio.channels == 10.0: # DTS-UHD + audio.channels = "5.1.4" # Unshackle does not recommend + audio.codec = Audio.Codec.DTS + audio.drm = None + + for subtitle in tracks.subtitles: + subtitle.codec = Subtitle.Codec.WebVTT + + return tracks + + def get_chapters(self, title: Title_T) -> Chapters: + try: + editorial = self.playback_data[title.id]["editorial"] + if not editorial: + return Chapters() + + LABEL_MAP = { + "intro_start": "intro_start", + "intro_end": "intro_end", + "recap_start": "recap_start", + "recap_end": "recap_end", + "FFER": "recap_start", # First Frame Episode Recap + "LFER": "recap_end", # Last Frame Episode Recap + "FFEI": "intro_start", # First Frame Episode Intro + "LFEI": "intro_end", # Last Frame Episode Intro + "FFEC": "credits_start", # First Frame End Credits + "LFEC": "lfec_marker", # Last Frame End Credits + "up_next": None, + "tag_start": None, + "tag_end": None, + } + + NAME_MAP = { + "recap_start": "Recap", + "recap_end": "Scene", + "intro_start": "Intro", + "intro_end": "Scene", + "credits_start": "Credits", + } + + grouped = {} + for marker in editorial: + group = LABEL_MAP.get(marker["label"]) + offset = marker["offsetMillis"] + if group and offset is not None: + grouped.setdefault(group, []).append(offset) + + raw_chapters = [] + total_runtime = title.data["visuals"]["metastringParts"]["runtime"]["runtimeMs"] + + for group, times in grouped.items(): + if not times: continue + + timestamp = min(times) if "start" in group else max(times) if "end" in group else times[0] + name = NAME_MAP.get(group) + + if group == "lfec_marker" and (total_runtime - timestamp) > 5000: + name = "Scene" + + if name: + raw_chapters.append((timestamp, name)) + + raw_chapters.sort(key=lambda x: x[0]) + unique_chapters = [] + seen_ms = set() + + for ms, name in raw_chapters: + if ms not in seen_ms: + unique_chapters.append({"ms": ms, "name": name}) + seen_ms.add(ms) + + if not unique_chapters: + unique_chapters.append({"ms": 0, "name": "Scene"}) + else: + first = unique_chapters[0] + if first["ms"] > 0: + if first["ms"] < 5000 and first["name"] in ("Intro", "Recap"): + first["ms"] = 0 + else: + unique_chapters.insert(0, {"ms": 0, "name": "Scene"}) + + chapters: List[Chapter] = [] + for i, chap_data in enumerate(unique_chapters): + time_sec = chap_data["ms"] / 1000.000 + chapter_title = chap_data["name"] + chapters.append( + Chapter( + timestamp=float(time_sec), + name=chapter_title if chapter_title != "Scene" else None + ) + ) + + return chapters + + except Exception as e: + self.log.warning(f"Failed to extract chapters: {e}") + return Chapters() + + def get_widevine_service_certificate(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Union[bytes, str]: + # endpoint = self.prod_config["services"]["drm"]["client"]["endpoints"]["widevineCertificate"]["href"] + # res = self.session.get(endpoint, data=challenge) + return self.config["certificate"] + + def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: + self._refresh() # Safe Access + endpoint = self.prod_config["services"]["drm"]["client"]["endpoints"]["widevineLicense"]["href"] + headers = {"Content-Type": "application/octet-stream"} + + try: + res = self.session.post(endpoint, headers=headers, data=challenge) + res.raise_for_status() + except Exception as e: + self.log.error(f"License request failed: {e}", exc_info=False) + sys.exit(1) + return res.content + + def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]: + self._refresh() # Safe Access + endpoint = self.prod_config["services"]["drm"]["client"]["endpoints"]["playReadyLicense"]["href"] + headers = { + "Accept": "application/xml, application/vnd.media-service+json; version=2", + "Content-Type": "text/xml; charset=utf-8", + "SOAPAction": "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense" + } + try: + res = self.session.post(endpoint, headers=headers, data=challenge) + res.raise_for_status() + except Exception as e: + self.log.error(f"License request failed: {e}", exc_info=False) + sys.exit(1) + return res.content + + def _get_deeplink(self, ref_id: str) -> dict: + endpoint = self._href( + self.prod_config["services"]["content"]["client"]["endpoints"]["getDeeplink"]["href"], + refIdType="deeplinkId", + refId=ref_id + ) + data = self._request("GET", endpoint) + return data + + def _get_deeplink_last(self, ref_id: str) -> dict: + endpoint = self._href(self.prod_config["services"]["explore"]["client"]["endpoints"]["getDeeplink"]["href"]) + params = { + "refIdType" : "deeplinkId", + "refId" : ref_id + } + data = self._request("GET", endpoint, params=params) + return data + + def _get_page(self, title_id: str) -> dict: + endpoint = self._href( + self.prod_config["services"]["explore"]["client"]["endpoints"]["getPage"]["href"], + pageId=title_id + ) + data = self._request("GET", endpoint, params={"disableSmartFocus": "true", "limit": 999}) + return data["data"]["page"] + + def _get_original_lang(self, availId: str) -> dict: + endpoint = self._href( + self.prod_config["services"]["explore"]["client"]["endpoints"]["getPlayerExperience"]["href"], + availId=availId + ) + data = self._request("GET", endpoint) + return data + + def _get_episodes_data(self, season_id: str) -> List[dict]: + endpoint = self._href( + self.prod_config["services"]["explore"]["client"]["endpoints"]["getSeason"]["href"], + seasonId=season_id + ) + data = self._request("GET", endpoint, params={'limit': 999})["data"]["season"]["items"] + return data + + def _get_playback(self, scenario: str, payload: dict) -> dict: + endpoint = self._href( + self.prod_config["services"]["media"]["client"]["endpoints"]["mediaPayload"]["href"], + scenario=scenario + ) + headers = { + "Accept": "application/vnd.media-service+json", + "X-DSS-Feature-Filtering": "true" + } + data = self._request("POST", endpoint, headers=headers, payload=payload) + return data["stream"] + + def _register_device(self, android_id: str, drm_id: str) -> str: + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["registerDevice"]["href"] + headers = { + "Authorization": self.config["bamsdk"]["api_key"], + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + } + payload = { + "variables": { + "registerDevice": { + "applicationRuntime": self.config["device"]["applicationRuntime"], + "attributes": { + "osDeviceIds": [ + { + "identifier": android_id, + "type": "android.vendor.id" + }, + { + "identifier": drm_id, + "type": "android.drm.id" + } + ], + "operatingSystem": self.config["device"]["operatingSystem"], + "operatingSystemVersion": self.config["device"]["operatingSystemVersion"] + }, + "deviceFamily": self.config["device"]["family"], + "deviceLanguage": self.config["device"]["deviceLanguage"], + "deviceProfile": self.config["device"]["profile"], + "devicePlatformId": self.config["device"]["platform_id"], + } + }, + "query": queries.REGISTER_DEVICE + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + return data["extensions"]["sdk"]["token"]["accessToken"] + + def _check_email(self, email: str, token: str) -> str: + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] + headers = { + "Authorization": token, + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + } + payload = { + "operationName": "check", + "variables": { + "email": email + }, + "query": queries.CHECK_EMAIL + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + return data["data"]["check"]["operations"][0] + + def _login_with_password(self, email: str, password: str, token: str) -> str: + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] + headers = { + "Authorization": token, + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + } + payload = { + "operationName": "login", + "variables": { + "input": { + "email": email, + "password": password + }, + "includeIdentity": True, + "includeAccountConsentToken": True + }, + "query": queries.LOGIN + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + return data["extensions"]["sdk"] + + def _request_otp(self, email: str, token: str) -> dict: + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] + headers = { + "Authorization": token, + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + } + payload = { + "operationName": "requestOtp", + "variables": { + "input": { + "email": email, + "reason": "Login" + } + }, + "query": queries.REQUESET_OTP + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + if not data["data"]["requestOtp"]["accepted"]: + self.log.error(" - OTP code request failed.", exc_info=False) + sys.exit(1) + + def _auth_action_with_otp(self, email: str, otp: str, token: str) -> dict: + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] + headers = { + "Authorization": token, + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + } + payload = { + "operationName": "authenticateWithOtp", + "variables": { + "input": { + "email": email, + "passcode": otp + } + }, + "query": queries.LOGIN_OTP + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + return data["data"]["authenticateWithOtp"]["actionGrant"] + + def _login_with_auth_action(self, auth_action: str, token: str) -> dict: + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] + headers = { + "Authorization": token, + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + } + payload = { + "operationName": "loginWithActionGrant", + "variables": { + "input": { + "actionGrant": auth_action + }, + "includeAccountConsentToken": True + }, + "query": queries.LOGIN_ACTION_GRANT + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + return data["extensions"]["sdk"] + + def _get_account_info(self, headers: dict = {}) -> dict: + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] + headers.update({"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]}) + payload = { + "operationName": "me", + "variables": { + "includeAccountConsentToken": True + }, + "query": queries.ME + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + return data["data"]["me"] + + def _switch_profile(self, profile_id: str, headers: dict, pin: str = None): + profile_input = {"profileId": profile_id} + if pin: profile_input["entryPin"] = pin + + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] + headers.update({"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]}) + payload = { + "operationName": "switchProfile", + "variables": { + "input": profile_input, + "includeIdentity": True, + "includeAccountConsentToken": True + }, + "query": queries.SWITCH_PROFILE + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + return data["extensions"]["sdk"] + + def _refresh_token(self, refresh_token: str) -> dict: + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["refreshToken"]["href"] + headers = { + "Authorization": self.config["bamsdk"]["api_key"], + "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] + } + payload = { + "operationName": "refreshToken", + "variables": { + "refreshToken": { + "refreshToken": refresh_token + } + }, + "query": queries.REFRESH_TOKEN + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + return data["extensions"]["sdk"] + + def _update_device(self, android_id: str, drm_id: str) -> str: + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] + headers = {"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]} + payload = { + "operationName": "updateDeviceOperatingSystem", + "variables": { + "updateDeviceOperatingSystem": { + "operatingSystem": self.config["device"]["operatingSystem"], + "operatingSystemVersion": self.config["device"]["operatingSystemVersion"], + "osDeviceIds": [ + { + "identifier": android_id, + "type": "android.vendor.id" + }, + { + "identifier": drm_id, + "type": "android.drm.id" + } + ] + } + }, + "query": queries.UPDATE_DEVICE + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + + if data["data"]["updateDeviceOperatingSystem"]["accepted"]: + return data["extensions"]["sdk"] + else: + self.log.warning(" - Failed to update Device Operating System.") + + def _set_imax_preference(self, enabled: bool) -> str: + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] + headers = {"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]} + payload = { + "operationName": "updateProfileImaxEnhancedVersion", + "variables": { + "input": { + "imaxEnhancedVersion": enabled, + }, + "includeProfile": True + }, + "query": queries.SET_IMAX, + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + + if data["data"]["updateProfileImaxEnhancedVersion"]["accepted"]: + self.log.info(f" + Updated IMAX Enhanced preference: {enabled}") + return data["extensions"]["sdk"] + else: + self.log.warning(" - Failed to set IMAX preference.") + + def _set_remastered_ar_preference(self, enabled: bool) -> str: + endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] + headers = {"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]} + payload = { + "operationName": "updateProfileRemasteredAspectRatio", + "variables": { + "input": { + "remasteredAspectRatio": enabled, + }, + "includeProfile": True + }, + "query": queries.SET_REMASTERED_AR, + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + + if data["data"]["updateProfileRemasteredAspectRatio"]["accepted"]: + self.log.info(f" + Updated Remastered Aspect Ratio preference: {enabled}") + return data["extensions"]["sdk"] + else: + self.log.warning(" - Failed to set Remastered Aspect Ratio preference.") + + def _href(self, href: str, **kwargs: Any) -> str: + _args = {"version": self.config["bamsdk"]["explore_version"]} + _args.update(**kwargs) + return href.format(**_args) + + def _request(self, method: str, endpoint: str, params: dict = None, headers: dict = None, payload: dict = None) -> Any[dict | str]: + _headers = self.session.headers.copy() + if headers: _headers.update(headers) + _headers.update({ + "X-BAMSDK-Transaction-ID": str(uuid.uuid4()), + "X-Request-ID": str(uuid.uuid4()) + }) + + req = Request(method, endpoint, headers=_headers, params=params, json=payload) + prepped = self.session.prepare_request(req) + try: + res = self.session.send(prepped) + res.raise_for_status() + data = res.json() + if data.get("errors"): + error_code = data["errors"][0]["extensions"]["code"] + if "token.service.invalid.grant" in error_code: + raise ConnectionError(f"Refresh Token Expired: {error_code}") + elif "token.service.unauthorized.client" in error_code: + raise ConnectionError(f"Unauthorized Client/IP: {error_code}") + elif "idp.error.identity.bad-credentials" in error_code: + raise ConnectionError(f"Bad Credentials: {error_code}") + elif "account.profile.pin.invalid" in error_code: + raise ConnectionError(f"Invalid PIN: {error_code}") + raise ConnectionError(data["errors"]) + if data.get("data") and data["data"].get("errors"): + raise ConnectionError(data["data"]["errors"]) + return data + except Exception as e: + if "Refresh Token Expired" in str(e) or "/deeplink" in endpoint: + raise e + else: + self.log.error(f"API Request failed: {e}", exc_info=False) + sys.exit(1) \ No newline at end of file diff --git a/DSNP-FIX/config.yaml b/DSNP-FIX/config.yaml new file mode 100644 index 0000000..9540ddc --- /dev/null +++ b/DSNP-FIX/config.yaml @@ -0,0 +1,54 @@ +certificate: | + CAUSugUKtAIIAxIQbj3s4jO5oUyWjDWqjfr9WRjA2afZBSKOAjCCAQoCggEBALhKWfnyA+FGn5P3tl6ffDjoGq2Oq86hKGl6aZIaGaF7XHPO5mIk7Q35ml + ZIgg1A458Udb4eXRws1n+kJFqtZXCY5S1yElLP0Om1WQsoEY2stpl+PZTGnVv/CsOJGKQ8K4KMr7rKjZem9lA9BrBoxgfXY3tbwlnSf3wTEohyANb5Qfpa + xsU4v8tQDA8PcjzzV9ICodl6crcFZhAy4QMNXfbWOv/ZrGFx5blSXrzP1sMQ64IY8bjUYw4coZM34NDhu8aCA692g8k2mTz2494x7u3Is8v7RKC9ZNiETE + K5/4oeVclXPpelNQokR4uvggnCD1L2EULG/pp6wnk1yWNNLxcCAwEAAToHYmFtdGVjaBKAA2FqHlqkE7EUmdOLiCi0hy5jRgBDJrU1CWNHfH6r2i6s5T5k + 6LK7ZfD65Tv6uyqq1k82PsDz4++kxbpfJDZaypFbae4XPc6lZxRCc5X0toX/x9TftOQQ4N82l5Hxoha569EPRkrnNy7rO7xrRILa3ZVj1alttEnEEjxEuw + SV8usdlUg8/LvLA2C59T/HA2I77k7yVbTrVdy0f81r2l+E2SslivCy1JD3xKlgoaKl4xBnRxItWt8+DCw1Xm2lemYl2LGoh1Wk9gvlXQvr2Jv2+dFX3RNs + i5sd00KS9sePszfjoTkQ6fmpRd7ZgFCGFWYB9JZ92aGUFQRE14OTST2uwSf32YCfsoATDNs4V6dB8YDoTGKFGrcoc4gtHPKySGNt7z/fOW4/01ZGzKqoVY + Fp3jPq7R0qyt5P6fU5NshbLh5VKcnQvwg62BuKsdwV9u4NV36b2a546hGRl/GBneQ+QDA7NRrgITR33Sz02Oq8yJr3sy24GfZRTbtLJ4qiWkjtw== + +## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ## +# Browser (windows, chrome) : /browser/v34.2/windows/chrome/prod.json +# Android Phone : /android/v15.0.0/google/handset/prod.json +# Android TV : /android/v15.0.0/google/tv/prod.json +# Amazon Fire TV : /android/v15.0.0/amazon/tv/prod.json + +endpoints: + config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v15.0.0/google/tv/prod.json" + +## user_agent (okhttp/5.0.0-alpha.14) ## +# android-phone : BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; phone) +# android-tv : BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv) + +## api_key ## +# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84 +# android : ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA + +## yp_service_id ## +# browser : 63626081279ebe65eb50fb54 +# android : 624b805dafc5c73635b1a216 + +bamsdk: + sdk_version: "15.0.1" + application_version: "4.21.1+rc3-2026.01.06.0" + explore_version: "v1.13" + client: "disney-svod-3d9324fc" + user_agent: "BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv)" + api_key: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA" + yp_service_id: "624b805dafc5c73635b1a216" + +device: + family: "android" + profile: "tv" + platform: "android/google/tv" # {deviceFamily}/{applicationRuntime}/{deviceProfile} + platform_id: "android-tv" + applicationRuntime: "android" + operatingSystem: "Android" + operatingSystemVersion: "16" + deviceLanguage: "ko" # Device language data independent of account language data + +# Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.) +# Automatically select a profile when commenting. +# profile: +# index: 0 diff --git a/DSNP-FIX/queries.py b/DSNP-FIX/queries.py new file mode 100644 index 0000000..f8ec5ba --- /dev/null +++ b/DSNP-FIX/queries.py @@ -0,0 +1,13 @@ +# REQUEST_DEVICE_CODE = """mutation requestLicensePlate($input: RequestLicensePlateInput!) { requestLicensePlate(requestLicensePlate: $input) { licensePlate expirationTime expiresInSeconds } }""" +CHECK_EMAIL = """query check($email: String!) { check(email: $email) { operations nextOperation } }""" +LOGIN = """mutation login($input: LoginInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { login(login: $input) { account { __typename ...accountGraphFragment } actionGrant activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }""" +LOGIN_ACTION_GRANT = """mutation loginWithActionGrant($input: LoginWithActionGrantInput!, $includeAccountConsentToken: Boolean!) { loginWithActionGrant(login: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } actionGrant } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }""" +LOGIN_OTP = """mutation authenticateWithOtp($input: AuthenticateWithOtpInput!) { authenticateWithOtp(authenticateWithOtp: $input) { actionGrant securityAction passwordRules { __typename ...passwordRulesFragment } } } fragment passwordRulesFragment on PasswordRules { minLength charTypes }""" +ME = """query me($includeAccountConsentToken: Boolean!) { me { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }""" +REFRESH_TOKEN = """mutation refreshToken($refreshToken: RefreshTokenInput!) { refreshToken(refreshToken: $refreshToken) { activeSession { sessionId } } }""" +REGISTER_DEVICE = """mutation ($registerDevice: RegisterDeviceInput!) { registerDevice(registerDevice: $registerDevice) { __typename } }""" +REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }""" +SET_IMAX = """mutation updateProfileImaxEnhancedVersion($input: UpdateProfileImaxEnhancedVersionInput!, $includeProfile: Boolean!) { updateProfileImaxEnhancedVersion(updateProfileImaxEnhancedVersion: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }""" +SET_REMASTERED_AR = """mutation updateProfileRemasteredAspectRatio($input: UpdateProfileRemasteredAspectRatioInput!, $includeProfile: Boolean!) { updateProfileRemasteredAspectRatio(updateProfileRemasteredAspectRatio: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }""" +SWITCH_PROFILE = """mutation switchProfile($input: SwitchProfileInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { switchProfile(switchProfile: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }""" +UPDATE_DEVICE = """mutation updateDeviceOperatingSystem($updateDeviceOperatingSystem: UpdateDeviceOperatingSystemInput!) {updateDeviceOperatingSystem(updateDeviceOperatingSystem: $updateDeviceOperatingSystem) {accepted}}""" \ No newline at end of file