feat(DSCP): Add feature to download by episode URL
This commit is contained in:
parent
79a706fdea
commit
4431ebec31
@ -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))
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user