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
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})?)?/(?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
@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))

View File

@ -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"
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