Unshackle-Services/DSCP/__init__.py
2026-01-03 17:53:34 +02:00

585 lines
23 KiB
Python

from __future__ import annotations
import json
import re
import sys
import uuid
from collections import defaultdict
from collections.abc import Generator
from copy import deepcopy
from http.cookiejar import CookieJar
from typing import Any
from urllib.parse import urljoin
from zlib import crc32
import click
from click import Context
from langcodes import Language
from lxml import etree
from unshackle.core.credential import Credential
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 as CurlSession
from unshackle.core.titles import Episode, Movie, Movies, Series
from unshackle.core.tracks import Audio, Chapter, Chapters, Subtitle, Track, Tracks
from unshackle.core.utilities import is_close_match
class DSCP(Service):
"""
\b
Service code for Discovery Plus streaming service (https://www.discoveryplus.com).
Credit to @sp4rk.y for the subtitle fix.
\b
Version: 1.0.1
Author: stabbedbybrick
Authorization: Cookies for subscription, none for freely available titles
Robustness:
Widevine:
L1: 2160p, 1080p
L3: 720p
PlayReady:
SL3000: 2160p
SL2000: 1080p, 720p
\b
Tips:
- Input can be either complete title URL or just the path:
SHOW: /show/eb26e00e-9582-4790-a61c-48d785926f58
STANDALONE: /standalone/5012ae3f-d9bd-46ec-ad42-b8116b811441
SPORT: /sport/9cc449de-2a64-524d-bcb6-cabd4ac70340
EPISODE: /video/watch/8685efdd-a3c4-4892-b1d1-5f9f071cacf1/de67ea8e-a90f-4609-81af-4f09906f60b2
\b
Notes:
- Language tags can be mislabelled or missing on some titles. List tracks with --list to verify.
- All qualities, codecs, and ranges are included when available. Use -v H.265, -r HDR10, -q 1080p, etc. to select.
\b
Bonus tip: With some minor adjustments to the code and config, you can convert this to an HMAX service.
- Replace all instances of "DSCP" with "HMAX"
- Replace all instances of "dplus" with "beam"
- Replace all instances of "discoveryplus" with "hbomax"
"""
ALIASES = ("discoveryplus",)
TITLE_RE = (
r"^(?:https?://play.discoveryplus\.com?)?/(?P<type>show|mini-series|video|movie|topical|standalone|sport)/(?P<id>[a-z0-9-/]+)"
)
@staticmethod
@click.command(name="DSCP", short_help="https://www.discoveryplus.com/", help=__doc__)
@click.argument("title", type=str)
@click.pass_context
def cli(ctx: Context, **kwargs: Any) -> DSCP:
return DSCP(ctx, **kwargs)
def __init__(self, ctx: Context, title: str):
super().__init__(ctx)
self.title = title
self.profile = ctx.parent.params.get("profile")
if not self.profile:
self.profile = "default"
self.cdm = ctx.obj.cdm
if self.cdm is not None:
self.drm_system = "playready"
self.security_level = "SL3000"
if self.cdm.security_level <= 3:
self.drm_system = "widevine"
self.security_level = "L1"
self.base_url = self.config["endpoints"]["default_url"]
def get_session(self) -> CurlSession:
return CurlSession("okhttp4", status_forcelist=[429, 502, 503, 504])
def authenticate(self, cookies: CookieJar | None = None, credential: Credential | None = None) -> None:
super().authenticate(cookies, credential)
tokens = {}
if cookies is not None:
st_token = next((c.value for c in cookies if c.name == "st"), None)
if not st_token:
raise ValueError("- Unable to find token in cookies, try refreshing.")
# Only use cache if cookies are present since it's not needed for free titles
cache = self.cache.get(f"tokens_{self.profile}")
if cache:
self.log.info(" + Using cached Tokens...")
tokens = cache.data
else:
self.log.info(" + Setting up new profile...")
profile = {"token": st_token, "device_id": str(uuid.uuid1())}
cache.set(profile)
tokens = cache.data
self.device_id = tokens.get("device_id") or str(uuid.uuid1())
client_id = self.config["client_id"]
self.session.headers.update({
"user-agent": "androidtv dplus/20.8.1.2 (android/9; en-US; SHIELD Android TV-NVIDIA; Build/1)",
"x-disco-client": "ANDROIDTV:9:dplus:20.8.1.2",
"x-disco-params": "realm=bolt,bid=dplus,features=ar",
"x-device-info": f"dplus/20.8.1.2 (NVIDIA/SHIELD Android TV; android/9-mdarcy; {self.device_id}/{client_id})",
})
access = self._request("GET", "/token", params={"realm": "bolt", "deviceId": self.device_id})
self.access_token = access["data"]["attributes"]["token"]
config = self._request("POST", "/session-context/headwaiter/v1/bootstrap")
self.base_url = self.config["endpoints"]["template"].format(config["routing"]["tenant"], config["routing"]["homeMarket"])
def search(self) -> Generator[SearchResult, None, None]:
params = {
"include": "default",
"decorators": "viewingHistory,isFavorite,playbackAllowed,contentAction,badges",
"contentFilter[query]": self.title,
"page[items.number]": "1",
"page[items.size]": "8",
}
data = self._request("GET", "/cms/routes/search/result", params=params)
results = [x.get("attributes") for x in data["included"] if x.get("type") == "show"]
for result in results:
yield SearchResult(
id_=f"/show/{result.get('alternateId')}",
title=result.get("name"),
description=result.get("description"),
label="show",
url=f"/show/{result.get('alternateId')}",
)
def get_titles(self) -> Movies | Series:
try:
entity, content_id = (re.match(self.TITLE_RE, self.title).group(i) for i in ("type", "id"))
except Exception:
raise ValueError("Could not parse ID from title - is the URL correct?")
if entity in ("show", "mini-series", "topical"):
episodes = self._show(content_id)
return Series(episodes)
elif entity in ("movie", "standalone"):
movie = self._movie(content_id, entity)
return Movies(movie)
elif entity == "sport":
sport = self._sport(content_id)
return Movies(sport)
elif entity == "video":
episodes = self._episode(content_id)
return Series(episodes)
else:
raise ValueError(f"Unknown content: {entity}")
def get_tracks(self, title: Movie | Episode) -> Tracks:
payload = {
"appBundle": "com.wbd.stream",
"applicationSessionId": self.device_id,
"capabilities": {
"codecs": {
"audio": {
"decoders": [
{"codec": "aac", "profiles": ["lc", "he", "hev2", "xhe"]},
{"codec": "eac3", "profiles": ["atmos"]},
]
},
"video": {
"decoders": [
{
"codec": "h264",
"levelConstraints": {
"framerate": {"max": 60, "min": 0},
"height": {"max": 2160, "min": 48},
"width": {"max": 3840, "min": 48},
},
"maxLevel": "5.2",
"profiles": ["baseline", "main", "high"],
},
{
"codec": "h265",
"levelConstraints": {
"framerate": {"max": 60, "min": 0},
"height": {"max": 2160, "min": 144},
"width": {"max": 3840, "min": 144},
},
"maxLevel": "5.1",
"profiles": ["main10", "main"],
},
],
"hdrFormats": ["hdr10", "hdr10plus", "dolbyvision", "dolbyvision5", "dolbyvision8", "hlg"],
},
},
"contentProtection": {
"contentDecryptionModules": [
{"drmKeySystem": self.drm_system, "maxSecurityLevel": self.security_level}
]
},
"manifests": {"formats": {"dash": {}}},
},
"consumptionType": "streaming",
"deviceInfo": {
"player": {
"mediaEngine": {"name": "", "version": ""},
"playerView": {"height": 2160, "width": 3840},
"sdk": {"name": "", "version": ""},
}
},
"editId": title.id,
"firstPlay": False,
"gdpr": False,
"playbackSessionId": str(uuid.uuid4()),
"userPreferences": {
#'uiLanguage': 'en'
},
}
playback = self._request(
"POST", "/playback-orchestrator/any/playback-orchestrator/v1/playbackInfo",
headers={"Authorization": f"Bearer {self.access_token}"},
json=payload,
)
original_language = next((
x.get("language")
for x in playback["videos"][0]["audioTracks"]
if "Original" in x.get("displayName", "")
), "")
manifest = (
playback.get("fallback", {}).get("manifest", {}).get("url", "").replace("_fallback", "")
or playback.get("manifest", {}).get("url")
)
license_url = (
playback.get("fallback", {}).get("drm", {}).get("schemes", {}).get(self.drm_system, {}).get("licenseUrl")
or playback.get("drm", {}).get("schemes", {}).get(self.drm_system, {}).get("licenseUrl")
)
title.data["license_url"] = license_url
title.data["chapters"] = next((x.get("annotations") for x in playback["videos"] if x["type"] == "main"), None)
dash = DASH.from_url(url=manifest, session=self.session)
tracks = dash.to_tracks(language="en", period_filter=self._period_filter)
for track in tracks:
track.is_original_lang = str(track.language) == original_language
track.name = "Original" if track.is_original_lang else track.name
if isinstance(track, Audio):
role = track.data["dash"]["representation"].find("Role")
if role is not None and role.get("value") in ["description", "alternative", "alternate"]:
track.descriptive = True
if isinstance(track, Subtitle):
tracks.subtitles.remove(track)
subtitles = self._process_subtitles(dash, original_language)
tracks.add(subtitles)
return tracks
def get_chapters(self, title: Movie | Episode) -> Chapters:
if not title.data.get("chapters"):
return Chapters()
chapters = []
for chapter in title.data["chapters"]:
if "recap" in chapter.get("secondaryType", "").lower():
chapters.append(Chapter(name="Recap", timestamp=chapter["start"]))
if chapter.get("end"):
chapters.append(Chapter(timestamp=chapter.get("end")))
if "intro" in chapter.get("secondaryType", "").lower():
chapters.append(Chapter(name="Intro", timestamp=chapter["start"]))
if chapter.get("end"):
chapters.append(Chapter(timestamp=chapter.get("end")))
elif "credits" in chapter.get("type", "").lower():
chapters.append(Chapter(name="Credits", timestamp=chapter["start"]))
if not any(c.timestamp == "00:00:00.000" for c in chapters):
chapters.append(Chapter(timestamp=0))
return sorted(chapters, key=lambda x: x.timestamp)
def get_widevine_service_certificate(self, challenge: bytes, title: Episode | Movie, **_: Any) -> str:
if not (license_url := title.data.get("license_url")):
return None
return self.session.post(url=license_url, data=challenge).content
def get_widevine_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
if not (license_url := title.data.get("license_url")):
return None
r = self.session.post(url=license_url, data=challenge)
if r.status_code != 200:
raise ConnectionError(r.status_code, r.text)
return r.content
def get_playready_license(self, *, challenge: bytes, title: Episode | Movie, track: Any) -> bytes | str | None:
if not (license_url := title.data.get("license_url")):
return None
r = self.session.post(url=license_url, data=challenge)
if r.status_code != 200:
raise ConnectionError(r.status_code, r.text)
return r.content
# Service specific functions
@staticmethod
def _process_subtitles(dash: DASH, language: str) -> list[Subtitle]:
subtitle_groups = defaultdict(list)
manifest = dash.manifest
for period in manifest.findall("Period"):
for adapt_set in period.findall("AdaptationSet"):
if adapt_set.get("contentType") != "text" or not adapt_set.get("lang"):
continue
role = adapt_set.find("Role")
label = adapt_set.find("Label")
key = (
adapt_set.get("lang"),
role.get("value") if role is not None else "subtitle",
label.text if label is not None else "",
)
subtitle_groups[key].append((period, adapt_set))
final_tracks = []
for (lang, role_value, label_text), adapt_set_group in subtitle_groups.items():
first_period, first_adapt = adapt_set_group[0]
if first_adapt.find("Representation") is None:
continue
s_elements_with_context = []
for _, adapt_set in adapt_set_group:
rep = adapt_set.find("Representation")
if rep is None:
continue
template = rep.find("SegmentTemplate") or adapt_set.find("SegmentTemplate")
timeline = template.find("SegmentTimeline") if template is not None else None
if timeline is not None:
start_num = int(template.get("startNumber", 1))
s_elements_with_context.extend((start_num, s_elem) for s_elem in timeline.findall("S"))
s_elements_with_context.sort(key=lambda x: x[0])
combined_adapt = deepcopy(first_adapt)
combined_rep = combined_adapt.find("Representation")
seg_template = combined_rep.find("SegmentTemplate")
if seg_template is None:
template_at_adapt = combined_adapt.find("SegmentTemplate")
if template_at_adapt is not None:
seg_template = deepcopy(template_at_adapt)
combined_rep.append(seg_template)
combined_adapt.remove(template_at_adapt)
else:
continue
if seg_template.find("SegmentTimeline") is not None:
seg_template.remove(seg_template.find("SegmentTimeline"))
new_timeline = etree.Element("SegmentTimeline")
new_timeline.extend(deepcopy(s) for _, s in s_elements_with_context)
seg_template.append(new_timeline)
seg_template.set("startNumber", "1")
if "endNumber" in seg_template.attrib:
del seg_template.attrib["endNumber"]
track_id = hex(crc32(f"sub-{lang}-{role_value}-{label_text}".encode()) & 0xFFFFFFFF)[2:]
lang_obj = Language.get(lang)
track_name = "Original" if (language and is_close_match(lang_obj, [language])) else lang_obj.display_name()
final_tracks.append(
Subtitle(
id_=track_id,
url=dash.url,
codec=Subtitle.Codec.WebVTT,
language=lang_obj,
is_original_lang=bool(language and is_close_match(lang_obj, [language])),
descriptor=Track.Descriptor.DASH,
sdh="sdh" in label_text.lower() or role_value == "caption",
forced="forced" in label_text.lower() or "forced" in role_value.lower(),
name=track_name,
data={
"dash": {
"manifest": manifest,
"period": first_period,
"adaptation_set": combined_adapt,
"representation": combined_rep,
}
},
)
)
return final_tracks
@staticmethod
def _period_filter(period: Any) -> bool:
"""Shouldn't be needed for fallback manifest"""
if not (duration := period.get("duration")):
return False
return DASH.pt_to_sec(duration) < 120
def _show(self, title: str) -> Episode:
params = {
"include": "default",
"decorators": "viewingHistory,badges,isFavorite,contentAction",
}
data = self._request("GET", "/cms/routes/show/{}".format(title), params=params)
info = next(x for x in data["included"] if x.get("attributes", {}).get("alternateId", "") == title)
content = next((x for x in data["included"] if "show-page-rail-episodes-tabbed-content" in x["attributes"].get("alias", "")), None)
if not content:
raise ValueError("Show not found")
content_id = content.get("id")
show_id = content["attributes"]["component"].get("mandatoryParams", "")
season_params = [x.get("parameter") for x in content["attributes"]["component"]["filters"][0]["options"]]
page = next(x for x in data["included"] if x.get("type", "") == "page")
seasons = [
self._request(
"GET", "/cms/collections/{}?{}&{}".format(content_id, season, show_id),
params={"include": "default", "decorators": "viewingHistory,badges,isFavorite,contentAction"},
)
for season in season_params
]
videos = [[x for x in season["included"] if x["type"] == "video"] for season in seasons]
return [
Episode(
id_=ep["relationships"]["edit"]["data"]["id"],
service=self.__class__,
title=page["attributes"].get("title") or info["attributes"].get("originalName"),
year=ep["attributes"]["airDate"][:4] if ep["attributes"].get("airDate") else None,
season=ep["attributes"].get("seasonNumber"),
number=ep["attributes"].get("episodeNumber"),
name=ep["attributes"]["name"],
data=ep,
)
for episodes in videos
for ep in episodes
if ep.get("attributes", {}).get("videoType", "") == "EPISODE"
]
def _episode(self, title: str) -> Episode:
video_id = title.split("/")[1]
params = {"decorators": "isFavorite", "include": "show"}
content = self._request("GET", "/content/videos/{}".format(video_id), params=params)
episode = content.get("data", {}).get("attributes")
video_type = episode.get("videoType")
relationships = content.get("data", {}).get("relationships")
show = next((x for x in content["included"] if x.get("type", "") == "show"), {})
show_title = show.get("attributes", {}).get("name") or show.get("attributes", {}).get("originalName")
episode_name = episode.get("originalName") or episode.get("secondaryTitle")
if video_type.lower() in ("clip", "standalone_event"):
show_title = episode.get("originalName")
episode_name = episode.get("secondaryTitle", "")
return [
Episode(
id_=relationships.get("edit", {}).get("data", {}).get("id"),
service=self.__class__,
title=show_title,
year=int(episode.get("airDate")[:4]) if episode.get("airDate") else None,
season=episode.get("seasonNumber") or 0,
number=episode.get("episodeNumber") or 0,
name=episode_name,
data=episode,
)
]
def _sport(self, title: str) -> Movie:
params = {
"include": "default",
"decorators": "viewingHistory,badges,isFavorite,contentAction",
}
data = self._request("GET", "/cms/routes/sport/{}".format(title), params=params)
content = next((x for x in data["included"] if x.get("attributes", {}).get("alternateId", "") == title), None)
if not content:
raise ValueError(f"Content not found for title: {title}")
movie = content.get("attributes")
relationships = content.get("relationships")
name = movie.get("name") or movie.get("originalName")
year = int(movie.get("firstAvailableDate")[:4]) if movie.get("firstAvailableDate") else None
return [
Movie(
id_=relationships.get("edit", {}).get("data", {}).get("id"),
service=self.__class__,
name=name + " - " + movie.get("secondaryTitle", ""),
year=year,
data=movie,
)
]
def _movie(self, title: str, entity: str) -> Movie:
params = {
"include": "default",
"decorators": "isFavorite,playbackAllowed,contentAction,badges",
}
data = self._request("GET", "/cms/routes/movie/{}".format(title), params=params)
movie = next((
x for x in data["included"]if x.get("attributes", {}).get("videoType", "").lower() == entity), None
)
if not movie:
raise ValueError("Movie not found")
return [
Movie(
id_=movie["relationships"]["edit"]["data"]["id"],
service=self.__class__,
name=movie["attributes"].get("name") or movie["attributes"].get("originalName"),
year=int(movie["attributes"]["airDate"][:4]) if movie["attributes"].get("airDate") else None,
data=movie,
)
]
def _request(self, method: str, endpoint: str, **kwargs: Any) -> Any[dict | str]:
url = urljoin(self.base_url, endpoint)
response = self.session.request(method, url, **kwargs)
try:
data = json.loads(response.content)
if errors := data.get("errors", []):
code = next((x.get("code", "") for x in errors), "")
if "missingpackage" in code.lower():
self.log.error("\nError: Subscription is required for this title.")
sys.exit(1)
return data
except Exception as e:
raise ConnectionError(f"Request failed for {url}: {e}")