Update Services DSNP and CF
This commit is contained in:
parent
5e1d2aa94a
commit
898b765d7e
777
CR-FIX/__init__.py
Normal file
777
CR-FIX/__init__.py
Normal file
@ -0,0 +1,777 @@
|
|||||||
|
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")
|
||||||
47
CR-FIX/config.yaml
Normal file
47
CR-FIX/config.yaml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# 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"
|
||||||
1116
DSNP-FIX/__init__.py
Normal file
1116
DSNP-FIX/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
54
DSNP-FIX/config.yaml
Normal file
54
DSNP-FIX/config.yaml
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
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
|
||||||
13
DSNP-FIX/queries.py
Normal file
13
DSNP-FIX/queries.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# 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}}"""
|
||||||
Loading…
Reference in New Issue
Block a user