2024-10-14 19:25:14 +02:00
|
|
|
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
|
2025-01-17 10:45:14 +01:00
|
|
|
Version: 1.0.0
|
2024-10-14 19:25:14 +02:00
|
|
|
Author: stabbedbybrick
|
|
|
|
Authorization: Credentials
|
|
|
|
Robustness:
|
|
|
|
AES-128: 1080p, DDP5.1
|
2025-01-14 18:18:16 +01:00
|
|
|
Widevine L3: 720p, DDP5.1
|
2024-10-14 19:25:14 +02:00
|
|
|
|
|
|
|
\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:
|
2025-01-14 18:18:16 +01:00
|
|
|
- DRM encrypted titles max out at 720p.
|
2024-10-15 18:48:57 +02:00
|
|
|
- CCExtrator v0.94 will likely fail to extract subtitles. It's recommended to downgrade to v0.93.
|
2024-10-14 19:25:14 +02:00
|
|
|
- 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):
|
2025-01-14 18:18:16 +01:00
|
|
|
self.title: str = title
|
2024-10-14 19:25:14 +02:00
|
|
|
super().__init__(ctx)
|
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
self.base_url: str = self.config["endpoints"]["base_url"]
|
2024-10-14 19:25:14 +02:00
|
|
|
|
|
|
|
def search(self) -> Generator[SearchResult, None, None]:
|
|
|
|
params = {
|
|
|
|
"device": "web",
|
|
|
|
"pageNumber": "1",
|
|
|
|
"pageSize": "20",
|
|
|
|
"term": self.title,
|
|
|
|
}
|
2025-01-14 18:18:16 +01:00
|
|
|
response: dict = self._request("GET", "/ott/catalog/v1/gem/search", params=params)
|
2024-10-14 19:25:14 +02:00
|
|
|
|
|
|
|
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.")
|
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
tokens: Optional[Any] = self.cache.get(f"tokens_{credential.sha1}")
|
2024-10-14 19:25:14 +02:00
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
"""
|
|
|
|
All grant types for future reference:
|
|
|
|
PASSWORD("password"),
|
|
|
|
ACCESS_TOKEN("access_token"),
|
|
|
|
REFRESH_TOKEN("refresh_token"),
|
|
|
|
CLIENT_CREDENTIALS("client_credentials"),
|
|
|
|
AUTHORIZATION_CODE("authorization_code"),
|
|
|
|
CODE("code");
|
|
|
|
"""
|
2024-10-14 19:25:14 +02:00
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
if tokens and not tokens.expired:
|
|
|
|
# cached
|
|
|
|
self.log.info(" + Using cached tokens")
|
|
|
|
auth_token: str = tokens.data["access_token"]
|
|
|
|
|
|
|
|
elif tokens and tokens.expired:
|
|
|
|
# expired, refresh
|
|
|
|
self.log.info("Refreshing cached tokens...")
|
|
|
|
auth_url, scopes = self.settings()
|
|
|
|
params = {
|
|
|
|
"client_id": self.config["client"]["id"],
|
|
|
|
"grant_type": "refresh_token",
|
|
|
|
"refresh_token": tokens.data["refresh_token"],
|
|
|
|
"scope": scopes,
|
2024-10-14 19:25:14 +02:00
|
|
|
}
|
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
access: dict = self._request("POST", auth_url, params=params)
|
2024-10-14 19:25:14 +02:00
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
# Shorten expiration by one hour to account for clock skew
|
|
|
|
tokens.set(access, expiration=int(access["expires_in"]) - 3600)
|
|
|
|
auth_token: str = access["access_token"]
|
2024-10-14 19:25:14 +02:00
|
|
|
|
|
|
|
else:
|
2025-01-14 18:18:16 +01:00
|
|
|
# new
|
|
|
|
self.log.info("Requesting new tokens...")
|
|
|
|
auth_url, scopes = self.settings()
|
|
|
|
params = {
|
|
|
|
"client_id": self.config["client"]["id"],
|
|
|
|
"grant_type": "password",
|
|
|
|
"username": credential.username,
|
2024-10-14 19:25:14 +02:00
|
|
|
"password": credential.password,
|
2025-01-14 18:18:16 +01:00
|
|
|
"scope": scopes,
|
2024-10-14 19:25:14 +02:00
|
|
|
}
|
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
access: dict = self._request("POST", auth_url, params=params)
|
2024-10-14 19:25:14 +02:00
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
# Shorten expiration by one hour to account for clock skew
|
|
|
|
tokens.set(access, expiration=int(access["expires_in"]) - 3600)
|
|
|
|
auth_token: str = access["access_token"]
|
2024-10-14 19:25:14 +02:00
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
claims_token: str = self.claims_token(auth_token)
|
2024-10-14 19:25:14 +02:00
|
|
|
self.session.headers.update({"x-claims-token": claims_token})
|
|
|
|
|
|
|
|
def get_titles(self) -> Union[Movies, Series]:
|
2025-01-14 18:18:16 +01:00
|
|
|
title_re: str = r"^(?:https?://(?:www.)?gem.cbc.ca/)?(?P<id>[a-zA-Z0-9_-]+)"
|
2024-10-14 19:25:14 +02:00
|
|
|
try:
|
2025-01-14 18:18:16 +01:00
|
|
|
title_id: str = re.match(title_re, self.title).group("id")
|
2024-10-14 19:25:14 +02:00
|
|
|
except Exception:
|
|
|
|
raise ValueError("- Could not parse ID from title")
|
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
params = {"device": "web"}
|
|
|
|
data: dict = self._request("GET", "/ott/catalog/v2/gem/show/{}".format(title_id), params=params)
|
|
|
|
label: str = data.get("contentType", "").lower()
|
2024-10-14 19:25:14 +02:00
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
if label in ("film", "movie", "standalone"):
|
|
|
|
movies: list[Movie] = self._movie(data)
|
|
|
|
return Movies(movies)
|
2024-10-14 19:25:14 +02:00
|
|
|
|
|
|
|
else:
|
2025-01-14 18:18:16 +01:00
|
|
|
episodes: list[Episode] = self._show(data)
|
2024-10-14 19:25:14 +02:00
|
|
|
return Series(episodes)
|
|
|
|
|
|
|
|
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
|
2025-01-14 18:18:16 +01:00
|
|
|
index: dict = self._request(
|
|
|
|
"GET", "/media/meta/v1/index.ashx", params={"appCode": "gem", "idMedia": title.id, "output": "jsonObject"}
|
2024-10-14 19:25:14 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
title.data["extra"] = {
|
|
|
|
"chapters": index["Metas"].get("Chapitres"),
|
|
|
|
"credits": index["Metas"].get("CreditStartTime"),
|
|
|
|
}
|
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
self.drm: bool = index["Metas"].get("isDrmActive") == "true"
|
2024-10-14 19:25:14 +02:00
|
|
|
if self.drm:
|
2025-01-14 18:18:16 +01:00
|
|
|
tech: str = next(tech["name"] for tech in index["availableTechs"] if "widevine" in tech["drm"])
|
2024-10-14 19:25:14 +02:00
|
|
|
else:
|
2025-01-14 18:18:16 +01:00
|
|
|
tech: str = next(tech["name"] for tech in index["availableTechs"] if not tech["drm"])
|
2024-10-14 19:25:14 +02:00
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
response: dict = self._request(
|
|
|
|
"GET", self.config["endpoints"]["validation"].format("android", title.id, "smart-tv", tech)
|
2024-10-14 19:25:14 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
stream_type: Union[HLS, DASH] = HLS if tech == "hls" else DASH
|
|
|
|
tracks: Tracks = stream_type.from_url(manifest, self.session).to_tracks(language=title.language)
|
2024-10-14 19:25:14 +02:00
|
|
|
|
|
|
|
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
|
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
for track in tracks:
|
|
|
|
track.language = title.language
|
|
|
|
|
2024-10-14 19:25:14 +02:00
|
|
|
return tracks
|
|
|
|
|
|
|
|
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
|
2025-01-14 18:18:16 +01:00
|
|
|
extra: dict = title.data["extra"]
|
2024-10-14 19:25:14 +02:00
|
|
|
|
|
|
|
chapters = []
|
|
|
|
if extra.get("chapters"):
|
2024-10-27 11:20:57 +01:00
|
|
|
chapters = [Chapter(timestamp=x) for x in set(extra["chapters"].split(","))]
|
2024-10-14 19:25:14 +02:00
|
|
|
|
|
|
|
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
|
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
def _show(self, data: dict) -> list[Episode]:
|
|
|
|
lineups: list = next((x["lineups"] for x in data["content"] if x.get("title", "").lower() == "episodes"), None)
|
|
|
|
if not lineups:
|
|
|
|
self.log.warning("No episodes found for: {}".format(data.get("title")))
|
|
|
|
return
|
|
|
|
|
|
|
|
titles = []
|
|
|
|
for season in lineups:
|
|
|
|
for episode in season["items"]:
|
|
|
|
if episode.get("mediaType", "").lower() == "episode":
|
|
|
|
parts = episode.get("title", "").split(".", 1)
|
|
|
|
episode_name = parts[1].strip() if len(parts) > 1 else parts[0].strip()
|
|
|
|
titles.append(
|
|
|
|
Episode(
|
|
|
|
id_=episode["idMedia"],
|
|
|
|
service=self.__class__,
|
|
|
|
title=data.get("title"),
|
|
|
|
season=int(season.get("seasonNumber", 0)),
|
|
|
|
number=int(episode.get("episodeNumber", 0)),
|
|
|
|
name=episode_name,
|
|
|
|
year=episode.get("metadata", {}).get("productionYear"),
|
|
|
|
language=data["structuredMetadata"].get("inLanguage", "en-CA"),
|
|
|
|
data=episode,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
return titles
|
|
|
|
|
|
|
|
def _movie(self, data: dict) -> list[Movie]:
|
|
|
|
unwanted: tuple = ("episodes", "trailers", "extras")
|
|
|
|
lineups: list = next((x["lineups"] for x in data["content"] if x.get("title", "").lower() not in unwanted), None)
|
|
|
|
if not lineups:
|
|
|
|
self.log.warning("No movies found for: {}".format(data.get("title")))
|
|
|
|
return
|
|
|
|
|
|
|
|
titles = []
|
|
|
|
for season in lineups:
|
|
|
|
for movie in season["items"]:
|
|
|
|
if movie.get("mediaType", "").lower() == "episode":
|
|
|
|
parts = movie.get("title", "").split(".", 1)
|
|
|
|
movie_name = parts[1].strip() if len(parts) > 1 else parts[0].strip()
|
|
|
|
titles.append(
|
|
|
|
Movie(
|
|
|
|
id_=movie.get("idMedia"),
|
|
|
|
service=self.__class__,
|
|
|
|
name=movie_name,
|
|
|
|
year=movie.get("metadata", {}).get("productionYear"),
|
|
|
|
language=data["structuredMetadata"].get("inLanguage", "en-CA"),
|
|
|
|
data=movie,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
return titles
|
|
|
|
|
|
|
|
def settings(self) -> tuple:
|
|
|
|
settings = self._request("GET", "/ott/catalog/v1/gem/settings", params={"device": "web"})
|
|
|
|
auth_url: str = settings["identityManagement"]["ropc"]["url"]
|
|
|
|
scopes: str = settings["identityManagement"]["ropc"]["scopes"]
|
|
|
|
return auth_url, scopes
|
2024-10-14 19:25:14 +02:00
|
|
|
|
|
|
|
def claims_token(self, token: str) -> str:
|
|
|
|
headers = {
|
2025-01-14 18:18:16 +01:00
|
|
|
"Authorization": "Bearer " + token,
|
2024-10-14 19:25:14 +02:00
|
|
|
}
|
2025-01-14 18:18:16 +01:00
|
|
|
params = {"device": "web"}
|
|
|
|
response: dict = self._request(
|
|
|
|
"GET", "/ott/subscription/v2/gem/Subscriber/profile", headers=headers, params=params
|
|
|
|
)
|
2024-10-14 19:25:14 +02:00
|
|
|
return response["claimsToken"]
|
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
def _request(self, method: str, api: str, **kwargs: Any) -> Any[dict | str]:
|
|
|
|
url: str = urljoin(self.base_url, api)
|
2024-10-14 19:25:14 +02:00
|
|
|
|
2025-01-14 18:18:16 +01:00
|
|
|
prep: Request = self.session.prepare_request(Request(method, url, **kwargs))
|
2024-10-14 19:25:14 +02:00
|
|
|
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))
|