Files
devine_services/RTLP/__init__.py

458 lines
21 KiB
Python

from __future__ import annotations
from http.cookiejar import MozillaCookieJar
from typing import Any, Optional, Union
import click
import requests
from devine.core.titles import Episode, Movie, Movies, Series
from devine.core.manifests import DASH
from devine.core.constants import AnyTrack
from devine.core.service import Service
from devine.core.credential import Credential
from devine.core.tracks import Chapters, Tracks, Track
import re
import json
import base64
import hashlib
import secrets
class RTLP(Service):
# List of Service Aliases. Do NOT include the Service Tag. All aliases must be lowercase.
ALIASES = ()
# List of regions of which the service offers support for.
GEOFENCE = ("de", "at")
TITLE_RE = r"^https?:\/\/plus\.rtl\.de\/video-tv\/(?P<kind>shows|serien|filme)\/(?:[^\/]+-)?(?P<show_id>\d+)(?:\/[^\/]+-)?(?P<season_id>\d+)?(?:\/[^\/]+-)?(?P<episode_id>\d+)?$"
AUTH_CODE_REGEX = r"code=([\w-]+\.[\w-]+\.[\w-]+)"
@staticmethod
@click.command(name="RTLP", short_help="https://plus.rtl.de", help=__doc__)
@click.argument("title", type=str)
@click.pass_context
def cli(ctx: click.Context, **kwargs: Any) -> RTLP:
return RTLP(ctx, **kwargs)
def __init__(self, ctx: click.Context, title: str):
self.title = title
super().__init__(ctx)
def get_session(self) -> requests.Session:
# modify the creation of the requests session (stored as self.session)
# make a super() call to take the original result and further modify it,
# or don't to make a completely fresh one if required.
session = super().get_session()
# Set default headers as specified
session.headers.update({
'Accept': '*/*',
'Accept-Language': 'de,de-DE;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
'Origin': 'https://plus.rtl.de',
'Referer': 'https://plus.rtl.de/',
'Rtlplus-Client-Id': 'rci:rtlplus:web',
'Rtlplus-Client-Version': '2024.7.29.2',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36 Edg/137.0.0.0'
})
return session
def authenticate(self, cookies: Optional[MozillaCookieJar] = None, credential: Optional[Credential] = None) -> None:
super().authenticate(cookies, credential) # important
if not cookies:
self._authenticate_anonymous()
else:
self._authenticate_with_cookies(cookies)
def generate_code_verifier(self):
return secrets.token_urlsafe(64)
def generate_code_challenge(self, verifier):
sha256_hash = hashlib.sha256(verifier.encode()).digest()
return base64.urlsafe_b64encode(sha256_hash).decode().rstrip("=")
def _authenticate_with_cookies(self, cookies: MozillaCookieJar) -> None:
auth_url = self.config["endpoints"]["auth_url"]
token_url = self.config["endpoints"]["token_url"]
code_verifier = self.generate_code_verifier()
code_challenge = self.generate_code_challenge(code_verifier)
redirect_url_request = self.session.get(
auth_url,
headers={
**self.session.headers,
},
params={
'client_id': 'rtlplus-web',
'redirect_uri': 'https://plus.rtl.de/silent-check-sso.html',
'response_type': 'code',
'scope': 'openid',
'code_challenge_method': 'S256',
'code_challenge': code_challenge
},
cookies=cookies,
)
redirect_url_request.raise_for_status()
redirect_url = redirect_url_request.url
auth_code_match = re.search(self.AUTH_CODE_REGEX, redirect_url)
if auth_code_match:
auth_code = auth_code_match.group(1)
self.log.debug(f"Auth Code: {auth_code}")
else:
self.log.error("Authorization code not found in redirect URL.")
raise EnvironmentError("Could not find authorization code.")
response = self.session.post(
token_url,
headers={
**self.session.headers,
'Content-Type': 'application/x-www-form-urlencoded'
},
cookies=cookies,
data=bytes(f'grant_type=authorization_code&client_id=rtlplus-web&redirect_uri=https%3A%2F%2Fplus.rtl.de%2Fsilent-check-sso.html&code={auth_code}&code_verifier={code_verifier}', 'utf-8'),
)
response.raise_for_status()
auth_response = response.json()
if 'access_token' in auth_response:
self._rtlp_auth_jwt = auth_response['access_token']
self.log.info("Successfully authenticated with cookies.")
else:
self.log.error("No access_token found in response.")
raise EnvironmentError("Cookie authentication failed: no access token in response.")
def _authenticate_anonymous(self) -> None:
token_url = self.config["endpoints"]["token_url"]
response = self.session.post(
token_url,
headers={
**self.session.headers,
'Content-Type': 'application/x-www-form-urlencoded'
},
data=bytes('grant_type=client_credentials&client_id=anonymous-user&client_secret=4bfeb73f-1c4a-4e9f-a7fa-96aa1ad3d94c', 'utf-8'),
)
response.raise_for_status()
auth_response = response.json()
if 'access_token' in auth_response:
self._rtlp_auth_jwt = auth_response['access_token']
self.log.info("Authenticated anonymously with RTL+ service successfully.")
else:
self.log.error("No access_token found in response.")
raise EnvironmentError("Anonymous authentication failed: no access token in response.")
# Required methods:
def get_titles(self) -> Union[Movies, Series]:
graphql_url = self.config["endpoints"]["graphql_url"]
try:
kind, show_id, season_id, episode_id = (
re.match(self.TITLE_RE, self.title).group(i) for i in ("kind", "show_id", "season_id", "episode_id")
)
except Exception:
raise ValueError("Could not parse ID from title - is the URL correct?")
if not kind or not show_id:
self.log.error(f"Invalid title URL: {self.title}. 'kind' or 'show_id' is missing.")
raise ValueError("Invalid title URL: 'kind' or 'show_id' is missing.")
if kind == "filme":
content_id = f'rrn:watch:videohub:movie:{show_id}'
response_data = self._execute_graphql_query('MovieDetail', {'id': content_id}, 'b1c360212cc518ddca2b8377813a54fa918ca424c08086204b7bf7d6ef626ac4')
if 'movie' in response_data:
movie_data = response_data['movie']
if 'id' not in movie_data or 'title' not in movie_data or 'productionYear' not in movie_data:
self.log.error("Invalid movie_data data received.")
raise ValueError("Invalid movie_data data received from RTL+ service.")
self.log.debug(f"Movie ID: {content_id}, Title: {movie_data['title']}")
return Movies(
[
Movie(
id_=content_id,
service=self.__class__,
name=movie_data['title'],
data=movie_data,
year=movie_data['productionYear'],
)
]
)
self.log.error(f"Failed to fetch Movie data: {response.status_code} - {response.text}")
raise EnvironmentError("Failed to fetch Movie data from RTL+ service.")
if kind == "shows" or kind == "serien":
if episode_id:
content_id = f'rrn:watch:videohub:episode:{episode_id}'
response_data = self._execute_graphql_query('EpisodeDetail', {'episodeId': content_id}, '2e5ef142c79f8620e8e93c8f21b31a463b16d89a557f7f5f0c4a7e063be96a8a')
if 'episode' in response_data:
episode_data = response_data['episode']
if 'id' not in episode_data or 'title' not in episode_data or 'number' not in episode_data or 'episodeSeason' not in episode_data:
self.log.error("Invalid episode data received.")
raise ValueError("Invalid episode data received from RTL+ service.")
if 'format' not in episode_data and 'title' not in episode_data['format']:
self.log.error("Invalid episode format received.")
raise ValueError("Invalid episode format received from RTL+ service.")
return Series(
[
Episode(
id_=content_id,
service=self.__class__,
title=episode_data['format']['title'],
season=self.get_episode_session(episode_data),
number=episode_data['number'],
name=episode_data['title'],
data=episode_data,
)
]
)
elif season_id:
content_id = f'rrn:watch:videohub:season:{season_id}'
response_data = self._execute_graphql_query('SeasonWithFormatAndEpisodes', {'seasonId': content_id}, 'cc0fbbe17143f549a35efa6f8665ceb9b1cfae44b590f0b2381a9a304304c584')
if 'season' in response_data:
season_data = response_data['season']
if 'format' not in season_data or 'title' not in season_data['format']:
self.log.error("Invalid season format received.")
raise ValueError("Invalid season format received from RTL+ service.")
if not 'episodes' in season_data or not isinstance(season_data['episodes'], list):
self.log.error("Invalid season data received.")
raise ValueError("Invalid season data received from RTL+ service.")
episodes = []
for episode in season_data['episodes']:
if 'id' not in episode or 'title' not in episode or 'number' not in episode or 'episodeSeason' not in episode:
self.log.error("Invalid episode data received.")
raise ValueError("Invalid episode data received from RTL+ service.")
episodes.append(
Episode(
id_=episode['id'],
service=self.__class__,
title=season_data['format']['title'],
season=self.get_episode_session(episode),
number=episode['number'],
name=episode['title'],
data=episode,
)
)
return Series(
episodes
)
elif show_id:
content_id = f'rrn:watch:videohub:format:{show_id}'
response_data = self._execute_graphql_query('Format', {'id': content_id}, 'd112638c0184ab5698af7b69532dfe2f12973f7af9cb137b9f70278130b1eafa')
if 'format' in response_data:
format_data = response_data['format']
if 'title' not in format_data or 'id' not in format_data:
self.log.error("Invalid format data received.")
raise ValueError("Invalid format data received from RTL+ service.")
if 'seasons' not in format_data or not isinstance(format_data['seasons'], list):
self.log.error("Invalid format seasons data received.")
raise ValueError("Invalid format seasons data received from RTL+ service.")
episodes = []
for season in format_data['seasons']:
if not 'id' in season or not 'seasonType' in season:
self.log.error("Invalid season data received.")
raise ValueError("Invalid season data received from RTL+ service.")
season_id = season['id']
response_data = self._execute_graphql_query('SeasonWithFormatAndEpisodes', {'seasonId': season_id}, 'cc0fbbe17143f549a35efa6f8665ceb9b1cfae44b590f0b2381a9a304304c584')
if 'season' in response_data:
season_data = response_data['season']
if 'format' not in season_data or 'title' not in season_data['format']:
self.log.error("Invalid season format received.")
raise ValueError("Invalid season format received from RTL+ service.")
if not 'episodes' in season_data or not isinstance(season_data['episodes'], list):
self.log.error("Invalid season data received.")
raise ValueError("Invalid season data received from RTL+ service.")
for episode in season_data['episodes']:
if 'id' not in episode or 'title' not in episode or 'number' not in episode or 'episodeSeason' not in episode:
self.log.error("Invalid episode data received.")
raise ValueError("Invalid episode data received from RTL+ service.")
episodes.append(
Episode(
id_=episode['id'],
service=self.__class__,
title=season_data['format']['title'],
season=self.get_episode_session(episode),
number=episode['number'],
name=episode['title'],
data=episode,
)
)
return Series(
episodes
)
self.log.error(f"Failed to fetch series data: {response.status_code} - {response.text}")
raise EnvironmentError("Failed to fetch series data from RTL+ service.")
def get_tracks(self, title: Union[Episode, Movie]) -> Tracks:
playout_url = self.config["endpoints"]["playout"]
if isinstance(title, Episode) or isinstance(title, Movie):
playout_url = playout_url.format(id=title.data['id'])
else:
self.log.error(f"Unsupported title type: {type(title)}. Expected Series or Movies.")
raise ValueError(f"Unsupported title type: {type(title)}. Expected Series or Movies.")
response = self.session.get(
playout_url,
headers={
**self.session.headers,
'x-auth-token': self._rtlp_auth_jwt
}
)
if response and response.status_code == 200:
response_data = response.json()
all_parsed_tracks = [] # Use a list to collect all individual track objects
for variant in response_data:
if 'name' not in variant:
self.log.error("Invalid playout variant data received.")
raise ValueError("Invalid playout variant data received from RTL+ service.")
# Assuming 'dashsd' and 'dashhd' variants contain the MPD URLs
if variant['name'] == 'dashhd':
if 'sources' not in variant and len(variant['sources']) == 0:
self.log.warning(f"Variant '{variant['name']}' has no sources. Skipping.")
continue
source_entry = variant['sources'][0]
# Assuming the 'url' key in each source_entry is the DASH manifest URL
if not 'url' in source_entry:
self.log.warning(f"DASH source entry missing 'url': {source_entry}. Skipping.")
continue
manifest_url = source_entry['url']
try:
all_parsed_tracks = DASH.from_url(manifest_url, self.session).to_tracks(language="de") # Use title's language for filtering/tagging
except Exception as e:
self.log.error(f"Failed to parse DASH manifest from {manifest_url}: {e}")
# Decide if you want to raise or just log and continue for other manifests
continue
# Return a new Tracks object containing all collected tracks
return Tracks(all_parsed_tracks)
else:
self.log.error(f"Failed to fetch tracks data: {response.status_code} - {response.text}")
raise EnvironmentError("Failed to fetch tracks data from RTL+ service.")
def get_chapters(self, title: Union[Movies, Series]) -> list[Chapter]:
# technically optional, but you must define and at least `return []`.
return Chapters()
def get_widevine_service_certificate(self, *, challenge: bytes, title: Union[Movies, Series], track: AnyTrack) -> Union[bytes, str]:
return None
def get_widevine_license(self, *, challenge: bytes, title: Union[Movies, Series], track: AnyTrack) -> Optional[Union[bytes, str]]:
license_url = self.config["endpoints"]["license"]
response = self.session.post(
license_url,
headers={
**self.session.headers,
'Content-Type': 'application/octet-stream',
'x-auth-token': self._rtlp_auth_jwt
},
data=challenge
)
if response.status_code != 200:
self.log.error(f"Failed to fetch license: {response.status_code} - {response.text}")
raise ConnectionError(response.text)
self.log.info("Successfully fetched Widevine license from RTL+ service.")
return response.content
def _execute_graphql_query(self, operation_name: str, variables: dict, persisted_query_hash: str) -> dict:
response = self.session.get(
self.config["endpoints"]["graphql_url"],
headers={
**self.session.headers,
'Authorization': f'Bearer {self._rtlp_auth_jwt}'
},
params={
'operationName': operation_name,
'variables': json.dumps(variables).encode(),
'extensions': json.dumps({
'persistedQuery': {'version': 1, 'sha256Hash': persisted_query_hash}
}).encode()
}
)
response.raise_for_status()
response_data = response.json()
if 'data' not in response_data:
self.log.error(f"GraphQL response for '{operation_name}' missing 'data' field.")
raise ValueError(f"Invalid GraphQL response for '{operation_name}'.")
return response_data['data']
def get_episode_session(self, episode) -> int:
season_value = None
if 'seasonType' not in episode['episodeSeason']:
self.log.error("Invalid episode season received.")
raise ValueError("Invalid episode season received from RTL+ service.")
seasonType = episode['episodeSeason']['seasonType']
if seasonType == 'ANNUAL':
if 'season' in episode['episodeSeason'] and 'year' in episode['episodeSeason']['season']:
season_value = int(episode['episodeSeason']['season']['year'])
elif seasonType == 'ORDINAL':
if 'season' in episode['episodeSeason'] and 'ordinal' in episode['episodeSeason']['season']:
season_value = int(episode['episodeSeason']['season']['ordinal'])
else:
self.log.error(f"Unknown season type '{seasonType}' received.")
raise ValueError(f"Unknown season type '{seasonType}' received from RTL+ service.")
return season_value