Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 820674a5ab |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@ -211,7 +211,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB
|
||||
|
||||
@staticmethod
|
||||
def get_session():
|
||||
return session("okhttp4")
|
||||
return session("OkHttp4_12")
|
||||
|
||||
@staticmethod
|
||||
@click.command(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
@ -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"
|
||||
@ -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")
|
||||
|
||||
1116
DSNP-FIX/__init__.py
1116
DSNP-FIX/__init__.py
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
@ -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}}"""
|
||||
484
DSNP/__init__.py
484
DSNP/__init__.py
@ -1,28 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import click
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from collections.abc import Generator
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import Any, Optional, Union, List
|
||||
from langcodes import Language
|
||||
|
||||
import click
|
||||
from click import Context
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from http.cookiejar import CookieJar
|
||||
from langcodes import Language
|
||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
||||
from requests import Request
|
||||
from typing import Any, Optional, Union, List
|
||||
|
||||
from unshackle.core.constants import AnyTrack
|
||||
from unshackle.core.credential import Credential
|
||||
from unshackle.core.manifests import HLS
|
||||
from unshackle.core.search_result import SearchResult
|
||||
from unshackle.core.service import Service
|
||||
from unshackle.core.manifests import HLS
|
||||
from unshackle.core.titles import Title_T, Titles_T, Episode, Movie, Movies, Series
|
||||
from unshackle.core.tracks import Chapter, Chapters, Tracks, Attachment, Video, Audio, Subtitle
|
||||
from unshackle.core.utils.ip_info import get_ip_info
|
||||
from unshackle.core.utils.collections import as_list
|
||||
from unshackle.core.utilities import get_ip_info
|
||||
|
||||
from . import queries
|
||||
|
||||
@ -37,7 +39,7 @@ class DSNP(Service):
|
||||
|
||||
ALIASES = ("DSNP", "disneyplus", "disney+")
|
||||
TITLE_RE = (
|
||||
r"^(?:https?://(?:www\.)?disneyplus\.com(?:/[a-z0-9-]+)?(?:/[a-z0-9-]+)?/(browse)/(?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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 } }"""
|
||||
# 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 } }"""
|
||||
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} } }"""
|
||||
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}}"""
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user