fix. updated ATVP and TUBI to use built in unshackle requests as n_m3u8_re and aria2c have been deprecated by unshackle. Fixed DSNP broken import path for get_ip_info

This commit is contained in:
comrade-aria 2026-06-19 23:36:25 -05:00
parent 898b765d7e
commit 820674a5ab
13 changed files with 376 additions and 2237 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__/

View File

@ -211,7 +211,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
@staticmethod
def get_session():
return session("okhttp4")
return session("OkHttp4_12")
@staticmethod
@click.command(

View File

@ -7,7 +7,7 @@ import click
import m3u8
import requests
from unshackle.core.downloaders import n_m3u8dl_re
from unshackle.core.downloaders import requests
from unshackle.core.manifests import m3u8 as m3u8_parser
from unshackle.core.service import Service
from unshackle.core.titles import Episode, Movie, Movies, Series
@ -255,7 +255,7 @@ class ATVP(Service):
for track in tracks:
if track not in tracks.attachments:
track.downloader = n_m3u8dl_re
track.downloader = requests
if isinstance(track, (Video, Audio)):
track.needs_repack = True

View File

@ -1,777 +0,0 @@
import re
import time
import uuid
from threading import Lock
from typing import Generator, Optional, Union
import click
import jwt
from langcodes import Language
from unshackle.core.manifests import DASH
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.session import session
from unshackle.core.titles import Episode, Series
from unshackle.core.tracks import Attachment, Chapters, Tracks
from unshackle.core.tracks.chapter import Chapter
from unshackle.core.tracks.subtitle import Subtitle
class CR(Service):
"""
Service code for Crunchyroll streaming service (https://www.crunchyroll.com).
\b
Version: 2.0.0
Author: sp4rk.y
Date: 2025-11-01
Authorization: Credentials
Robustness:
Widevine:
L3: 1080p, AAC2.0
\b
Tips:
- Input should be complete URL or series ID
https://www.crunchyroll.com/series/GRMG8ZQZR/series-name OR GRMG8ZQZR
- Supports multiple audio and subtitle languages
- Device ID is cached for consistent authentication across runs
\b
Notes:
- Uses password-based authentication with token caching
- Manages concurrent stream limits automatically
"""
TITLE_RE = r"^(?:https?://(?:www\.)?crunchyroll\.com/(?:series|watch)/)?(?P<id>[A-Z0-9]+)"
LICENSE_LOCK = Lock()
MAX_CONCURRENT_STREAMS = 3
ACTIVE_STREAMS: list[tuple[str, str]] = []
@staticmethod
def get_session():
return session("okhttp4")
@staticmethod
@click.command(name="CR", short_help="https://crunchyroll.com")
@click.argument("title", type=str, required=True)
@click.pass_context
def cli(ctx, **kwargs) -> "CR":
return CR(ctx, **kwargs)
def __init__(self, ctx, title: str):
self.title = title
self.account_id: Optional[str] = None
self.access_token: Optional[str] = None
self.token_expiration: Optional[int] = None
self.anonymous_id = str(uuid.uuid4())
super().__init__(ctx)
device_cache_key = "cr_device_id"
cached_device = self.cache.get(device_cache_key)
if cached_device and not cached_device.expired:
self.device_id = cached_device.data["device_id"]
else:
self.device_id = str(uuid.uuid4())
cached_device.set(
data={"device_id": self.device_id},
expiration=60 * 60 * 24 * 365 * 10,
)
self.device_name = self.config.get("device", {}).get("name", "SHIELD Android TV")
self.device_type = self.config.get("device", {}).get("type", "ANDROIDTV")
self.session.headers.update(self.config.get("headers", {}))
self.session.headers["etp-anonymous-id"] = self.anonymous_id
@property
def auth_header(self) -> dict:
"""Return authorization header dict."""
return {"authorization": f"Bearer {self.access_token}"}
def ensure_authenticated(self) -> None:
"""Check if token is expired and re-authenticate if needed."""
if not self.token_expiration:
cache_key = f"cr_auth_token_{self.credential.sha1 if self.credential else 'default'}"
cached = self.cache.get(cache_key)
if cached and not cached.expired:
self.access_token = cached.data["access_token"]
self.account_id = cached.data.get("account_id")
self.token_expiration = cached.data.get("token_expiration")
self.session.headers.update(self.auth_header)
self.log.debug("Loaded authentication from cache")
else:
self.log.debug("No valid cached token, authenticating")
self.authenticate(credential=self.credential)
return
current_time = int(time.time())
if current_time >= (self.token_expiration - 60):
self.log.debug("Authentication token expired or expiring soon, re-authenticating")
self.authenticate(credential=self.credential)
def authenticate(self, cookies=None, credential=None) -> None:
"""Authenticate using username and password credentials."""
super().authenticate(cookies, credential)
cache_key = f"cr_auth_token_{credential.sha1 if credential else 'default'}"
cached = self.cache.get(cache_key)
if cached and not cached.expired:
self.access_token = cached.data["access_token"]
self.account_id = cached.data.get("account_id")
self.token_expiration = cached.data.get("token_expiration")
else:
if not credential:
class HardcodedCreds:
username = "akjrtx@gmail.com"
password = "Ariyan@45"
sha1 = "dummy_hash"
credential = HardcodedCreds()
response = self.session.post(
url=self.config["endpoints"]["token"],
headers={
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"request-type": "SignIn",
},
data={
"grant_type": "password",
"username": credential.username,
"password": credential.password,
"scope": "offline_access",
"client_id": self.config["client"]["id"],
"client_secret": self.config["client"]["secret"],
"device_type": self.device_type,
"device_id": self.device_id,
"device_name": self.device_name,
},
)
if response.status_code != 200:
self.log.error(f"Login failed: {response.status_code}")
try:
error_data = response.json()
error_msg = error_data.get("error", "Unknown error")
error_code = error_data.get("code", "")
self.log.error(f"Error: {error_msg} ({error_code})")
except Exception:
self.log.error(f"Response: {response.text}")
response.raise_for_status()
token_data = response.json()
self.access_token = token_data["access_token"]
self.account_id = self.get_account_id()
try:
decoded_token = jwt.decode(self.access_token, options={"verify_signature": False})
self.token_expiration = decoded_token.get("exp")
except Exception:
self.token_expiration = int(time.time()) + token_data.get("expires_in", 3600)
cached.set(
data={
"access_token": self.access_token,
"account_id": self.account_id,
"token_expiration": self.token_expiration,
},
expiration=self.token_expiration
if isinstance(self.token_expiration, int) and self.token_expiration > int(time.time())
else 3600,
)
self.session.headers.update(self.auth_header)
if self.ACTIVE_STREAMS:
self.ACTIVE_STREAMS.clear()
try:
self.clear_all_sessions()
except Exception as e:
self.log.warning(f"Failed to clear previous sessions: {e}")
def get_titles(self) -> Union[Series]:
"""Fetch series and episode information."""
series_id = self.parse_series_id(self.title)
series_response = self.session.get(
url=self.config["endpoints"]["series"].format(series_id=series_id),
params={"locale": self.config["params"]["locale"]},
).json()
if "error" in series_response:
raise ValueError(f"Series not found: {series_id}")
series_data = (
series_response.get("data", [{}])[0] if isinstance(series_response.get("data"), list) else series_response
)
series_title = series_data.get("title", "Unknown Series")
seasons_response = self.session.get(
url=self.config["endpoints"]["seasons"].format(series_id=series_id),
params={"locale": self.config["params"]["locale"]},
).json()
seasons_data = seasons_response.get("data", [])
if not seasons_data:
raise ValueError(f"No seasons found for series: {series_id}")
all_episode_data = []
special_episodes = []
for season in seasons_data:
season_id = season["id"]
season_number = season.get("season_number", 0)
episodes_response = self.session.get(
url=self.config["endpoints"]["season_episodes"].format(season_id=season_id),
params={"locale": self.config["params"]["locale"]},
).json()
episodes_data = episodes_response.get("data", [])
for episode_data in episodes_data:
episode_number = episode_data.get("episode_number")
if episode_number is None or isinstance(episode_number, float):
special_episodes.append(episode_data)
all_episode_data.append((episode_data, season_number))
if not all_episode_data:
raise ValueError(f"No episodes found for series: {series_id}")
series_year = None
if all_episode_data:
first_episode_data = all_episode_data[0][0]
first_air_date = first_episode_data.get("episode_air_date")
if first_air_date:
series_year = int(first_air_date[:4])
special_episodes.sort(key=lambda x: x.get("episode_air_date", ""))
special_episode_numbers = {ep["id"]: idx + 1 for idx, ep in enumerate(special_episodes)}
episodes = []
season_episode_counts = {}
for episode_data, season_number in all_episode_data:
episode_number = episode_data.get("episode_number")
if episode_number is None or isinstance(episode_number, float):
final_season = 0
final_number = special_episode_numbers[episode_data["id"]]
else:
final_season = season_number
if final_season not in season_episode_counts:
season_episode_counts[final_season] = 0
season_episode_counts[final_season] += 1
final_number = season_episode_counts[final_season]
original_language = None
versions = episode_data.get("versions", [])
for version in versions:
if "main" in version.get("roles", []):
original_language = version.get("audio_locale")
break
episode = Episode(
id_=episode_data["id"],
service=self.__class__,
title=series_title,
season=final_season,
number=final_number,
name=episode_data.get("title"),
year=series_year,
language=original_language,
description=episode_data.get("description"),
data=episode_data,
)
episodes.append(episode)
return Series(episodes)
def set_track_metadata(self, tracks: Tracks, episode_id: str, is_original: bool) -> None:
"""Set metadata for video and audio tracks."""
for video in tracks.videos:
video.needs_repack = True
video.data["episode_id"] = episode_id
video.is_original_lang = is_original
for audio in tracks.audio:
audio.data["episode_id"] = episode_id
audio.is_original_lang = is_original
def get_tracks(self, title: Episode) -> Tracks:
"""Fetch video, audio, and subtitle tracks for an episode."""
self.ensure_authenticated()
episode_id = title.id
if self.ACTIVE_STREAMS:
self.ACTIVE_STREAMS.clear()
self.clear_all_sessions()
initial_response = self.get_playback_data(episode_id, track_stream=False)
versions = initial_response.get("versions", [])
if not versions:
self.log.warning("No versions found in playback response, using single version")
versions = [{"audio_locale": initial_response.get("audioLocale", "ja-JP")}]
tracks = None
for idx, version in enumerate(versions):
audio_locale = version.get("audio_locale")
version_guid = version.get("guid")
is_original = version.get("original", False)
if not audio_locale:
continue
request_episode_id = version_guid if version_guid else episode_id
if idx == 0 and not version_guid:
version_response = initial_response
version_token = version_response.get("token")
else:
if idx == 1 and not versions[0].get("guid"):
initial_token = initial_response.get("token")
if initial_token:
self.close_stream(episode_id, initial_token)
try:
version_response = self.get_playback_data(request_episode_id, track_stream=False)
except ValueError as e:
self.log.warning(f"Could not get playback info for audio {audio_locale}: {e}")
continue
version_token = version_response.get("token")
hard_subs = version_response.get("hardSubs", {})
dash_url = None
if "none" in hard_subs:
dash_url = hard_subs["none"].get("url")
elif hard_subs:
first_key = list(hard_subs.keys())[0]
dash_url = hard_subs[first_key].get("url")
if not dash_url:
self.log.warning(f"No DASH manifest found for audio {audio_locale}, skipping")
if version_token:
self.close_stream(request_episode_id, version_token)
continue
try:
version_tracks = DASH.from_url(
url=dash_url,
session=self.session,
).to_tracks(language=audio_locale)
if tracks is None:
tracks = version_tracks
self.set_track_metadata(tracks, request_episode_id, is_original)
else:
self.set_track_metadata(version_tracks, request_episode_id, is_original)
for video in version_tracks.videos:
tracks.add(video)
for audio in version_tracks.audio:
tracks.add(audio)
except Exception as e:
self.log.warning(f"Failed to parse DASH manifest for audio {audio_locale}: {e}")
if version_token:
self.close_stream(request_episode_id, version_token)
continue
if is_original:
captions = version_response.get("captions", {})
subtitles_data = version_response.get("subtitles", {})
all_subs = {**captions, **subtitles_data}
for lang_code, sub_data in all_subs.items():
if lang_code == "none":
continue
if isinstance(sub_data, dict) and "url" in sub_data:
try:
lang = Language.get(lang_code)
except (ValueError, LookupError):
lang = Language.get("en")
subtitle_format = sub_data.get("format", "vtt").lower()
if subtitle_format == "ass" or subtitle_format == "ssa":
codec = Subtitle.Codec.SubStationAlphav4
else:
codec = Subtitle.Codec.WebVTT
tracks.add(
Subtitle(
id_=f"subtitle-{audio_locale}-{lang_code}",
url=sub_data["url"],
codec=codec,
language=lang,
forced=False,
sdh=False,
),
warn_only=True,
)
if version_token:
self.close_stream(request_episode_id, version_token)
if versions and versions[0].get("guid"):
initial_token = initial_response.get("token")
if initial_token:
self.close_stream(episode_id, initial_token)
if tracks is None:
raise ValueError(f"Failed to fetch any tracks for episode: {episode_id}")
for track in tracks.audio + tracks.subtitles:
if track.language:
try:
lang_obj = Language.get(str(track.language))
base_lang = Language.get(lang_obj.language)
lang_display = base_lang.language_name()
track.name = lang_display
except (ValueError, LookupError):
pass
images = title.data.get("images", {})
thumbnails = images.get("thumbnail", [])
if thumbnails:
thumb_variants = thumbnails[0] if isinstance(thumbnails[0], list) else [thumbnails[0]]
if thumb_variants:
thumb_index = min(7, len(thumb_variants) - 1)
thumb = thumb_variants[thumb_index]
if isinstance(thumb, dict) and "source" in thumb:
thumbnail_name = f"{title.name or title.title} - S{title.season:02d}E{title.number:02d}"
return tracks
def get_widevine_license(self, challenge: bytes, title: Episode, track) -> bytes:
"""
Get Widevine license for decryption.
Creates a fresh playback session for each track, gets the license, then immediately
closes the stream. This prevents hitting the 3 concurrent stream limit.
CDN authorization is embedded in the manifest URLs, not tied to active sessions.
"""
self.ensure_authenticated()
track_episode_id = track.data.get("episode_id", title.id)
with self.LICENSE_LOCK:
playback_token = None
try:
playback_data = self.get_playback_data(track_episode_id, track_stream=True)
playback_token = playback_data.get("token")
if not playback_token:
raise ValueError(f"No playback token in response for {track_episode_id}")
track.data["playback_token"] = playback_token
license_response = self.session.post(
url=self.config["endpoints"]["license_widevine"],
params={"specConform": "true"},
data=challenge,
headers={
**self.auth_header,
"content-type": "application/octet-stream",
"accept": "application/octet-stream",
"x-cr-content-id": track_episode_id,
"x-cr-video-token": playback_token,
},
)
if license_response.status_code != 200:
self.log.error(f"License request failed with status {license_response.status_code}")
self.log.error(f"Response: {license_response.text[:500]}")
self.close_stream(track_episode_id, playback_token)
raise ValueError(f"License request failed: {license_response.status_code}")
self.close_stream(track_episode_id, playback_token)
return license_response.content
except Exception:
if playback_token:
try:
self.close_stream(track_episode_id, playback_token)
except Exception:
pass
raise
def cleanup_active_streams(self) -> None:
"""
Close all remaining active streams.
Called to ensure no streams are left open.
"""
if self.ACTIVE_STREAMS:
try:
self.authenticate()
except Exception as e:
self.log.warning(f"Failed to re-authenticate during cleanup: {e}")
for episode_id, token in list(self.ACTIVE_STREAMS):
try:
self.close_stream(episode_id, token)
except Exception as e:
self.log.warning(f"Failed to close stream {episode_id}: {e}")
if (episode_id, token) in self.ACTIVE_STREAMS:
self.ACTIVE_STREAMS.remove((episode_id, token))
def __del__(self) -> None:
"""Cleanup any remaining streams when service is destroyed."""
try:
self.cleanup_active_streams()
except Exception:
pass
def get_chapters(self, title: Episode) -> Chapters:
"""Get chapters/skip events for an episode."""
chapters = Chapters()
chapter_response = self.session.get(
url=self.config["endpoints"]["skip_events"].format(episode_id=title.id),
)
if chapter_response.status_code == 200:
try:
chapter_data = chapter_response.json()
except Exception as e:
self.log.warning(f"Failed to parse chapter data: {e}")
return chapters
for chapter_type in ["intro", "recap", "credits", "preview"]:
if chapter_info := chapter_data.get(chapter_type):
try:
chapters.add(
Chapter(
timestamp=int(chapter_info["start"] * 1000),
name=chapter_info["type"].capitalize(),
)
)
except Exception as e:
self.log.debug(f"Failed to add {chapter_type} chapter: {e}")
return chapters
def search(self) -> Generator[SearchResult, None, None]:
"""Search for content on Crunchyroll."""
try:
response = self.session.get(
url=self.config["endpoints"]["search"],
params={
"q": self.title,
"type": "series",
"start": 0,
"n": 20,
"locale": self.config["params"]["locale"],
},
)
if response.status_code != 200:
self.log.error(f"Search request failed with status {response.status_code}")
return
search_data = response.json()
for result_group in search_data.get("data", []):
for series in result_group.get("items", []):
series_id = series.get("id")
if not series_id:
continue
title = series.get("title", "Unknown")
description = series.get("description", "")
year = series.get("series_launch_year")
if len(description) > 300:
description = description[:300] + "..."
url = f"https://www.crunchyroll.com/series/{series_id}"
label = f"SERIES ({year})" if year else "SERIES"
yield SearchResult(
id_=series_id,
title=title,
label=label,
description=description,
url=url,
)
except Exception as e:
self.log.error(f"Search failed: {e}")
return
def get_account_id(self) -> str:
"""Fetch and return the account ID."""
response = self.session.get(url=self.config["endpoints"]["account_me"], headers=self.auth_header)
if response.status_code != 200:
self.log.error(f"Failed to get account info: {response.status_code}")
self.log.error(f"Response: {response.text}")
response.raise_for_status()
data = response.json()
return data["account_id"]
def close_stream(self, episode_id: str, token: str) -> None:
"""Close an active playback stream to free up concurrent stream slots."""
should_remove = False
try:
response = self.session.delete(
url=self.config["endpoints"]["playback_delete"].format(episode_id=episode_id, token=token),
headers=self.auth_header,
)
if response.status_code in (200, 204, 403):
should_remove = True
else:
self.log.error(
f"Failed to close stream for {episode_id} (status {response.status_code}): {response.text[:200]}"
)
except Exception as e:
self.log.error(f"Error closing stream for {episode_id}: {e}")
finally:
if should_remove and (episode_id, token) in self.ACTIVE_STREAMS:
self.ACTIVE_STREAMS.remove((episode_id, token))
def get_active_sessions(self) -> list:
"""Get all active streaming sessions for the account."""
try:
response = self.session.get(
url=self.config["endpoints"]["playback_sessions"],
headers=self.auth_header,
)
if response.status_code == 200:
data = response.json()
return data.get("items", [])
else:
self.log.warning(f"Failed to get active sessions (status {response.status_code})")
return []
except Exception as e:
self.log.warning(f"Error getting active sessions: {e}")
return []
def clear_all_sessions(self) -> int:
"""
Clear all active streaming sessions created during this or previous runs.
Tries multiple approaches to ensure all streams are closed:
1. Clear tracked streams with known tokens
2. Query active sessions API and close all found streams
3. Try alternate token formats if needed
"""
cleared = 0
if self.ACTIVE_STREAMS:
streams_to_close = self.ACTIVE_STREAMS[:]
for episode_id, playback_token in streams_to_close:
try:
self.close_stream(episode_id, playback_token)
cleared += 1
except Exception:
if (episode_id, playback_token) in self.ACTIVE_STREAMS:
self.ACTIVE_STREAMS.remove((episode_id, playback_token))
sessions = self.get_active_sessions()
if sessions:
for session_data in sessions:
content_id = session_data.get("contentId")
session_token = session_data.get("token")
if content_id and session_token:
tokens_to_try = (
["11-" + session_token[3:], session_token]
if session_token.startswith("08-")
else [session_token]
)
session_closed = False
for token in tokens_to_try:
try:
response = self.session.delete(
url=self.config["endpoints"]["playback_delete"].format(
episode_id=content_id, token=token
),
headers=self.auth_header,
)
if response.status_code in (200, 204):
cleared += 1
session_closed = True
break
elif response.status_code == 403:
session_closed = True
break
except Exception:
pass
if not session_closed:
self.log.warning(f"Unable to close session {content_id} with any token format")
return cleared
def get_playback_data(self, episode_id: str, track_stream: bool = True) -> dict:
"""
Get playback data for an episode with automatic retry on stream limits.
Args:
episode_id: The episode ID to get playback data for
track_stream: Whether to track this stream in active_streams (False for temporary streams)
Returns:
dict: The playback response data
Raises:
ValueError: If playback request fails after retry
"""
self.ensure_authenticated()
max_retries = 2
for attempt in range(max_retries + 1):
response = self.session.get(
url=self.config["endpoints"]["playback"].format(episode_id=episode_id),
params={"queue": "false"},
).json()
if "error" in response:
error_code = response.get("code", "")
error_msg = response.get("message", response.get("error", "Unknown error"))
if error_code == "TOO_MANY_ACTIVE_STREAMS" and attempt < max_retries:
self.log.warning(f"Hit stream limit: {error_msg}")
cleared = self.clear_all_sessions()
if cleared == 0 and attempt == 0:
wait_time = 30
self.log.warning(
f"Found orphaned sessions from previous run. Waiting {wait_time}s for them to expire..."
)
time.sleep(wait_time)
continue
self.log.error(f"Playback API error: {error_msg}")
self.log.debug(f"Full response: {response}")
raise ValueError(f"Could not get playback info for episode: {episode_id} - {error_msg}")
playback_token = response.get("token")
if playback_token and track_stream:
self.ACTIVE_STREAMS.append((episode_id, playback_token))
return response
raise ValueError(f"Failed to get playback data for episode: {episode_id}")
def parse_series_id(self, title_input: str) -> str:
"""Parse series ID from URL or direct ID input."""
match = re.match(self.TITLE_RE, title_input, re.IGNORECASE)
if not match:
raise ValueError(f"Could not parse series ID from: {title_input}")
return match.group("id")

View File

@ -1,47 +0,0 @@
# Crunchyroll API Configuration
client:
id: "lkesi7snsy9oojmi2r9h"
secret: "-aGDXFFNTluZMLYXERngNYnEjvgH5odv"
# API Endpoints
endpoints:
# Authentication
token: "https://www.crunchyroll.com/auth/v1/token"
# Account
account_me: "https://www.crunchyroll.com/accounts/v1/me"
multiprofile: "https://www.crunchyroll.com/accounts/v1/{account_id}/multiprofile"
# Content Metadata
series: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}"
seasons: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}/seasons"
season_episodes: "https://www.crunchyroll.com/content/v2/cms/seasons/{season_id}/episodes"
skip_events: "https://static.crunchyroll.com/skip-events/production/{episode_id}.json"
# Playback
playback: "https://www.crunchyroll.com/playback/v2/{episode_id}/tv/android_tv/play"
playback_delete: "https://www.crunchyroll.com/playback/v1/token/{episode_id}/{token}"
playback_sessions: "https://www.crunchyroll.com/playback/v1/sessions/streaming"
license_widevine: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/widevine"
# Discovery
search: "https://www.crunchyroll.com/content/v2/discover/search"
# Headers for Android TV client
headers:
user-agent: "Crunchyroll/ANDROIDTV/3.49.1_22281 (Android 11; en-US; SHIELD Android TV)"
accept: "application/json"
accept-charset: "UTF-8"
accept-encoding: "gzip"
connection: "Keep-Alive"
content-type: "application/x-www-form-urlencoded; charset=UTF-8"
# Query parameters
params:
locale: "en-US"
# Device parameters for authentication
device:
type: "ANDROIDTV"
name: "SHIELD Android TV"
model: "SHIELD Android TV"

View File

@ -51,7 +51,7 @@ class CR(Service):
@staticmethod
def get_session():
return session("okhttp4")
return session("OkHttp4_12")
@staticmethod
@click.command(name="CR", short_help="https://crunchyroll.com")

File diff suppressed because it is too large Load Diff

View File

@ -1,54 +0,0 @@
certificate: |
CAUSugUKtAIIAxIQbj3s4jO5oUyWjDWqjfr9WRjA2afZBSKOAjCCAQoCggEBALhKWfnyA+FGn5P3tl6ffDjoGq2Oq86hKGl6aZIaGaF7XHPO5mIk7Q35ml
ZIgg1A458Udb4eXRws1n+kJFqtZXCY5S1yElLP0Om1WQsoEY2stpl+PZTGnVv/CsOJGKQ8K4KMr7rKjZem9lA9BrBoxgfXY3tbwlnSf3wTEohyANb5Qfpa
xsU4v8tQDA8PcjzzV9ICodl6crcFZhAy4QMNXfbWOv/ZrGFx5blSXrzP1sMQ64IY8bjUYw4coZM34NDhu8aCA692g8k2mTz2494x7u3Is8v7RKC9ZNiETE
K5/4oeVclXPpelNQokR4uvggnCD1L2EULG/pp6wnk1yWNNLxcCAwEAAToHYmFtdGVjaBKAA2FqHlqkE7EUmdOLiCi0hy5jRgBDJrU1CWNHfH6r2i6s5T5k
6LK7ZfD65Tv6uyqq1k82PsDz4++kxbpfJDZaypFbae4XPc6lZxRCc5X0toX/x9TftOQQ4N82l5Hxoha569EPRkrnNy7rO7xrRILa3ZVj1alttEnEEjxEuw
SV8usdlUg8/LvLA2C59T/HA2I77k7yVbTrVdy0f81r2l+E2SslivCy1JD3xKlgoaKl4xBnRxItWt8+DCw1Xm2lemYl2LGoh1Wk9gvlXQvr2Jv2+dFX3RNs
i5sd00KS9sePszfjoTkQ6fmpRd7ZgFCGFWYB9JZ92aGUFQRE14OTST2uwSf32YCfsoATDNs4V6dB8YDoTGKFGrcoc4gtHPKySGNt7z/fOW4/01ZGzKqoVY
Fp3jPq7R0qyt5P6fU5NshbLh5VKcnQvwg62BuKsdwV9u4NV36b2a546hGRl/GBneQ+QDA7NRrgITR33Sz02Oq8yJr3sy24GfZRTbtLJ4qiWkjtw==
## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ##
# Browser (windows, chrome) : /browser/v34.2/windows/chrome/prod.json
# Android Phone : /android/v15.0.0/google/handset/prod.json
# Android TV : /android/v15.0.0/google/tv/prod.json
# Amazon Fire TV : /android/v15.0.0/amazon/tv/prod.json
endpoints:
config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v15.0.0/google/tv/prod.json"
## user_agent (okhttp/5.0.0-alpha.14) ##
# android-phone : BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; phone)
# android-tv : BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv)
## api_key ##
# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84
# android : ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA
## yp_service_id ##
# browser : 63626081279ebe65eb50fb54
# android : 624b805dafc5c73635b1a216
bamsdk:
sdk_version: "15.0.1"
application_version: "4.21.1+rc3-2026.01.06.0"
explore_version: "v1.13"
client: "disney-svod-3d9324fc"
user_agent: "BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv)"
api_key: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
yp_service_id: "624b805dafc5c73635b1a216"
device:
family: "android"
profile: "tv"
platform: "android/google/tv" # {deviceFamily}/{applicationRuntime}/{deviceProfile}
platform_id: "android-tv"
applicationRuntime: "android"
operatingSystem: "Android"
operatingSystemVersion: "16"
deviceLanguage: "ko" # Device language data independent of account language data
# Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.)
# Automatically select a profile when commenting.
# profile:
# index: 0

View File

@ -1,13 +0,0 @@
# REQUEST_DEVICE_CODE = """mutation requestLicensePlate($input: RequestLicensePlateInput!) { requestLicensePlate(requestLicensePlate: $input) { licensePlate expirationTime expiresInSeconds } }"""
CHECK_EMAIL = """query check($email: String!) { check(email: $email) { operations nextOperation } }"""
LOGIN = """mutation login($input: LoginInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { login(login: $input) { account { __typename ...accountGraphFragment } actionGrant activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
LOGIN_ACTION_GRANT = """mutation loginWithActionGrant($input: LoginWithActionGrantInput!, $includeAccountConsentToken: Boolean!) { loginWithActionGrant(login: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } actionGrant } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
LOGIN_OTP = """mutation authenticateWithOtp($input: AuthenticateWithOtpInput!) { authenticateWithOtp(authenticateWithOtp: $input) { actionGrant securityAction passwordRules { __typename ...passwordRulesFragment } } } fragment passwordRulesFragment on PasswordRules { minLength charTypes }"""
ME = """query me($includeAccountConsentToken: Boolean!) { me { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
REFRESH_TOKEN = """mutation refreshToken($refreshToken: RefreshTokenInput!) { refreshToken(refreshToken: $refreshToken) { activeSession { sessionId } } }"""
REGISTER_DEVICE = """mutation ($registerDevice: RegisterDeviceInput!) { registerDevice(registerDevice: $registerDevice) { __typename } }"""
REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }"""
SET_IMAX = """mutation updateProfileImaxEnhancedVersion($input: UpdateProfileImaxEnhancedVersionInput!, $includeProfile: Boolean!) { updateProfileImaxEnhancedVersion(updateProfileImaxEnhancedVersion: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
SET_REMASTERED_AR = """mutation updateProfileRemasteredAspectRatio($input: UpdateProfileRemasteredAspectRatioInput!, $includeProfile: Boolean!) { updateProfileRemasteredAspectRatio(updateProfileRemasteredAspectRatio: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
SWITCH_PROFILE = """mutation switchProfile($input: SwitchProfileInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { switchProfile(switchProfile: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
UPDATE_DEVICE = """mutation updateDeviceOperatingSystem($updateDeviceOperatingSystem: UpdateDeviceOperatingSystemInput!) {updateDeviceOperatingSystem(updateDeviceOperatingSystem: $updateDeviceOperatingSystem) {accepted}}"""

View File

@ -1,28 +1,30 @@
from __future__ import annotations
import base64
import click
import re
import secrets
import sys
import uuid
from datetime import datetime
from collections.abc import Generator
from http.cookiejar import CookieJar
from typing import Any, Optional, Union, List
from langcodes import Language
import click
from click import Context
from collections.abc import Generator
from datetime import datetime
from http.cookiejar import CookieJar
from langcodes import Language
from pyplayready.cdm import Cdm as PlayReadyCdm
from requests import Request
from typing import Any, Optional, Union, List
from unshackle.core.constants import AnyTrack
from unshackle.core.credential import Credential
from unshackle.core.manifests import HLS
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
from unshackle.core.manifests import HLS
from unshackle.core.titles import Title_T, Titles_T, Episode, Movie, Movies, Series
from unshackle.core.tracks import Chapter, Chapters, Tracks, Attachment, Video, Audio, Subtitle
from unshackle.core.utils.ip_info import get_ip_info
from unshackle.core.utils.collections import as_list
from unshackle.core.utilities import get_ip_info
from . import queries
@ -37,7 +39,7 @@ class DSNP(Service):
ALIASES = ("DSNP", "disneyplus", "disney+")
TITLE_RE = (
r"^(?:https?://(?:www\.)?disneyplus\.com(?:/[a-z0-9-]+)?(?:/[a-z0-9-]+)?/(browse)/(?P<id>entity-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))(?:\?.*)?$",
r"^(?:https?://(?:www\.)?disneyplus\.com(?:/(?!browse)[a-z0-9-]+)?(?:/(?!browse)[a-z0-9-]+)?/(browse)/(?P<id>entity-[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<id>[a-zA-Z0-9-]+)(?:\?.*)?$",
)
@ -46,11 +48,12 @@ class DSNP(Service):
@click.argument("title", type=str)
@click.option("--imax", is_flag=True, default=False, help="Prefer IMAX Enhanced version if available.")
@click.option("--remastered-ar", is_flag=True, default=False, help="Prefer Remastered Aspect Ratio if available.")
@click.option("-ext", "--extras", is_flag=True, default=False, help="Select a extras video if available.")
@click.pass_context
def cli(ctx: Context, **kwargs: Any) -> DSNP:
return DSNP(ctx, **kwargs)
def __init__(self, ctx: Context, title: str, imax: bool, remastered_ar: bool):
def __init__(self, ctx: Context, title: str, imax: bool, remastered_ar: bool, extras: bool):
self.title = title
super().__init__(ctx)
@ -61,8 +64,9 @@ class DSNP(Service):
self.title_id = match.group("id")
break
self.prefer_imax = imax or False
self.prefer_remastered_ar = remastered_ar or False
self.prefer_imax = imax
self.prefer_remastered_ar = remastered_ar
self.extras = extras
self.vcodec = ctx.parent.params.get("vcodec") or Video.Codec.AVC
self.acodec : Audio.Codec = ctx.parent.params.get("acodec")
@ -74,8 +78,8 @@ class DSNP(Service):
self.chapters_only = ctx.parent.params.get("chapters_only")
self.cdm = ctx.obj.cdm
self.playready = isinstance(self.cdm, PlayReadyCdm)
self.is_l3 = (self.cdm.security_level < 3000) if self.playready else (self.cdm.security_level == 3)
self.playready = isinstance(self.cdm, PlayReadyCdm) if self.cdm else False
self.is_l3 = (self.cdm.security_level < 3000) if self.playready else (self.cdm.security_level == 3) if self.cdm else False
self.region = None
self.prod_config = {}
@ -91,13 +95,13 @@ class DSNP(Service):
self.quality = [720]
self.log.warning(" + Switched video to HD. This CDM only support HD.")
else:
if self.quality > [1080] and self.range == [Video.Range.SDR]:
if self.quality > [1080] and self.range[0] == [Video.Range.SDR]:
self.range = [Video.Range.HDR10]
self.log.info(" + Switched range to HDR10. 4K resolution requires HDR.")
if (self.range != [Video.Range.SDR] or self.quality > [1080]) and self.vcodec != Video.Codec.HEVC:
self.vcodec = Video.Codec.HEVC
self.log.info(f" + Switched video codec to H265 to be able to get {self.range} dynamic range.")
self.log.info(f" + Switched video codec to H265 to be able to get {self.range[0]} dynamic range.")
if self.acodec == Audio.Codec.DTS and not self.prefer_imax:
self.prefer_imax = True
@ -164,7 +168,7 @@ class DSNP(Service):
self._perform_switch_profile(target_profile, self.session.headers)
self.log.info(" + Refreshing session data after profile switch...")
full_account_info = self._get_account_info_raw()
full_account_info = self._get_account_info()
self.active_session = full_account_info["activeSession"]
self.active_session['account'] = full_account_info['account']
self.log.info("Session data updated successfully.")
@ -193,12 +197,14 @@ class DSNP(Service):
current_imax_setting = full_profile_object["attributes"]["playbackSettings"]["preferImaxEnhancedVersion"]
self.log.info(f" + IMAX Enhanced: {current_imax_setting}")
if current_imax_setting is not self.prefer_imax:
self._set_imax_preference(self.prefer_imax)
update_tokens = self._set_imax_preference(self.prefer_imax)
self._apply_new_tokens(update_tokens["token"])
current_133_setting = full_profile_object["attributes"]["playbackSettings"]["prefer133"] # Original Aspect Ratio
self.log.info(f" + Remastered Aspect Ratio: {not current_133_setting}")
if not current_133_setting is not self.prefer_remastered_ar:
self._set_remastered_ar_preference(self.prefer_remastered_ar)
update_tokens = self._set_remastered_ar_preference(self.prefer_remastered_ar)
self._apply_new_tokens(update_tokens["token"])
def _login(self) -> None:
cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}")
@ -208,7 +214,7 @@ class DSNP(Service):
self.log.info(" + Using cached tokens...")
self.account_tokens = cache.data
bearer = self.account_tokens["token"]["accessToken"]
bearer = self.account_tokens["accessToken"]
if not bearer:
raise ValueError("accessToken not found in cache")
self.session.headers.update({'Authorization': f'Bearer {bearer}'})
@ -231,19 +237,45 @@ class DSNP(Service):
self._perform_full_login()
self.log.info(" + Fetching session data...")
full_account_info = self._get_account_info_raw()
full_account_info = self._get_account_info()
self.active_session = full_account_info["activeSession"]
self.active_session['account'] = full_account_info['account']
self.log.info("Session data setup successfully.")
def _perform_full_login(self) -> None:
device_token = self._register_device()
android_id = secrets.token_bytes(8).hex()
drm_id = f"{base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8')}\n"
device_token = self._register_device(android_id, drm_id)
email_status = self._check_email(self.credentials.username, device_token)
if email_status.lower() != "login":
if email_status.lower() == "OTP":
self.log.error(" - Account requires 2FA passcode.", exc_info=False)
if email_status.lower() == "otp":
self.log.warning(" - Account requires OTP code login.")
self._request_otp(self.credentials.username, device_token)
otp_code = None
try:
otp_code = input("Enter a OTP code (Check email): ")
if not otp_code:
self.log.error(" - OTP code is required, but no value was entered.", exc_info=False)
sys.exit(1)
if not otp_code.isdigit():
self.log.error(" - Invalid OTP code. Please enter only numbers.", exc_info=False)
sys.exit(1)
if len(otp_code) < 6:
self.log.error(" - OTP code is too short. Please enter at least 6 digits.", exc_info=False)
sys.exit(1)
if len(otp_code) > 6:
self.log.warning(" - OTP code is longer than 6 digits. Using the first 6 digits.")
otp_code = otp_code[:6]
except KeyboardInterrupt:
self.log.error("\n - OTP code input cancelled by user.", exc_info=False)
sys.exit(1)
auth_action = self._auth_action_with_otp(self.credentials.username, otp_code, device_token)
login_tokens = self._login_with_auth_action(auth_action, device_token)
elif email_status.lower() == "register":
self.log.error(" - Account is not registered. Please register first.", exc_info=False)
sys.exit(1)
@ -251,10 +283,11 @@ class DSNP(Service):
self.log.error(f" - Email status is '{email_status}'. Account status verification required.", exc_info=False)
sys.exit(1)
else:
login_tokens = self._login_with_password(self.credentials.username, self.credentials.password, device_token)
temp_auth_header = {"Authorization": f'Bearer {login_tokens["accessToken"]}'}
account_info = self._get_account_info_raw(temp_auth_header)
temp_auth_header = {"Authorization": f'Bearer {login_tokens["token"]["accessToken"]}'}
account_info = self._get_account_info(temp_auth_header)
profiles = account_info["account"]["profiles"]
selected_profile = None
@ -308,10 +341,8 @@ class DSNP(Service):
self.log.error("\n - PIN input cancelled by user.", exc_info=False)
sys.exit(1)
switch_profile_data = self._switch_profile(target_profile['id'], auth_headers, profile_pin)
final_token_data = self._refresh_token(switch_profile_data["token"]["refreshToken"])
self._apply_new_tokens(final_token_data)
self._apply_new_tokens(switch_profile_data["token"])
def _refresh(self) -> str:
cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}")
@ -321,23 +352,21 @@ class DSNP(Service):
self.log.warning(" + Token expired. Refreshing...")
try:
refreshed_data = self._refresh_token(self.account_tokens["token"]["refreshToken"])
bearer = self._apply_new_tokens(refreshed_data)
return bearer
refreshed_data = self._refresh_token(self.account_tokens["refreshToken"])
self._apply_new_tokens(refreshed_data["token"])
except Exception as _:
self.log.error("Refresh Token Expired", exc_info=False)
sys.exit(1)
raise Exception("Refresh Token Expired")
def _apply_new_tokens(self, token_data: dict) -> str:
self.account_tokens = token_data
bearer = self.account_tokens["token"]["accessToken"]
bearer = self.account_tokens["accessToken"]
if not bearer:
self.log.error("Invalid token data: accessToken not found.", exc_info=False)
sys.exit(1)
self.session.headers.update({'Authorization': f'Bearer {bearer}'})
expires_in = self.account_tokens["token"]["expiresIn"] or 3600
expires_in = self.account_tokens["expiresIn"] or 3600
cache = self.cache.get(f"tokens_{self.region}_{self.credentials.sha1}")
cache.set(self.account_tokens, expires_in - 60)
self.log.debug(f" + New Token is valid until: {datetime.fromtimestamp(cache.expiration.timestamp()).strftime('%Y-%m-%d %H:%M:%S')}")
@ -363,6 +392,7 @@ class DSNP(Service):
)
def get_titles(self) -> Titles_T:
if not self.extras:
try:
content_info = self._get_deeplink(self.title_id)
content_type = content_info["data"]["deeplink"]["actions"][0]["contentType"]
@ -370,57 +400,63 @@ class DSNP(Service):
try:
actions_info = self._get_deeplink_last(self.title_id)
if actions_info["data"]["deeplink"]["actions"][0]["type"] == "browse":
info_block = base64.b64decode(actions_info["data"]["deeplink"]["actions"][0]["infoBlock"])
if b"movie" in info_block:
content_type = "movie"
elif b"series" in info_block:
content_type = "series"
else:
content_type = "other"
self.log.warning(" - The content is not standard. however, it tries to look up the data.")
except Exception as e:
self.log.error(f" - Failed to determine content type via deeplink ({e}).", exc_info=False)
sys.exit(1)
else:
content_type = "extras"
self.log.debug(f" + Content Type: {content_type.upper()}")
page = self._get_page(self.title_id)
orig_lang = "en"
if not content_type == "other":
playback_action = next(x for x in page["actions"] if x["type"] == "playback")
avail_id = playback_action["availId"]
self.log.debug(f" + Avail ID: {avail_id}")
lang_data = self._get_original_lang(avail_id)
orig_lang = lang_data["data"]["playerExperience"]["originalLanguage"]
year = None
if year_data := page["visuals"]["metastringParts"].get("releaseYearRange"):
year = year_data.get("startYear")
if content_type != "extras":
playback_action = next(
(x for x in page["actions"] if x["type"] == "playback"),
None
)
if not playback_action:
self.log.error(f" - No content is available. (Playback action not found)", exc_info=False)
sys.exit(1)
lang_data = self._get_original_lang(playback_action["availId"])
player_exp = lang_data["data"]["playerExperience"]
orig_lang = player_exp.get("originalLanguage") or player_exp.get("targetLanguage") or "en"
self.log.debug(f' + Original Language: {orig_lang}')
if content_type == "movie":
return Movies(
[
if content_type in ("movie", "other"):
return Movies([
Movie(
id_=page["id"],
service=self.__class__,
name=page["visuals"]["title"],
year=page["visuals"]["metastringParts"]["releaseYearRange"]["startYear"],
year=year,
language=Language.get(orig_lang),
data=page
)
]
)
])
elif content_type == "series":
return Series(self._get_series(page, orig_lang))
return Series(self._get_series(page, year, orig_lang))
elif content_type == "extras":
return Series(self._get_extras(page, year))
elif content_type == "other":
return Movies(
[
Movie(
id_=page["id"],
service=self.__class__,
name=page["visuals"]["title"],
data=page
)
]
)
else:
self.log.error(f" - Unsupported content type: {content_type}", exc_info=False)
sys.exit(1)
def _get_series(self, page: dict, orig_lang: str) -> Series:
def _get_series(self, page: dict, year: int, orig_lang: str) -> Series:
container = next(x for x in page["containers"] if x["type"] == "episodes")
season_ids = [s["id"] for s in container["seasons"]]
@ -440,7 +476,7 @@ class DSNP(Service):
season=int(ep["visuals"]["seasonNumber"]),
number=int(ep["visuals"]["episodeNumber"]),
name=ep["visuals"]["episodeTitle"],
year=page["visuals"]["metastringParts"]["releaseYearRange"]["startYear"],
year=year,
language=Language.get(orig_lang),
data=ep
)
@ -448,6 +484,55 @@ class DSNP(Service):
return episodes
def _get_extras(self, page: dict, year: int) -> Series:
extras_containers = [
x for x in page["containers"]
if x["type"] == "set" and x["style"]["name"] == "standard_compact_list"
]
if not extras_containers:
self.log.error(" - No extras found.", exc_info=False)
sys.exit(1)
extras_episodes : List[Episode] = []
ep_count = 1
first_item = extras_containers[0]["items"][0]
first_action = next((x for x in first_item["actions"] if x["type"] in ("playback", "trailer")), None)
if first_action:
lang_data = self._get_original_lang(first_action["availId"])
player_exp = lang_data["data"]["playerExperience"]
orig_lang = player_exp.get("originalLanguage") or player_exp.get("targetLanguage") or "en"
self.log.debug(f' + Original Language: {orig_lang}')
for container in extras_containers:
items = container["items"]
for item in items:
if item["type"] == "view":
action = next((x for x in item["actions"] if x["type"] in ("playback", "trailer")), None)
if action:
extras_episodes.append(
Episode(
id_=item["id"],
service=self.__class__,
title=page["visuals"]["title"],
season=0, # Special
number=ep_count,
name=item["visuals"]["title"],
year=year,
language=Language.get(orig_lang),
data=item
)
)
ep_count += 1
if not extras_episodes:
self.log.error(" - No playable extras found.", exc_info=False)
sys.exit(1)
return extras_episodes
def get_tracks(self, title: Title_T) -> Tracks:
playback = next(x for x in title.data["actions"] if x.get("type") == "playback")
media_id = playback["resourceId"] or None
@ -462,7 +547,7 @@ class DSNP(Service):
self._refresh() # Safe Access
if Video.Range.HYBRID in self.range and not self.is_l3:
if Video.Range.HYBRID in self.range[0] and not self.is_l3:
self.log.warning("DV+HDR Multi-range requested.")
self.log.info(" + Fetching Dolby Vision tracks...")
@ -475,15 +560,14 @@ class DSNP(Service):
else:
video_ranges = []
if not self.is_l3:
if Video.Range.DV in self.range:
if Video.Range.DV in self.range[0]:
video_ranges = ["DOLBY_VISION"]
elif Video.Range.HDR10 in self.range or Video.Range.HDR10P in self.range:
elif Video.Range.HDR10 in self.range[0] or Video.Range.HDR10P in self.range[0]:
video_ranges = ["HDR10"] # HDR10PLUS
tracks = self._fetch_manifest_tracks(title, media_id, scenario, video_ranges or None)
tracks.add(self._get_thumbnail(title))
return self._post_process_tracks(tracks)
def _fetch_manifest_tracks(self, title: Title_T, media_id: str, scenario: str, video_ranges: List[str] = None) -> Tracks:
@ -494,8 +578,13 @@ class DSNP(Service):
},
"protocol": "HTTPS",
"frameRates": [60],
"assetInsertionStrategy": "SGAI", # Server-Guided Ad Insertion
"playbackInitiationContext": "ONLINE"
"assetInsertionStrategies": {
"point": "SGAI", # Server-Guided Ad Insertion
"range": "SGAI" # Server-Guided Ad Insertion
},
"playbackInitiationContext": "ONLINE",
"slugDuration": "SLUG_500_MS", # SLUG_1000_MS, SLUG_750_MS ?
"maxSlideDuration": "4_HOUR" # 15_MIN ?
}
if self.is_l3:
@ -517,9 +606,9 @@ class DSNP(Service):
"attributes": attributes
}
}
self.playback_data[title.id] = self._get_video(scenario, payload)
self.playback_data[title.id] = self._get_playback(scenario, payload)
manifest_url = self.playback_data[title.id]["sources"][0]['complete']['url']
self.log.debug(f" + Manifest URL: {manifest_url}")
return HLS.from_url(url=manifest_url, session=self.session).to_tracks(title.language)
def _get_thumbnail(self, title: Title_T) -> Attachment:
@ -548,6 +637,10 @@ class DSNP(Service):
audio.bitrate = 768_000 # DSNP lies about the Atmos bitrate
if audio.channels == 6.0:
audio.channels = 5.1
if audio.channels == 10.0: # DTS-UHD
audio.channels = "5.1.4" # Unshackle does not recommend
audio.codec = Audio.Codec.DTS
audio.drm = None
for subtitle in tracks.subtitles:
subtitle.codec = Subtitle.Codec.WebVTT
@ -557,58 +650,26 @@ class DSNP(Service):
def get_chapters(self, title: Title_T) -> Chapters:
try:
editorial = self.playback_data[title.id]["editorial"]
if not editorial:
return []
return Chapters()
label_to_group = {
LABEL_MAP = {
"intro_start": "intro_start",
"FFEI": "intro_start", # First Frame Episode Intro
"intro_end": "intro_end",
"LFEI": "intro_end", # Last Frame Episode Intro
"recap_start": "recap_start",
"FFER": "recap_start", # First Frame Episode Recap
"recap_end": "recap_end",
"FFER": "recap_start", # First Frame Episode Recap
"LFER": "recap_end", # Last Frame Episode Recap
"FFEI": "intro_start", # First Frame Episode Intro
"LFEI": "intro_end", # Last Frame Episode Intro
"FFEC": "credits_start", # First Frame End Credits
"LFEC": "lfec_marker", # Last Frame End Credits
"FFCB": None, # First Frame Credits Bumper
"LFCB": None, # Last Frame Credits Bumper
"up_next": None,
"tag_start": None,
"tag_end": None,
}
# Collision Correction
grouped_timestamps = {}
for marker in editorial:
label = marker.get("label")
group = label_to_group.get(label)
if group:
timestamp = marker.get("offsetMillis")
if timestamp is not None:
if group not in grouped_timestamps:
grouped_timestamps[group] = []
grouped_timestamps[group].append(timestamp)
resolved_markers = []
for group, timestamps in grouped_timestamps.items():
if not timestamps:
continue
final_timestamp = 0
if "start" in group:
final_timestamp = min(timestamps)
elif "end" in group:
final_timestamp = max(timestamps)
else:
final_timestamp = timestamps[0]
resolved_markers.append({"group": group, "ms": final_timestamp})
# Create Chapter Data
raw_chapter_data = []
group_to_name = {
NAME_MAP = {
"recap_start": "Recap",
"recap_end": "Scene",
"intro_start": "Intro",
@ -616,66 +677,63 @@ class DSNP(Service):
"credits_start": "Credits",
}
total_runtime_ms = 0
if "visuals" in title.data and "metastringParts" in title.data["visuals"]:
total_runtime_ms = title.data["visuals"]["metastringParts"]["runtime"]["runtimeMs"]
grouped = {}
for marker in editorial:
group = LABEL_MAP.get(marker["label"])
offset = marker["offsetMillis"]
if group and offset is not None:
grouped.setdefault(group, []).append(offset)
for marker in resolved_markers:
group = marker["group"]
timestamp_ms = marker["ms"]
name = None
raw_chapters = []
total_runtime = title.data["visuals"]["metastringParts"]["runtime"]["runtimeMs"]
if group == "lfec_marker":
if total_runtime_ms and (total_runtime_ms - timestamp_ms) > 5000: # 5 sec
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"
else:
name = group_to_name.get(group)
if name:
raw_chapter_data.append({"ms": timestamp_ms, "name": name})
raw_chapters.append((timestamp, name))
# Sorting and deduplication in chronological order
if not raw_chapter_data:
return []
unique_chapters_data = []
raw_chapters.sort(key=lambda x: x[0])
unique_chapters = []
seen_ms = set()
for chap in sorted(raw_chapter_data, key=lambda x: x["ms"]):
if chap["ms"] not in seen_ms:
unique_chapters_data.append(chap)
seen_ms.add(chap["ms"])
# Processe the First Chapter
if not unique_chapters_data:
unique_chapters_data.append({"ms": 0, "name": "Scene"})
for ms, name in raw_chapters:
if ms not in seen_ms:
unique_chapters.append({"ms": ms, "name": name})
seen_ms.add(ms)
if not unique_chapters:
unique_chapters.append({"ms": 0, "name": "Scene"})
else:
first_chapter = unique_chapters_data[0]
if first_chapter["ms"] > 0:
if not (first_chapter["ms"] < 5000 and first_chapter["name"] in ["Intro", "Recap"]):
unique_chapters_data.insert(0, {"ms": 0, "name": "Scene"})
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"})
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(
chapters: List[Chapter] = []
for i, chap_data in enumerate(unique_chapters):
time_sec = chap_data["ms"] / 1000.000
chapter_title = chap_data["name"]
chapters.append(
Chapter(
timestamp=chap_info["ms"] / 1000.000,
name=name if name != "Scene" else None
timestamp=float(time_sec),
name=chapter_title if chapter_title != "Scene" else None
)
)
return final_chapters
return chapters
except Exception as e:
self.log.warning(f"Failed to extract chapters: {e}")
return []
return Chapters()
def get_widevine_service_certificate(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Union[bytes, str]:
# endpoint = self.prod_config["services"]["drm"]["client"]["endpoints"]["widevineCertificate"]["href"]
@ -691,7 +749,7 @@ class DSNP(Service):
res = self.session.post(endpoint, headers=headers, data=challenge)
res.raise_for_status()
except Exception as e:
self.log.error(f" - License request failed: {e}", exc_info=False)
self.log.error(f"License request failed: {e}", exc_info=False)
sys.exit(1)
return res.content
@ -707,7 +765,7 @@ class DSNP(Service):
res = self.session.post(endpoint, headers=headers, data=challenge)
res.raise_for_status()
except Exception as e:
self.log.error(f" - License request failed: {e}", exc_info=False)
self.log.error(f"License request failed: {e}", exc_info=False)
sys.exit(1)
return res.content
@ -753,7 +811,7 @@ class DSNP(Service):
data = self._request("GET", endpoint, params={'limit': 999})["data"]["season"]["items"]
return data
def _get_video(self, scenario: str, payload: dict) -> dict:
def _get_playback(self, scenario: str, payload: dict) -> dict:
endpoint = self._href(
self.prod_config["services"]["media"]["client"]["endpoints"]["mediaPayload"]["href"],
scenario=scenario
@ -765,7 +823,7 @@ class DSNP(Service):
data = self._request("POST", endpoint, headers=headers, payload=payload)
return data["stream"]
def _register_device(self) -> str:
def _register_device(self, android_id: str, drm_id: str) -> str:
endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["registerDevice"]["href"]
headers = {
"Authorization": self.config["bamsdk"]["api_key"],
@ -776,6 +834,16 @@ class DSNP(Service):
"registerDevice": {
"applicationRuntime": self.config["device"]["applicationRuntime"],
"attributes": {
"osDeviceIds": [
{
"identifier": android_id,
"type": "android.vendor.id"
},
{
"identifier": drm_id,
"type": "android.drm.id"
}
],
"operatingSystem": self.config["device"]["operatingSystem"],
"operatingSystemVersion": self.config["device"]["operatingSystemVersion"]
},
@ -797,7 +865,7 @@ class DSNP(Service):
"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]
}
payload = {
"operationName": "Check",
"operationName": "check",
"variables": {
"email": email
},
@ -813,25 +881,88 @@ class DSNP(Service):
"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]
}
payload = {
"operationName": "loginTv",
"operationName": "login",
"variables": {
"input": {
"email": email,
"password": password
}
},
"includeIdentity": True,
"includeAccountConsentToken": True
},
"query": queries.LOGIN
}
data = self._request("POST", endpoint, payload=payload, headers=headers)
return data["extensions"]["sdk"]["token"]
return data["extensions"]["sdk"]
def _get_account_info_raw(self, headers: dict = {}) -> dict:
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": "EntitledGraphMeQuery",
"variables": {},
"query": queries.ENTITLEMENTS
"operationName": "me",
"variables": {
"includeAccountConsentToken": True
},
"query": queries.ME
}
data = self._request("POST", endpoint, payload=payload, headers=headers)
return data["data"]["me"]
@ -845,7 +976,9 @@ class DSNP(Service):
payload = {
"operationName": "switchProfile",
"variables": {
"input": profile_input
"input": profile_input,
"includeIdentity": True,
"includeAccountConsentToken": True
},
"query": queries.SWITCH_PROFILE
}
@ -861,7 +994,7 @@ class DSNP(Service):
payload = {
"operationName": "refreshToken",
"variables": {
"input": {
"refreshToken": {
"refreshToken": refresh_token
}
},
@ -870,7 +1003,7 @@ class DSNP(Service):
data = self._request("POST", endpoint, payload=payload, headers=headers)
return data["extensions"]["sdk"]
def _update_device(self) -> str:
def _update_device(self, android_id: str, drm_id: str) -> str:
endpoint = self.prod_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
headers = {"X-BAMSDK-Platform-Id": self.config["device"]["platform_id"]}
payload = {
@ -878,7 +1011,17 @@ class DSNP(Service):
"variables": {
"updateDeviceOperatingSystem": {
"operatingSystem": self.config["device"]["operatingSystem"],
"operatingSystemVersion": self.config["device"]["operatingSystemVersion"]
"operatingSystemVersion": self.config["device"]["operatingSystemVersion"],
"osDeviceIds": [
{
"identifier": android_id,
"type": "android.vendor.id"
},
{
"identifier": drm_id,
"type": "android.drm.id"
}
]
}
},
"query": queries.UPDATE_DEVICE
@ -947,7 +1090,6 @@ class DSNP(Service):
req = Request(method, endpoint, headers=_headers, params=params, json=payload)
prepped = self.session.prepare_request(req)
try:
res = self.session.send(prepped)
res.raise_for_status()
@ -956,13 +1098,15 @@ class DSNP(Service):
error_code = data["errors"][0]["extensions"]["code"]
if "token.service.invalid.grant" in error_code:
raise ConnectionError(f"Refresh Token Expired: {error_code}")
if "token.service.unauthorized.client" in error_code:
elif "token.service.unauthorized.client" in error_code:
raise ConnectionError(f"Unauthorized Client/IP: {error_code}")
elif "idp.error.identity.bad-credentials" in error_code:
raise ConnectionError(f"Bad Credentials: {error_code}")
elif "account.profile.pin.invalid" in error_code:
raise ConnectionError(f"Invalid PIN: {error_code}")
raise ConnectionError(data["errors"])
if data.get("data") and data["data"].get("errors"):
raise ConnectionError(data["data"]["errors"])
return data
except Exception as e:
if "Refresh Token Expired" in str(e) or "/deeplink" in endpoint:

View File

@ -10,16 +10,16 @@ certificate: |
## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ##
# Browser (windows, chrome) : /browser/v34.2/windows/chrome/prod.json
# Android Phone : /android/v12.0.0/google/handset/prod.json
# Android TV : /android/v12.0.0/google/tv/prod.json
# Amazon Fire TV : /android/v12.0.0/amazon/tv/prod.json
# Android Phone : /android/v15.0.0/google/handset/prod.json
# Android TV : /android/v15.0.0/google/tv/prod.json
# Amazon Fire TV : /android/v15.0.0/amazon/tv/prod.json
endpoints:
config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v13.0.0/google/tv/prod.json"
config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v15.0.0/google/tv/prod.json"
## user_agent ##
# android-phone : BAMSDK/v13.3.0 (disney-svod-3d9324fc 4.18.1+rc6-2025.11.05.0; v7.0/v13.0.0; android; phone)
# android-tv : BAMSDK/v13.3.0 (disney-svod-3d9324fc 4.18.1+rc6-2025.11.05.0; v7.0/v13.0.0; android; tv)
## user_agent (okhttp/5.0.0-alpha.14) ##
# android-phone : BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; phone)
# android-tv : BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv)
## api_key ##
# browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84
@ -30,11 +30,11 @@ endpoints:
# android : 624b805dafc5c73635b1a216
bamsdk:
sdk_version: "13.3.0"
application_version: "4.18.1+rc6-2025.11.05.0"
explore_version: "v1.11"
sdk_version: "15.0.1"
application_version: "4.21.1+rc3-2026.01.06.0"
explore_version: "v1.13"
client: "disney-svod-3d9324fc"
user_agent: "BAMSDK/v13.3.0 (disney-svod-3d9324fc 4.18.1+rc6-2025.11.05.0; v7.0/v13.0.0; android; tv)"
user_agent: "BAMSDK/v15.0.1 (disney-svod-3d9324fc 4.21.1+rc3-2026.01.06.0; v7.0/v15.0.0; android; tv)"
api_key: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA"
yp_service_id: "624b805dafc5c73635b1a216"
@ -46,7 +46,9 @@ device:
applicationRuntime: "android"
operatingSystem: "Android"
operatingSystemVersion: "16"
deviceLanguage: "ko" # en
deviceLanguage: "ko" # Device language data independent of account language data
profile:
index: 0
# Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.)
# Automatically select a profile when commenting.
# profile:
# index: 0

View File

@ -1,13 +1,13 @@
SWITCH_PROFILE = """mutation switchProfile($input: SwitchProfileInput!) { switchProfile(switchProfile: $input) { __typename account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } } } fragment accountGraphFragment on Account { __typename id activeProfile { __typename id } profiles { __typename ...profileGraphFragment } parentalControls { __typename isProfileCreationProtected } flows { __typename star { __typename isOnboarded } } attributes { __typename email emailVerified userVerified locations { __typename manual { __typename country } purchase { __typename country } registration { __typename geoIp { __typename country } } } } } fragment profileGraphFragment on Profile { __typename id name maturityRating { __typename ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating } isAge21Verified flows { __typename star { __typename eligibleForOnboarding isOnboarded } } attributes { __typename isDefault kidsModeEnabled groupWatch { __typename enabled } languagePreferences { __typename appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { __typename isPinProtected kidProofExitEnabled liveAndUnratedContent { __typename enabled } } playbackSettings { __typename autoplay backgroundVideo prefer133 } avatar { __typename id userSelected } } } fragment sessionGraphFragment on Session { __typename sessionId device { __typename id } entitlements experiments { __typename featureId variantId version } homeLocation { __typename countryCode } inSupportedLocation isSubscriber location { __typename countryCode } portabilityLocation { __typename countryCode } preferredMaturityRating { __typename impliedMaturityRating ratingSystem } }"""
ENTITLEMENTS = """query EntitledGraphMeQuery { me { __typename account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } } } fragment accountGraphFragment on Account { __typename id activeProfile { __typename id } profiles { __typename ...profileGraphFragment } parentalControls { __typename isProfileCreationProtected } flows { __typename star { __typename isOnboarded } } attributes { __typename email emailVerified userVerified locations { __typename manual { __typename country } purchase { __typename country } registration { __typename geoIp { __typename country } } } } } fragment profileGraphFragment on Profile { __typename id name maturityRating { __typename ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating } isAge21Verified flows { __typename star { __typename eligibleForOnboarding isOnboarded } } attributes { __typename isDefault kidsModeEnabled groupWatch { __typename enabled } languagePreferences { __typename appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { __typename isPinProtected kidProofExitEnabled liveAndUnratedContent { __typename enabled } } playbackSettings { __typename autoplay backgroundVideo prefer133 preferImaxEnhancedVersion} avatar { __typename id userSelected } } } fragment sessionGraphFragment on Session { __typename sessionId device { __typename id } entitlements experiments { __typename featureId variantId version } homeLocation { __typename countryCode } inSupportedLocation isSubscriber location { __typename countryCode } portabilityLocation { __typename countryCode } preferredMaturityRating { __typename impliedMaturityRating ratingSystem } }"""
REGISTER_DEVICE = """mutation ($registerDevice: RegisterDeviceInput!) {registerDevice(registerDevice: $registerDevice) {__typename}}"""
SET_IMAX = """mutation updateProfileImaxEnhancedVersion($input: UpdateProfileImaxEnhancedVersionInput!, $includeProfile: Boolean!) { updateProfileImaxEnhancedVersion(updateProfileImaxEnhancedVersion: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
SET_REMASTERED_AR = """mutation updateProfileRemasteredAspectRatio($input: UpdateProfileRemasteredAspectRatioInput!, $includeProfile: Boolean!) { updateProfileRemasteredAspectRatio(updateProfileRemasteredAspectRatio: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
# REQUEST_DEVICE_CODE = ("""mutation requestLicensePlate {requestLicensePlate {__typename licensePlate expirationTime expiresInSeconds}}""")
CHECK_EMAIL = """query Check($email: String!) { check(email: $email) { operations nextOperation } }"""
# REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }"""
LOGIN = """mutation loginTv($input: LoginInput!) { login(login: $input) { __typename account { __typename ...accountGraphFragment } actionGrant activeSession { __typename ...sessionGraphFragment } }} fragment accountGraphFragment on Account { __typename id activeProfile { __typename id } profiles { __typename ...profileGraphFragment } parentalControls { __typename isProfileCreationProtected } flows { __typename star { __typename isOnboarded } } attributes { __typename email emailVerified userVerified locations { __typename manual { __typename country } purchase { __typename country } registration { __typename geoIp { __typename country } } } }}\nfragment profileGraphFragment on Profile { __typename id name maturityRating { __typename ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating } isAge21Verified flows { __typename star { __typename eligibleForOnboarding isOnboarded } } attributes { __typename isDefault kidsModeEnabled groupWatch { __typename enabled } languagePreferences { __typename appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { __typename isPinProtected kidProofExitEnabled liveAndUnratedContent { __typename enabled } } playbackSettings { __typename autoplay backgroundVideo prefer133 } avatar { __typename id userSelected } }}\nfragment sessionGraphFragment on Session { __typename sessionId device { __typename id } entitlements experiments { __typename featureId variantId version } homeLocation { __typename countryCode } inSupportedLocation isSubscriber location { __typename countryCode } portabilityLocation { __typename countryCode } preferredMaturityRating { __typename impliedMaturityRating ratingSystem }}"""
# LOGIN_OTP = """mutation authenticateWithOtp($input: AuthenticateWithOtpInput!) { authenticateWithOtp(authenticateWithOtp: $input) { actionGrant securityAction } }"""
# LOGIN_ACTION_GRANT = """\n mutation loginWithActionGrant($input: LoginWithActionGrantInput!) {\n loginWithActionGrant(login: $input) {\n account {\n ...account\n\n profiles {\n ...profile\n }\n }\n activeSession {\n ...session\n }\n identity {\n ...identity\n }\n }\n }\n\n \nfragment identity on Identity {\n attributes {\n securityFlagged\n createdAt\n passwordResetRequired\n }\n flows {\n marketingPreferences {\n eligibleForOnboarding\n isOnboarded\n }\n personalInfo {\n eligibleForCollection\n requiresCollection\n }\n }\n personalInfo {\n dateOfBirth\n gender\n }\n subscriber {\n subscriberStatus\n subscriptionAtRisk\n overlappingSubscription\n doubleBilled\n doubleBilledProviders\n subscriptions {\n id\n groupId\n state\n partner\n isEntitled\n source {\n sourceType\n sourceProvider\n sourceRef\n subType\n }\n paymentProvider\n product {\n id\n sku\n offerId\n promotionId\n name\n nextPhase {\n sku\n offerId\n campaignCode\n voucherCode\n }\n entitlements {\n id\n name\n desc\n partner\n }\n categoryCodes\n redeemed {\n campaignCode\n redemptionCode\n voucherCode\n }\n bundle\n bundleType\n subscriptionPeriod\n earlyAccess\n trial {\n duration\n }\n }\n term {\n purchaseDate\n startDate\n expiryDate\n nextRenewalDate\n pausedDate\n churnedDate\n isFreeTrial\n }\n externalSubscriptionId,\n cancellation {\n type\n restartEligible\n }\n stacking {\n status\n overlappingSubscriptionProviders\n previouslyStacked\n previouslyStackedByProvider\n }\n }\n }\n}\n\n \nfragment account on Account {\n id\n attributes {\n blocks {\n expiry\n reason\n }\n consentPreferences {\n dataElements {\n name\n value\n }\n purposes {\n consentDate\n firstTransactionDate\n id\n lastTransactionCollectionPointId\n lastTransactionCollectionPointVersion\n lastTransactionDate\n name\n status\n totalTransactionCount\n version\n }\n }\n dssIdentityCreatedAt\n email\n emailVerified\n lastSecurityFlaggedAt\n locations {\n manual {\n country\n }\n purchase {\n country\n source\n }\n registration {\n geoIp {\n country\n }\n }\n }\n securityFlagged\n tags\n taxId\n userVerified\n }\n parentalControls {\n isProfileCreationProtected\n }\n flows {\n star {\n isOnboarded\n }\n }\n}\n\n \nfragment profile on Profile {\n id\n name\n isAge21Verified\n attributes {\n avatar {\n id\n userSelected\n }\n isDefault\n kidsModeEnabled\n languagePreferences {\n appLanguage\n playbackLanguage\n preferAudioDescription\n preferSDH\n subtitleAppearance {\n backgroundColor\n backgroundOpacity\n description\n font\n size\n textColor\n }\n subtitleLanguage\n subtitlesEnabled\n }\n groupWatch {\n enabled\n }\n parentalControls {\n kidProofExitEnabled\n isPinProtected\n }\n playbackSettings {\n autoplay\n backgroundVideo\n prefer133\n preferImaxEnhancedVersion\n previewAudioOnHome\n previewVideoOnHome\n }\n }\n personalInfo {\n dateOfBirth\n gender\n age\n }\n maturityRating {\n ...maturityRating\n }\n personalInfo {\n dateOfBirth\n age\n gender\n }\n flows {\n personalInfo {\n eligibleForCollection\n requiresCollection\n }\n star {\n eligibleForOnboarding\n isOnboarded\n }\n }\n}\n\n\nfragment maturityRating on MaturityRating {\n ratingSystem\n ratingSystemValues\n contentMaturityRating\n maxRatingSystemValue\n isMaxContentMaturityRating\n}\n\n\n \nfragment session on Session {\n device {\n id\n platform\n }\n entitlements\n features {\n coPlay\n }\n inSupportedLocation\n isSubscriber\n location {\n type\n countryCode\n dma\n asn\n regionName\n connectionType\n zipCode\n }\n sessionId\n experiments {\n featureId\n variantId\n version\n }\n identity {\n id\n }\n account {\n id\n }\n profile {\n id\n parentalControls {\n liveAndUnratedContent {\n enabled\n }\n }\n }\n partnerName\n preferredMaturityRating {\n impliedMaturityRating\n ratingSystem\n }\n homeLocation {\n countryCode\n }\n portabilityLocation {\n countryCode\n type\n }\n}\n\n"""
REFRESH_TOKEN = """mutation refreshToken($input:RefreshTokenInput!) { refreshToken(refreshToken:$input) { activeSession{sessionId} } }"""
# REQUEST_DEVICE_CODE = """mutation requestLicensePlate($input: RequestLicensePlateInput!) { requestLicensePlate(requestLicensePlate: $input) { licensePlate expirationTime expiresInSeconds } }"""
CHECK_EMAIL = """query check($email: String!) { check(email: $email) { operations nextOperation } }"""
LOGIN = """mutation login($input: LoginInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { login(login: $input) { account { __typename ...accountGraphFragment } actionGrant activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
LOGIN_ACTION_GRANT = """mutation loginWithActionGrant($input: LoginWithActionGrantInput!, $includeAccountConsentToken: Boolean!) { loginWithActionGrant(login: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } actionGrant } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
LOGIN_OTP = """mutation authenticateWithOtp($input: AuthenticateWithOtpInput!) { authenticateWithOtp(authenticateWithOtp: $input) { actionGrant securityAction passwordRules { __typename ...passwordRulesFragment } } } fragment passwordRulesFragment on PasswordRules { minLength charTypes }"""
ME = """query me($includeAccountConsentToken: Boolean!) { me { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
REFRESH_TOKEN = """mutation refreshToken($refreshToken: RefreshTokenInput!) { refreshToken(refreshToken: $refreshToken) { activeSession { sessionId } } }"""
REGISTER_DEVICE = """mutation ($registerDevice: RegisterDeviceInput!) { registerDevice(registerDevice: $registerDevice) { __typename } }"""
REQUESET_OTP = """mutation requestOtp($input: RequestOtpInput!) { requestOtp(requestOtp: $input) { accepted } }"""
SET_IMAX = """mutation updateProfileImaxEnhancedVersion($input: UpdateProfileImaxEnhancedVersionInput!, $includeProfile: Boolean!) { updateProfileImaxEnhancedVersion(updateProfileImaxEnhancedVersion: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
SET_REMASTERED_AR = """mutation updateProfileRemasteredAspectRatio($input: UpdateProfileRemasteredAspectRatioInput!, $includeProfile: Boolean!) { updateProfileRemasteredAspectRatio(updateProfileRemasteredAspectRatio: $input) { accepted profile @include(if: $includeProfile) { __typename ...profileGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } }"""
SWITCH_PROFILE = """mutation switchProfile($input: SwitchProfileInput!, $includeIdentity: Boolean!, $includeAccountConsentToken: Boolean!) { switchProfile(switchProfile: $input) { account { __typename ...accountGraphFragment } activeSession { __typename ...sessionGraphFragment } identity @include(if: $includeIdentity) { __typename ...identityGraphFragment } } } fragment profileGraphFragment on Profile { id name personalInfo { dateOfBirth gender } maturityRating { ratingSystem ratingSystemValues contentMaturityRating maxRatingSystemValue isMaxContentMaturityRating suggestedMaturityRatings { minimumAge maximumAge ratingSystemValue } } isAge21Verified flows { star { eligibleForOnboarding isOnboarded } personalInfo { eligibleForCollection requiresCollection } } attributes { isDefault kidsModeEnabled languagePreferences { appLanguage playbackLanguage preferAudioDescription preferSDH subtitleLanguage subtitlesEnabled } parentalControls { isPinProtected kidProofExitEnabled liveAndUnratedContent { enabled available } } playbackSettings { autoplay backgroundVideo backgroundAudio prefer133 preferImaxEnhancedVersion } avatar { id userSelected } privacySettings { consents { consentType value } } } } fragment accountGraphFragment on Account { id umpMessages { data { messages { messageId messageSource displayLocations content } } } accountConsentToken @include(if: $includeAccountConsentToken) activeProfile { id umpMessages { data { messages { messageId content } } } } profiles { __typename ...profileGraphFragment } profileRequirements { primaryProfiles { personalInfo { requiresCollection } } secondaryProfiles { personalInfo { requiresCollection } personalInfoJrMode { requiresCollection } } } parentalControls { isProfileCreationProtected } flows { star { isOnboarded } } attributes { email emailVerified userVerified maxNumberOfProfilesAllowed locations { manual { country } purchase { country } registration { geoIp { country } } } } } fragment sessionGraphFragment on Session { sessionId device { id } entitlements experiments { featureId variantId version } features { coPlay download noAds } homeLocation { countryCode adsSupported } inSupportedLocation isSubscriber location { countryCode adsSupported } portabilityLocation { countryCode } preferredMaturityRating { impliedMaturityRating ratingSystem } } fragment identityGraphFragment on Identity { id email repromptSubscriberAgreement attributes { passwordResetRequired } commerce { notifications { subscriptionId type showNotification offerData { productType expectedTransition { date price { amount currency } } cypherKeys { key value type } } currentOffer { offerId price { amount currency frequency } } } } flows { marketingPreferences { isOnboarded eligibleForOnboarding } personalInfo { eligibleForCollection requiresCollection } } personalInfo { dateOfBirth gender } locations { purchase { country } } subscriber { subscriberStatus subscriptionAtRisk overlappingSubscription doubleBilled doubleBilledProviders subscriptions { id groupId state partner isEntitled source { sourceProvider sourceType subType sourceRef } product { id sku name entitlements { id name partner } bundle subscriptionPeriod earlyAccess trial { duration } categoryCodes } stacking { status overlappingSubscriptionProviders previouslyStacked previouslyStackedByProvider } term { purchaseDate startDate expiryDate nextRenewalDate pausedDate churnedDate isFreeTrial } } } consent { id idType token } }"""
UPDATE_DEVICE = """mutation updateDeviceOperatingSystem($updateDeviceOperatingSystem: UpdateDeviceOperatingSystemInput!) {updateDeviceOperatingSystem(updateDeviceOperatingSystem: $updateDeviceOperatingSystem) {accepted}}"""

View File

@ -13,7 +13,7 @@ import click
from langcodes import Language
from pyplayready.cdm import Cdm as PlayReadyCdm
from unshackle.core.credential import Credential
from unshackle.core.downloaders import aria2c, requests
from unshackle.core.downloaders import requests
from unshackle.core.manifests import DASH
from unshackle.core.search_result import SearchResult
from unshackle.core.service import Service
@ -50,7 +50,6 @@ class TUBI(Service):
Notes:
- Authentication is currently not required, but cookies are used if provided.
- If 1080p exists, it's currently only available as H.265.
- Unshackle fails to mux properly when n_m3u8dl_re is used, so aria2c is forced as downloader.
- Search is currently disabled.
"""
@ -239,7 +238,7 @@ class TUBI(Service):
track_base = rep_base.text
track.url = f"{base_url}/{track_base}"
track.descriptor = Track.Descriptor.URL
track.downloader = aria2c
track.downloader = requests
if isinstance(track, Audio):
role = track.data["dash"]["adaptation_set"].find("Role")