feat(services): Add CBC Gem service
This commit is contained in:
parent
fa23e46559
commit
9164e65784
313
services/CBC/__init__.py
Normal file
313
services/CBC/__init__.py
Normal file
@ -0,0 +1,313 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
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
|
||||
from devine.core.constants import AnyTrack
|
||||
from devine.core.credential import Credential
|
||||
from devine.core.manifests import DASH, HLS
|
||||
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, Chapters, Tracks
|
||||
from requests import Request
|
||||
|
||||
|
||||
class CBC(Service):
|
||||
"""
|
||||
\b
|
||||
Service code for CBC Gem streaming service (https://gem.cbc.ca/).
|
||||
|
||||
\b
|
||||
Author: stabbedbybrick
|
||||
Authorization: Credentials
|
||||
Robustness:
|
||||
AES-128: 1080p, DDP5.1
|
||||
Widevine: 720p, DDP5.1
|
||||
|
||||
\b
|
||||
Tips:
|
||||
- Input can be complete title URL or just the slug:
|
||||
SHOW: https://gem.cbc.ca/murdoch-mysteries OR murdoch-mysteries
|
||||
MOVIE: https://gem.cbc.ca/the-babadook OR the-babadook
|
||||
|
||||
\b
|
||||
Notes:
|
||||
- The CCExtrator implementation in Devine can't handle this service properly and might fail.
|
||||
If it does fail, you'll need to remove/comment out the code block in 'devine/commands/dl.py', line 577-616
|
||||
This will keep CCExtractor from being called at all.
|
||||
- Some audio tracks contain invalid data, causing warning messages from mkvmerge during muxing
|
||||
These can be ignored.
|
||||
|
||||
"""
|
||||
|
||||
GEOFENCE = ("ca",)
|
||||
ALIASES = ("gem", "cbcgem",)
|
||||
|
||||
@staticmethod
|
||||
@click.command(name="CBC", short_help="https://gem.cbc.ca/", help=__doc__)
|
||||
@click.argument("title", type=str)
|
||||
@click.pass_context
|
||||
def cli(ctx: Context, **kwargs: Any) -> CBC:
|
||||
return CBC(ctx, **kwargs)
|
||||
|
||||
def __init__(self, ctx: Context, title: str):
|
||||
self.title = title
|
||||
super().__init__(ctx)
|
||||
|
||||
self.base_url = self.config["endpoints"]["base_url"]
|
||||
|
||||
def search(self) -> Generator[SearchResult, None, None]:
|
||||
params = {
|
||||
"device": "web",
|
||||
"pageNumber": "1",
|
||||
"pageSize": "20",
|
||||
"term": self.title,
|
||||
}
|
||||
response = self._request("GET", "/ott/catalog/v1/gem/search", params=params)
|
||||
|
||||
for result in response.get("result", []):
|
||||
yield SearchResult(
|
||||
id_="https://gem.cbc.ca/{}".format(result.get("url")),
|
||||
title=result.get("title"),
|
||||
description=result.get("synopsis"),
|
||||
label=result.get("type"),
|
||||
url="https://gem.cbc.ca/{}".format(result.get("url")),
|
||||
)
|
||||
|
||||
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
|
||||
super().authenticate(cookies, credential)
|
||||
if not credential:
|
||||
raise EnvironmentError("Service requires Credentials for Authentication.")
|
||||
|
||||
login = self.cache.get(f"login_{credential.sha1}")
|
||||
tokens = self.cache.get(f"tokens_{credential.sha1}")
|
||||
|
||||
if login and not login.expired:
|
||||
# cached
|
||||
self.log.info(" + Using cached login tokens")
|
||||
auth_token = login.data["access_token"]
|
||||
|
||||
elif login and login.expired:
|
||||
payload = {
|
||||
"email": credential.username,
|
||||
"password": credential.password,
|
||||
"refresh_token": login.data["refresh_token"],
|
||||
}
|
||||
|
||||
params = {"apikey": self.config["endpoints"]["api_key"]}
|
||||
auth = self._request(
|
||||
"POST", "https://api.loginradius.com/identity/v2/auth/login",
|
||||
payload=payload,
|
||||
params=params,
|
||||
)
|
||||
|
||||
login.set(auth, expiration=auth["expires_in"])
|
||||
auth_token = login.data["access_token"]
|
||||
|
||||
self.log.info(" + Refreshed login tokens")
|
||||
|
||||
else:
|
||||
payload = {
|
||||
"email": credential.username,
|
||||
"password": credential.password,
|
||||
}
|
||||
params = {"apikey": self.config["endpoints"]["api_key"]}
|
||||
auth = self._request(
|
||||
"POST", "https://api.loginradius.com/identity/v2/auth/login",
|
||||
payload=payload,
|
||||
params=params,
|
||||
)
|
||||
|
||||
login.set(auth, expiration=auth["expires_in"])
|
||||
auth_token = login.data["access_token"]
|
||||
|
||||
self.log.info(" + Acquired fresh login tokens")
|
||||
|
||||
if tokens and not tokens.expired:
|
||||
# cached
|
||||
self.log.info(" + Using cached access tokens")
|
||||
access_token = tokens.data["accessToken"]
|
||||
|
||||
else:
|
||||
access = self.access_token(auth_token)
|
||||
tokens.set(access, expiration=access["accessTokenExpiresIn"])
|
||||
access_token = access["accessToken"]
|
||||
self.log.info(" + Acquired fresh access tokens")
|
||||
|
||||
claims_token = self.claims_token(access_token)
|
||||
self.session.headers.update({"x-claims-token": claims_token})
|
||||
|
||||
def get_titles(self) -> Union[Movies, Series]:
|
||||
title_re = r"^(?:https?://(?:www.)?gem.cbc.ca/)?(?P<id>[a-zA-Z0-9_-]+)"
|
||||
try:
|
||||
title_id = re.match(title_re, self.title).group("id")
|
||||
except Exception:
|
||||
raise ValueError("- Could not parse ID from title")
|
||||
|
||||
data = self._request("GET", "/ott/cbc-api/v2/shows/{}".format(title_id))
|
||||
label = data.get("seasons", [])[0].get("title")
|
||||
|
||||
if label.lower() in ("film", "movie"):
|
||||
movie = self._movie(data)
|
||||
return Movies(movie)
|
||||
|
||||
else:
|
||||
episodes = self._show(data)
|
||||
return Series(episodes)
|
||||
|
||||
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
||||
media_id = title.data["playSession"].get("mediaId")
|
||||
index = self._request(
|
||||
"GET", "/media/meta/v1/index.ashx",
|
||||
params={"appCode": "gem", "idMedia": media_id, "output": "jsonObject"}
|
||||
)
|
||||
|
||||
title.data["extra"] = {
|
||||
"chapters": index["Metas"].get("Chapitres"),
|
||||
"credits": index["Metas"].get("CreditStartTime"),
|
||||
}
|
||||
|
||||
self.drm = index["Metas"].get("isDrmActive") == "true"
|
||||
if self.drm:
|
||||
tech = next(tech["name"] for tech in index["availableTechs"] if "widevine" in tech["drm"])
|
||||
else:
|
||||
tech = next(tech["name"] for tech in index["availableTechs"] if not tech["drm"])
|
||||
|
||||
response = self._request(
|
||||
"GET", self.config["endpoints"]["validation"].format("android", media_id, "smart-tv", tech)
|
||||
)
|
||||
|
||||
manifest = response.get("url")
|
||||
self.license = next((x["value"] for x in response["params"] if "widevineLicenseUrl" in x["name"]), None)
|
||||
self.token = next((x["value"] for x in response["params"] if "widevineAuthToken" in x["name"]), None)
|
||||
|
||||
stream_type = HLS if tech == "hls" else DASH
|
||||
tracks = stream_type.from_url(manifest, self.session).to_tracks(language=index.get("Language", "en"))
|
||||
|
||||
if stream_type == DASH:
|
||||
for track in tracks.audio:
|
||||
label = track.data["dash"]["adaptation_set"].find("Label")
|
||||
if label is not None and "descriptive" in label.text.lower():
|
||||
track.descriptive = True
|
||||
|
||||
return tracks
|
||||
|
||||
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
|
||||
extra = title.data["extra"]
|
||||
|
||||
chapters = []
|
||||
if extra.get("chapters"):
|
||||
chapters = [Chapter(timestamp=x) for x in extra["chapters"].split(",")]
|
||||
|
||||
if extra.get("credits"):
|
||||
chapters.append(Chapter(name="Credits", timestamp=float(extra["credits"])))
|
||||
|
||||
return Chapters(chapters)
|
||||
|
||||
def get_widevine_service_certificate(self, **_: Any) -> str:
|
||||
return None
|
||||
|
||||
def get_widevine_license(
|
||||
self, *, challenge: bytes, title: Union[Movies, Series], track: AnyTrack
|
||||
) -> Optional[Union[bytes, str]]:
|
||||
if not self.license or not self.token:
|
||||
return None
|
||||
|
||||
headers = {"x-dt-auth-token": self.token}
|
||||
r = self.session.post(self.license, headers=headers, data=challenge)
|
||||
r.raise_for_status()
|
||||
return r.content
|
||||
|
||||
# Service specific
|
||||
|
||||
def _show(self, data: dict) -> Episode:
|
||||
episodes = [episode for season in data["seasons"] for episode in season["assets"] if not episode["isTrailer"]]
|
||||
|
||||
return Series(
|
||||
[
|
||||
Episode(
|
||||
id_=episode["id"],
|
||||
service=self.__class__,
|
||||
title=data.get("title"),
|
||||
season=int(episode.get("season", 0)),
|
||||
number=int(episode.get("episode", 0)),
|
||||
name=episode.get("title"),
|
||||
data=episode,
|
||||
)
|
||||
for episode in episodes
|
||||
]
|
||||
)
|
||||
|
||||
def _movie(self, data: dict) -> Movie:
|
||||
movies = [movie for season in data["seasons"] for movie in season["assets"] if not movie["isTrailer"]]
|
||||
|
||||
return [
|
||||
Movie(
|
||||
id_=movie.get("id"),
|
||||
service=self.__class__,
|
||||
name=data.get("title"),
|
||||
data=movie,
|
||||
)
|
||||
for movie in movies
|
||||
]
|
||||
|
||||
def access_token(self, token: str) -> str:
|
||||
params = {
|
||||
"access_token": token,
|
||||
"apikey": self.config["endpoints"]["api_key"],
|
||||
"jwtapp": "jwt",
|
||||
}
|
||||
|
||||
headers = {"content-type": "application/json"}
|
||||
resp = self._request(
|
||||
"GET", "https://cloud-api.loginradius.com/sso/jwt/api/token",
|
||||
headers=headers,
|
||||
params=params
|
||||
)
|
||||
|
||||
payload = {"jwt": resp.get("signature")}
|
||||
headers = {"content-type": "application/json", "ott-device-type": "web"}
|
||||
auth = self._request("POST", "/ott/cbc-api/v2/token", headers=headers, payload=payload)
|
||||
|
||||
return auth
|
||||
|
||||
def claims_token(self, token: str) -> str:
|
||||
headers = {
|
||||
"content-type": "application/json",
|
||||
"ott-device-type": "web",
|
||||
"ott-access-token": token,
|
||||
}
|
||||
response = self._request("GET", "/ott/cbc-api/v2/profile", headers=headers)
|
||||
return response["claimsToken"]
|
||||
|
||||
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)
|
||||
headers = headers or self.session.headers
|
||||
|
||||
prep = self.session.prepare_request(Request(method, url, params=params, headers=headers, json=payload))
|
||||
response = self.session.send(prep)
|
||||
if response.status_code not in (200, 426):
|
||||
raise ConnectionError(f"{response.status_code} - {response.text}")
|
||||
|
||||
try:
|
||||
data = json.loads(response.content)
|
||||
error_keys = ["errorMessage", "ErrorMessage", "ErrorCode", "errorCode", "error"]
|
||||
error_message = next((data.get(key) for key in error_keys if key in data), None)
|
||||
if error_message:
|
||||
self.log.error(f"\n - Error: {error_message}\n")
|
||||
sys.exit(1)
|
||||
|
||||
return data
|
||||
|
||||
except json.JSONDecodeError:
|
||||
raise ConnectionError("Request for {} failed: {}".format(response.url, response.text))
|
4
services/CBC/config.yaml
Normal file
4
services/CBC/config.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
endpoints:
|
||||
base_url: "https://services.radio-canada.ca"
|
||||
validation: "/media/validation/v2?appCode=gem&&deviceType={}&idMedia={}&manifestType={}&output=json&tech={}"
|
||||
api_key: "3f4beddd-2061-49b0-ae80-6f1f2ed65b37"
|
Loading…
Reference in New Issue
Block a user