From 7e4627af47d788503435dfe788996d873b81a81c Mon Sep 17 00:00:00 2001 From: VineFeeder Date: Wed, 31 Dec 2025 11:17:54 +0000 Subject: [PATCH] updates --- .../src/envied/services/DSNP/__init__.py | 1473 ++++++++++------- .../src/envied/services/DSNP/config.yaml | 71 +- .../src/envied/services/DSNP/queries.py | 16 +- .../envied/src/envied/unshackle-example.yaml | 504 ++++++ 4 files changed, 1417 insertions(+), 647 deletions(-) create mode 100644 packages/envied/src/envied/unshackle-example.yaml diff --git a/packages/envied/src/envied/services/DSNP/__init__.py b/packages/envied/src/envied/services/DSNP/__init__.py index 12c1111..b4d0c8a 100644 --- a/packages/envied/src/envied/services/DSNP/__init__.py +++ b/packages/envied/src/envied/services/DSNP/__init__.py @@ -3,725 +3,970 @@ from __future__ import annotations import re import sys import uuid +from datetime import datetime from collections.abc import Generator from http.cookiejar import CookieJar -from typing import Any, Optional, Union +from typing import Any, Optional, Union, List +from langcodes import Language import click from click import Context +from pyplayready.cdm import Cdm as PlayReadyCdm from requests import Request -from envied.core.downloaders import n_m3u8dl_re +from envied.core.constants import AnyTrack from envied.core.credential import Credential -from envied.core.manifests import HLS from envied.core.search_result import SearchResult from envied.core.service import Service -from envied.core.titles import Episode, Movie, Movies, Series -from envied.core.tracks import Chapters, Tracks, Video, Hybrid +from envied.core.manifests import HLS +from envied.core.titles import Title_T, Titles_T, Episode, Movie, Movies, Series +from envied.core.tracks import Chapter, Chapters, Tracks, Attachment, Video, Audio, Subtitle from envied.core.utils.collections import as_list +from envied.core.utilities import get_ip_info from . import queries - class DSNP(Service): """ - \b - Service code for DisneyPlus streaming service (https://www.disneyplus.com). - - \b - Authorization: Credentials - Robustness: - Widevine: - L1: 2160p, 1080p - L3: 720p - PlayReady: - SL3: 2160p, 1080p - - \b - Tips: - - Input should be only the entity ID for both series and movies: - MOVIE: entity-99e15d53-926e-4074-b9f4-6524d10c8bed - SERIES: entity-30429ad6-dd12-41bf-924e-19131fa66bb5 - - Use the --lang LANG_RANGE option to request non-english tracks - - CDM level dictates playback quality (L3 == 720p, L1 == 1080p, 2160p) - - \b - Notes: - - On first run, the program will look for the first account profile that doesn't - have kids mode or pin protection enabled. If none are found, the program will exit. - - The profile will be cached and re-used until cache is cleared. + 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 """ - @staticmethod - @click.command(name="DSNP", short_help="https://www.disneyplus.com", help=__doc__) - @click.argument("title", type=str) - @click.option( - "-m", "--movie", is_flag=True, default=False, help="Title is a Movie." + 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(?:/[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.pass_context def cli(ctx: Context, **kwargs: Any) -> DSNP: return DSNP(ctx, **kwargs) - def __init__(self, ctx: Context, title: str, movie): + def __init__(self, ctx: Context, title: str, imax: bool, remastered_ar: bool): self.title = title - self.movie = movie 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 or False + self.prefer_remastered_ar = remastered_ar or False + + 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) + self.is_l3 = (self.cdm.security_level < 3000) if self.playready else (self.cdm.security_level == 3) - vcodec = ctx.parent.params.get("vcodec") - range = ctx.parent.params.get("range_") + self.region = None + self.prod_config = {} + self.account_tokens = {} + self.active_session = {} + self.playback_data = {} - self.range = range[0].name if range else "SDR" - self.vcodec = "H.265" if vcodec and vcodec == Video.Codec.HEVC else "H.264" - if self.range != "SDR" and self.vcodec != "H.265": - self.vcodec = "H.265" + 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 == [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.") + + 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.") - # Use exact headers from working Vinetrimmer implementation to avoid geoblocking - self.session.headers.update({ - "Accept-Language": "en-US,en;q=0.5", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - "Origin": "https://www.disneyplus.com", - "x-bamsdk-platform": "javascript/windows/chrome", - "x-bamsdk-version": "28.0", - "x-bamsdk-client-id": "disney-svod", - "Accept-Encoding": "gzip", - }) - self.session.headers.update({"x-bamsdk-transaction-id": str(uuid.uuid4())}) - self.prd_config = self.session.get(self.config["CONFIG_URL"]).json() + self.log.info("Logging into Disney+...") + self._login() - self._cache = self.cache.get(f"tokens_{credential.sha1}") - if self._cache: - self.log.info(" + Refreshing Tokens") - profile = self.refresh_token(self._cache.data["token"]["refreshToken"]) - self._cache.set(profile, expiration=profile["token"]["expiresIn"] - 30) - token = self._cache.data["token"]["accessToken"] - self.session.headers.update({"Authorization": "Bearer {}".format(token)}) - self.active_session = self.account()["activeSession"] - else: - self.log.info(" + Setting up new profile...") - token = self.register_device() - status = self.check_email(credential.username, token) - if status.lower() == "register": - raise ValueError("Account is not registered. Please register first.") - elif status.lower() == "otp": - self.log.error(" - Account requires passcode for login.") + 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) - else: - tokens = self.login(credential.username, credential.password, token) - self.session.headers.update({"Authorization": "Bearer {}".format(tokens["accessToken"])}) - account = self.account() - profile_id = next( - ( - x.get("id") - for x in account["account"]["profiles"] - if not x["attributes"]["kidsModeEnabled"] - and not x["attributes"]["parentalControls"]["isPinProtected"] - ), - None, - ) - if not profile_id: - self.log.error( - " - Missing profile - you need at least one profile with kids mode and pin protection disabled" - ) - sys.exit(1) + 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'] - set_profile = self.switch_profile(profile_id) - profile = self.refresh_token(set_profile["token"]["refreshToken"]) - self._cache.set(profile, expiration=profile["token"]["expiresIn"] - 30) - token = self._cache.data["token"]["accessToken"] - self.session.headers.update({"Authorization": "Bearer {}".format(token)}) - self.active_session = self.account()["activeSession"] + if target_profile['id'] != active_profile_id: + self._perform_switch_profile(target_profile, self.session.headers) - self.log.info(" + Acquired tokens...") + self.log.info(" + Refreshing session data after profile switch...") + full_account_info = self._get_account_info_raw() + self.active_session = full_account_info["activeSession"] + self.active_session['account'] = full_account_info['account'] + self.log.info("Session data updated successfully.") - def search(self) -> Generator[SearchResult, None, None]: - params = { - "query": self.title, - } - endpoint = self.href( - self.prd_config["services"]["explore"]["client"]["endpoints"]["search"]["href"], - version=self.config["EXPLORE_VERSION"], + 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: + self._set_imax_preference(self.prefer_imax) + + 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) + + 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["token"]["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_raw() + 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() + + 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) + 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) + + 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) + 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) + final_token_data = self._refresh_token(switch_profile_data["token"]["refreshToken"]) + self._apply_new_tokens(final_token_data) + + 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["token"]["refreshToken"]) + bearer = self._apply_new_tokens(refreshed_data) + return bearer + except Exception as _: + self.log.error("Refresh Token Expired", exc_info=False) + sys.exit(1) + + def _apply_new_tokens(self, token_data: dict) -> str: + self.account_tokens = token_data + + bearer = self.account_tokens["token"]["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 + 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.get("id") + entity = "entity-" + result["id"] yield SearchResult( id_=entity, - title=result["visuals"].get("title"), - description=result["visuals"]["description"].get("brief"), - label=result["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"), + 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) -> Union[Movies, Series]: - # Use Vinetrimmer logic - handle both entity IDs and other formats - if not "entity" in self.title: - # Convert to entity ID like Vinetrimmer does + 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: try: - deeplinkId_response = self.session.get( - url='https://disney.api.edge.bamgrid.com/explore/v1.3/deeplink', - params={ - 'refId': self.title, - 'refIdType': 'encodedFamilyId' # Try movie first - } - ) - if deeplinkId_response.status_code == 200: - deeplinkId = deeplinkId_response.json() - self.title = deeplinkId["data"]["deeplink"]["actions"][0]["deeplinkId"] - else: - # Try with encodedSeriesId - deeplinkId_response = self.session.get( - url='https://disney.api.edge.bamgrid.com/explore/v1.3/deeplink', - params={ - 'refId': self.title, - 'refIdType': 'encodedSeriesId' - } + 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.") + except Exception as e: + self.log.error(f" - Failed to determine content type via deeplink ({e}).", exc_info=False) + sys.exit(1) + 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"] + 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 deeplinkId_response.status_code == 200: - deeplinkId = deeplinkId_response.json() - self.title = deeplinkId["data"]["deeplink"]["actions"][0]["deeplinkId"] - except Exception: - # If all fails, assume it's already an entity ID - pass - - content = self.get_deeplink(self.title) - - # Handle deeplink response structure - determine if series or movie from infoBlock - if "data" in content and "deeplink" in content["data"]: - actions = content["data"]["deeplink"]["actions"] - if actions and len(actions) > 0: - action = actions[0] - # Check the infoBlock to determine content type like Vinetrimmer does - info_block = action.get("infoBlock", "") - if "urn:ds:cmp:eva:series" in info_block: - _type = "series" - elif "urn:ds:cmp:eva:movie" in info_block or "urn:ds:cmp:eva:film" in info_block: - _type = "movie" - else: - # Fallback: assume series for browse type - _type = "series" if action.get("type") == "browse" else "movie" - else: - raise ValueError("No actions found in deeplink response") - else: - raise ValueError("Invalid deeplink response structure") - - if _type == "movie" or self.movie: - movie = self._movie(self.title) - return Movies(movie) - - elif _type == "series": - episodes = self._show(self.title) - return Series(episodes) - - else: - raise ValueError(f"Unknown content type: {_type}") - - def get_tracks(self, title: Union[Movie, Episode]) -> Tracks: - resource_id = title.data.get("resourceId") - - # =============================== - # TOKEN REFRESH - # =============================== - if self._cache: - refresh_token = self._cache.data["token"]["refreshToken"] - fresh_token = self.refresh_token(refresh_token) - self._cache.set(fresh_token, expiration=fresh_token["token"]["expiresIn"] - 30) - - # Update session header - token = fresh_token["token"]["accessToken"] - self.session.headers.update({"Authorization": f"Bearer {token}"}) - - # =============================== - # INTERNAL MANIFEST FETCHER - # =============================== - def get_manifest_for_scenario(scenario_name, quality=None): - original_headers = dict(self.session.headers) - - # Vinetrimmer-style headers - self.session.headers.update({ - 'x-dss-feature-filtering': 'true', - 'x-application-version': '1.1.2', - 'x-bamsdk-client-id': 'disney-svod', - 'x-bamsdk-platform': 'javascript/windows/chrome', - 'x-bamsdk-version': '28.0' - }) - - # Default resolution (Vinetrimmer-style) - if quality is None: - quality = '1280' if hasattr(self, 'cdm') and self.cdm.security_level == 3 else '1920' - - json_data = { - 'playback': { - 'attributes': { - 'resolution': { - 'max': [quality], - }, - 'protocol': 'HTTPS', - 'assetInsertionStrategy': 'SGAI', - 'playbackInitiationContext': 'ONLINE', - 'frameRates': [60], - }, - }, - 'playbackId': resource_id, - } - - try: - res = self.session.post( - f'https://disney.playback.edge.bamgrid.com/v7/playback/{scenario_name}', - json=json_data - ) - if res.status_code == 200: - data = res.json() - manifest_url = data["stream"]["sources"][0]['complete']['url'] - return HLS.from_url(url=manifest_url, session=self.session).to_tracks(language="en-US") - return None - finally: - self.session.headers.clear() - self.session.headers.update(original_headers) - - # ========================================================== - # =============== HYBRID RANGE (DV + HDR10) ================= - # ========================================================== - if self.range == "HYBRID": - self.log.info("HYBRID mode — fetching HDR10 + DV manifests") - - all_tracks = Tracks() - - # -------- Fetch HDR10 ------------ - self.log.info("Fetching HDR10 tracks") - self.range = Video.Range.HDR10 - hdr_scenario = 'tv-drm-cbcs-h265-hdr10' - hdr_tracks = get_manifest_for_scenario(hdr_scenario, '2160') - if hdr_tracks: - all_tracks.add(hdr_tracks, warn_only=True) - - # -------- Fetch DV --------------- - self.log.info("Fetching DV tracks") - self.range = Video.Range.DV - dv_scenario = 'tv-drm-cbcs-h265-dovi' - dv_tracks = get_manifest_for_scenario(dv_scenario, '2160') - if dv_tracks: - all_tracks.add(dv_tracks, warn_only=True) - - # Restore range - self.range = "HYBRID" - - tracks = all_tracks - self.log.info("HYBRID fetch complete — merge will occur after download") - - # ========================================================== - # =============== NON-HYBRID MODES ========================= - # ========================================================== - else: - if self.vcodec == "H.265" and self.range == 'HDR10': - scenario = 'tv-drm-cbcs-h265-hdr10' - quality = '2160' - elif self.vcodec == "H.265" and self.range == 'DV': - scenario = 'tv-drm-cbcs-h265-dovi' - quality = '2160' - else: - scenario = 'tv-drm-cbcs' - quality = '1920' - - tracks = get_manifest_for_scenario(scenario, quality) - if not tracks: - raise ValueError("Failed to fetch DSNP manifest") - - # ===================================================== - # Fetch ATMOS/H265 secondary manifest (like Vinetrimmer) - # ===================================================== - atmos_tracks = get_manifest_for_scenario('tv-drm-ctr-h265-atmos') - if atmos_tracks: - tracks.videos.extend(atmos_tracks.videos) - tracks.audio.extend(atmos_tracks.audio) - tracks.subtitles.extend(atmos_tracks.subtitles) - - # ===================================================== - # AUDIO BITRATE FIX - # ===================================================== - for audio in tracks.audio: - bitrate = re.search( - r"(?<=r/composite_)\d+|\d+(?=_complete.m3u8)", - as_list(audio.url)[0], + ] ) - audio.bitrate = int(bitrate.group()) * 1000 - if audio.bitrate == 1000_000: # DSNP lies about Atmos - audio.bitrate = 768_000 + + elif content_type == "series": + return Series(self._get_series(page, orig_lang)) + + 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: + 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=page["visuals"]["metastringParts"]["releaseYearRange"]["startYear"], + language=Language.get(orig_lang), + data=ep + ) + ) + + return 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 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: + video_ranges = ["DOLBY_VISION"] + elif Video.Range.HDR10 in self.range or Video.Range.HDR10P in self.range: + 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], + "assetInsertionStrategy": "SGAI", # Server-Guided Ad Insertion + "playbackInitiationContext": "ONLINE" + } + + 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_video(scenario, payload) + + manifest_url = self.playback_data[title.id]["sources"][0]['complete']['url'] + return HLS.from_url(url=manifest_url, session=self.session).to_tracks(title.language) - # ===================================================== - # FINAL CONFIG - # ===================================================== + 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 track not in tracks.attachments: - track.downloader = n_m3u8dl_re - track.needs_repack = True - + 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 + + for subtitle in tracks.subtitles: + subtitle.codec = Subtitle.Codec.WebVTT + return tracks - def get_chapters(self, title: Union[Movie, Episode]) -> Chapters: - return Chapters() - - def get_widevine_service_certificate(self, **_: Any) -> str: - return None - - def get_widevine_license(self, *, challenge: bytes, title, track) -> bytes: - """Get Widevine license for Disney+ content - Adapted from working Vinetrimmer implementation""" - # Force token refresh like Vinetrimmer does for license calls - if self._cache: - refresh_token = self._cache.data["token"]["refreshToken"] - fresh_token = self.refresh_token(refresh_token) - self._cache.set(fresh_token, expiration=fresh_token["token"]["expiresIn"] - 30) - token = fresh_token["token"]["accessToken"] - else: - return None - - headers = { - "Authorization": f'Bearer {token}', - "Content-Type": "application/octet-stream", - } - r = self.session.post(url=self.config["LICENSE"], headers=headers, data=challenge) - if r.status_code != 200: - raise ConnectionError(r.text) - return r.content - - def get_playready_license(self, *, challenge: bytes, title, track) -> Optional[bytes]: - """Get PlayReady license for Disney+ content - Adapted from working Vinetrimmer implementation""" - # Refresh token if needed - token = self._cache.data["token"]["accessToken"] if self._cache else None - if not token: - return None - - r = self.session.post( - url='https://disney.playback.edge.bamgrid.com/playready/v1/obtain-license.asmx', - headers={'authorization': f'Bearer {token}'}, - data=challenge - ) - - if r.status_code != 200: - raise ConnectionError(r.text) - return r.text.encode() - - # Service specific functions - - def _show(self, title: str) -> Episode: - page = self.get_page(title) - container = next(x for x in page["containers"] if x.get("type") == "episodes") - season_ids = [x.get("id") for x in container["seasons"] if x.get("type") == "season"] - - episodes = [] - for season in season_ids: - # Use direct Disney+ API URL like Vinetrimmer does - endpoint = f'https://disney.api.edge.bamgrid.com/explore/v1.3/season/{season}' - params = {'limit': 80} - - response = self.session.get(endpoint, params=params) - if response.status_code == 200: - data = response.json()["data"]["season"]["items"] - episodes.extend(data) - else: - self.log.warning(f"Failed to get season {season}: {response.status_code}") - - return [ - Episode( - id_=episode.get("id"), - service=self.__class__, - title=episode["visuals"].get("title"), - year=episode["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"), - season=int(episode["visuals"].get("seasonNumber", 0)), - number=int(episode["visuals"].get("episodeNumber", 0)), - name=episode["visuals"].get("episodeTitle"), - data=next(x for x in episode["actions"] if x.get("type") == "playback"), - ) - for episode in episodes - if episode.get("type") == "view" - ] - - def _movie(self, title: str) -> Movie: - movie = self.get_page(title) - - return [ - Movie( - id_=movie.get("id"), - service=self.__class__, - name=movie["visuals"].get("title"), - year=movie["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"), - data=next(x for x in movie["actions"] if x.get("type") == "playback"), - ) - ] - - def _request( - self, - method: str, - endpoint: str, - params: dict = None, - headers: dict = None, - payload: dict = None, - ) -> Any[dict | str]: - _headers = headers if headers else self.session.headers - prep = self.session.prepare_request(Request(method, endpoint, headers=_headers, params=params, json=payload)) - response = self.session.send(prep) - - # Check for geoblocking - if response.status_code == 404 and 'x-dss-edge' in response.headers: - edge_error = response.headers.get('x-dss-edge', '') - if 'location.invalid' in edge_error: - raise ConnectionError("Disney+ content is geoblocked in your region. Please use a VPN to US/supported region.") - + def get_chapters(self, title: Title_T) -> Chapters: try: - data = response.json() - if data.get("errors"): - code = data["errors"][0]["extensions"].get("code") + editorial = self.playback_data[title.id]["editorial"] - if "token.service.unauthorized.client" in code: - raise ConnectionError("Unauthorized Client/IP: " + code) - if "idp.error.identity.bad-credentials" in code: - raise ConnectionError("Bad Credentials: " + code) + if not editorial: + return [] + + label_to_group = { + "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 + "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: - raise ConnectionError(data["errors"]) - return data + final_timestamp = timestamps[0] + + resolved_markers.append({"group": group, "ms": final_timestamp}) - except Exception as e: - if response.status_code == 404: - raise ConnectionError(f"Disney+ content not found or geoblocked. Status: {response.status_code}") - raise ConnectionError(f"Request failed. Status: {response.status_code}, Content: {response.content}") - - def get_page(self, title): - # Use direct Disney+ API URL like Vinetrimmer does - no need for SDK endpoints - params = { - "disableSmartFocus": True, - "enhancedContainersLimit": 12, - "limit": 24, - } - - # Use exact Vinetrimmer URL pattern - endpoint = f'https://disney.api.edge.bamgrid.com/explore/v1.4/page/{title}' - - response = self.session.get(endpoint, params=params) - if response.status_code == 200: - return response.json()["data"]["page"] - else: - raise ConnectionError(f"Failed to get page data. Status: {response.status_code}") - - def get_video(self, content_id: str) -> dict: - # Use Vinetrimmer's approach - get manifest directly using playback API - # Add special headers like Vinetrimmer does to avoid geoblocking - original_headers = dict(self.session.headers) - self.session.headers.update({ - 'x-dss-feature-filtering': 'true', - 'x-application-version': '1.1.2', - 'x-bamsdk-client-id': 'disney-svod', - 'x-bamsdk-platform': 'javascript/windows/chrome', - 'x-bamsdk-version': '28.0' - }) - - try: - # Use playback API like Vinetrimmer with exact configuration - # Use exact Vinetrimmer configuration - L3 CAN get 1080p if done correctly - json_data = { - 'playback': { - 'attributes': { - 'resolution': { - 'max': ['1920'], # Same as Vinetrimmer - no artificial L3 limitation - }, - 'protocol': 'HTTPS', - 'assetInsertionStrategy': 'SGAI', - 'playbackInitiationContext': 'ONLINE', - 'frameRates': [60], - }, - }, - 'playbackId': content_id, + # Create Chapter Data + raw_chapter_data = [] + group_to_name = { + "recap_start": "Recap", + "recap_end": "Scene", + "intro_start": "Intro", + "intro_end": "Scene", + "credits_start": "Credits", } - # Use exact Vinetrimmer playback URL and scenario - manifest_response = self.session.post( - 'https://disney.playback.edge.bamgrid.com/v7/playback/tv-drm-ctr', - json=json_data - ) - - if manifest_response.status_code == 200: - manifest_data = manifest_response.json() - # Return in expected format - return { - "video": { - "mediaMetadata": { - "playbackUrls": [{"url": manifest_data["stream"]["sources"][0]['complete']['url']}] - } - } - } - else: - # Fallback: try original method but with new headers - endpoint = self.href( - self.prd_config["services"]["content"]["client"]["endpoints"]["getDmcVideo"]["href"], - contentId=content_id - ) - data = self._request("GET", endpoint)["data"]["DmcVideo"] - return data - except Exception as e: - # Final fallback - endpoint = self.href( - self.prd_config["services"]["content"]["client"]["endpoints"]["getDmcVideo"]["href"], - contentId=content_id - ) - data = self._request("GET", endpoint)["data"]["DmcVideo"] - return data + total_runtime_ms = 0 + if "visuals" in title.data and "metastringParts" in title.data["visuals"]: + total_runtime_ms = title.data["visuals"]["metastringParts"]["runtime"]["runtimeMs"] + + for marker in resolved_markers: + group = marker["group"] + timestamp_ms = marker["ms"] + name = None + + 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) - finally: - # Restore original headers - self.session.headers.clear() - self.session.headers.update(original_headers) + if name: + raw_chapter_data.append({"ms": timestamp_ms, "name": name}) + + # Sorting and deduplication in chronological order + if not raw_chapter_data: + return [] - def get_deeplink(self, ref_id: str) -> str: - # Use direct Disney+ API URL like Vinetrimmer does - params = { - "refId": ref_id, - "refIdType": "deeplinkId", + unique_chapters_data = [] + 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"}) + 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 + + # Create Final Chapter List + final_chapters = [] + for i, chap_info in enumerate(unique_chapters_data): + name = chap_info["name"] + + final_chapters.append( + Chapter( + timestamp=chap_info["ms"] / 1000.000, + name=name if name != "Scene" else None + ) + ) + + return final_chapters + + except Exception as e: + self.log.warning(f"Failed to extract chapters: {e}") + return [] + + 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" } - endpoint = "https://disney.api.edge.bamgrid.com/explore/v1.3/deeplink" - - response = self.session.get(endpoint, params=params) - if response.status_code == 200: - return response.json() - else: - raise ConnectionError(f"Failed to get deeplink. Status: {response.status_code}") - - def series_bundle(self, series_id: str) -> dict: - endpoint = self.href( - self.prd_config["services"]["content"]["client"]["endpoints"]["getDmcSeriesBundle"]["href"], - encodedSeriesId=series_id, + 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 ) - - return self.session.get(endpoint).json()["data"]["DmcSeriesBundle"] - - def refresh_token(self, refresh_token: str): - payload = { - "operationName": "refreshToken", - "variables": { - "input": { - "refreshToken": refresh_token, - }, - }, - "query": queries.REFRESH_TOKEN, + 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 - endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["refreshToken"]["href"] - data = self._request("POST", endpoint, payload=payload, headers={"authorization": self.config["API_KEY"]}) - return data["extensions"]["sdk"] + def _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 _refresh(self): - if not self._cache.expired: - return self._cache.data["token"]["accessToken"] + 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 - profile = self.refresh_token(self._cache.data["token"]["refreshToken"]) - self._cache.set(profile, expiration=profile["token"]["expiresIn"] - 30) - return self._cache.data["token"]["accessToken"] + def _get_video(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) -> dict: + def _register_device(self) -> 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.get("BAM_APPLICATION_RUNTIME", "android"), + "applicationRuntime": self.config["device"]["applicationRuntime"], "attributes": { - "operatingSystem": "Android", - "operatingSystemVersion": "8.1.0", + "operatingSystem": self.config["device"]["operatingSystem"], + "operatingSystemVersion": self.config["device"]["operatingSystemVersion"] }, - "deviceFamily": self.config.get("BAM_FAMILY", "browser"), # Use Vinetrimmer family - "deviceLanguage": "en", - "deviceProfile": self.config.get("BAM_PROFILE", "tv"), + "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, + "query": queries.REGISTER_DEVICE } - - endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["registerDevice"]["href"] - data = self._request("POST", endpoint, payload=payload, headers={"authorization": self.config["API_KEY"]}) + data = self._request("POST", endpoint, payload=payload, headers=headers) return data["extensions"]["sdk"]["token"]["accessToken"] - def login(self, email: str, password: str, token: str) -> dict: + 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": "loginTv", "variables": { "input": { "email": email, - "password": password, - }, + "password": password + } }, - "query": queries.LOGIN, + "query": queries.LOGIN } - - endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - data = self._request("POST", endpoint, payload=payload, headers={"authorization": token}) + data = self._request("POST", endpoint, payload=payload, headers=headers) return data["extensions"]["sdk"]["token"] - def href(self, href, **kwargs) -> str: - _args = { - "apiVersion": "{apiVersion}", - "region": self.active_session["location"]["countryCode"], - "impliedMaturityRating": 1850, - "kidsModeEnabled": "false", - "appLanguage": "en-US", - "partner": "disney", - } - _args.update(**kwargs) - - href = href.format(**_args) - - # [3.0, 3.1, 3.2, 5.0, 3.3, 5.1, 6.0, 5.2, 6.1] - api_version = "6.1" - if "/search/" in href: - api_version = "5.1" - - return href.format(apiVersion=api_version) - - def check_email(self, email: str, token: str) -> str: - payload = { - "operationName": "Check", - "variables": { - "email": email, - }, - "query": queries.CHECK_EMAIL, - } - - endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - data = self._request("POST", endpoint, payload=payload, headers={"authorization": token}) - return data["data"]["check"]["operations"][0] - - def account(self) -> dict: - endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - + def _get_account_info_raw(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, + "query": queries.ENTITLEMENTS } - - data = self._request("POST", endpoint, payload=payload) + data = self._request("POST", endpoint, payload=payload, headers=headers) return data["data"]["me"] - def switch_profile(self, profile_id: str) -> dict: + 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": { - "profileId": profile_id, - }, + "input": profile_input }, - "query": queries.SWITCH_PROFILE, + "query": queries.SWITCH_PROFILE } - - endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - data = self._request("POST", endpoint, payload=payload) + 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": { + "input": { + "refreshToken": refresh_token + } + }, + "query": queries.REFRESH_TOKEN + } + data = self._request("POST", endpoint, payload=payload, headers=headers) + return data["extensions"]["sdk"] + def _update_device(self) -> 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"] + } + }, + "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}") + if "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"]) + 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/packages/envied/src/envied/services/DSNP/config.yaml b/packages/envied/src/envied/services/DSNP/config.yaml index c0611d0..5def0ba 100644 --- a/packages/envied/src/envied/services/DSNP/config.yaml +++ b/packages/envied/src/envied/services/DSNP/config.yaml @@ -1,29 +1,52 @@ -# Use exact configuration from working Vinetrimmer implementation -CLIENT_ID: disney-svod-3d9324fc -CLIENT_VERSION: "6.0.4" +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== -API_KEY: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA" -CONFIG_URL: https://bam-sdk-configs.bamgrid.com/bam-sdk/v3.0/disney-svod-3d9324fc/android/v6.0.0/google/tv/prod.json +## 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 -PAGE_SIZE_SETS: 15 -PAGE_SIZE_CONTENT: 30 -SEARCH_QUERY_TYPE: ge -BAM_PARTNER: disney -EXPLORE_VERSION: v1.3 +endpoints: + config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v13.0.0/google/tv/prod.json" -# Use Vinetrimmer family/platform combination that avoids geoblocking -BAM_FAMILY: browser -BAM_PLATFORM: android-tv -BAM_PROFILE: tv -BAM_APPLICATION_RUNTIME: android +## 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) -HEADERS: - User-Agent: BAMSDK/v6.0.4 (disney-svod-3d9324fc 1.15.0.0; v3.0/v6.0.0; android; tv) google Nexus Player (OPR2.170623.027; Linux; 8.0.0; API 26) - x-application-version: google - x-bamsdk-platform-id: android-tv - x-bamsdk-client-id: disney-svod-3d9324fc - x-bamsdk-platform: android-tv - x-bamsdk-version: "6.0.4" - Accept-Encoding: gzip +## api_key ## +# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84 +# android : ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA -LICENSE: https://disney.playback.edge.bamgrid.com/widevine/v1/obtain-license +## yp_service_id ## +# browser : 63626081279ebe65eb50fb54 +# android : 624b805dafc5c73635b1a216 + +bamsdk: + sdk_version: "13.3.0" + application_version: "4.18.1+rc6-2025.11.05.0" + explore_version: "v1.11" + 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)" + 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" # en + +profile: + index: 0 diff --git a/packages/envied/src/envied/services/DSNP/queries.py b/packages/envied/src/envied/services/DSNP/queries.py index a3d3246..975395a 100644 --- a/packages/envied/src/envied/services/DSNP/queries.py +++ b/packages/envied/src/envied/services/DSNP/queries.py @@ -1,15 +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!) {updateProfileImaxEnhancedVersion(updateProfileImaxEnhancedVersion: $input) {accepted}}""" -REQUEST_DEVICE_CODE = ( - """mutation requestLicensePlate {requestLicensePlate {__typename licensePlate expirationTime expiresInSeconds}}""" -) +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 } }""" +# 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""" +# 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} } }""" - - +UPDATE_DEVICE = """mutation updateDeviceOperatingSystem($updateDeviceOperatingSystem: UpdateDeviceOperatingSystemInput!) {updateDeviceOperatingSystem(updateDeviceOperatingSystem: $updateDeviceOperatingSystem) {accepted}}""" \ No newline at end of file diff --git a/packages/envied/src/envied/unshackle-example.yaml b/packages/envied/src/envied/unshackle-example.yaml new file mode 100644 index 0000000..d6e08c2 --- /dev/null +++ b/packages/envied/src/envied/unshackle-example.yaml @@ -0,0 +1,504 @@ +# API key for The Movie Database (TMDB) +tmdb_api_key: "" + +# Client ID for SIMKL API (optional, improves metadata matching) +# Get your free client ID at: https://simkl.com/settings/developer/ +simkl_client_id: "" + +# Group or Username to postfix to the end of all download filenames following a dash +tag: user_tag + +# Enable/disable tagging with group name (default: true) +tag_group_name: true + +# Enable/disable tagging with IMDB/TMDB/TVDB details (default: true) +tag_imdb_tmdb: true + +# Set terminal background color (custom option not in CONFIG.md) +set_terminal_bg: false + +# Set file naming convention +# true for style - Prime.Suspect.S07E01.The.Final.Act.Part.One.1080p.ITV.WEB-DL.AAC2.0.H.264 +# false for style - Prime Suspect S07E01 The Final Act - Part One +scene_naming: true + +# Whether to include the year in series names for episodes and folders (default: true) +# true for style - Show Name (2023) S01E01 Episode Name +# false for style - Show Name S01E01 Episode Name +series_year: true + +# Check for updates from GitHub repository on startup (default: true) +update_checks: true + +# How often to check for updates, in hours (default: 24) +update_check_interval: 24 + +# Title caching configuration +# Cache title metadata to reduce redundant API calls +title_cache_enabled: true # Enable/disable title caching globally (default: true) +title_cache_time: 1800 # Cache duration in seconds (default: 1800 = 30 minutes) +title_cache_max_retention: 86400 # Maximum cache retention for fallback when API fails (default: 86400 = 24 hours) + +# Debug logging configuration +# Comprehensive JSON-based debug logging for troubleshooting and service development +debug: + false # Enable structured JSON debug logging (default: false) + # When enabled with --debug flag or set to true: + # - Creates JSON Lines (.jsonl) log files with complete debugging context + # - Logs: session info, CLI params, service config, CDM details, authentication, + # titles, tracks metadata, DRM operations, vault queries, errors with stack traces + # - File location: logs/unshackle_debug_{service}_{timestamp}.jsonl + # - Also creates text log: logs/unshackle_root_{timestamp}.log + +debug_keys: + false # Log decryption keys in debug logs (default: false) + # Set to true to include actual decryption keys in logs + # Useful for debugging key retrieval and decryption issues + # SECURITY NOTE: Passwords, tokens, cookies, and session tokens + # are ALWAYS redacted regardless of this setting + # Only affects: content_key, key fields (the actual CEKs) + # Never affects: kid, keys_count, key_id (metadata is always logged) + +# Muxing configuration +muxing: + set_title: false + +# Login credentials for each Service +credentials: + # Direct credentials (no profile support) + EXAMPLE: email@example.com:password + + # Per-profile credentials with default fallback + SERVICE_NAME: + default: default@email.com:password # Used when no -p/--profile is specified + profile1: user1@email.com:password1 + profile2: user2@email.com:password2 + + # Per-profile credentials without default (requires -p/--profile) + SERVICE_NAME2: + john: john@example.com:johnspassword + jane: jane@example.com:janespassword + + # You can also use list format for passwords with special characters + SERVICE_NAME3: + default: ["user@email.com", ":PasswordWith:Colons"] + +# Override default directories used across unshackle +directories: + cache: Cache + cookies: Cookies + dcsl: DCSL # Device Certificate Status List + downloads: Downloads + logs: Logs + temp: Temp + wvds: WVDs + prds: PRDs + # Additional directories that can be configured: + # commands: Commands + services: + - /path/to/services + - /other/path/to/services + # vaults: Vaults + # fonts: Fonts + +# Pre-define which Widevine or PlayReady device to use for each Service +cdm: + # Global default CDM device (fallback for all services/profiles) + default: WVD_1 + + # Direct service-specific CDM + DIFFERENT_EXAMPLE: PRD_1 + + # Per-profile CDM configuration + EXAMPLE: + john_sd: chromecdm_903_l3 # Profile 'john_sd' uses Chrome CDM L3 + jane_uhd: nexus_5_l1 # Profile 'jane_uhd' uses Nexus 5 L1 + default: generic_android_l3 # Default CDM for this service + + # NEW: Quality-based CDM selection + # Use different CDMs based on video resolution + # Supports operators: >=, >, <=, <, or exact match + EXAMPLE_QUALITY: + "<=1080": generic_android_l3 # Use L3 for 1080p and below + ">1080": nexus_5_l1 # Use L1 for above 1080p (1440p, 2160p) + default: generic_android_l3 # Optional: fallback if no quality match + + # You can mix profiles and quality thresholds in the same service + NETFLIX: + # Profile-based selection (existing functionality) + john: netflix_l3_profile + jane: netflix_l1_profile + # Quality-based selection (new functionality) + "<=720": netflix_mobile_l3 + "1080": netflix_standard_l3 + ">=1440": netflix_premium_l1 + # Fallback + default: netflix_standard_l3 + +# Use pywidevine Serve-compliant Remote CDMs + +# Example: Custom CDM API Configuration +# This demonstrates the highly configurable custom_api type that can adapt to any CDM API format +# - name: "chrome" +# type: "custom_api" +# host: "http://remotecdm.test/" +# timeout: 30 +# device: +# name: "ChromeCDM" +# type: "CHROME" +# system_id: 34312 +# security_level: 3 +# auth: +# type: "header" +# header_name: "x-api-key" +# key: "YOUR_API_KEY_HERE" +# custom_headers: +# User-Agent: "Unshackle/2.0.0" +# endpoints: +# get_request: +# path: "/get-challenge" +# method: "POST" +# timeout: 30 +# decrypt_response: +# path: "/get-keys" +# method: "POST" +# timeout: 30 +# request_mapping: +# get_request: +# param_names: +# scheme: "device" +# init_data: "init_data" +# static_params: +# scheme: "Widevine" +# decrypt_response: +# param_names: +# scheme: "device" +# license_request: "license_request" +# license_response: "license_response" +# static_params: +# scheme: "Widevine" +# response_mapping: +# get_request: +# fields: +# challenge: "challenge" +# session_id: "session_id" +# message: "message" +# message_type: "message_type" +# response_types: +# - condition: "message_type == 'license-request'" +# type: "license_request" +# success_conditions: +# - "message == 'success'" +# decrypt_response: +# fields: +# keys: "keys" +# message: "message" +# key_fields: +# kid: "kid" +# key: "key" +# type: "type" +# success_conditions: +# - "message == 'success'" +# caching: +# enabled: true +# use_vaults: true +# check_cached_first: true + +remote_cdm: + - name: "chrome" + device_name: chrome + device_type: CHROME + system_id: 27175 + security_level: 3 + host: https://domain.com/api + secret: secret_key + - name: "chrome-2" + device_name: chrome + device_type: CHROME + system_id: 26830 + security_level: 3 + host: https://domain-2.com/api + secret: secret_key + + - name: "decrypt_labs_chrome" + type: "decrypt_labs" # Required to identify as DecryptLabs CDM + device_name: "ChromeCDM" # Scheme identifier - must match exactly + device_type: CHROME + system_id: 4464 # Doesn't matter + security_level: 3 + host: "https://keyxtractor.decryptlabs.com" + secret: "your_decrypt_labs_api_key_here" # Replace with your API key + - name: "decrypt_labs_l1" + type: "decrypt_labs" + device_name: "L1" # Scheme identifier - must match exactly + device_type: ANDROID + system_id: 4464 + security_level: 1 + host: "https://keyxtractor.decryptlabs.com" + secret: "your_decrypt_labs_api_key_here" + + - name: "decrypt_labs_l2" + type: "decrypt_labs" + device_name: "L2" # Scheme identifier - must match exactly + device_type: ANDROID + system_id: 4464 + security_level: 2 + host: "https://keyxtractor.decryptlabs.com" + secret: "your_decrypt_labs_api_key_here" + + - name: "decrypt_labs_playready_sl2" + type: "decrypt_labs" + device_name: "SL2" # Scheme identifier - must match exactly + device_type: PLAYREADY + system_id: 0 + security_level: 2000 + host: "https://keyxtractor.decryptlabs.com" + secret: "your_decrypt_labs_api_key_here" + + - name: "decrypt_labs_playready_sl3" + type: "decrypt_labs" + device_name: "SL3" # Scheme identifier - must match exactly + device_type: PLAYREADY + system_id: 0 + security_level: 3000 + host: "https://keyxtractor.decryptlabs.com" + secret: "your_decrypt_labs_api_key_here" + +# Key Vaults store your obtained Content Encryption Keys (CEKs) +# Use 'no_push: true' to prevent a vault from receiving pushed keys +# while still allowing it to provide keys when requested +key_vaults: + - type: SQLite + name: Local + path: key_store.db + # Additional vault types: + # - type: API + # name: "Remote Vault" + # uri: "https://key-vault.example.com" + # token: "secret_token" + # no_push: true # This vault will only provide keys, not receive them + # - type: MySQL + # name: "MySQL Vault" + # host: "127.0.0.1" + # port: 3306 + # database: vault + # username: user + # password: pass + # no_push: false # Default behavior - vault both provides and receives keys + +# Choose what software to use to download data +downloader: aria2c +# Options: requests | aria2c | curl_impersonate | n_m3u8dl_re +# Can also be a mapping: +# downloader: +# NF: requests +# AMZN: n_m3u8dl_re +# DSNP: n_m3u8dl_re +# default: requests + +# aria2c downloader configuration +aria2c: + max_concurrent_downloads: 4 + max_connection_per_server: 3 + split: 5 + file_allocation: falloc # none | prealloc | falloc | trunc + +# N_m3u8DL-RE downloader configuration +n_m3u8dl_re: + thread_count: 16 + ad_keyword: "advertisement" + use_proxy: true + +# curl_impersonate downloader configuration +curl_impersonate: + browser: chrome120 + +# Pre-define default options and switches of the dl command +dl: + sub_format: srt + downloads: 4 + workers: 16 + lang: + - en + - fr + EXAMPLE: + bitrate: CBR + +# Chapter Name to use when exporting a Chapter without a Name +chapter_fallback_name: "Chapter {j:02}" + +# Case-Insensitive dictionary of headers for all Services +headers: + Accept-Language: "en-US,en;q=0.8" + User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36" + +# Override default filenames used across unshackle +filenames: + debug_log: "unshackle_debug_{service}_{time}.jsonl" # JSON Lines debug log file + config: "config.yaml" + root_config: "envied.yaml" + chapters: "Chapters_{title}_{random}.txt" + subtitle: "Subtitle_{id}_{language}.srt" + +# conversion_method: +# - auto (default): Smart routing - subby for WebVTT/SAMI, pycaption for others +# - subby: Always use subby with advanced processing +# - pycaption: Use only pycaption library (no SubtitleEdit, no subby) +# - subtitleedit: Prefer SubtitleEdit when available, fall back to pycaption +# - pysubs2: Use pysubs2 library (supports SRT/SSA/ASS/WebVTT/TTML/SAMI/MicroDVD/MPL2/TMP) +subtitle: + conversion_method: auto + # sdh_method: Method to use for SDH (hearing impaired) stripping + # - auto (default): Try subby (SRT only), then SubtitleEdit (if available), then subtitle-filter + # - subby: Use subby library (SRT only) + # - subtitleedit: Use SubtitleEdit tool (Windows only, falls back to subtitle-filter) + # - filter-subs: Use subtitle-filter library directly + sdh_method: auto + # strip_sdh: Automatically create stripped (non-SDH) versions of SDH subtitles + # Set to false to disable automatic SDH stripping entirely (default: true) + strip_sdh: true + # convert_before_strip: Auto-convert VTT/other formats to SRT before using subtitle-filter + # This ensures compatibility when subtitle-filter is used as fallback (default: true) + convert_before_strip: true + # preserve_formatting: Preserve original subtitle formatting (tags, positioning, styling) + # When true, skips pycaption processing for WebVTT files to keep tags like , , positioning intact + # Combined with no sub_format setting, ensures subtitles remain in their original format (default: true) + preserve_formatting: true + +# Configuration for pywidevine's serve functionality +serve: + api_secret: "your-secret-key-here" + users: + secret_key_for_user: + devices: + - generic_nexus_4464_l3 + username: user + # devices: + # - '/path/to/device.wvd' + +# Configuration data for each Service +services: + # Service-specific configuration goes here + # Profile-specific configurations can be nested under service names + + # You can override ANY global configuration option on a per-service basis + # This allows fine-tuned control for services with special requirements + # Supported overrides: dl, aria2c, n_m3u8dl_re, curl_impersonate, subtitle, muxing, headers, etc. + + # Example: Comprehensive service configuration showing all features + EXAMPLE: + # Standard service config + api_key: "service_api_key" + + # Service certificate for Widevine L1/L2 (base64 encoded) + # This certificate is automatically used when L1/L2 schemes are selected + # Services obtain this from their DRM provider or license server + certificate: | + CAUSwwUKvQIIAxIQ5US6QAvBDzfTtjb4tU/7QxiH8c+TBSKOAjCCAQoCggEBAObzvlu2hZRsapAPx4Aa4GUZj4/GjxgXUtBH4THSkM40x63wQeyVxlEEo + # ... (full base64 certificate here) + + # Profile-specific device configurations + profiles: + john_sd: + device: + app_name: "AIV" + device_model: "SHIELD Android TV" + jane_uhd: + device: + app_name: "AIV" + device_model: "Fire TV Stick 4K" + + # NEW: Configuration overrides (can be combined with profiles and certificates) + # Override dl command defaults for this service + dl: + downloads: 4 # Limit concurrent track downloads (global default: 6) + workers: 8 # Reduce workers per track (global default: 16) + lang: ["en", "es-419"] # Different language priority for this service + sub_format: srt # Force SRT subtitle format + + # Override n_m3u8dl_re downloader settings + n_m3u8dl_re: + thread_count: 8 # Lower thread count for rate-limited service (global default: 16) + use_proxy: true # Force proxy usage for this service + retry_count: 10 # More retries for unstable connections + ad_keyword: "advertisement" # Service-specific ad filtering + + # Override aria2c downloader settings + aria2c: + max_concurrent_downloads: 2 # Limit concurrent downloads (global default: 4) + max_connection_per_server: 1 # Single connection per server + split: 3 # Fewer splits (global default: 5) + file_allocation: none # Faster allocation for this service + + # Override subtitle processing for this service + subtitle: + conversion_method: pycaption # Use specific subtitle converter + sdh_method: auto + + # Service-specific headers + headers: + User-Agent: "Service-specific user agent string" + Accept-Language: "en-US,en;q=0.9" + + # Override muxing options + muxing: + set_title: true + + # Example: Service with different regions per profile + SERVICE_NAME: + profiles: + us_account: + region: "US" + api_endpoint: "https://api.us.service.com" + uk_account: + region: "GB" + api_endpoint: "https://api.uk.service.com" + + # Example: Rate-limited service + RATE_LIMITED_SERVICE: + dl: + downloads: 2 # Limit concurrent downloads + workers: 4 # Reduce workers to avoid rate limits + n_m3u8dl_re: + thread_count: 4 # Very low thread count + retry_count: 20 # More retries for flaky service + aria2c: + max_concurrent_downloads: 1 # Download tracks one at a time + max_connection_per_server: 1 # Single connection only + + # Notes on service-specific overrides: + # - Overrides are merged with global config, not replaced + # - Only specified keys are overridden, others use global defaults + # - Reserved keys (profiles, api_key, certificate, etc.) are NOT treated as overrides + # - Any dict-type config option can be overridden (dl, aria2c, n_m3u8dl_re, subtitle, etc.) + # - CLI arguments always take priority over service-specific config + +# External proxy provider services +proxy_providers: + nordvpn: + username: username_from_service_credentials + password: password_from_service_credentials + server_map: + us: 12 # force US server #12 for US proxies + surfsharkvpn: + username: your_surfshark_service_username # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn + password: your_surfshark_service_password # Service credentials (not your login password) + server_map: + us: 3844 # force US server #3844 for US proxies + gb: 2697 # force GB server #2697 for GB proxies + au: 4621 # force AU server #4621 for AU proxies + windscribevpn: + username: your_windscribe_username # Service credentials from https://windscribe.com/getconfig/openvpn + password: your_windscribe_password # Service credentials (not your login password) + server_map: + us: "us-central-096.totallyacdn.com" # force US server + gb: "uk-london-055.totallyacdn.com" # force GB server + basic: + GB: + - "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham) + - "socks5://username:password@gla.socks.ipvanish.com:1080" # 2 (Glasgow) + AU: + - "socks5://username:password@syd.socks.ipvanish.com:1080" # 1 (Sydney) + - "https://username:password@au-syd.prod.surfshark.com" # 2 (Sydney) + - "https://username:password@au-bne.prod.surfshark.com" # 3 (Brisbane) + BG: "https://username:password@bg-sof.prod.surfshark.com"