diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba0430d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ \ No newline at end of file diff --git a/ADN/__init__.py b/ADN/__init__.py index d356934..24dc6af 100644 --- a/ADN/__init__.py +++ b/ADN/__init__.py @@ -211,7 +211,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB @staticmethod def get_session(): - return session("okhttp4") + return session("OkHttp4_12") @staticmethod @click.command( diff --git a/ATVP/__init__.py b/ATVP/__init__.py index 8acf2f0..94a8fea 100644 --- a/ATVP/__init__.py +++ b/ATVP/__init__.py @@ -7,7 +7,7 @@ import click import m3u8 import requests -from unshackle.core.downloaders import n_m3u8dl_re +from unshackle.core.downloaders import requests from unshackle.core.manifests import m3u8 as m3u8_parser from unshackle.core.service import Service from unshackle.core.titles import Episode, Movie, Movies, Series @@ -255,7 +255,7 @@ class ATVP(Service): for track in tracks: if track not in tracks.attachments: - track.downloader = n_m3u8dl_re + track.downloader = requests if isinstance(track, (Video, Audio)): track.needs_repack = True diff --git a/CR-FIX/__init__.py b/CR-FIX/__init__.py deleted file mode 100644 index fbcd320..0000000 --- a/CR-FIX/__init__.py +++ /dev/null @@ -1,777 +0,0 @@ -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 deleted file mode 100644 index 6651db7..0000000 --- a/CR-FIX/config.yaml +++ /dev/null @@ -1,47 +0,0 @@ -# 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/CR/__init__.py b/CR/__init__.py index fbcd320..65d7d25 100644 --- a/CR/__init__.py +++ b/CR/__init__.py @@ -51,7 +51,7 @@ class CR(Service): @staticmethod def get_session(): - return session("okhttp4") + return session("OkHttp4_12") @staticmethod @click.command(name="CR", short_help="https://crunchyroll.com") diff --git a/DSNP-FIX/__init__.py b/DSNP-FIX/__init__.py deleted file mode 100644 index db9dd7b..0000000 --- a/DSNP-FIX/__init__.py +++ /dev/null @@ -1,1116 +0,0 @@ -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 deleted file mode 100644 index 9540ddc..0000000 --- a/DSNP-FIX/config.yaml +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index f8ec5ba..0000000 --- a/DSNP-FIX/queries.py +++ /dev/null @@ -1,13 +0,0 @@ -# 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 diff --git a/DSNP/__init__.py b/DSNP/__init__.py index 2513fc2..74d00b6 100644 --- a/DSNP/__init__.py +++ b/DSNP/__init__.py @@ -1,28 +1,30 @@ from __future__ import annotations +import base64 +import click import re +import secrets import sys import uuid -from datetime import datetime -from collections.abc import Generator -from http.cookiejar import CookieJar -from typing import Any, Optional, Union, List -from langcodes import Language -import click 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.manifests import HLS 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.utils.ip_info import get_ip_info from unshackle.core.utils.collections import as_list -from unshackle.core.utilities import get_ip_info from . import queries @@ -37,7 +39,7 @@ class DSNP(Service): ALIASES = ("DSNP", "disneyplus", "disney+") TITLE_RE = ( - r"^(?:https?://(?:www\.)?disneyplus\.com(?:/[a-z0-9-]+)?(?:/[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(?:/(?!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-]+)(?:\?.*)?$", ) @@ -46,11 +48,12 @@ class DSNP(Service): @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): + def __init__(self, ctx: Context, title: str, imax: bool, remastered_ar: bool, extras: bool): self.title = title super().__init__(ctx) @@ -61,8 +64,9 @@ class DSNP(Service): self.title_id = match.group("id") break - self.prefer_imax = imax or False - self.prefer_remastered_ar = remastered_ar or False + 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") @@ -74,8 +78,8 @@ class DSNP(Service): self.chapters_only = ctx.parent.params.get("chapters_only") self.cdm = ctx.obj.cdm - self.playready = isinstance(self.cdm, PlayReadyCdm) - self.is_l3 = (self.cdm.security_level < 3000) if self.playready else (self.cdm.security_level == 3) + 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 = {} @@ -91,13 +95,13 @@ class DSNP(Service): self.quality = [720] self.log.warning(" + Switched video to HD. This CDM only support HD.") else: - if self.quality > [1080] and self.range == [Video.Range.SDR]: + 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} dynamic range.") + 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 @@ -164,7 +168,7 @@ class DSNP(Service): 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_raw() + 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.") @@ -193,12 +197,14 @@ class DSNP(Service): 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: - self._set_imax_preference(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: - self._set_remastered_ar_preference(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}") @@ -208,7 +214,7 @@ class DSNP(Service): self.log.info(" + Using cached tokens...") self.account_tokens = cache.data - bearer = self.account_tokens["token"]["accessToken"] + bearer = self.account_tokens["accessToken"] if not bearer: raise ValueError("accessToken not found in cache") self.session.headers.update({'Authorization': f'Bearer {bearer}'}) @@ -231,19 +237,45 @@ class DSNP(Service): self._perform_full_login() self.log.info(" + Fetching session data...") - full_account_info = self._get_account_info_raw() + 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: - device_token = self._register_device() + 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.error(" - Account requires 2FA passcode.", exc_info=False) - sys.exit(1) + 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) @@ -251,10 +283,11 @@ class DSNP(Service): self.log.error(f" - Email status is '{email_status}'. Account status verification required.", exc_info=False) sys.exit(1) - login_tokens = self._login_with_password(self.credentials.username, self.credentials.password, device_token) + else: + login_tokens = self._login_with_password(self.credentials.username, self.credentials.password, device_token) - temp_auth_header = {"Authorization": f'Bearer {login_tokens["accessToken"]}'} - account_info = self._get_account_info_raw(temp_auth_header) + 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 @@ -308,10 +341,8 @@ class DSNP(Service): 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) - final_token_data = self._refresh_token(switch_profile_data["token"]["refreshToken"]) - self._apply_new_tokens(final_token_data) + self._apply_new_tokens(switch_profile_data["token"]) def _refresh(self) -> str: cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}") @@ -321,23 +352,21 @@ class DSNP(Service): self.log.warning(" + Token expired. Refreshing...") try: - refreshed_data = self._refresh_token(self.account_tokens["token"]["refreshToken"]) - bearer = self._apply_new_tokens(refreshed_data) - return bearer + refreshed_data = self._refresh_token(self.account_tokens["refreshToken"]) + self._apply_new_tokens(refreshed_data["token"]) except Exception as _: - self.log.error("Refresh Token Expired", exc_info=False) - sys.exit(1) + raise Exception("Refresh Token Expired") def _apply_new_tokens(self, token_data: dict) -> str: self.account_tokens = token_data - bearer = self.account_tokens["token"]["accessToken"] + 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["token"]["expiresIn"] or 3600 + 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')}") @@ -363,64 +392,71 @@ class DSNP(Service): ) def get_titles(self) -> Titles_T: - try: - content_info = self._get_deeplink(self.title_id) - content_type = content_info["data"]["deeplink"]["actions"][0]["contentType"] - except Exception as e: + if not self.extras: try: - actions_info = self._get_deeplink_last(self.title_id) - if actions_info["data"]["deeplink"]["actions"][0]["type"] == "browse": - content_type = "other" - self.log.warning(" - The content is not standard. however, it tries to look up the data.") + content_info = self._get_deeplink(self.title_id) + content_type = content_info["data"]["deeplink"]["actions"][0]["contentType"] except Exception as e: - self.log.error(f" - Failed to determine content type via deeplink ({e}).", exc_info=False) - sys.exit(1) + 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) - orig_lang = "en" - if not content_type == "other": - playback_action = next(x for x in page["actions"] if x["type"] == "playback") - avail_id = playback_action["availId"] - self.log.debug(f" + Avail ID: {avail_id}") - lang_data = self._get_original_lang(avail_id) - orig_lang = lang_data["data"]["playerExperience"]["originalLanguage"] + 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 == "movie": - return Movies( - [ - Movie( - id_=page["id"], - service=self.__class__, - name=page["visuals"]["title"], - year=page["visuals"]["metastringParts"]["releaseYearRange"]["startYear"], - language=Language.get(orig_lang), - data=page - ) - ] - ) + 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, orig_lang)) + return Series(self._get_series(page, year, orig_lang)) + + elif content_type == "extras": + return Series(self._get_extras(page, year)) - elif content_type == "other": - return Movies( - [ - Movie( - id_=page["id"], - service=self.__class__, - name=page["visuals"]["title"], - data=page - ) - ] - ) else: self.log.error(f" - Unsupported content type: {content_type}", exc_info=False) sys.exit(1) - def _get_series(self, page: dict, orig_lang: str) -> Series: + 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"]] @@ -440,13 +476,62 @@ class DSNP(Service): season=int(ep["visuals"]["seasonNumber"]), number=int(ep["visuals"]["episodeNumber"]), name=ep["visuals"]["episodeTitle"], - year=page["visuals"]["metastringParts"]["releaseYearRange"]["startYear"], + 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") @@ -462,7 +547,7 @@ class DSNP(Service): self._refresh() # Safe Access - if Video.Range.HYBRID in self.range and not self.is_l3: + 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...") @@ -475,15 +560,14 @@ class DSNP(Service): else: video_ranges = [] if not self.is_l3: - if Video.Range.DV in self.range: + if Video.Range.DV in self.range[0]: video_ranges = ["DOLBY_VISION"] - elif Video.Range.HDR10 in self.range or Video.Range.HDR10P in self.range: + 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: @@ -494,8 +578,13 @@ class DSNP(Service): }, "protocol": "HTTPS", "frameRates": [60], - "assetInsertionStrategy": "SGAI", # Server-Guided Ad Insertion - "playbackInitiationContext": "ONLINE" + "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: @@ -517,9 +606,9 @@ class DSNP(Service): "attributes": attributes } } - self.playback_data[title.id] = self._get_video(scenario, payload) - + 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: @@ -548,67 +637,39 @@ class DSNP(Service): 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 [] + return Chapters() - label_to_group = { + LABEL_MAP = { "intro_start": "intro_start", - "FFEI": "intro_start", # First Frame Episode Intro "intro_end": "intro_end", - "LFEI": "intro_end", # Last Frame Episode Intro "recap_start": "recap_start", - "FFER": "recap_start", # First Frame Episode Recap "recap_end": "recap_end", - "LFER": "recap_end", # Last Frame Episode Recap + "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 - "FFCB": None, # First Frame Credits Bumper - "LFCB": None, # Last Frame Credits Bumper "up_next": None, "tag_start": None, "tag_end": None, } - # Collision Correction - grouped_timestamps = {} - for marker in editorial: - label = marker.get("label") - group = label_to_group.get(label) - if group: - timestamp = marker.get("offsetMillis") - if timestamp is not None: - if group not in grouped_timestamps: - grouped_timestamps[group] = [] - grouped_timestamps[group].append(timestamp) - - resolved_markers = [] - for group, timestamps in grouped_timestamps.items(): - if not timestamps: - continue - - final_timestamp = 0 - if "start" in group: - final_timestamp = min(timestamps) - elif "end" in group: - final_timestamp = max(timestamps) - else: - final_timestamp = timestamps[0] - - resolved_markers.append({"group": group, "ms": final_timestamp}) - - # Create Chapter Data - raw_chapter_data = [] - group_to_name = { + NAME_MAP = { "recap_start": "Recap", "recap_end": "Scene", "intro_start": "Intro", @@ -616,66 +677,63 @@ class DSNP(Service): "credits_start": "Credits", } - total_runtime_ms = 0 - if "visuals" in title.data and "metastringParts" in title.data["visuals"]: - total_runtime_ms = title.data["visuals"]["metastringParts"]["runtime"]["runtimeMs"] + 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) - for marker in resolved_markers: - group = marker["group"] - timestamp_ms = marker["ms"] - name = None + raw_chapters = [] + total_runtime = title.data["visuals"]["metastringParts"]["runtime"]["runtimeMs"] - if group == "lfec_marker": - if total_runtime_ms and (total_runtime_ms - timestamp_ms) > 5000: # 5 sec - name = "Scene" - else: - name = group_to_name.get(group) + 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_chapter_data.append({"ms": timestamp_ms, "name": name}) - - # Sorting and deduplication in chronological order - if not raw_chapter_data: - return [] + raw_chapters.append((timestamp, name)) - unique_chapters_data = [] + raw_chapters.sort(key=lambda x: x[0]) + unique_chapters = [] seen_ms = set() - for chap in sorted(raw_chapter_data, key=lambda x: x["ms"]): - if chap["ms"] not in seen_ms: - unique_chapters_data.append(chap) - seen_ms.add(chap["ms"]) - # Processe the First Chapter - if not unique_chapters_data: - unique_chapters_data.append({"ms": 0, "name": "Scene"}) + 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_chapter = unique_chapters_data[0] - if first_chapter["ms"] > 0: - if not (first_chapter["ms"] < 5000 and first_chapter["name"] in ["Intro", "Recap"]): - unique_chapters_data.insert(0, {"ms": 0, "name": "Scene"}) - - if unique_chapters_data: - first_chapter = unique_chapters_data[0] - if first_chapter["name"] in ["Intro", "Recap"] and first_chapter["ms"] > 0: - first_chapter["ms"] = 0 + 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"}) - # Create Final Chapter List - final_chapters = [] - for i, chap_info in enumerate(unique_chapters_data): - name = chap_info["name"] - - final_chapters.append( + 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=chap_info["ms"] / 1000.000, - name=name if name != "Scene" else None + timestamp=float(time_sec), + name=chapter_title if chapter_title != "Scene" else None ) ) - return final_chapters + return chapters except Exception as e: self.log.warning(f"Failed to extract chapters: {e}") - return [] + 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"] @@ -691,7 +749,7 @@ class DSNP(Service): 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) + self.log.error(f"License request failed: {e}", exc_info=False) sys.exit(1) return res.content @@ -707,7 +765,7 @@ class DSNP(Service): 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) + self.log.error(f"License request failed: {e}", exc_info=False) sys.exit(1) return res.content @@ -753,7 +811,7 @@ class DSNP(Service): data = self._request("GET", endpoint, params={'limit': 999})["data"]["season"]["items"] return data - def _get_video(self, scenario: str, payload: dict) -> dict: + def _get_playback(self, scenario: str, payload: dict) -> dict: endpoint = self._href( self.prod_config["services"]["media"]["client"]["endpoints"]["mediaPayload"]["href"], scenario=scenario @@ -765,7 +823,7 @@ class DSNP(Service): data = self._request("POST", endpoint, headers=headers, payload=payload) return data["stream"] - def _register_device(self) -> str: + 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"], @@ -776,6 +834,16 @@ class DSNP(Service): "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"] }, @@ -797,7 +865,7 @@ class DSNP(Service): "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] } payload = { - "operationName": "Check", + "operationName": "check", "variables": { "email": email }, @@ -805,7 +873,7 @@ class DSNP(Service): } 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 = { @@ -813,25 +881,88 @@ class DSNP(Service): "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"] } payload = { - "operationName": "loginTv", + "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"]["token"] + 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 _get_account_info_raw(self, headers: dict = {}) -> dict: + 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": "EntitledGraphMeQuery", - "variables": {}, - "query": queries.ENTITLEMENTS + "operationName": "me", + "variables": { + "includeAccountConsentToken": True + }, + "query": queries.ME } data = self._request("POST", endpoint, payload=payload, headers=headers) return data["data"]["me"] @@ -845,7 +976,9 @@ class DSNP(Service): payload = { "operationName": "switchProfile", "variables": { - "input": profile_input + "input": profile_input, + "includeIdentity": True, + "includeAccountConsentToken": True }, "query": queries.SWITCH_PROFILE } @@ -861,7 +994,7 @@ class DSNP(Service): payload = { "operationName": "refreshToken", "variables": { - "input": { + "refreshToken": { "refreshToken": refresh_token } }, @@ -870,7 +1003,7 @@ class DSNP(Service): data = self._request("POST", endpoint, payload=payload, headers=headers) return data["extensions"]["sdk"] - def _update_device(self) -> str: + 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 = { @@ -878,7 +1011,17 @@ class DSNP(Service): "variables": { "updateDeviceOperatingSystem": { "operatingSystem": self.config["device"]["operatingSystem"], - "operatingSystemVersion": self.config["device"]["operatingSystemVersion"] + "operatingSystemVersion": self.config["device"]["operatingSystemVersion"], + "osDeviceIds": [ + { + "identifier": android_id, + "type": "android.vendor.id" + }, + { + "identifier": drm_id, + "type": "android.drm.id" + } + ] } }, "query": queries.UPDATE_DEVICE @@ -906,10 +1049,10 @@ class DSNP(Service): data = self._request("POST", endpoint, payload=payload, headers=headers) if data["data"]["updateProfileImaxEnhancedVersion"]["accepted"]: - self.log.info(f" + Updated IMAX Enhanced preference: {enabled}") + self.log.info(f" + Updated IMAX Enhanced preference: {enabled}") return data["extensions"]["sdk"] else: - self.log.warning(" - Failed to set IMAX preference.") + 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"] @@ -927,10 +1070,10 @@ class DSNP(Service): data = self._request("POST", endpoint, payload=payload, headers=headers) if data["data"]["updateProfileRemasteredAspectRatio"]["accepted"]: - self.log.info(f" + Updated Remastered Aspect Ratio preference: {enabled}") + 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.") + 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"]} @@ -947,7 +1090,6 @@ class DSNP(Service): 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() @@ -956,13 +1098,15 @@ class DSNP(Service): error_code = data["errors"][0]["extensions"]["code"] if "token.service.invalid.grant" in error_code: raise ConnectionError(f"Refresh Token Expired: {error_code}") - if "token.service.unauthorized.client" in 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: diff --git a/DSNP/config.yaml b/DSNP/config.yaml index 5def0ba..9540ddc 100644 --- a/DSNP/config.yaml +++ b/DSNP/config.yaml @@ -10,16 +10,16 @@ certificate: | ## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ## # Browser (windows, chrome) : /browser/v34.2/windows/chrome/prod.json -# Android Phone : /android/v12.0.0/google/handset/prod.json -# Android TV : /android/v12.0.0/google/tv/prod.json -# Amazon Fire TV : /android/v12.0.0/amazon/tv/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/v13.0.0/google/tv/prod.json" + config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v15.0.0/google/tv/prod.json" -## user_agent ## -# android-phone : BAMSDK/v13.3.0 (disney-svod-3d9324fc 4.18.1+rc6-2025.11.05.0; v7.0/v13.0.0; android; phone) -# android-tv : BAMSDK/v13.3.0 (disney-svod-3d9324fc 4.18.1+rc6-2025.11.05.0; v7.0/v13.0.0; android; tv) +## 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 @@ -30,11 +30,11 @@ endpoints: # android : 624b805dafc5c73635b1a216 bamsdk: - sdk_version: "13.3.0" - application_version: "4.18.1+rc6-2025.11.05.0" - explore_version: "v1.11" + 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/v13.3.0 (disney-svod-3d9324fc 4.18.1+rc6-2025.11.05.0; v7.0/v13.0.0; android; tv)" + 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" @@ -46,7 +46,9 @@ device: applicationRuntime: "android" operatingSystem: "Android" operatingSystemVersion: "16" - deviceLanguage: "ko" # en + deviceLanguage: "ko" # Device language data independent of account language data -profile: - index: 0 +# 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/queries.py b/DSNP/queries.py index 975395a..f8ec5ba 100644 --- a/DSNP/queries.py +++ b/DSNP/queries.py @@ -1,13 +1,13 @@ -SWITCH_PROFILE = """mutation switchProfile($input: SwitchProfileInput!) { switchProfile(switchProfile: $input) { __typename account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } } } fragment accountGraphFragment on Account { __typename id activeProfile { __typename id } profiles { __typename ...profileGraphFragment } parentalControls { __typename isProfileCreationProtected } flows { __typename star { __typename isOnboarded } } attributes { __typename email emailVerified userVerified locations { __typename manual { __typename country } purchase { __typename country } registration { __typename geoIp { __typename country } } } } } fragment profileGraphFragment on Profile { __typename id name maturityRating { __typename ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating } isAge21Verified flows { __typename star { __typename eligibleForOnboarding isOnboarded } } attributes { __typename isDefault kidsModeEnabled groupWatch { __typename enabled } languagePreferences { __typename appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { __typename isPinProtected kidProofExitEnabled liveAndUnratedContent { __typename enabled } } playbackSettings { __typename autoplay backgroundVideo prefer133 } avatar { __typename id userSelected } } } fragment sessionGraphFragment on Session { __typename sessionId device { __typename id } entitlements experiments { __typename featureId variantId version } homeLocation { __typename countryCode } inSupportedLocation isSubscriber location { __typename countryCode } portabilityLocation { __typename countryCode } preferredMaturityRating { __typename impliedMaturityRating ratingSystem } }""" -ENTITLEMENTS = """query EntitledGraphMeQuery { me { __typename account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } } } fragment accountGraphFragment on Account { __typename id activeProfile { __typename id } profiles { __typename ...profileGraphFragment } parentalControls { __typename isProfileCreationProtected } flows { __typename star { __typename isOnboarded } } attributes { __typename email emailVerified userVerified locations { __typename manual { __typename country } purchase { __typename country } registration { __typename geoIp { __typename country } } } } } fragment profileGraphFragment on Profile { __typename id name maturityRating { __typename ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating } isAge21Verified flows { __typename star { __typename eligibleForOnboarding isOnboarded } } attributes { __typename isDefault kidsModeEnabled groupWatch { __typename enabled } languagePreferences { __typename appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { __typename isPinProtected kidProofExitEnabled liveAndUnratedContent { __typename enabled } } playbackSettings { __typename autoplay backgroundVideo prefer133 preferImaxEnhancedVersion} avatar { __typename id userSelected } } } fragment sessionGraphFragment on Session { __typename sessionId device { __typename id } entitlements experiments { __typename featureId variantId version } homeLocation { __typename countryCode } inSupportedLocation isSubscriber location { __typename countryCode } portabilityLocation { __typename countryCode } preferredMaturityRating { __typename impliedMaturityRating ratingSystem } }""" -REGISTER_DEVICE = """mutation ($registerDevice: RegisterDeviceInput!) {registerDevice(registerDevice: $registerDevice) {__typename}}""" -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 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 prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }""" -# REQUEST_DEVICE_CODE = ("""mutation requestLicensePlate {requestLicensePlate {__typename licensePlate expirationTime expiresInSeconds}}""") -CHECK_EMAIL = """query Check($email: String!) { check(email: $email) { operations nextOperation } }""" -# REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }""" -LOGIN = """mutation loginTv($input: LoginInput!) { login(login: $input) { __typename account { __typename ...accountGraphFragment } actionGrant activeSession { __typename ...sessionGraphFragment } }} fragment accountGraphFragment on Account { __typename id activeProfile { __typename id } profiles { __typename ...profileGraphFragment } parentalControls { __typename isProfileCreationProtected } flows { __typename star { __typename isOnboarded } } attributes { __typename email emailVerified userVerified locations { __typename manual { __typename country } purchase { __typename country } registration { __typename geoIp { __typename country } } } }}\nfragment profileGraphFragment on Profile { __typename id name maturityRating { __typename ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating } isAge21Verified flows { __typename star { __typename eligibleForOnboarding isOnboarded } } attributes { __typename isDefault kidsModeEnabled groupWatch { __typename enabled } languagePreferences { __typename appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { __typename isPinProtected kidProofExitEnabled liveAndUnratedContent { __typename enabled } } playbackSettings { __typename autoplay backgroundVideo prefer133 } avatar { __typename id userSelected } }}\nfragment sessionGraphFragment on Session { __typename sessionId device { __typename id } entitlements experiments { __typename featureId variantId version } homeLocation { __typename countryCode } inSupportedLocation isSubscriber location { __typename countryCode } portabilityLocation { __typename countryCode } preferredMaturityRating { __typename impliedMaturityRating ratingSystem }}""" -# LOGIN_OTP = """mutation authenticateWithOtp($input: AuthenticateWithOtpInput!) { authenticateWithOtp(authenticateWithOtp: $input) { actionGrant securityAction } }""" -# LOGIN_ACTION_GRANT = """\n mutation loginWithActionGrant($input: LoginWithActionGrantInput!) {\n loginWithActionGrant(login: $input) {\n account {\n ...account\n\n profiles {\n ...profile\n }\n }\n activeSession {\n ...session\n }\n identity {\n ...identity\n }\n }\n }\n\n \nfragment identity on Identity {\n attributes {\n securityFlagged\n createdAt\n passwordResetRequired\n }\n flows {\n marketingPreferences {\n eligibleForOnboarding\n isOnboarded\n }\n personalInfo {\n eligibleForCollection\n requiresCollection\n }\n }\n personalInfo {\n dateOfBirth\n gender\n }\n subscriber {\n subscriberStatus\n subscriptionAtRisk\n overlappingSubscription\n doubleBilled\n doubleBilledProviders\n subscriptions {\n id\n groupId\n state\n partner\n isEntitled\n source {\n sourceType\n sourceProvider\n sourceRef\n subType\n }\n paymentProvider\n product {\n id\n sku\n offerId\n promotionId\n name\n nextPhase {\n sku\n offerId\n campaignCode\n voucherCode\n }\n entitlements {\n id\n name\n desc\n partner\n }\n categoryCodes\n redeemed {\n campaignCode\n redemptionCode\n voucherCode\n }\n bundle\n bundleType\n subscriptionPeriod\n earlyAccess\n trial {\n duration\n }\n }\n term {\n purchaseDate\n startDate\n expiryDate\n nextRenewalDate\n pausedDate\n churnedDate\n isFreeTrial\n }\n externalSubscriptionId,\n cancellation {\n type\n restartEligible\n }\n stacking {\n status\n overlappingSubscriptionProviders\n previouslyStacked\n previouslyStackedByProvider\n }\n }\n }\n}\n\n \nfragment account on Account {\n id\n attributes {\n blocks {\n expiry\n reason\n }\n consentPreferences {\n dataElements {\n name\n value\n }\n purposes {\n consentDate\n firstTransactionDate\n id\n lastTransactionCollectionPointId\n lastTransactionCollectionPointVersion\n lastTransactionDate\n name\n status\n totalTransactionCount\n version\n }\n }\n dssIdentityCreatedAt\n email\n emailVerified\n lastSecurityFlaggedAt\n locations {\n manual {\n country\n }\n purchase {\n country\n source\n }\n registration {\n geoIp {\n country\n }\n }\n }\n securityFlagged\n tags\n taxId\n userVerified\n }\n parentalControls {\n isProfileCreationProtected\n }\n flows {\n star {\n isOnboarded\n }\n }\n}\n\n \nfragment profile on Profile {\n id\n name\n isAge21Verified\n attributes {\n avatar {\n id\n userSelected\n }\n isDefault\n kidsModeEnabled\n languagePreferences {\n appLanguage\n playbackLanguage\n preferAudioDescription\n preferSDH\n subtitleAppearance {\n backgroundColor\n backgroundOpacity\n description\n font\n size\n textColor\n }\n subtitleLanguage\n subtitlesEnabled\n }\n groupWatch {\n enabled\n }\n parentalControls {\n kidProofExitEnabled\n isPinProtected\n }\n playbackSettings {\n autoplay\n backgroundVideo\n prefer133\n preferImaxEnhancedVersion\n previewAudioOnHome\n previewVideoOnHome\n }\n }\n personalInfo {\n dateOfBirth\n gender\n age\n }\n maturityRating {\n ...maturityRating\n }\n personalInfo {\n dateOfBirth\n age\n gender\n }\n flows {\n personalInfo {\n eligibleForCollection\n requiresCollection\n }\n star {\n eligibleForOnboarding\n isOnboarded\n }\n }\n}\n\n\nfragment maturityRating on MaturityRating {\n ratingSystem\n ratingSystemValues\n contentMaturityRating\n maxRatingSystemValue\n isMaxContentMaturityRating\n}\n\n\n \nfragment session on Session {\n device {\n id\n platform\n }\n entitlements\n features {\n coPlay\n }\n inSupportedLocation\n isSubscriber\n location {\n type\n countryCode\n dma\n asn\n regionName\n connectionType\n zipCode\n }\n sessionId\n experiments {\n featureId\n variantId\n version\n }\n identity {\n id\n }\n account {\n id\n }\n profile {\n id\n parentalControls {\n liveAndUnratedContent {\n enabled\n }\n }\n }\n partnerName\n preferredMaturityRating {\n impliedMaturityRating\n ratingSystem\n }\n homeLocation {\n countryCode\n }\n portabilityLocation {\n countryCode\n type\n }\n}\n\n""" -REFRESH_TOKEN = """mutation refreshToken($input:RefreshTokenInput!) { refreshToken(refreshToken:$input) { activeSession{sessionId} } }""" +# 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 diff --git a/TUBI/__init__.py b/TUBI/__init__.py index e022d7c..58431ee 100644 --- a/TUBI/__init__.py +++ b/TUBI/__init__.py @@ -13,7 +13,7 @@ import click from langcodes import Language from pyplayready.cdm import Cdm as PlayReadyCdm from unshackle.core.credential import Credential -from unshackle.core.downloaders import aria2c, requests +from unshackle.core.downloaders import requests from unshackle.core.manifests import DASH from unshackle.core.search_result import SearchResult from unshackle.core.service import Service @@ -50,7 +50,6 @@ class TUBI(Service): Notes: - Authentication is currently not required, but cookies are used if provided. - If 1080p exists, it's currently only available as H.265. - - Unshackle fails to mux properly when n_m3u8dl_re is used, so aria2c is forced as downloader. - Search is currently disabled. """ @@ -239,7 +238,7 @@ class TUBI(Service): track_base = rep_base.text track.url = f"{base_url}/{track_base}" track.descriptor = Track.Descriptor.URL - track.downloader = aria2c + track.downloader = requests if isinstance(track, Audio): role = track.data["dash"]["adaptation_set"].find("Role")