From 8e8a1cc49c27111ecb1bb0ddf78b8d4a6760744e Mon Sep 17 00:00:00 2001 From: VineFeeder Date: Thu, 4 Jun 2026 13:18:27 +0100 Subject: [PATCH] updates 5.1.0 --- .../envied/src/envied/services/DSNP/README.md | 76 -- .../src/envied/services/DSNP/__init__.py | 1144 ----------------- .../src/envied/services/DSNP/config.yaml | 84 -- .../src/envied/services/DSNP/queries.py | 14 - .../envied/src/envied/services/UNXT/README.md | 19 - .../src/envied/services/UNXT/__init__.py | 662 ---------- .../src/envied/services/UNXT/config.yaml | 12 - 7 files changed, 2011 deletions(-) delete mode 100644 packages/envied/src/envied/services/DSNP/README.md delete mode 100644 packages/envied/src/envied/services/DSNP/__init__.py delete mode 100644 packages/envied/src/envied/services/DSNP/config.yaml delete mode 100644 packages/envied/src/envied/services/DSNP/queries.py delete mode 100644 packages/envied/src/envied/services/UNXT/README.md delete mode 100644 packages/envied/src/envied/services/UNXT/__init__.py delete mode 100644 packages/envied/src/envied/services/UNXT/config.yaml diff --git a/packages/envied/src/envied/services/DSNP/README.md b/packages/envied/src/envied/services/DSNP/README.md deleted file mode 100644 index 94e6abf..0000000 --- a/packages/envied/src/envied/services/DSNP/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# DisneyPlus(디즈니플러스) - -``` -uv run envied.dl -vl all -al orig -sl ko,en,ja -q 1080,2160 -v h.264,h.265 -r SDR,HDR10,DV DSNP entity-4d12671a-f0ad-4c3f-8526-09ae6772390b -``` - -## Information(정보) - -- Authorization: Credentials, Web Token -- Security: UHD@L1/SL3000, FHD@L1/SL3000, HD@L3/SL2000 -- Working Client Agent: AndroidTV -- Support Codec - - Video: H264, H265 - - Audio: AAC, AC3, ATMOS, DTS:X(P2:IMAX) - - Range: SDR, HDR10, DV - -## Support Args(지원하는 명령어 인자) - -- `-i`, `--imax`: Prefer IMAX Enhanced version if available. -- `-r`, `--remastered-ar`: Prefer Remastered Aspect Ratio if available. -- `-e`, `--extras`: Select a extras video if available. -- `-tu`, `--tier-unlimits`: Remove stream quality restrictions for a specific account. - -## Tips - -- To enable the web refresh token-based login method, please comment out or delete the DSNP section under credentials in `envied.yaml`. - 웹 리프레시 토큰 기반 로그인 방식을 활성화하려면 `envied.yaml`의 credentials에서 DSNP부분을 주석 처리하거나 삭제하세요. - - ``` - credentials: - ... - # DSNP: example@example.com:example - ``` - -- Configure user settings within the `envied.yaml` file. - 사용자 설정은 `envied.yaml`에서 다음과 같이 사용하세요. - - ``` - services: - DSNP: - ## 사용자 환경설정 - ## User configuration - # 해당 설정값이 주석처리 되어 있는 경우에는 설정값들이 자동으로 선택됩니다. - # If these settings are commented out, values will be selected automatically. - preferences: - # 사용할 프로필의 인덱스 번호를 지정합니다. (0 = 첫 번째 프로필, 1 = 두 번째 프로필 등) - # Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.) - # 값이 설정되지 않은 경우에는 자동으로 PIN이 안 걸려 있고 키즈 모드가 아닌 프로필로 자동 선택됩니다. - # If no value is set, a profile without a PIN and not in Kids Mode will be automatically selected. - profile: 0 - - # 서비스 내에서 표시되는 메타데이터 언어를 선택합니다. - # Selects the metadata language displayed within the service. - # 언어 설정은 Disney+에서 지원하는 언어 코드(예: "ko", "en")만 사용 가능합니다. - # Language settings are only available for language codes supported by Disney+ (e.g., "ko", "en"). - # 값이 설정되지 않은 경우에는 현재 프로필에 설정된 언어 설정을 사용합니다. - # If no value is set, the language settings of the current profile will be used. - # language: "ko" - - # 매니페스트 로그 출력 레벨을 설정합니다. - # Sets the manifest log output level. - # 로그를 항상 표시해야 하는 경우 "info"를 사용하고, 그 외의 모든 경우에는 가급적 "debug"를 사용하십시오. - # Use "info" if the log must always be displayed; otherwise, use "debug" whenever possible. - # 값이 설정되지 않은 경우 기본값은 "debug"로 적용됩니다. - # If no value is set, the default level is "debug". - # manifest_log: "info" - ``` - -- To enable the tier_unlimits command by default, add the following to `envied.yaml`. - tier_unlimits 명령을 기본값으로 활성화하려면 `envied.yaml`에 다음을 추가하세요. - ``` - dl: - ... - DSNP: - tier_unlimits: True - ``` diff --git a/packages/envied/src/envied/services/DSNP/__init__.py b/packages/envied/src/envied/services/DSNP/__init__.py deleted file mode 100644 index 68bb05b..0000000 --- a/packages/envied/src/envied/services/DSNP/__init__.py +++ /dev/null @@ -1,1144 +0,0 @@ -from __future__ import annotations - -import base64 -import click -import re -import secrets -import sys -import uuid - -from click import Context -from collections.abc import Generator -from datetime import datetime -from http.cookiejar import CookieJar -from langcodes import Language, LanguageTagError -from typing import Any, Optional, Union, List -from urllib.parse import urlparse, urlunparse - -from envied.core.cdm.detect import is_playready_cdm -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 Title_T, Titles_T, Episode, Movie, Movies, Series -from envied.core.tracks import Chapter, Chapters, Tracks, Attachment, Video, Audio, Subtitle -from envied.core.utilities import get_ip_info -from envied.core.utils.collections import as_list - -from . import queries - - -class DSNP(Service): - """ - Service code for Disney+ Streaming Service (https://disneyplus.com).\n - Version: 26.03.28 - - Author: Made by CodeName393 with Special Thanks to narakama, Sam\n - Authorization: Credentials, Web Token\n - Security: UHD@L1/SL3000 FHD@L1/SL3000 HD@L3/SL2000 - """ - - ALIASES = ("DSNP", "disneyplus", "disney+") - TITLE_RE = ( - r"^(?:https?://(?:www\.)?disneyplus\.com(?:/(?!browse)[a-z0-9-]+)?(?:/(?!browse)[a-z0-9-]+)?/(browse)/(?Pentity-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))(?:\?.*)?$", - r"^(?:https?://(?:www\.)?disneyplus\.com(?:/(?!browse)[a-z0-9-]+){0,2}/(play)/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))(?:\?.*)?$", - ) - - @staticmethod - @click.command(name="DisneyPlus", short_help="https://disneyplus.com", help=__doc__) - @click.argument("title", type=str) - @click.option("-i", "--imax", is_flag=True, default=False, help="Prefer IMAX Enhanced version if available.") - @click.option("-r", "--remastered-ar", is_flag=True, default=False, help="Prefer Remastered Aspect Ratio if available.") - @click.option("-e", "--extras", is_flag=True, default=False, help="Select a extras video if available.") - @click.option("-tu", "--tier-unlimits", is_flag=True, default=False, help="Remove stream quality restrictions for a specific account.") - @click.pass_context - def cli(ctx: Context, **kwargs: Any) -> DSNP: - return DSNP(ctx, **kwargs) - - def __init__(self, ctx: Context, title: str, imax: bool, remastered_ar: bool, extras: bool, tier_unlimits: bool): - self.title = title - super().__init__(ctx) - - self.title_id = self.title - for pattern in self.TITLE_RE: - match = re.match(pattern, self.title) - if match: - self.title_id = match.group("id") - break - - self.prefer_imax = imax - self.prefer_remastered_ar = remastered_ar - self.extras = extras - self.tier_unlimits = tier_unlimits - - self.acodec: List[Audio.Codec] = ctx.parent.params.get("acodec") or [Audio.Codec.AAC] - self.cdm = ctx.obj.cdm - self.is_l3 = ((self.cdm.security_level < 3000) if is_playready_cdm(self.cdm) else (self.cdm.security_level == 3)) if self.cdm else False - - self.region = None - self.cache_key = None - self.prod_config = {} - self.account_tokens = {} - self.active_session = {} - self.playback_data = {} - - self.log.info("Preparing...") - - if self.is_l3: - self.log.warning(" + This CDM only support HD.") - self.tier_unlimits = False - else: - if Audio.Codec.DTS in self.acodec and not self.prefer_imax: - self.prefer_imax = True - self.log.info(" + Switched IMAX prefer. DTS audio can only be get from IMAX prefer.") - if self.tier_unlimits: - self.log.warning(" + Unlock quality limits for restricted streams") - - 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)") - if self.region in ["ID", "IN", "TH", "MY", "PH", "ZA"]: # It's not Global service. - self.log.error(" - It is not currently available in the country.", exc_info=False) - sys.exit(1) - - 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 self.credentials: - self.cache_key = f"tokens_{self.region}_{self.credentials.sha1}" - else: - self.cache_key = f"tokens_{self.region}_web_session" - self.log.warning(" - Credentials not found. Attempting Web Token login.") - - self.log.info("Logging into Disney+...") - self._login() - - if self.config.get("preferences") and "profile" in self.config["preferences"]: - try: - target_profile_index = int(self.config["preferences"]["profile"]) - except (ValueError, TypeError, KeyError): - self.log.error(" - Profile index in configuration is invalid.", exc_info=False) - sys.exit(1) - - profiles = self.active_session["account"]["profiles"] - if not 0 <= target_profile_index < len(profiles): - self.log.error(f" - Invalid profile index: {target_profile_index}. Please choose between 0 and {len(profiles) - 1}.", exc_info=False) - sys.exit(1) - - target_profile = profiles[target_profile_index] - active_profile_id = self.active_session["account"]["activeProfile"]["id"] - - if target_profile["id"] != active_profile_id: - self._perform_switch_profile(target_profile, self.session.headers) - - self.log.info(" + Refreshing session data after profile switch...") - full_account_info = self._get_account_info() - self.active_session = full_account_info["activeSession"] - self.active_session["account"] = full_account_info["account"] - self.log.info("Session data updated successfully.") - - self.log.debug(self.active_session) - - if not self.active_session["isSubscriber"]: - self.log.error(" - Cannot continue, account is not subscribed to Disney+", exc_info=False) - sys.exit(1) - if not self.active_session["inSupportedLocation"]: - self.log.error(" - Cannot continue, Not available in your Region.", exc_info=False) - sys.exit(1) - - self.log.info(f" + Account ID: {self.active_session['account']['id']}") - self.log.info(f" + Profile ID: {self.active_session['account']['activeProfile']['id']}") - self.log.info(f" + Subscribed: {self.active_session['isSubscriber']}") - self.log.debug(f" + Account Region: {self.active_session['homeLocation']['countryCode']}") - self.log.debug(f" + Detected Location: {self.active_session['location']['countryCode']}") - self.log.debug(f" + Supported Location: {self.active_session['inSupportedLocation']}") - - active_profile_id = self.active_session["account"]["activeProfile"]["id"] - full_profile_object = next(p for p in self.active_session["account"]["profiles"] if p["id"] == active_profile_id) - - current_imax_setting = full_profile_object["attributes"]["playbackSettings"]["preferImaxEnhancedVersion"] - self.log.info(f" + IMAX Enhanced: {current_imax_setting}") - if current_imax_setting is not self.prefer_imax: - update_tokens = self._set_imax_preference(self.prefer_imax) - self._apply_new_tokens(update_tokens["token"]) - - current_133_setting = full_profile_object["attributes"]["playbackSettings"]["prefer133"] # Original Aspect Ratio - self.log.info(f" + Remastered Aspect Ratio: {not current_133_setting}") - if not current_133_setting is not self.prefer_remastered_ar: - update_tokens = self._set_remastered_ar_preference(self.prefer_remastered_ar) - self._apply_new_tokens(update_tokens["token"]) - - current_app_lang = full_profile_object["attributes"]["languagePreferences"]["appLanguage"] - self.log.info(f" + App Language: {Language.get(current_app_lang).display_name()}") - prefe_app_lang = self.config.get("preferences", {}).get("language") - if prefe_app_lang and current_app_lang != prefe_app_lang: - try: - if Language.get(prefe_app_lang).is_valid(): - update_tokens = self._set_language_preference(prefe_app_lang) - self._apply_new_tokens(update_tokens["token"]) - else: - raise LanguageTagError() - except LanguageTagError: - self.log.warning(f" - Invalid language tag '{prefe_app_lang}' in preferences. Skipping update.") - - def _login(self) -> None: - cache = self.cache.get(self.cache_key) - - if cache: - try: - self.log.info(" + Using cached tokens...") - self.account_tokens = cache.data - - bearer = self.account_tokens["accessToken"] - if not bearer: - raise ValueError("accessToken not found in cache") - self.session.headers.update({"Authorization": f"Bearer {bearer}"}) - - except (KeyError, ValueError, TypeError) as e: - self.log.warning(f" - Cached token data is invalid or corrupted ({e}). Getting new tokens...") - self._perform_full_login() - - try: - self._refresh() - except Exception as e: - self.log.warning(f" - Failed to refresh token from cache ({e}). Getting new tokens...") - self._perform_full_login() - - # No problem if don't use it - # self._update_device() - - else: - self.log.info(" + Getting new tokens...") - self._perform_full_login() - - self.log.info(" + Fetching session data...") - full_account_info = self._get_account_info() - self.active_session = full_account_info["activeSession"] - self.active_session["account"] = full_account_info["account"] - self.log.info("Session data setup successfully.") - - def _perform_full_login(self) -> None: - if self.credentials: - android_id = secrets.token_bytes(8).hex() - drm_id = f"{base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8')}\n" - device_token = self._register_device(android_id, drm_id) - - email_status = self._check_email(self.credentials.username, device_token) - - if email_status.lower() != "login": - if email_status.lower() == "otp": - self.log.warning(" - Account requires OTP code login.") - self._request_otp(self.credentials.username, device_token) - - otp_code = None - try: - otp_code = input("Enter a OTP code (Check email): ") - if not otp_code: - self.log.error(" - OTP code is required, but no value was entered.", exc_info=False) - sys.exit(1) - if not otp_code.isdigit(): - self.log.error(" - Invalid OTP code. Please enter only numbers.", exc_info=False) - sys.exit(1) - if len(otp_code) < 6: - self.log.error(" - OTP code is too short. Please enter at least 6 digits.", exc_info=False) - sys.exit(1) - if len(otp_code) > 6: - self.log.warning(" - OTP code is longer than 6 digits. Using the first 6 digits.") - otp_code = otp_code[:6] - except KeyboardInterrupt: - self.log.error("\n - OTP code input cancelled by user.", exc_info=False) - sys.exit(1) - - auth_action = self._auth_action_with_otp(self.credentials.username, otp_code, device_token) - login_tokens = self._login_with_auth_action(auth_action, device_token) - - elif email_status.lower() == "register": - self.log.error(" - Account is not registered. Please register first.", exc_info=False) - sys.exit(1) - else: - self.log.error(f" - Email status is '{email_status}'. Account status verification required.", exc_info=False) - sys.exit(1) - - else: - login_tokens = self._login_with_password(self.credentials.username, self.credentials.password, device_token) - - else: - try: - web_refresh_token = input("Enter a Web Refresh Token: ").strip("'\"") - login_tokens = self._refresh_token(web_refresh_token) - except KeyboardInterrupt: - self.log.error("\n - Web Refresh Token input cancelled by user.", exc_info=False) - sys.exit(1) - except Exception: - self.log.error(" - Invalid Web Refresh Token.", exc_info=False) - sys.exit(1) - - temp_auth_header = {"Authorization": f"Bearer {login_tokens['token']['accessToken']}"} - account_info = self._get_account_info(temp_auth_header) - profiles = account_info["account"]["profiles"] - - selected_profile = None - if self.config.get("preferences") and "profile" in self.config["preferences"]: - try: - profile_index = int(self.config["preferences"]["profile"]) - if not 0 <= profile_index < len(profiles): - raise ValueError(f"Index out of range (0-{len(profiles) - 1})") - - selected_profile = profiles[profile_index] - except (ValueError, TypeError): - self.log.error(" - Profile index in configuration is invalid.", exc_info=False) - sys.exit(1) - else: - selected_profile = next( - (p for p in profiles if not p["attributes"]["kidsModeEnabled"] and not p["attributes"]["parentalControls"]["isPinProtected"]), - None, - ) - if not selected_profile: - self.log.error(" - Auto-selection failed: No suitable profile found (non-kids, no PIN). Please configure a specific profile.", exc_info=False) - sys.exit(1) - - if selected_profile: - self._perform_switch_profile(selected_profile, temp_auth_header) - - def _perform_switch_profile(self, target_profile: dict, auth_headers: dict) -> None: - self.log.info(f" + Switching to profile: {target_profile['name']}({target_profile['id']})") - - if target_profile["attributes"]["kidsModeEnabled"]: - self.log.error(" - Kids Profile and cannot be used.", exc_info=False) - sys.exit(1) - - profile_pin = None - if target_profile["attributes"]["parentalControls"]["isPinProtected"]: - self.log.warning(" - This profile is PIN protected.") - try: - profile_pin = input("Enter a profile pin: ") - if not profile_pin: - self.log.error(" - PIN is required, but no value was entered.", exc_info=False) - sys.exit(1) - if not profile_pin.isdigit(): - self.log.error(" - Invalid PIN. Please enter only numbers.", exc_info=False) - sys.exit(1) - if len(profile_pin) < 4: - self.log.error(" - PIN is too short. Please enter at least 4 digits.", exc_info=False) - sys.exit(1) - if len(profile_pin) > 4: - self.log.warning(" - PIN is longer than 4 digits. Using the first 4 digits.") - profile_pin = profile_pin[:4] - except KeyboardInterrupt: - self.log.error("\n - PIN input cancelled by user.", exc_info=False) - sys.exit(1) - - switch_profile_data = self._switch_profile(target_profile["id"], auth_headers, profile_pin) - self._apply_new_tokens(switch_profile_data["token"]) - - def _refresh(self) -> None: - cache = self.cache.get(self.cache_key) - 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.log.warning(" + Token expired. Refreshing...") - try: - refreshed_data = self._refresh_token(self.account_tokens["refreshToken"]) - self._apply_new_tokens(refreshed_data["token"]) - except Exception as _: - raise Exception("Refresh Token Expired") - - def _apply_new_tokens(self, token_data: dict) -> None: - self.account_tokens = token_data - - bearer = self.account_tokens["accessToken"] - if not bearer: - raise ValueError("Invalid token data: accessToken not found.") - self.session.headers.update({"Authorization": f"Bearer {bearer}"}) - - expires_in = self.account_tokens["expiresIn"] - cache = self.cache.get(self.cache_key) - 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 - - def search(self) -> Generator[SearchResult, None, None]: - data = self._get_search(self.title) - if not data["page"].get("containers"): - return - results = data["page"]["containers"][0]["items"] - for result in results: - entity = "entity-" + result["id"] - yield SearchResult( - id_=entity, - title=result["visuals"]["title"], - description=result["visuals"]["description"]["brief"], - label=result["visuals"]["metastringParts"]["releaseYearRange"]["startYear"], - url=f"https://disneyplus.com/browse/{entity}", - ) - - def get_titles(self) -> Titles_T: - try: - if not self.title_id.startswith("entity-"): - actions_info = self._get_deeplink(self.title_id, action="playback") - self.title_id = actions_info["data"]["deeplink"]["actions"][1]["pageId"] - - if not self.extras: - actions_info = self._get_deeplink(self.title_id) - if actions_info["data"]["deeplink"]["actions"][0]["type"] == "browse": - info_block = base64.b64decode(actions_info["data"]["deeplink"]["actions"][0]["infoBlock"]) - if b"movie" in info_block: - content_type = "movie" - elif b"series" in info_block: - content_type = "series" - else: - content_type = "other" - self.log.warning(" - The content is not standard. however, it tries to look up the data.") - else: - content_type = "extras" - 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) - - year = None - if year_data := page["visuals"]["metastringParts"].get("releaseYearRange"): - year = year_data.get("startYear") - - if content_type != "extras": - playback_action = next( - (x for x in page["actions"] if x["type"] == "playback"), - None, - ) - if not playback_action: - self.log.error(" - No content is available. (Playback action not found)", exc_info=False) - sys.exit(1) - data = self._get_player_experience(playback_action["availId"]) - player_exp = data["data"]["playerExperience"] - orig_lang = player_exp.get("originalLanguage") or player_exp.get("targetLanguage") or "en" - self.log.debug(f" + Original Language: {orig_lang}") - - if content_type in ("movie", "other"): - return Movies( - [ - Movie( - id_=page["id"], - service=self.__class__, - name=page["visuals"]["title"], - description=page["visuals"]["description"]["full"], - year=year, - language=Language.get(orig_lang), - data=page, - ) - ] - ) - - elif content_type == "series": - return Series(self._get_series(page, year, orig_lang)) - - elif content_type == "extras": - return Series(self._get_extras(page, year)) - - else: - self.log.error(f" - Unsupported content type: {content_type}", exc_info=False) - sys.exit(1) - - def _get_series(self, page: dict, year: int, orig_lang: str) -> Series: - container = next(x for x in page["containers"] if x["type"] == "episodes") - season_ids = [s["id"] for s in container["seasons"]] - - episodes: List[Episode] = [] - for season_id in season_ids: - episodes_data = self._get_episodes_data(season_id) - - for ep in episodes_data: - if ep["type"] != "view": - continue - - episodes.append( - Episode( - id_=ep["id"], - service=self.__class__, - title=page["visuals"]["title"], - season=int(ep["visuals"]["seasonNumber"]), - number=int(ep["visuals"]["episodeNumber"]), - name=ep["visuals"]["episodeTitle"], - description=ep["visuals"]["description"]["full"], - year=year, - language=Language.get(orig_lang), - data=ep, - ) - ) - - return episodes - - def _get_extras(self, page: dict, year: int) -> Series: - extras_containers = [x for x in page["containers"] if x["type"] == "set" and x["style"]["name"] == "standard_compact_list"] - - if not extras_containers: - self.log.error(" - No extras found.", exc_info=False) - sys.exit(1) - - extras_episodes: List[Episode] = [] - ep_count = 1 - - first_item = extras_containers[0]["items"][0] - first_action = next( - (x for x in first_item["actions"] if x["type"] in ("playback", "trailer")), - None, - ) - if first_action: - data = self._get_player_experience(first_action["availId"]) - player_exp = data["data"]["playerExperience"] - orig_lang = player_exp.get("originalLanguage") or player_exp.get("targetLanguage") or "en" - self.log.debug(f" + Original Language: {orig_lang}") - - for container in extras_containers: - items = container["items"] - for item in items: - if item["type"] == "view": - action = next( - (x for x in item["actions"] if x["type"] in ("playback", "trailer")), - None, - ) - - if action: - extras_episodes.append( - Episode( - id_=item["id"], - service=self.__class__, - title=page["visuals"]["title"], - season=0, # Special - number=ep_count, - name=item["visuals"]["title"], - description=item["visuals"]["description"]["full"], - year=year, - language=Language.get(orig_lang), - data=item, - ) - ) - ep_count += 1 - - if not extras_episodes: - self.log.error(" - No playable extras found.", exc_info=False) - sys.exit(1) - - return extras_episodes - - def get_tracks(self, title: Title_T) -> Tracks: - playback = next(x for x in title.data["actions"] if x["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._refresh() # Safe Access - - self.log.debug(f" + Playback Scenario: {scenario}") - self.log.debug(f" + Media ID: {media_id}") - - self.playback_data[title.id] = self._get_playback(scenario, media_id) - manifest_url = self.playback_data[title.id]["sources"][0]["complete"]["url"] - if self.tier_unlimits: - parsed_url = urlparse(manifest_url) - manifest_url = urlunparse(parsed_url._replace(query="")) # Delete tier params - - log_level = self.config.get("preferences", {}).get("manifest_log", "debug").lower() - log_func = getattr(self.log, log_level, self.log.debug) - log_func(f" + Manifest URL: {manifest_url}") - tracks = HLS.from_url(url=manifest_url, session=self.session).to_tracks(title.language) - - artwork_type = "background" if isinstance(title, Movie) else "thumbnail" - thumbnail_id = title.data["visuals"]["artwork"]["standard"][artwork_type]["1.78"]["imageId"] - thumbnail_url = self._href( - self.prod_config["services"]["ripcut"]["client"]["endpoints"]["mainCompose"]["href"], - version="v2", - partnerId="disney", - imageId=thumbnail_id, - ) - tracks.add(Attachment.from_url(url=thumbnail_url, name="thumbnail", description=thumbnail_id, mime_type="image/png", session=self.session)) - - return self._post_process_tracks(title, tracks) - - def _post_process_tracks(self, title: Title_T, tracks: Tracks) -> Tracks: - for track in tracks: - if isinstance(track, Video) and isinstance(title, Movie): - is_imax_content = any(flag["value"] == "imax_enhanced" for flag in title.data["visuals"]["metastringParts"]["audioVisual"]["flags"]) - is_imax_video = float(self.playback_data[title.id]["attributes"]["imageAspectRatio"]) == 1.9 - if is_imax_content and is_imax_video: - track.edition = ["IMAX"] - if isinstance(track, (Audio, Subtitle)): - track.name = track.language.display_name() - track.name += " [Original]" if track.is_original_lang else "" - - for audio in tracks.audio: - bitrate_match = re.search(r"(?<=composite_)\d+|\d+(?=_(?:hdri|complete))|(?<=-)\d+(?=K/)", as_list(audio.url)[0]) - if bitrate_match: - audio.bitrate = int(bitrate_match.group()) * 1000 - if audio.bitrate == 1_000_000: - audio.bitrate = 768_000 # DSNP lies about the Atmos bitrate - if audio.channels == 6.0: - audio.channels = 5.1 - if audio.channels == 10.0: # DTS-UHD - audio.channels = "5.1.4" # envied.does not recommend - audio.codec = Audio.Codec.DTS - audio.drm = None # It need HW decording - - # No longer supported - tracks.audio = [audio for audio in tracks.audio if not (audio.codec == Audio.Codec.EC3 and audio.channels == 2.0)] - - return tracks - - def get_chapters(self, title: Title_T) -> Chapters: - try: - editorial = self.playback_data[title.id]["editorial"] - if not editorial: - return [] - - LABEL_MAP = { - "intro_start": "intro_start", - "intro_end": "intro_end", - "recap_start": "recap_start", - "recap_end": "recap_end", - "FFER": "recap_start", # First Frame Episode Recap - "LFER": "recap_end", # Last Frame Episode Recap - "FFEI": "intro_start", # First Frame Episode Intro - "LFEI": "intro_end", # Last Frame Episode Intro - "FFEC": "credits_start", # First Frame End Credits - "LFEC": "lfec_marker", # Last Frame End Credits - "FFCB": None, # First Frame Credits Bumper - "LFCB": None, # Last Frame Credits Bumper - "up_next": None, - "tag_start": None, - "tag_end": None, - } - - NAME_MAP = { - "recap_start": "Recap", - "recap_end": "Scene", - "intro_start": "Intro", - "intro_end": "Scene", - "credits_start": "Credits", - } - - grouped = {} - for marker in editorial: - group = LABEL_MAP.get(marker["label"]) - offset = marker["offsetMillis"] - if group and offset is not None: - grouped.setdefault(group, []).append(offset) - - raw_chapters = [] - total_runtime = title.data["visuals"]["metastringParts"]["runtime"]["runtimeMs"] - - for group, times in grouped.items(): - if not times: - continue - - timestamp = min(times) if "start" in group else max(times) if "end" in group else times[0] - name = NAME_MAP.get(group) - - if group == "lfec_marker" and (total_runtime - timestamp) > 5000: - name = "Scene" - - if name: - raw_chapters.append((timestamp, name)) - - raw_chapters.sort(key=lambda x: x[0]) - unique_chapters = [] - seen_ms = set() - - for ms, name in raw_chapters: - if ms not in seen_ms: - unique_chapters.append({"ms": ms, "name": name}) - seen_ms.add(ms) - - if not unique_chapters: - unique_chapters.append({"ms": 0, "name": "Scene"}) - else: - first = unique_chapters[0] - if first["ms"] > 0: - if first["ms"] < 5000 and first["name"] in ("Intro", "Recap"): - first["ms"] = 0 - else: - unique_chapters.insert(0, {"ms": 0, "name": "Scene"}) - - chapters: List[Chapter] = [] - for i, chap_data in enumerate(unique_chapters): - time_sec = chap_data["ms"] / 1000.000 - chapter_title = chap_data["name"] - chapters.append( - Chapter( - timestamp=float(time_sec), - name=chapter_title if chapter_title != "Scene" else None, - ) - ) - - return chapters - - except Exception as e: - self.log.warning(f"Failed to extract chapters: {e}") - return Chapters() - - def get_widevine_service_certificate(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Union[bytes, str]: - # endpoint = self.prod_config["services"]["drm"]["client"]["endpoints"]["widevineCertificate"]["href"] - # res = self.session.get(endpoint, data=challenge) - return self.config["certificate"] - - def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: - self._refresh() # Safe Access - endpoint = self.prod_config["services"]["drm"]["client"]["endpoints"]["widevineLicense"]["href"] - headers = {"Content-Type": "application/octet-stream"} - res = self.session.post(endpoint, headers=headers, data=challenge) - if not res.ok: - try: - error = (d := res.json()).get("errors", [d])[0] - raise ConnectionError(error) - except (ValueError, TypeError, KeyError): - res.raise_for_status() - return res.content - - def get_playready_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"]["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", - } - res = self.session.post(endpoint, headers=headers, data=challenge) - if not res.ok: - try: - error = (d := res.json()).get("errors", [d])[0] - raise ConnectionError(error) - except (ValueError, TypeError, KeyError): - res.raise_for_status() - return res.content - - def _get_search(self, title: str): - params = {"query": title} - endpoint = self._href(self.prod_config["services"]["explore"]["client"]["endpoints"]["search"]["href"]) - data = self._request("GET", endpoint, params=params) - return data["data"] - - def _get_deeplink(self, ref_id: str, action: str = None) -> dict: - endpoint = self._href(self.prod_config["services"]["explore"]["client"]["endpoints"]["getDeeplink"]["href"]) - params = { - "refIdType": "deeplinkId", - "refId": ref_id, - } - if action: - params["action"] = action - - data = self._request("GET", endpoint, params=params) - return data - - def _get_page(self, title_id: str) -> dict: - endpoint = self._href(self.prod_config["services"]["explore"]["client"]["endpoints"]["getPage"]["href"], pageId=title_id) - params = { - "disableSmartFocus": "true", - "limit": 999, - } - data = self._request("GET", endpoint, params=params) - return data["data"]["page"] - - def _get_player_experience(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) - params = {"limit": 999} - data = self._request("GET", endpoint, params=params)["data"]["season"]["items"] - return data - - def _get_playback(self, scenario: str, media_id: str) -> dict: - attributes = { - "codecs": { - "supportsMultiCodecMaster": not self.is_l3, - "video": ["h.264"] if self.is_l3 else ["h.264", "h.265"], - }, - "protocol": "HTTPS", - "frameRates": [60], - "assetInsertionStrategies": { - "point": "SGAI", # Server-Guided Ad Insertion - "range": "SGAI", # Server-Guided Ad Insertion - }, - "playbackInitiationContext": "ONLINE", - "slugDuration": "SLUG_500_MS", # SLUG_1000_MS, SLUG_750_MS ? - "maxSlideDuration": "4_HOUR", # 15_MIN ? - "resolution": { - "max": ["1280x720"] if self.is_l3 else ["3840x2160"], - }, - **( - { - "videoRanges": ["DOLBY_VISION", "HDR10"], - "audioTypes": ["ATMOS", "DTS_X"], - } - if not self.is_l3 - else {} - ), - } - 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", - } - payload = { - "playbackId": media_id, - "playback": { - "attributes": attributes, - }, - } - data = self._request("POST", endpoint, headers=headers, payload=payload) - return data["stream"] - - def _register_device(self, android_id: str, drm_id: str) -> str: - endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["registerDevice"]["href"] - headers = { - "Authorization": self.config["bamsdk"]["api_key"], - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], - } - payload = { - "variables": { - "registerDevice": { - "applicationRuntime": self.config["device"]["applicationRuntime"], - "attributes": { - "osDeviceIds": [ - { - "identifier": android_id, - "type": "android.vendor.id", - }, - { - "identifier": drm_id, - "type": "android.drm.id", - }, - ], - "operatingSystem": self.config["device"]["operatingSystem"], - "operatingSystemVersion": self.config["device"]["operatingSystemVersion"], - }, - "deviceFamily": self.config["device"]["family"], - "deviceLanguage": self.config.get("preferences", {}).get("language", "en"), - "deviceProfile": self.config["device"]["profile"], - "devicePlatformId": self.config["device"]["platform_id"], - } - }, - "query": queries.REGISTER_DEVICE, - } - data = self._request("POST", endpoint, payload=payload, headers=headers) - return data["extensions"]["sdk"]["token"]["accessToken"] - - def _check_email(self, email: str, token: str) -> str: - endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - headers = { - "Authorization": token, - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], - } - payload = { - "operationName": "check", - "variables": { - "email": email, - }, - "query": queries.CHECK_EMAIL, - } - data = self._request("POST", endpoint, payload=payload, headers=headers) - return data["data"]["check"]["operations"][0] - - def _login_with_password(self, email: str, password: str, token: str) -> str: - endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - headers = { - "Authorization": token, - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], - } - payload = { - "operationName": "login", - "variables": { - "input": { - "email": email, - "password": password, - }, - "includeIdentity": True, - "includeAccountConsentToken": True, - }, - "query": queries.LOGIN, - } - data = self._request("POST", endpoint, payload=payload, headers=headers) - return data["extensions"]["sdk"] - - def _request_otp(self, email: str, token: str) -> dict: - endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - headers = { - "Authorization": token, - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], - } - payload = { - "operationName": "requestOtp", - "variables": { - "input": { - "email": email, - "reason": "Login", - }, - }, - "query": queries.REQUESET_OTP, - } - data = self._request("POST", endpoint, payload=payload, headers=headers) - if not data["data"]["requestOtp"]["accepted"]: - self.log.error(" - OTP code request failed.", exc_info=False) - sys.exit(1) - - def _auth_action_with_otp(self, email: str, otp: str, token: str) -> dict: - endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - headers = { - "Authorization": token, - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], - } - payload = { - "operationName": "authenticateWithOtp", - "variables": { - "input": { - "email": email, - "passcode": otp, - }, - }, - "query": queries.LOGIN_OTP, - } - data = self._request("POST", endpoint, payload=payload, headers=headers) - return data["data"]["authenticateWithOtp"]["actionGrant"] - - def _login_with_auth_action(self, auth_action: str, token: str) -> dict: - endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - headers = { - "Authorization": token, - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], - } - payload = { - "operationName": "loginWithActionGrant", - "variables": { - "input": { - "actionGrant": auth_action, - }, - "includeAccountConsentToken": True, - }, - "query": queries.LOGIN_ACTION_GRANT, - } - data = self._request("POST", endpoint, payload=payload, headers=headers) - return data["extensions"]["sdk"] - - def _get_account_info(self, headers: dict = {}) -> dict: - endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - headers.update({"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]}) - payload = { - "operationName": "me", - "variables": { - "includeAccountConsentToken": True, - }, - "query": queries.ME, - } - data = self._request("POST", endpoint, payload=payload, headers=headers) - return data["data"]["me"] - - def _switch_profile(self, profile_id: str, headers: dict, pin: str = None): - profile_input = {"profileId": profile_id} - if pin: - profile_input["entryPin"] = pin - - endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - headers.update({"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]}) - payload = { - "operationName": "switchProfile", - "variables": { - "input": profile_input, - "includeIdentity": True, - "includeAccountConsentToken": True, - }, - "query": queries.SWITCH_PROFILE, - } - data = self._request("POST", endpoint, payload=payload, headers=headers) - return data["extensions"]["sdk"] - - def _refresh_token(self, refresh_token: str) -> dict: - endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["refreshToken"]["href"] - headers = { - "Authorization": self.config["bamsdk"]["api_key"], - "X-BAMSDK-Platform-Id": self.config["device"]["platform_id"], - } - payload = { - "operationName": "refreshToken", - "variables": { - "refreshToken": { - "refreshToken": refresh_token, - }, - }, - "query": queries.REFRESH_TOKEN, - } - data = self._request("POST", endpoint, payload=payload, headers=headers) - return data["extensions"]["sdk"] - - def _update_device(self, android_id: str, drm_id: str) -> str: - endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - headers = {"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]} - payload = { - "operationName": "updateDeviceOperatingSystem", - "variables": { - "updateDeviceOperatingSystem": { - "operatingSystem": self.config["device"]["operatingSystem"], - "operatingSystemVersion": self.config["device"]["operatingSystemVersion"], - "osDeviceIds": [ - { - "identifier": android_id, - "type": "android.vendor.id", - }, - { - "identifier": drm_id, - "type": "android.drm.id", - }, - ], - } - }, - "query": queries.UPDATE_DEVICE, - } - data = self._request("POST", endpoint, payload=payload, headers=headers) - - if data["data"]["updateDeviceOperatingSystem"]["accepted"]: - return data["extensions"]["sdk"] - else: - self.log.warning(" - Failed to update Device Operating System.") - - def _set_imax_preference(self, enabled: bool) -> str: - endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - headers = {"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]} - payload = { - "operationName": "updateProfileImaxEnhancedVersion", - "variables": { - "input": { - "imaxEnhancedVersion": enabled, - }, - "includeProfile": True, - }, - "query": queries.SET_IMAX, - } - data = self._request("POST", endpoint, payload=payload, headers=headers) - - if data["data"]["updateProfileImaxEnhancedVersion"]["accepted"]: - self.log.info(f" + Updated IMAX Enhanced preference: {enabled}") - return data["extensions"]["sdk"] - else: - self.log.warning(" - Failed to update 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 update Remastered Aspect Ratio preference.") - - def _set_language_preference(self, lang: str) -> str: - endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"] - headers = {"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]} - payload = { - "operationName": "updateProfileAppLanguage", - "variables": { - "input": { - "profileId": self.active_session["account"]["activeProfile"]["id"], - "appLanguage": lang, - }, - "includeProfile": True, - }, - "query": queries.SET_APP_LANGUAGE, - } - data = self._request("POST", endpoint, payload=payload, headers=headers) - - if data["data"]["updateProfileAppLanguage"]["accepted"]: - self.log.info(f" + Updated App Language preference: {Language.get(lang).display_name()}") - return data["extensions"]["sdk"] - else: - self.log.warning(" - Failed to update App Language 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()), - } - ) - - try: - res = self.session.request(method=method, url=endpoint, headers=_headers, params=params, json=payload) - res.raise_for_status() - data = res.json() - if data.get("errors"): - error_code = data["errors"][0]["extensions"]["code"] - if "token.service.invalid.grant" in error_code: - raise ConnectionError(f"Refresh Token Expired: {error_code}") - elif "token.service.unauthorized.client" in error_code: - raise ConnectionError(f"Unauthorized Client/IP: {error_code}") - elif "idp.error.identity.bad-credentials" in error_code: - raise ConnectionError(f"Bad Credentials: {error_code}") - elif "account.profile.pin.invalid" in error_code: - raise ConnectionError(f"Invalid PIN: {error_code}") - raise ConnectionError(data["errors"]) - if data.get("data") and data["data"].get("errors"): - raise ConnectionError(data["data"]["errors"]) - return data - except Exception as e: - if "Refresh Token Expired" in str(e) or "/deeplink" in endpoint: - raise e - else: - self.log.error(f"API Request failed: {e}", exc_info=False) - sys.exit(1) diff --git a/packages/envied/src/envied/services/DSNP/config.yaml b/packages/envied/src/envied/services/DSNP/config.yaml deleted file mode 100644 index 8d6da8c..0000000 --- a/packages/envied/src/envied/services/DSNP/config.yaml +++ /dev/null @@ -1,84 +0,0 @@ -## DO NOT EDIT THIS FILE -# 해당 config 파일은 개발자 외에는 수정하지 마세요. -# This configuration file should not be modified by anyone other than developers. -# 사용자 환경설정은 반드시 "envied.yaml"에서만 사용하세요. -# User configuration must be performed only in "envied.yaml". - -certificate: | - CAUSugUKtAIIAxIQbj3s4jO5oUyWjDWqjfr9WRjA2afZBSKOAjCCAQoCggEBALhKWfnyA+FGn5P3tl6ffDjoGq2Oq86hKGl6aZIaGaF7XHPO5mIk7Q35ml - ZIgg1A458Udb4eXRws1n+kJFqtZXCY5S1yElLP0Om1WQsoEY2stpl+PZTGnVv/CsOJGKQ8K4KMr7rKjZem9lA9BrBoxgfXY3tbwlnSf3wTEohyANb5Qfpa - xsU4v8tQDA8PcjzzV9ICodl6crcFZhAy4QMNXfbWOv/ZrGFx5blSXrzP1sMQ64IY8bjUYw4coZM34NDhu8aCA692g8k2mTz2494x7u3Is8v7RKC9ZNiETE - K5/4oeVclXPpelNQokR4uvggnCD1L2EULG/pp6wnk1yWNNLxcCAwEAAToHYmFtdGVjaBKAA2FqHlqkE7EUmdOLiCi0hy5jRgBDJrU1CWNHfH6r2i6s5T5k - 6LK7ZfD65Tv6uyqq1k82PsDz4++kxbpfJDZaypFbae4XPc6lZxRCc5X0toX/x9TftOQQ4N82l5Hxoha569EPRkrnNy7rO7xrRILa3ZVj1alttEnEEjxEuw - SV8usdlUg8/LvLA2C59T/HA2I77k7yVbTrVdy0f81r2l+E2SslivCy1JD3xKlgoaKl4xBnRxItWt8+DCw1Xm2lemYl2LGoh1Wk9gvlXQvr2Jv2+dFX3RNs - i5sd00KS9sePszfjoTkQ6fmpRd7ZgFCGFWYB9JZ92aGUFQRE14OTST2uwSf32YCfsoATDNs4V6dB8YDoTGKFGrcoc4gtHPKySGNt7z/fOW4/01ZGzKqoVY - Fp3jPq7R0qyt5P6fU5NshbLh5VKcnQvwg62BuKsdwV9u4NV36b2a546hGRl/GBneQ+QDA7NRrgITR33Sz02Oq8yJr3sy24GfZRTbtLJ4qiWkjtw== - -## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ## -# Browser (windows, chrome) : /browser/v34.4/windows/chrome/prod.json -# Android Phone : /android/v18.0.0/google/handset/prod.json -# Android TV : /android/v18.0.0/google/tv/prod.json -# Amazon Fire TV : /android/v18.0.0/amazon/tv/prod.json -# Apple Iphone(old) : https://bam-sdk-configs.bamgrid.com/bam-sdk/v2.0/disney-svod-3d9324fc/apple/v9.10.0/ios/iphone/prod.json -# Apple Ipad(old) : https://bam-sdk-configs.bamgrid.com/bam-sdk/v2.0/disney-svod-3d9324fc/apple/v9.10.0/ios/ipad/prod.json - -endpoints: - config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v18.0.0/google/tv/prod.json" - -## user_agent (okhttp/5.0.0-alpha.14) ## -# android-phone : BAMSDK/v18.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v18.0.0; android; phone) -# android-tv : BAMSDK/v18.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v18.0.0; android; tv) - -## api_key ## -# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84 -# android : ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA -# apple : ZGlzbmV5JmFwcGxlJjEuMC4w.H9L7eJvc2oPYwDgmkoar6HzhBJRuUUzt_PcaC3utBI4 - -## yp_service_id ## -# browser : 63626081279ebe65eb50fb54 -# android : 624b805dafc5c73635b1a216 - -bamsdk: - sdk_version: "18.0.1" - application_version: "26.3.0+rc4-2026.03.12.0" - explore_version: "v1.13" - client: "disney-svod-3d9324fc" - user_agent: "BAMSDK/v18.0.1 (disney-svod-3d9324fc 26.3.0+rc4-2026.03.12.0; v7.0/v18.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" - -# ## 사용자 환경설정 -# ## User configuration -# # 해당 설정값이 주석처리 되어 있는 경우에는 설정값들이 자동으로 선택됩니다. -# # If these settings are commented out, values will be selected automatically. -# preferences: -# # 사용할 프로필의 인덱스 번호를 지정합니다. (0 = 첫 번째 프로필, 1 = 두 번째 프로필 등) -# # Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.) -# # 값이 설정되지 않은 경우에는 자동으로 PIN이 안 걸려 있고 키즈 모드가 아닌 프로필로 자동 선택됩니다. -# # If no value is set, a profile without a PIN and not in Kids Mode will be automatically selected. -# profile: 0 - -# # 서비스 내에서 표시되는 메타데이터 언어를 선택합니다. -# # Selects the metadata language displayed within the service. -# # 언어 설정은 Disney+에서 지원하는 언어 코드(예: "ko", "en")만 사용 가능합니다. -# # Language settings are only available for language codes supported by Disney+ (e.g., "ko", "en"). -# # 값이 설정되지 않은 경우에는 현재 프로필에 설정된 언어 설정을 사용합니다. -# # If no value is set, the language settings of the current profile will be used. -# language: "ko" - -# # 매니페스트 로그 출력 레벨을 설정합니다. -# # Sets the manifest log output level. -# # 로그를 항상 표시해야 하는 경우 "info"를 사용하고, 그 외의 모든 경우에는 가급적 "debug"를 사용하십시오. -# # Use "info" if the log must always be displayed; otherwise, use "debug" whenever possible. -# # 값이 설정되지 않은 경우 기본값은 "debug"로 적용됩니다. -# # If no value is set, the default level is "debug". -# manifest_log: "debug" diff --git a/packages/envied/src/envied/services/DSNP/queries.py b/packages/envied/src/envied/services/DSNP/queries.py deleted file mode 100644 index 4ebf957..0000000 --- a/packages/envied/src/envied/services/DSNP/queries.py +++ /dev/null @@ -1,14 +0,0 @@ -# REQUEST_DEVICE_CODE = """mutation requestLicensePlate($input: RequestLicensePlateInput!) { requestLicensePlate(requestLicensePlate: $input) { licensePlate expirationTime expiresInSeconds } }""" -CHECK_EMAIL = """query check($email: String!) { check(email: $email) { operations nextOperation } }""" -LOGIN = """mutation login($input: LoginInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { login(login: $input) { account { __typename ...accountGraphFragment } actionGrant activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }""" -LOGIN_ACTION_GRANT = """mutation loginWithActionGrant($input: LoginWithActionGrantInput!, $includeAccountConsentToken: Boolean!) { loginWithActionGrant(login: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } actionGrant } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }""" -LOGIN_OTP = """mutation authenticateWithOtp($input: AuthenticateWithOtpInput!) { authenticateWithOtp(authenticateWithOtp: $input) { actionGrant securityAction passwordRules { __typename ...passwordRulesFragment } } } fragment passwordRulesFragment on PasswordRules { minLength charTypes }""" -ME = """query me($includeAccountConsentToken: Boolean!) { me { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }""" -REFRESH_TOKEN = """mutation refreshToken($refreshToken: RefreshTokenInput!) { refreshToken(refreshToken: $refreshToken) { activeSession { sessionId } } }""" -REGISTER_DEVICE = """mutation ($registerDevice: RegisterDeviceInput!) { registerDevice(registerDevice: $registerDevice) { __typename } }""" -REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }""" -SET_IMAX = """mutation updateProfileImaxEnhancedVersion($input: UpdateProfileImaxEnhancedVersionInput!, $includeProfile: Boolean!) { updateProfileImaxEnhancedVersion(updateProfileImaxEnhancedVersion: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }""" -SET_REMASTERED_AR = """mutation updateProfileRemasteredAspectRatio($input: UpdateProfileRemasteredAspectRatioInput!, $includeProfile: Boolean!) { updateProfileRemasteredAspectRatio(updateProfileRemasteredAspectRatio: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }""" -SET_APP_LANGUAGE = """mutation updateProfileAppLanguage($input: UpdateProfileAppLanguageInput!, $includeProfile: Boolean!) { updateProfileAppLanguage(updateProfileAppLanguage: $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 isGeminiOnboarded profileLinked languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } linkedProfile { pinProtected } } }""" -SWITCH_PROFILE = """mutation switchProfile($input: SwitchProfileInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { switchProfile(switchProfile: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }""" -UPDATE_DEVICE = """mutation updateDeviceOperatingSystem($updateDeviceOperatingSystem: UpdateDeviceOperatingSystemInput!) {updateDeviceOperatingSystem(updateDeviceOperatingSystem: $updateDeviceOperatingSystem) {accepted}}""" diff --git a/packages/envied/src/envied/services/UNXT/README.md b/packages/envied/src/envied/services/UNXT/README.md deleted file mode 100644 index 113ba1e..0000000 --- a/packages/envied/src/envied/services/UNXT/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# U-NEXT(유넥스트) - -``` -uv run envied.dl --list -vl all -al orig -sl all -q 2160 -v h.265 -r HDR10 UNXT SID0087607 -``` - -## Information - -- Authorization: Credentials -- Security: UHD@L3/SL2000, FHD@L3/SL2000 -- Working Client Agent: AndroidTV -- Support Codec - - Video: H264, H265 - - Audio: AAC, AC3, ATMOS - - Range: SDR, HDR10, DV - -## Support Args - -- `-t`, `--type`: Prefer subtitle or dubbing version for content type if available. diff --git a/packages/envied/src/envied/services/UNXT/__init__.py b/packages/envied/src/envied/services/UNXT/__init__.py deleted file mode 100644 index 1932d27..0000000 --- a/packages/envied/src/envied/services/UNXT/__init__.py +++ /dev/null @@ -1,662 +0,0 @@ -from __future__ import annotations - -import click -import re -import sys -import time -import uuid - -from click import Context -from collections.abc import Generator -from datetime import datetime -from http.cookiejar import CookieJar -from langcodes import Language -from pathlib import Path -from requests import Request -from typing import Any, Optional, Union, List, Literal -from urllib.parse import urlparse, parse_qs, urlencode - -from envied.core.cdm.detect import is_playready_cdm -from envied.core.constants import AnyTrack -from envied.core.credential import Credential -from envied.core.manifests import DASH, ISM -from envied.core.search_result import SearchResult -from envied.core.service import Service -from envied.core.titles import Title_T, Titles_T, Episode, Movie, Movies, Series -from envied.core.tracks import Chapters, Tracks, Video, Audio, Subtitle, Attachment, Chapter -from envied.core.utilities import get_ip_info - - -class UNXT(Service): - """ - Service code for U-NEXT Streaming Service (https://video.unext.jp).\n - Version: 26.03.01 - - Author: Made by CodeName393 with Special Thanks to narakama\n - Authorization: Credentials\n - Security: UHD@L3/SL2000, FHD@L3/SL2000 - """ - - ALIASES = ("UNXT", "UNEXT", "U-NEXT") - GEOFENCE = ("JP",) - TITLE_RE = (r"https?://(?:[\w-]+\.)?unext\.jp/.*?(?:/|td=)(?PSID[0-9]+)",) - - @staticmethod - @click.command(name="U-NEXT", short_help="https://video.unext.jp", help=__doc__) - @click.argument("title", type=str) - @click.option( - "-t", - "--type", - type=click.Choice(["sub", "dub"], case_sensitive=False), - default="sub", - help="Prefer subtitle or dubbing version for content type if available.", - ) - @click.pass_context - def cli(ctx: Context, **kwargs: Any) -> UNXT: - return UNXT(ctx, **kwargs) - - def __init__(self, ctx: Context, title: str, type: Literal["sub", "dub"]): - self.title = title - super().__init__(ctx) - - self.title_id = self.title - for pattern in self.TITLE_RE: - match = re.match(pattern, self.title) - if match: - self.title_id = match.group("id") - break - - self.dub_type = type - self.cdm = ctx.obj.cdm - - self.device_id = None - self.account_tokens = {} - self.active_session = {} - self.playback_data = {} - - self.log.info("Preparing...") - - 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: - region = str(ip_info[country_key]).upper() - self.log.info(f" + IP Region: {region}") - else: - self.log.warning(f" - The region could not be determined from IP information: {ip_info}") - region = "US" - self.log.info(f" + IP Region: {region} (By Default)") - if region != "JP": - self.log.error(" - It is not currently available in the country.", exc_info=False) - sys.exit(1) - - self.session.headers.update( - { - "User-Agent": self.config["device"]["user_agent"], - "Content-Type": "application/json; charset=utf-8", - "Accept-Encoding": "gzip", - } - ) - - def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: - super().authenticate(cookies, credential) - self.credentials = credential - if not credential: - raise EnvironmentError("Service requires Credentials for Authentication.") - - self.log.info("Logging into U-NEXT...") - self._login() - - self.log.debug(self.active_session) - self.log.info(f" + Account ID: {self.active_session['cuid']}") - self.log.info(f" + Platform ID: {self.active_session['pfid']}") - self.log.info(f" + Subscribed: {self.active_session['contractStatusCode'] == 'ACTIVE'}") - - def _login(self) -> None: - cache = self.cache.get(f"tokens_{self.credentials.sha1}") - - if cache: - try: - self.log.info(" + Using cached tokens...") - self.account_tokens = cache.data.get("tokens", {}) - self.device_id = cache.data.get("device_id") - - required_keys = ["userToken", "userTokenExpire", "securityToken"] - if not self.account_tokens or not all(k in self.account_tokens for k in required_keys) or not self.device_id: - raise ValueError("Invalid token data in cache") - - 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 ({e}). Getting new tokens...") - self._perform_full_login() - - else: - self.log.info(" + Getting new tokens...") - self._perform_full_login() - - self.log.info(" + Fetching session data...") - self.active_session = self._get_account_info() - self.log.info("Session data setup successfully.") - - def _perform_full_login(self) -> None: - if not self.device_id: - self.device_id = str(uuid.uuid4()) - - user_info = self._login_with_password(self.credentials.username, self.credentials.password) - token_data = { - "userToken": user_info["userToken"], - "userTokenExpire": user_info["userTokenExpire"], - "securityToken": user_info["securityToken"], - } - self._apply_new_tokens(token_data) - - def _refresh(self) -> None: - cache = self.cache.get(f"tokens_{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.log.warning(" + Token expired. Refreshing...") - try: - user_info = self._refresh_token() - token_data = { - "userToken": user_info["userToken"], - "userTokenExpire": user_info["userTokenExpire"], - "securityToken": user_info["securityToken"], - } - self._apply_new_tokens(token_data) - - except Exception as e: - self.log.error(f"Refresh failed: {e}.", exc_info=False) - raise e - - def _apply_new_tokens(self, token_data: dict) -> None: - self.account_tokens = token_data - - expire_time = token_data["userTokenExpire"] - valid_duration = max(0, expire_time - int(time.time())) - - cache_data = { - "tokens": self.account_tokens, - "device_id": self.device_id, - } - cache = self.cache.get(f"tokens_{self.credentials.sha1}") - cache.set(cache_data, valid_duration - 60) - self.log.debug(f" + New Token is valid until: {datetime.fromtimestamp(cache.expiration.timestamp()).strftime('%Y-%m-%d %H:%M:%S')}") - - def search(self) -> Generator[SearchResult, None, None]: - data = self._get_search(self.title) - if not data["title_list"]: - return - for result in data["title_list"]: - year = result["production_year"] - if not year: - year = result["since_year"] - - yield SearchResult( - id_=result["title_code"], - title=result["title_name"], - description=result["catchphrase"], - label=year, - url=f"https://video.unext.jp/title/{result['title_code']}", - ) - - def get_titles(self) -> Titles_T: - stage_data = self._get_stage_data(self.title_id) - - content_type = stage_data["media_type_code"] - self.log.debug(f" + Content Type: {content_type.upper()}") - - if content_type in ["MOVIE", "VIDEO", "OV"]: - year = stage_data["production_year"] - if not year: - year = stage_data["since_year"] - - movie_data = self._get_movie_data(self.title_id) - return Movies( - [ - Movie( - id_=movie_data["episode"]["episode_code"], - service=self.__class__, - name=stage_data["title_name"], - description=stage_data["story"], - year=year, - language=Language.get("en"), - data=movie_data["episode"], - ) - ] - ) - - elif content_type == "TV": - return Series(self._get_series(stage_data)) - - else: - self.log.error(f" - Unsupported content type: {content_type}", exc_info=False) - sys.exit(1) - - def _get_series(self, stage_data: dict) -> Series: - title_relation = self._get_title_relation(self.title_id) - - all_seasons = {} - special_keywords = ["メイキング", "making", "special", "特典", "映像"] - - current_year = stage_data["production_year"] - if not current_year: - current_year = stage_data["since_year"] - - title_name_main = stage_data["title_name"] - is_special_main = any(k in title_name_main.lower() for k in special_keywords) - - raw_order_main = int(stage_data["series_in_order"]) - if is_special_main: - order_main = 0 - else: - order_main = raw_order_main if raw_order_main > 0 else 1 - - series_name = stage_data["series_name"] - if not series_name: - series_name = title_name_main - - all_seasons[self.title_id] = { - "order": order_main, - "name": series_name, - "year": current_year, - } - - if "relation" in title_relation: - for rel_group in title_relation["relation"]: - if rel_group["groupcode"] != stage_data["series_code"]: - continue - - for item in rel_group["grouplist"]: - if item["media_type_code"] != "TV": - continue - - item_year = item["production_year"] - if not item_year: - item_year = item["since_year"] - - title_name_sub = item["title_name"] - is_special_sub = any(k in title_name_sub.lower() for k in special_keywords) - - raw_order_sub = int(item["series_in_order"]) - if is_special_sub: - order_sub = 0 - else: - order_sub = raw_order_sub if raw_order_sub > 0 else 1 - - all_seasons[item["title_code"]] = { - "order": order_sub, - "name": item["series_name"], - "year": item_year, - } - - sorted_season_ids = sorted(all_seasons.keys(), key=lambda x: all_seasons[x]["order"]) - - episodes: List[Episode] = [] - regular_season_idx = 1 - special_episode_idx = 1 - - for season_id in sorted_season_ids: - season_info = all_seasons[season_id] - - if season_info["order"] == 0: - current_season_number = 0 - else: - current_season_number = regular_season_idx - regular_season_idx += 1 - - episode_list_data = self._get_episode_list(season_id) - for idx, episode in enumerate(episode_list_data["items"], start=1): - if current_season_number == 0: - episode_number = special_episode_idx - special_episode_idx += 1 - else: - episode_number = idx - - episodes.append( - Episode( - id_=episode["episode_code"], - service=self.__class__, - title=season_info["name"], - season=current_season_number, - number=episode_number, - name=episode["episode_name"], - description=episode["introduction"], - year=season_info["year"], - language=Language.get("en"), - data=episode, - ) - ) - - return episodes - - def get_tracks(self, title: Title_T) -> Tracks: - self._refresh() # Safe Access - - play_mode = "caption" - if title.data["has_dub"] and self.dub_type == "dub": - play_mode = "dub" - title.language = Language.get("ja") - - fetched_ranges = set() - - def _fetch_variant(title: Title_T, codec: Optional[Video.Codec], range_: Video.Range) -> Tracks: - if codec == Video.Codec.AVC and range_ in (Video.Range.DV, Video.Range.HDR10, Video.Range.HDR10P): - return Tracks() - nonlocal fetched_ranges - - video_ranges = ["SDR"] - if range_ == Video.Range.DV: - video_ranges = ["VISION"] - elif range_ in (Video.Range.HDR10, Video.Range.HDR10P): - video_ranges = ["HDR10"] - - range_key = video_ranges[0] - if range_key in fetched_ranges: - return Tracks() - fetched_ranges.add(range_key) - - self.log.debug(f"Fetching {range_.name} manifest...") - return self._fetch_manifest_tracks(title, video_ranges, play_mode) - - tracks = self._get_tracks_for_variants(title, _fetch_variant) - - if thumb := title.data.get("thumbnail"): - url = f"https://{thumb['standard']}" - description = Path(url).stem - tracks.add(Attachment.from_url(url=url, name="thumbnail", description=description, session=self.session)) - - for track in tracks: - if isinstance(track, (Audio, Subtitle)): - track.name = track.language.display_name() - track.name += " [Original]" if track.is_original_lang else "" - - for audio in tracks.audio: - if audio.codec == Audio.Codec.EC3 and audio.bitrate > 448_000: - audio.joc = 16 # U-NEXT Bug... - - return tracks - - def _fetch_manifest_tracks(self, title: Title_T, video_ranges: List[str], play_mode: str) -> Tracks: - self.playback_data[title.id] = self._get_playlist(title.id, video_ranges, play_mode) - - if (res_code := self.playback_data[title.id]["result_status"]) != 200: - self.log.debug(f" - Get stream error({res_code})") - return Tracks() - - movie_profile = self.playback_data[title.id]["url_info"][0]["movie_profile"] - target_drm_key = "playready" if is_playready_cdm(self.cdm) else "widevine" - priority_protocols = ["dash", "smooth"] - - selected_protocol = next( - (proto for proto in priority_protocols if proto in movie_profile and target_drm_key in movie_profile[proto].get("license_url_list", {})), - None, - ) - - if not selected_protocol: - self.log.debug(f" - Unable to handle with invalid DRM type. {target_drm_key} info not found or license_url is empty in response.") - return Tracks() - - self.playback_data[title.id]["protocol"] = selected_protocol - base_url = movie_profile[selected_protocol].get("playlist_url") - u = urlparse(base_url) - query = parse_qs(u.query) - query["play_token"] = self.playback_data[title.id]["play_token"] - new_query = urlencode(query, doseq=True) - manifest_url = u._replace(query=new_query).geturl() - - self.log.debug(f" + Manifest URL: {manifest_url}") - if ".mpd/" in manifest_url: - tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(title.language) - elif ".ism/manifest" in manifest_url: - tracks = ISM.from_url(url=manifest_url, session=self.session).to_tracks(title.language) - else: - self.log.debug(" - Manifest type cannot be handled.") - return Tracks() - - for video in tracks.videos: - if video.codec == Video.Codec.HEVC and "HDR10" in video_ranges: - video.range = Video.Range.HDR10 # U-NEXT Bug... - - return tracks - - def get_chapters(self, title: Titles_T) -> Chapters: - url_info = self.playback_data[title.id]["url_info"][0] - endroll_start_position = float(url_info["endroll_start_position"]) - movie_parts_list = url_info["movie_parts_position_list"] - - pre_chapter = [] - - has_intro = False - has_credits = False - - if movie_parts_list: - for part in movie_parts_list: - part_type = part.get("movie_parts_type") - start = float(part.get("from", 0)) - end = float(part.get("to", 0)) - - chapter_name = "Scene" - if part_type == "Opening": - if not has_intro: - chapter_name = "Intro" - has_intro = True - elif part_type == "Ending": - if not has_credits: - chapter_name = "Credits" - has_credits = True - - pre_chapter.append((chapter_name, start)) - - if end > start: - pre_chapter.append(("Scene", end)) - - if endroll_start_position > 0: - is_duplicate = any(abs(t - endroll_start_position) < 1.0 for _, t in pre_chapter) - - if not is_duplicate: - if has_credits: - pre_chapter.append(("Scene", endroll_start_position)) - else: - pre_chapter.append(("Credits", endroll_start_position)) - - pre_chapter.sort(key=lambda x: x[1]) - - if not pre_chapter or pre_chapter[0][1] > 0: - pre_chapter.insert(0, ("Scene", 0.0)) - - if len(pre_chapter) == 1 and pre_chapter[0] == ("Scene", 0): - return [] - - chapters: List[Chapter] = [] - for i, (chapter_title, time_sec) in enumerate(pre_chapter): - chapters.append( - Chapter( - timestamp=float(time_sec), - name=chapter_title if chapter_title != "Scene" else None, - ) - ) - - return chapters - - def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: - protocol = self.playback_data[title.id]["protocol"] - license_url = self.playback_data[title.id]["url_info"][0]["movie_profile"][protocol]["license_url_list"]["widevine"] - params = {"play_token": self.playback_data[title.id]["play_token"]} - headers = {"Content-Type": "application/octet-stream"} - res = self.session.post(license_url, params=params, headers=headers, data=challenge) - res.raise_for_status() - return res.content - - def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: - protocol = self.playback_data[title.id]["protocol"] - license_url = self.playback_data[title.id]["url_info"][0]["movie_profile"][protocol]["license_url_list"]["playready"] - params = {"play_token": self.playback_data[title.id]["play_token"]} - headers = { - "Accept": "application/xml", - "Content-Type": "text/xml; charset=utf-8", - } - res = self.session.post(license_url, params=params, headers=headers, data=challenge) - res.raise_for_status() - return res.content - - def _get_search(self, title: str) -> dict: - endpoint = self.config["endpoints"]["search"] - data_payload = { - "searchString": title, - "numParPage": 32, - "pageNo": 1, - "order": "recommend", - "film_rating_code": "R15", - "need_fulldata": 1, - } - payload = self._build_payload( - data_payload=data_payload, - user_token=self.account_tokens["userToken"], - ) - data = self._request("POST", endpoint, payload=payload) - return data["data"] - - def _get_stage_data(self, title_id: str) -> dict: - endpoint = self.config["endpoints"]["stage"] - data_payload = {"title_code": title_id} - payload = self._build_payload( - data_payload=data_payload, - user_token=self.account_tokens["userToken"], - ) - data = self._request("POST", endpoint, payload=payload) - return data["data"] - - def _get_movie_data(self, title_id: str) -> dict: - endpoint = self.config["endpoints"]["movie"] - data_payload = {"title_code": title_id} - payload = self._build_payload( - data_payload=data_payload, - user_token=self.account_tokens["userToken"], - ) - data = self._request("POST", endpoint, payload=payload) - return data["data"] - - def _get_title_relation(self, title_id: str) -> dict: - endpoint = self.config["endpoints"]["relation"] - data_payload = {"title_code": title_id} - payload = self._build_payload( - data_payload=data_payload, - user_token=self.account_tokens["userToken"], - ) - data = self._request("POST", endpoint, payload=payload) - return data["data"] - - def _get_episode_list(self, season_id: str) -> dict: - endpoint = self.config["endpoints"]["episode"] - data_payload = { - "title_code": season_id, - "page_size": 1000, - "page_number": 1, - } - payload = self._build_payload( - data_payload=data_payload, - user_token=self.account_tokens["userToken"], - ) - data = self._request("POST", endpoint, payload=payload) - return data["data"] - - def _get_playlist(self, title_id: str, video_ranges: List[str], play_mode: str) -> dict: - data_payload = { - "code": title_id, - "bitrate_low": 192, - "play_type": 2, - "play_mode": play_mode, - "keyonly_flg": 0, - "validation_flg": 0, - "codec": ["H264", "H265"], - "dynamic_range_list": video_ranges, - "audio_type_list": ["ac-3", "ec-3", "mp4a"], - } - endpoint = self.config["endpoints"]["playlist"] - payload = self._build_payload( - data_payload=data_payload, - user_token=self.account_tokens["userToken"], - ) - data = self._request("POST", endpoint, payload=payload) - return data["data"] - - def _login_with_password(self, username: str, password: str) -> dict: - endpoint = self.config["endpoints"]["auth"] - data_payload = { - "loginId": username, - "password": password, - } - payload = self._build_payload(data_payload=data_payload) - data = self._request("POST", endpoint, payload=payload) - return data["common"]["userInfo"] - - def _get_account_info(self) -> dict: - endpoint = self.config["endpoints"]["account"] - payload = self._build_payload( - data_payload={}, - user_token=self.account_tokens["userToken"], - security_token=self.account_tokens["securityToken"], - ) - data = self._request("POST", endpoint, payload=payload) - return data["common"]["userInfo"] - - def _refresh_token(self) -> dict: - endpoint = self.config["endpoints"]["auth"] - data_payload = {"securityToken": self.account_tokens["securityToken"]} - payload = self._build_payload( - data_payload=data_payload, - user_token=self.account_tokens["userToken"], - ) - data = self._request("POST", endpoint, payload=payload) - return data["common"]["userInfo"] - - def _build_payload(self, data_payload: dict, user_token: str = "", security_token: str = None) -> dict: - user_info = { - "userToken": user_token, - "service_name": "unext", - } - if security_token: - user_info["securityToken"] = security_token - device_info = { - "deviceType": "980", - "appVersion": "1", - "deviceUuid": self.device_id, - } - payload = { - "common": { - "userInfo": user_info, - "deviceInfo": device_info, - }, - "data": data_payload, - } - return payload - - 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) - - 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 error_code := data["common"]["result"]["errorCode"]: - raise ConnectionError(f"{error_code}: {data['common']['result']['errorMessage']}") - return data - except Exception as e: - self.log.error(f"API Request failed: {e}", exc_info=False) - sys.exit(1) diff --git a/packages/envied/src/envied/services/UNXT/config.yaml b/packages/envied/src/envied/services/UNXT/config.yaml deleted file mode 100644 index 6006dc6..0000000 --- a/packages/envied/src/envied/services/UNXT/config.yaml +++ /dev/null @@ -1,12 +0,0 @@ -endpoints: - auth: "https://napi.unext.jp/1/auth/login" - account: "https://napi.unext.jp/2/user/account/get" - search: "https://napi.unext.jp/1/cmscommon/freeword/video" - stage: "https://napi.unext.jp/1/cmsuser/stage" - movie: "https://napi.unext.jp/1/cmsuser/episode/current" - relation: "https://napi.unext.jp/1/cmsuser/titledetail/relation" - episode: "https://napi.unext.jp/1/cmsuser/episode" - playlist: "https://napi.unext.jp/3/cmsuser/playlisturl/file" - -device: - user_agent: "U-NEXT TV App Android16 5.61.0 AOSP TV"