From f0f7e26268e343ba4e407ba6ca6ec1187944835a Mon Sep 17 00:00:00 2001
From: stabbedbybrick <125766685+stabbedbybrick@users.noreply.github.com>
Date: Wed, 22 May 2024 14:41:58 +0200
Subject: [PATCH] feat(services): Add Discovery Plus service

---
 services/DSCP/__init__.py | 316 ++++++++++++++++++++++++++++++++++++++
 services/DSCP/config.yaml |   7 +
 2 files changed, 323 insertions(+)
 create mode 100644 services/DSCP/__init__.py
 create mode 100644 services/DSCP/config.yaml

diff --git a/services/DSCP/__init__.py b/services/DSCP/__init__.py
new file mode 100644
index 0000000..6c9c498
--- /dev/null
+++ b/services/DSCP/__init__.py
@@ -0,0 +1,316 @@
+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 devine.core.credential import Credential
+from devine.core.manifests.dash import DASH
+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 Chapter, Tracks
+
+
+class DSCP(Service):
+    """
+    \b
+    Service code for Discovery Plus (https://discoveryplus.com).
+
+    \b
+    Author: stabbedbybrick
+    Authorization: Cookies
+    Robustness:
+      L3: 1080p, AAC2.0
+
+    \b
+    Tips:
+        - Input can be either complete title URL or just the path: '/show/richard-hammonds-workshop'
+        - Use the --lang LANG_RANGE option to request non-english tracks
+        - Single video URLs are currently not supported
+
+    """
+
+    ALIASES = ("dplus", "discoveryplus", "discovery+")
+    TITLE_RE = r"^(?:https?://(?:www\.)?discoveryplus\.com(?:/[a-z]{2})?)?/(?P<type>show|video)/(?P<id>[a-z0-9-]+)"
+
+    @staticmethod
+    @click.command(name="DSCP", short_help="https://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):
+        self.title = title
+        super().__init__(ctx)
+
+        self.license = None
+
+    def authenticate(
+        self,
+        cookies: Optional[CookieJar] = None,
+        credential: Optional[Credential] = None,
+    ) -> None:
+        super().authenticate(cookies, credential)
+        if not cookies:
+            raise EnvironmentError("Service requires Cookies for Authentication.")
+
+        self.session.cookies.update(cookies)
+        self.configure()
+
+    def search(self) -> Generator[SearchResult, None, None]:
+        r = self.session.get(self.config["endpoints"]["search"].format(region=self.region, query=self.title))
+        r.raise_for_status()
+        data = r.json()
+
+        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) -> Union[Movies, Series]:
+        try:
+            kind, 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 kind == "video":
+            self.log.error("Single videos are not supported by this service.")
+            sys.exit(1)
+
+        if kind == "show":
+            r = self.session.get(self.config["endpoints"]["show"].format(region=self.region, title_id=content_id))
+            r.raise_for_status()
+            data = r.json()
+
+            content = next(x for x in data["included"] if x["attributes"].get("alias") == "generic-show-episodes")
+            content_id = content["id"]
+            show_id = content["attributes"]["component"]["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.session.get(
+                    self.config["endpoints"]["seasons"].format(
+                        region=self.region, content_id=content_id, season=season, show_id=show_id
+                    )
+                ).json()
+                for season in season_params
+            ]
+
+            videos = [[x for x in season["included"] if x["type"] == "video"] for season in seasons]
+
+            return Series(
+                [
+                    Episode(
+                        id_=ep["id"],
+                        service=self.__class__,
+                        title=page["attributes"]["title"],
+                        year=ep["attributes"]["airDate"][:4],
+                        season=ep["attributes"].get("seasonNumber"),
+                        number=ep["attributes"].get("episodeNumber"),
+                        name=ep["attributes"]["name"],
+                        language=ep["attributes"]["audioTracks"][0]
+                        if ep["attributes"].get("audioTracks")
+                        else self.territory,
+                        data=ep,
+                    )
+                    for episodes in videos
+                    for ep in episodes
+                    if ep["attributes"]["videoType"] == "EPISODE"
+                ]
+            )
+
+    def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
+        res = self.session.post(
+            self.config["endpoints"]["playback"].format(region=self.region),
+            json={
+                "videoId": title.id,
+                "wisteriaProperties": {
+                    "advertiser": {
+                        "adId": "|84958235701907329361495486486652228049||17163182474853637414c74993b0cb4f9a42062d41449",
+                        "firstPlay": 0,
+                        "fwDid": "",
+                        "fwIsLat": 0,
+                        "interactiveCapabilities": [
+                            "brightline",
+                        ],
+                    },
+                    "appBundle": "undefined",
+                    "device": {
+                        "browser": {
+                            "name": "firefox",
+                            "version": "126.0",
+                        },
+                        "id": "",
+                        "language": "en",
+                        "make": "",
+                        "model": "",
+                        "name": "firefox",
+                        "os": "Windows",
+                        "osVersion": "NT 10.0",
+                        "player": {
+                            "name": "Discovery Player Web",
+                            "version": "",
+                        },
+                        "type": "desktop",
+                    },
+                    "gdpr": 0,
+                    "platform": "desktop",
+                    "playbackId": str(uuid.uuid4()),
+                    "product": self.site_id,
+                    "sessionId": str(uuid.uuid4()),
+                    "siteId": self.site_id,
+                    "streamProvider": {
+                        "hlsVersion": 6,
+                        "pingConfig": 0,
+                        "suspendBeaconing": 0,
+                        "version": "1.0.0",
+                    },
+                },
+                "deviceCapabilities": {
+                    "manifests": {
+                        "formats": {
+                            "dash": {},
+                        },
+                    },
+                    "segments": {
+                        "formats": {
+                            "fmp4": {},
+                        },
+                    },
+                    "codecs": {
+                        "audio": {
+                            "decoders": [
+                                {
+                                    "codec": "aac",
+                                    "profiles": [
+                                        "lc",
+                                        "hev",
+                                        "hev2",
+                                    ],
+                                },
+                            ],
+                        },
+                        "video": {
+                            "decoders": [
+                                {
+                                    "codec": "h264",
+                                    "profiles": [
+                                        "high",
+                                        "main",
+                                        "baseline",
+                                    ],
+                                    "maxLevel": "5.2",
+                                },
+                            ],
+                            "hdrFormats": [],
+                        },
+                    },
+                    "contentProtection": {
+                        "contentDecryptionModules": [
+                            {
+                                "drmKeySystem": "clearkey",
+                            },
+                            {
+                                "drmKeySystem": "widevine",
+                                "maxSecurityLevel": "l3",
+                            },
+                        ],
+                    },
+                },
+                "deviceInfo": {
+                    "adBlocker": False,
+                    "deviceId": "",
+                    "drmTypes": {
+                        "widevine": True,
+                        "playready": False,
+                        "fairplay": False,
+                        "clearkey": True,
+                    },
+                    "drmSupported": True,
+                    "hdrCapabilities": [
+                        "SDR",
+                    ],
+                    "hwDecodingCapabilities": [
+                        "H264",
+                    ],
+                    "soundCapabilities": [
+                        "STEREO",
+                    ],
+                },
+            },
+        ).json()
+
+        if "errors" in res:
+            if "missingpackage" in res["errors"][0]["code"]:
+                self.log.error("- Access Denied. Please check your subscription.")
+                sys.exit(1)
+
+            raise ConnectionError(res["errors"])
+
+        streaming = res["data"]["attributes"]["streaming"][0]
+
+        manifest = streaming["url"]
+        if streaming["protection"]["drmEnabled"]:
+            self.token = streaming["protection"]["drmToken"]
+            self.license = streaming["protection"]["schemes"]["widevine"]["licenseUrl"]
+
+        tracks = DASH.from_url(url=manifest, session=self.session).to_tracks(
+            language=title.language, period_filter=self.period_filter
+        )
+
+        return tracks
+
+    def get_chapters(self, title: Union[Movie, Episode]) -> list[Chapter]:
+        return []
+
+    def get_widevine_service_certificate(self, **_: Any) -> str:
+        return None
+
+    def get_widevine_license(self, challenge: bytes, **_: Any) -> str:
+        if not self.license:
+            return None
+
+        r = self.session.post(self.license, headers={"Preauthorization": self.token}, data=challenge)
+        if not r.ok:
+            raise ConnectionError(r.text)
+
+        return r.content
+
+    # Service specific functions
+
+    def period_filter(self, period):
+        return "dash_clear" in period.findtext("BaseURL")
+
+    def configure(self):
+        self.session.headers.update(
+            {
+                "origin": "https://www.discoveryplus.com",
+                "referer": "https://www.discoveryplus.com/",
+                "x-disco-client": "WEB:UNKNOWN:dplus_us:2.44.4",
+                "x-disco-params": "realm=go,siteLookupKey=dplus_us,bid=dplus,features=ar",
+            }
+        )
+
+        info = self.session.get(self.config["endpoints"]["info"]).json()
+        self.region = info["data"]["attributes"]["baseApiUrl"].split("-")[0].split("//")[1]
+        self.territory = info["data"]["attributes"]["mainTerritoryCode"]
+
+        site = self.session.get(self.config["endpoints"]["prod"].format(region=self.region))
+        if not site.ok:
+            raise ConnectionError(site.json())
+
+        self.site_id = site.json()["meta"]["site"]["id"]
diff --git a/services/DSCP/config.yaml b/services/DSCP/config.yaml
new file mode 100644
index 0000000..f900904
--- /dev/null
+++ b/services/DSCP/config.yaml
@@ -0,0 +1,7 @@
+endpoints:
+  info: https://global-prod.disco-api.com/bootstrapInfo
+  prod: "https://{region}-prod-direct.discoveryplus.com/cms/configs/web-prod"
+  show: "https://{region}-prod-direct.discoveryplus.com/cms/routes/show/{title_id}?include=default&decorators=viewingHistory,isFavorite,playbackAllowed"
+  seasons: "https://{region}-prod-direct.discoveryplus.com/cms/collections/{content_id}?include=default&decorators=viewingHistory,isFavorite,playbackAllowed,contentAction,badges&{season}&{show_id}"
+  playback: "https://{region}-prod-direct.discoveryplus.com/playback/v3/videoPlaybackInfo"
+  search: "https://{region}-prod-direct.discoveryplus.com/cms/routes/search/result?include=default&decorators=viewingHistory,isFavorite,playbackAllowed,contentAction,badges&contentFilter[query]={query}&page[items.number]=1&page[items.size]=8"
\ No newline at end of file