feat(DSCP): Add feature to download by episode URL

This commit is contained in:
stabbedbybrick 2024-08-01 16:27:18 +02:00
parent 79a706fdea
commit 4431ebec31
2 changed files with 159 additions and 100 deletions

View File

@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
import json
import re import re
import sys import sys
import uuid import uuid
from collections.abc import Generator from collections.abc import Generator
from http.cookiejar import CookieJar from http.cookiejar import CookieJar
from typing import Any, Optional, Union from typing import Any, Optional, Union
from urllib.parse import urljoin
import click import click
from click import Context from click import Context
@ -15,6 +17,7 @@ from devine.core.search_result import SearchResult
from devine.core.service import Service from devine.core.service import Service
from devine.core.titles import Episode, Movie, Movies, Series from devine.core.titles import Episode, Movie, Movies, Series
from devine.core.tracks import Chapter, Tracks from devine.core.tracks import Chapter, Tracks
from requests import Request
class DSCP(Service): class DSCP(Service):
@ -30,15 +33,22 @@ class DSCP(Service):
\b \b
Tips: 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 - 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 - 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+") ALIASES = ("dplus", "discoveryplus", "discovery+")
TITLE_RE = r"^(?:https?://(?:www\.)?discoveryplus\.com(?:/[a-z]{2})?)?/(?P<type>show|video)/(?P<id>[a-z0-9-]+)" TITLE_RE = r"^(?:https?://(?:www\.)?discoveryplus\.com(?:/[a-z]{2})?)?/(?P<type>show|video)/(?P<id>[a-z0-9-/]+)"
@staticmethod @staticmethod
@click.command(name="DSCP", short_help="https://discoveryplus.com", help=__doc__) @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") self.vcodec = ctx.parent.params.get("vcodec")
super().__init__(ctx) super().__init__(ctx)
self.license = None
def authenticate( def authenticate(
self, self,
cookies: Optional[CookieJar] = None, cookies: Optional[CookieJar] = None,
@ -64,12 +72,24 @@ class DSCP(Service):
raise EnvironmentError("Service requires Cookies for Authentication.") raise EnvironmentError("Service requires Cookies for Authentication.")
self.session.cookies.update(cookies) 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]: def search(self) -> Generator[SearchResult, None, None]:
r = self.session.get(self.config["endpoints"]["search"].format(base_api=self.base_api, query=self.title)) params = {
r.raise_for_status() "include": "default",
data = r.json() "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"] 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?") raise ValueError("Could not parse ID from title - is the URL correct?")
if kind == "video": if kind == "video":
self.log.error("Single videos are not supported by this service.") episodes = self._episode(content_id)
sys.exit(1)
if kind == "show": if kind == "show":
data = self.session.get( episodes = self._show(content_id)
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)
raise ConnectionError(data["errors"]) return Series(episodes)
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"
]
)
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks: def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
platform = "firetv" if self.vcodec == "H.265" else "desktop" platform = "firetv" if self.vcodec == "H.265" else "desktop"
res = self.session.post( res = self._request(
self.config["endpoints"]["playback"].format(base_api=self.base_api), "POST",
json={ "/playback/v3/videoPlaybackInfo",
payload={
"videoId": title.id, "videoId": title.id,
"deviceInfo": { "deviceInfo": {
"adBlocker": "false", "adBlocker": "false",
@ -163,21 +139,10 @@ class DSCP(Service):
"streamProvider": {"suspendBeaconing": 0, "hlsVersion": 7, "pingConfig": 1}, "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] streaming = res["data"]["attributes"]["streaming"][0]
manifest = streaming["url"] manifest = streaming["url"]
if streaming["protection"]["drmEnabled"]: if streaming["protection"]["drmEnabled"]:
self.token = streaming["protection"]["drmToken"] self.token = streaming["protection"]["drmToken"]
@ -205,26 +170,124 @@ class DSCP(Service):
# Service specific functions # Service specific functions
def configure(self): def _show(self, title: str) -> Episode:
self.session.headers.update( params = {
{ "include": "default",
"user-agent": "Chrome/96.0.4664.55", "decorators": "playbackAllowed,contentAction,badges",
"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", data = self._request("GET", "/cms/routes/show/{}".format(title), params=params)
}
)
info = self.session.get(self.config["endpoints"]["info"]).json() content = next(x for x in data["included"] if x["attributes"].get("alias") == "generic-show-episodes")
self.base_api = info["data"]["attributes"]["baseApiUrl"] 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() seasons = [
if "errors" in user: self._request(
if "invalid.token" in user["errors"][0]["code"]: "GET",
self.log.error("- Invalid Token. Cookies are invalid or may have expired.") "/cms/collections/{}?{}&{}".format(content_id, season, show_id),
sys.exit(1) 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"] return [
self.user_language = user["data"]["attributes"]["clientTranslationLanguageTags"][0] Episode(
self.site_id = user["meta"]["site"]["id"] 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))

View File

@ -1,8 +1,4 @@
endpoints: headers:
info: https://global-prod.disco-api.com/bootstrapInfo user-agent: Chrome/96.0.4664.55
user: "{base_api}/users/me" x-disco-client: WEB:UNKNOWN:dplus_us:2.44.4
prod: "{base_api}/cms/configs/web-prod" x-disco-params: realm=go,siteLookupKey=dplus_us,bid=dplus,hn=www.discoveryplus.com,hth=,uat=false
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"