diff --git a/services/DSCP/__init__.py b/services/DSCP/__init__.py index 5955850..a7e4f65 100644 --- a/services/DSCP/__init__.py +++ b/services/DSCP/__init__.py @@ -1,11 +1,13 @@ from __future__ import annotations +import json import re import sys import uuid from collections.abc import Generator from http.cookiejar import CookieJar from typing import Any, Optional, Union +from urllib.parse import urljoin import click from click import Context @@ -15,6 +17,7 @@ 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 +from requests import Request class DSCP(Service): @@ -30,15 +33,22 @@ class DSCP(Service): \b Tips: - - Input can be either complete title URL or just the path: '/show/richard-hammonds-workshop' + - Input can be either complete title URL or just the path: + SHOW: /show/richard-hammonds-workshop + EPISODE: /video/richard-hammonds-workshop/new-beginnings + SPORT: /video/sport/tnt-sports-1/uefa-champions-league - Use the --lang LANG_RANGE option to request non-english tracks - - Single video URLs are currently not supported - use -v H.265 to request H.265 tracks + \b + Known issues: + - Devine can't properly parse certain manifests, causing an error in download workers. + - Sport streams specifically seem to be the most affected by this. + """ ALIASES = ("dplus", "discoveryplus", "discovery+") - TITLE_RE = r"^(?:https?://(?:www\.)?discoveryplus\.com(?:/[a-z]{2})?)?/(?Pshow|video)/(?P[a-z0-9-]+)" + TITLE_RE = r"^(?:https?://(?:www\.)?discoveryplus\.com(?:/[a-z]{2})?)?/(?Pshow|video)/(?P[a-z0-9-/]+)" @staticmethod @click.command(name="DSCP", short_help="https://discoveryplus.com", help=__doc__) @@ -52,8 +62,6 @@ class DSCP(Service): self.vcodec = ctx.parent.params.get("vcodec") super().__init__(ctx) - self.license = None - def authenticate( self, cookies: Optional[CookieJar] = None, @@ -64,12 +72,24 @@ class DSCP(Service): raise EnvironmentError("Service requires Cookies for Authentication.") self.session.cookies.update(cookies) - self.configure() + + info = self._request("GET", "https://global-prod.disco-api.com/bootstrapInfo") + self.base_url = info["data"]["attributes"].get("baseApiUrl") + + user = self._request("GET", "/users/me") + self.territory = user["data"]["attributes"]["currentLocationTerritory"] + self.user_language = user["data"]["attributes"]["clientTranslationLanguageTags"][0] + self.site_id = user["meta"]["site"]["id"] def search(self) -> Generator[SearchResult, None, None]: - r = self.session.get(self.config["endpoints"]["search"].format(base_api=self.base_api, query=self.title)) - r.raise_for_status() - data = r.json() + 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"] @@ -89,63 +109,19 @@ class DSCP(Service): 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) + episodes = self._episode(content_id) if kind == "show": - data = self.session.get( - self.config["endpoints"]["show"].format(base_api=self.base_api, title_id=content_id) - ).json() - if "errors" in data: - if "invalid.token" in data["errors"][0]["code"]: - self.log.error("- Invalid Token. Cookies are invalid or may have expired.") - sys.exit(1) + episodes = self._show(content_id) - raise ConnectionError(data["errors"]) - - 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( - base_api=self.base_api, 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.user_language, - data=ep, - ) - for episodes in videos - for ep in episodes - if ep["attributes"]["videoType"] == "EPISODE" - ] - ) + return Series(episodes) def get_tracks(self, title: Union[Movie, Episode]) -> Tracks: platform = "firetv" if self.vcodec == "H.265" else "desktop" - res = self.session.post( - self.config["endpoints"]["playback"].format(base_api=self.base_api), - json={ + res = self._request( + "POST", + "/playback/v3/videoPlaybackInfo", + payload={ "videoId": title.id, "deviceInfo": { "adBlocker": "false", @@ -163,21 +139,10 @@ class DSCP(Service): "streamProvider": {"suspendBeaconing": 0, "hlsVersion": 7, "pingConfig": 1}, }, }, - ).json() - - if "errors" in res: - if "missingpackage" in res["errors"][0]["code"]: - self.log.error("- Access Denied. Title is not available for this account.") - sys.exit(1) - - if "invalid.token" in res["errors"][0]["code"]: - self.log.error("- Invalid Token. Cookies are invalid or may have expired.") - sys.exit(1) - - raise ConnectionError(res["errors"]) + ) + self.license = None streaming = res["data"]["attributes"]["streaming"][0] - manifest = streaming["url"] if streaming["protection"]["drmEnabled"]: self.token = streaming["protection"]["drmToken"] @@ -205,26 +170,124 @@ class DSCP(Service): # Service specific functions - def configure(self): - self.session.headers.update( - { - "user-agent": "Chrome/96.0.4664.55", - "x-disco-client": "WEB:UNKNOWN:dplus_us:2.44.4", - "x-disco-params": "realm=go,siteLookupKey=dplus_us,bid=dplus,hn=www.discoveryplus.com,hth=,uat=false", - } - ) + def _show(self, title: str) -> Episode: + params = { + "include": "default", + "decorators": "playbackAllowed,contentAction,badges", + } + data = self._request("GET", "/cms/routes/show/{}".format(title), params=params) - info = self.session.get(self.config["endpoints"]["info"]).json() - self.base_api = info["data"]["attributes"]["baseApiUrl"] + 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") - user = self.session.get(self.config["endpoints"]["user"].format(base_api=self.base_api)).json() - if "errors" in user: - if "invalid.token" in user["errors"][0]["code"]: - self.log.error("- Invalid Token. Cookies are invalid or may have expired.") - sys.exit(1) + seasons = [ + self._request( + "GET", + "/cms/collections/{}?{}&{}".format(content_id, season, show_id), + params={ + "include": "default", + "decorators": "playbackAllowed,contentAction,badges", + }, + ) + for season in season_params + ] - raise ConnectionError(user["errors"]) + videos = [[x for x in season["included"] if x["type"] == "video"] for season in seasons] - self.territory = user["data"]["attributes"]["currentLocationTerritory"] - self.user_language = user["data"]["attributes"]["clientTranslationLanguageTags"][0] - self.site_id = user["meta"]["site"]["id"] + return [ + 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.user_language, + data=ep, + ) + for episodes in videos + for ep in episodes + if ep["attributes"]["videoType"] == "EPISODE" + ] + + def _episode(self, title: str) -> Episode: + params = { + "include": "default", + "decorators": "playbackAllowed,contentAction,badges", + } + data = self._request("GET", "/cms/routes/video/{}".format(title), params=params) + page = next((x for x in data["included"] if x.get("type", "") == "page"), None) + if not page: + raise IndexError("Episode page not found") + + video_id = page["relationships"].get("primaryContent", {}).get("data", {}).get("id") + if not video_id: + raise IndexError("Episode id not found") + + params = { + "decorators": "isFavorite", + "include": "primaryChannel", + } + content = self._request("GET", "/content/videos/{}".format(video_id), params=params) + episode = content["data"]["attributes"] + name = episode.get("name") + if episode.get("secondaryTitle"): + name += " - " + episode.get("secondaryTitle") + + return [ + Episode( + id_=content["data"].get("id"), + service=self.__class__, + title=page["attributes"]["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=name, + language=episode["audioTracks"][0] if episode.get("audioTracks") else self.user_language, + data=episode, + ) + ] + + def _request( + self, + method: str, + api: str, + params: dict = None, + headers: dict = None, + payload: dict = None, + ) -> Any[dict | str]: + url = urljoin(self.base_url, api) + self.session.headers.update(self.config["headers"]) + + if params: + self.session.params.update(params) + if headers: + self.session.headers.update(headers) + + prep = self.session.prepare_request(Request(method, url, json=payload)) + response = self.session.send(prep) + + try: + data = json.loads(response.content) + + if data.get("errors"): + if "invalid.token" in data["errors"][0]["code"]: + self.log.error("- Invalid Token. Cookies are invalid or may have expired.") + sys.exit(1) + + if "missingpackage" in data["errors"][0]["code"]: + self.log.error("- Access Denied. Title is not available for this subscription.") + sys.exit(1) + + raise ConnectionError(data["errors"]) + + return data + + except Exception as e: + raise ConnectionError("Request failed: {}".format(e)) diff --git a/services/DSCP/config.yaml b/services/DSCP/config.yaml index fd71540..dcb25ee 100644 --- a/services/DSCP/config.yaml +++ b/services/DSCP/config.yaml @@ -1,8 +1,4 @@ -endpoints: - info: https://global-prod.disco-api.com/bootstrapInfo - user: "{base_api}/users/me" - prod: "{base_api}/cms/configs/web-prod" - show: "{base_api}/cms/routes/show/{title_id}?include=default&decorators=viewingHistory,isFavorite,playbackAllowed" - seasons: "{base_api}/cms/collections/{content_id}?include=default&decorators=viewingHistory,isFavorite,playbackAllowed,contentAction,badges&{season}&{show_id}" - playback: "{base_api}/playback/v3/videoPlaybackInfo" - search: "{base_api}/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 +headers: + user-agent: Chrome/96.0.4664.55 + x-disco-client: WEB:UNKNOWN:dplus_us:2.44.4 + x-disco-params: realm=go,siteLookupKey=dplus_us,bid=dplus,hn=www.discoveryplus.com,hth=,uat=false