sp4rky-devine-services/services/DSNP/__init__.py
2025-04-09 15:07:35 -06:00

631 lines
24 KiB
Python

from __future__ import annotations
import re
import sys
import uuid
from collections.abc import Generator
from http.cookiejar import CookieJar
from typing import Any, Optional, Union
import click
from click import Context
from requests import Request
from devine.core.credential import Credential
from devine.core.manifests import HLS
from devine.core.search_result import SearchResult
from devine.core.service import Service
from devine.core.titles import Episode, Movie, Movies, Series
from devine.core.tracks import Chapters, Tracks, Video, Chapter
from devine.core.utils.collections import as_list
from . import queries
class DSNP(Service):
"""
\b
Service code for DisneyPlus streaming service (https://www.disneyplus.com).
\b
Authorization: Credentials
Robustness:
Widevine:
L1: 2160p, 1080p
L3: 720p
PlayReady:
SL3: 2160p, 1080p
\b
Tips:
- Input should be only the entity ID for both series and movies:
MOVIE: entity-99e15d53-926e-4074-b9f4-6524d10c8bed
SERIES: entity-30429ad6-dd12-41bf-924e-19131fa66bb5
- Use the --lang LANG_RANGE option to request non-english tracks
- CDM level dictates playback quality (L3 == 720p, L1 == 1080p, 2160p)
\b
Notes:
- On first run, the program will look for the first account profile that doesn't
have kids mode or pin protection enabled. If none are found, the program will exit.
- The profile will be cached and re-used until cache is cleared.
"""
@staticmethod
@click.command(name="DSNP", short_help="https://www.disneyplus.com", help=__doc__)
@click.argument("title", type=str)
@click.pass_context
def cli(ctx: Context, **kwargs: Any) -> DSNP:
return DSNP(ctx, **kwargs)
def __init__(self, ctx: Context, title: str):
self.title = title
super().__init__(ctx)
self.cdm = ctx.obj.cdm
self.playback_data = {}
vcodec = ctx.parent.params.get("vcodec")
range = ctx.parent.params.get("range_")
self.range = range[0].name if range else "SDR"
self.vcodec = "H265" if vcodec and vcodec == Video.Codec.HEVC else "H264"
if self.range != "SDR" and self.vcodec != "H265":
self.vcodec = "H265"
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential)
if not credential:
raise EnvironmentError("Service requires Credentials for Authentication.")
self.session.headers.update(self.config["HEADERS"])
self.session.headers.update({"x-bamsdk-transaction-id": str(uuid.uuid4())})
self.prd_config = self.session.get(self.config["CONFIG_URL"]).json()
self._cache = self.cache.get(f"tokens_{credential.sha1}")
if self._cache:
self.log.info(" + Refreshing Tokens")
profile = self.refresh_token(self._cache.data["token"]["refreshToken"])
self._cache.set(profile, expiration=profile["token"]["expiresIn"] - 30)
token = self._cache.data["token"]["accessToken"]
self.session.headers.update({"Authorization": "Bearer {}".format(token)})
self.active_session = self.account()["activeSession"]
else:
self.log.info(" + Setting up new profile...")
token = self.register_device()
status = self.check_email(credential.username, token)
if status.lower() == "register":
raise ValueError("Account is not registered. Please register first.")
elif status.lower() == "otp":
self.log.error(" - Account requires passcode for login.")
sys.exit(1)
else:
tokens = self.login(credential.username, credential.password, token)
self.session.headers.update({"Authorization": "Bearer {}".format(tokens["accessToken"])})
account = self.account()
profile_id = next(
(
x.get("id")
for x in account["account"]["profiles"]
if not x["attributes"]["kidsModeEnabled"]
and not x["attributes"]["parentalControls"]["isPinProtected"]
),
None,
)
if not profile_id:
self.log.error(
" - Missing profile - you need at least one profile with kids mode and pin protection disabled"
)
sys.exit(1)
set_profile = self.switch_profile(profile_id)
profile = self.refresh_token(set_profile["token"]["refreshToken"])
self._cache.set(profile, expiration=profile["token"]["expiresIn"] - 30)
token = self._cache.data["token"]["accessToken"]
self.session.headers.update({"Authorization": "Bearer {}".format(token)})
self.active_session = self.account()["activeSession"]
self.log.info(" + Acquired tokens...")
def search(self) -> Generator[SearchResult, None, None]:
params = {
"query": self.title,
}
endpoint = self.href(
self.prd_config["services"]["explore"]["client"]["endpoints"]["search"]["href"],
version=self.config["EXPLORE_VERSION"],
)
data = self._request("GET", endpoint, params=params)["data"]["page"]
if not data.get("containers"):
return
results = data["containers"][0]["items"]
for result in results:
entity = "entity-" + result.get("id")
yield SearchResult(
id_=entity,
title=result["visuals"].get("title"),
description=result["visuals"]["description"].get("brief"),
label=result["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"),
url=f"https://www.disneyplus.com/browse/{entity}",
)
def get_titles(self) -> Union[Movies, Series]:
if not self.title.startswith("entity"):
raise ValueError("Invalid input - Use only entity IDs.")
content = self.get_deeplink(self.title)
_type = content["data"]["deeplink"]["actions"][0]["contentType"]
if _type == "movie":
movie = self._movie(self.title)
return Movies(movie)
elif _type == "series":
episodes = self._show(self.title)
return Series(episodes)
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
resource_id = title.data.get("resourceId")
content_id = title.data["partnerFeed"].get("dmcContentId")
content = self.get_video(content_id)
playback = content["video"]["mediaMetadata"]["playbackUrls"][0]["href"]
token = self._refresh()
headers = {
"accept": "application/vnd.media-service+json; version=5",
"authorization": token,
"x-dss-feature-filtering": "true",
}
payload = {
"playbackId": resource_id,
"playback": {
"attributes": {
"codecs": {
"supportsMultiCodecMaster": False,
},
"protocol": "HTTPS",
# "ads": "",
"frameRates": [60],
"assetInsertionStrategy": "SGAI",
"playbackInitializationContext": "ONLINE",
},
},
}
video_ranges = []
audio_types = []
audio_types.append("ATMOS")
audio_types.append("DTS_X")
if not self.cdm.security_level == 3 and self.range == "DV":
video_ranges.append("DOLBY_VISION")
if not self.cdm.security_level == 3 and self.range == "HDR10":
video_ranges.append("HDR10")
if self.vcodec == "H265":
payload["playback"]["attributes"]["codecs"] = {"video": ["h264", "h265"]}
if audio_types:
payload["playback"]["attributes"]["audioTypes"] = audio_types
if video_ranges:
payload["playback"]["attributes"]["videoRanges"] = video_ranges
if self.cdm.security_level == 3:
payload["playback"]["attributes"]["resolution"] = {"max": ["1280x720"]}
scenario = "ctr-regular" if self.cdm.security_level == 3 else "ctr-high"
endpoint = playback.format(scenario=scenario)
res = self._request("POST", endpoint, payload=payload, headers=headers)
self.playback_data[title.id] = self._request(
"POST", f"https://disney.playback.edge.bamgrid.com/v7/playback/{scenario}", payload=payload, headers=headers
)
manifest = res["stream"]["complete"][0]["url"]
tracks = HLS.from_url(url=manifest, session=self.session).to_tracks(language="en-US")
for audio in tracks.audio:
bitrate = re.search(
r"(?<=r/composite_)\d+|\d+(?=_complete.m3u8)",
as_list(audio.url)[0],
)
audio.bitrate = int(bitrate.group()) * 1000
if audio.bitrate == 1000_000:
# DSNP lies about the Atmos bitrate
audio.bitrate = 768_000
return tracks
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
"""
Extract chapter information from the title data if available.
Returns chapter markers for intro, credits, and scenes.
"""
chapters = Chapters()
try:
# First try to get chapters from the new API via playback data
if title.id in self.playback_data and "stream" in self.playback_data[title.id]:
playback_res = self.playback_data[title.id]
# Check for editorial markers in playback data
if "editorial" in playback_res.get("stream", {}):
editorial = playback_res["stream"]["editorial"]
# Add "Start" chapter if not already present
if not any(item.get("offsetMillis") == 0 for item in editorial):
chapters.add(Chapter(timestamp=0, name="Start"))
# Map editorial labels to chapter names
mapping = {
"recap_start": "Recap",
"FFER": "Recap", # First Frame Episode Recap
"recap_end": "Scene",
"LFER": "Scene", # Last Frame Episode Recap
"intro_start": "Title Sequence",
"intro_end": "Scene",
"FFEI": "Title Sequence", # First Frame Episode Intro
"LFEI": "Scene", # Last Frame Episode Intro
"FFCB": None, # First Frame Credits Bumper
"LFCB": "Scene", # Last Frame Credits Bumper
"FFEC": "End Credits", # First Frame End Credits
"LFEC": None, # Last Frame End Credits
"up_next": None,
}
# Sort by timestamp to ensure proper scene numbering
editorial.sort(key=lambda x: x.get("offsetMillis", 0))
# Track chapters we've already added by timestamp to avoid duplicates
seen_timestamps = set()
scene_count = 0
for marker in editorial:
if "label" in marker and "offsetMillis" in marker:
timestamp = marker["offsetMillis"]
name = mapping.get(marker["label"])
# Skip if no mapping or already processed timestamp
if not name or timestamp in seen_timestamps:
continue
# Mark this timestamp as seen
seen_timestamps.add(timestamp)
if name == "Scene":
scene_count += 1
name = f"Scene {scene_count}"
chapters.add(Chapter(timestamp=timestamp, name=name))
# If we found chapters in the playback data, return them
if chapters:
return chapters
# If no chapters found in playback data, try the original method
content_id = title.data["partnerFeed"].get("dmcContentId")
content = self.get_video(content_id)
# Check for chapter/milestone data
video_info = content.get("video", {}).get("milestone", {})
if not video_info:
return chapters
# Mapping of milestone types to chapter names
mapping = {
"recap_start": "Recap",
"recap_end": "Scene",
"intro_start": "Title Sequence",
"intro_end": "Scene",
"FFEI": "Title Sequence", # First Frame Episode Intro
"LFEI": "Scene", # Last Frame Episode Intro
"FFCB": None, # First Frame Credits Bumper
"LFCB": "Scene", # Last Frame Credits Bumper
"FFEC": "End Credits", # First Frame End Credits
"LFEC": None, # Last Frame End Credits
"up_next": None,
}
# Flatten the milestone data and sort by start time
flattened = []
for chapter_type, items in video_info.items():
for entry in items:
if "milestoneTime" in entry and entry["milestoneTime"]:
start = entry["milestoneTime"][0]["startMillis"]
flattened.append({"type": chapter_type, "start": start})
flattened.sort(key=lambda x: x["start"])
# Create chapters
chapter_list = []
scene_count = 0
for f in flattened:
name = mapping.get(f["type"])
if not name:
continue
if name == "Scene":
scene_count += 1
name = f"Scene {scene_count}"
chapter_list.append(Chapter(timestamp=f["start"], name=name))
# Add a "Start" chapter at 0 if we have end credits
if "FFEC" in video_info and not any(ch.timestamp == 0 for ch in chapter_list):
chapter_list.insert(0, Chapter(timestamp=0, name="Start"))
# Remove duplicates (same time and name)
prev_time, prev_name = None, None
for ch in chapter_list:
# Convert timestamp to milliseconds for comparison
if isinstance(ch.timestamp, str):
ts_parts = ch.timestamp.split(":")
hour, minute, second = int(ts_parts[0]), int(ts_parts[1]), float(ts_parts[2])
ts_ms = (hour * 3600 + minute * 60 + second) * 1000
else:
ts_ms = ch.timestamp
if prev_time is None or (ts_ms != prev_time and ch.name != prev_name):
chapters.add(ch)
prev_time, prev_name = ts_ms, ch.name
return chapters
except Exception as e:
self.log.warning(f"Failed to extract chapters: {e}")
return chapters
def get_widevine_service_certificate(self, **_: Any) -> str:
return None
def get_widevine_license(self, *, challenge: bytes, title, track) -> None:
headers = {
"Authorization": f"Bearer {self._cache.data['token']['accessToken']}",
"Content-Type": "application/octet-stream",
}
r = self.session.post(url=self.config["LICENSE"], headers=headers, data=challenge)
if r.status_code != 200:
raise ConnectionError(r.text)
return r.content
# Service specific functions
def _show(self, title: str) -> Episode:
page = self.get_page(title)
container = next(x for x in page["containers"] if x.get("type") == "episodes")
season_ids = [x.get("id") for x in container["seasons"] if x.get("type") == "season"]
episodes = []
for season in season_ids:
endpoint = self.href(
self.prd_config["services"]["explore"]["client"]["endpoints"]["getSeason"]["href"],
version=self.config["EXPLORE_VERSION"],
seasonId=season,
)
data = self.session.get(endpoint).json()["data"]["season"]["items"]
episodes.extend(data)
return [
Episode(
id_=episode.get("id"),
service=self.__class__,
title=episode["visuals"].get("title"),
year=episode["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"),
season=int(episode["visuals"].get("seasonNumber", 0)),
number=int(episode["visuals"].get("episodeNumber", 0)),
name=episode["visuals"].get("episodeTitle"),
data=next(x for x in episode["actions"] if x.get("type") == "playback"),
)
for episode in episodes
if episode.get("type") == "view"
]
def _movie(self, title: str) -> Movie:
movie = self.get_page(title)
return [
Movie(
id_=movie.get("id"),
service=self.__class__,
name=movie["visuals"].get("title"),
year=movie["visuals"]["metastringParts"].get("releaseYearRange", {}).get("startYear"),
data=next(x for x in movie["actions"] if x.get("type") == "playback"),
)
]
def _request(
self,
method: str,
endpoint: str,
params: dict = None,
headers: dict = None,
payload: dict = None,
) -> Any[dict | str]:
_headers = headers if headers else self.session.headers
prep = self.session.prepare_request(Request(method, endpoint, headers=_headers, params=params, json=payload))
response = self.session.send(prep)
try:
data = response.json()
if data.get("errors"):
code = data["errors"][0]["extensions"].get("code")
if "token.service.unauthorized.client" in code:
raise ConnectionError("Unauthorized Client/IP: " + code)
if "idp.error.identity.bad-credentials" in code:
raise ConnectionError("Bad Credentials: " + code)
else:
raise ConnectionError(data["errors"])
return data
except Exception as e:
raise ConnectionError("Request failed: {}".format(response.content))
def get_page(self, title):
params = {
"disableSmartFocus": "true",
"limit": 999,
"enhancedContainersLimit": 0,
}
endpoint = self.href(
self.prd_config["services"]["explore"]["client"]["endpoints"]["getPage"]["href"],
version=self.config["EXPLORE_VERSION"],
pageId=title,
)
return self._request("GET", endpoint, params=params)["data"]["page"]
def get_video(self, content_id: str) -> dict:
endpoint = self.href(
self.prd_config["services"]["content"]["client"]["endpoints"]["getDmcVideo"]["href"], contentId=content_id
)
return self._request("GET", endpoint)["data"]["DmcVideo"]
def get_deeplink(self, ref_id: str) -> str:
params = {
"refId": ref_id,
"refIdType": "deeplinkId",
}
endpoint = "https://disney.content.edge.bamgrid.com/explore/v1.0/deeplink"
return self._request("GET", endpoint, params=params)
def series_bundle(self, series_id: str) -> dict:
endpoint = self.href(
self.prd_config["services"]["content"]["client"]["endpoints"]["getDmcSeriesBundle"]["href"],
encodedSeriesId=series_id,
)
return self.session.get(endpoint).json()["data"]["DmcSeriesBundle"]
def refresh_token(self, refresh_token: str):
payload = {
"operationName": "refreshToken",
"variables": {
"input": {
"refreshToken": refresh_token,
},
},
"query": queries.REFRESH_TOKEN,
}
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["refreshToken"]["href"]
data = self._request("POST", endpoint, payload=payload, headers={"authorization": self.config["API_KEY"]})
return data["extensions"]["sdk"]
def _refresh(self):
if not self._cache.expired:
return self._cache.data["token"]["accessToken"]
profile = self.refresh_token(self._cache.data["token"]["refreshToken"])
self._cache.set(profile, expiration=profile["token"]["expiresIn"] - 30)
return self._cache.data["token"]["accessToken"]
def register_device(self) -> dict:
payload = {
"variables": {
"registerDevice": {
"applicationRuntime": "android",
"attributes": {
"operatingSystem": "Android",
"operatingSystemVersion": "8.1.0",
},
"deviceFamily": "android",
"deviceLanguage": "en",
"deviceProfile": "tv",
}
},
"query": queries.REGISTER_DEVICE,
}
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["registerDevice"]["href"]
data = self._request("POST", endpoint, payload=payload, headers={"authorization": self.config["API_KEY"]})
return data["extensions"]["sdk"]["token"]["accessToken"]
def login(self, email: str, password: str, token: str) -> dict:
payload = {
"operationName": "loginTv",
"variables": {
"input": {
"email": email,
"password": password,
},
},
"query": queries.LOGIN,
}
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
data = self._request("POST", endpoint, payload=payload, headers={"authorization": token})
return data["extensions"]["sdk"]["token"]
def href(self, href, **kwargs) -> str:
_args = {
"apiVersion": "{apiVersion}",
"region": self.active_session["location"]["countryCode"],
"impliedMaturityRating": 1850,
"kidsModeEnabled": "false",
"appLanguage": "en-US",
"partner": "disney",
}
_args.update(**kwargs)
href = href.format(**_args)
# [3.0, 3.1, 3.2, 5.0, 3.3, 5.1, 6.0, 5.2, 6.1]
api_version = "6.1"
if "/search/" in href:
api_version = "5.1"
return href.format(apiVersion=api_version)
def check_email(self, email: str, token: str) -> str:
payload = {
"operationName": "Check",
"variables": {
"email": email,
},
"query": queries.CHECK_EMAIL,
}
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
data = self._request("POST", endpoint, payload=payload, headers={"authorization": token})
return data["data"]["check"]["operations"][0]
def account(self) -> dict:
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
payload = {
"operationName": "EntitledGraphMeQuery",
"variables": {},
"query": queries.ENTITLEMENTS,
}
data = self._request("POST", endpoint, payload=payload)
return data["data"]["me"]
def switch_profile(self, profile_id: str) -> dict:
payload = {
"operationName": "switchProfile",
"variables": {
"input": {
"profileId": profile_id,
},
},
"query": queries.SWITCH_PROFILE,
}
endpoint = self.prd_config["services"]["orchestration"]["client"]["endpoints"]["query"]["href"]
data = self._request("POST", endpoint, payload=payload)
return data["extensions"]["sdk"]