Add discovery

This commit is contained in:
Nirvana
2026-03-10 12:37:57 +01:00
parent 6c03133087
commit f0e13c992d
4 changed files with 102 additions and 29 deletions
@@ -114,9 +114,20 @@ class RTLPlusDefaults:
"romance", "science-fiction", "thriller",
]
# Shows root / genre constants
VOD_SHOWS_ROOT_WATCH_PATH = "/video-tv/shows"
VOD_SHOWS_GENRE_WATCH_PATH = "/video-tv/shows/genre"
VOD_SHOWS_ROOT_ID = "/video-tv/shows"
VOD_SHOW_GENRE_SLUGS = [
"action", "comedy", "crime", "dokumentation",
"drama", "kinder", "reality", "romance", "thriller",
]
# OverviewPage elementType values and pagination
VOD_ELEMENT_TYPE_MOVIE = "MOVIE"
VOD_ELEMENT_TYPE_SERIES = "SERIES"
VOD_ELEMENT_TYPE_SHOW = "SHOW"
VOD_OVERVIEW_PAGE_LIMIT = 48
@@ -139,8 +150,9 @@ class RTLPlusGraphQL:
"MRE": "0c77404637570adff548e329a48654498c54ce1c36a459d72586ff18999bebaa",
"Episode": "87dbde15a0d269b11606f5ff458d555e98eb493bb4fb6ddc150d812d5e9a9cf8",
"WatchPlayerConfigV3":"fea0311fb572b6fded60c5a1a9d652f97f55d182bc4cedbdad676354a8d2797c",
"SeoUrlData": "fcc4a812d6b93496f00c3068234db7722f553032bb760e09e5e6c74586c86f8d",
"OverviewPage": "28aad4e992bb63330bfcd40a6906af3119d8a2612fa9fd28dae9c19127e247ca",
"SeoUrlData": "fcc4a812d6b93496f00c3068234db7722f553032bb760e09e5e6c74586c86f8d",
"OverviewPage": "28aad4e992bb63330bfcd40a6906af3119d8a2612fa9fd28dae9c19127e247ca",
"LiveEventsOverview": "77a8f26d5de76daf801fcd7ae54bebd0ecabbeeb3ecb90889bc4a2e5590b7d20",
}
@classmethod
@@ -172,6 +184,11 @@ class RTLPlusGraphQL:
f'{{"area":"{area}","offset":{offset},"take":{take}}}',
)
@classmethod
def live_events_overview_page(cls) -> dict:
"""LiveEventsOverview — all upcoming/live events grouped by teaserRows."""
return cls._build("LiveEventsOverview", "{}")
# --- VOD ---
@classmethod
@@ -200,21 +200,22 @@ class RTLPlusLiveEvent:
@classmethod
def from_api_node(cls, node: Dict[str, Any]) -> "RTLPlusLiveEvent":
"""Parse a single LiveEvent GraphQL node."""
"""Parse a single LiveEvent GraphQL node from LiveEventsOverview."""
details = node.get("details") or {}
images = node.get("images") or {}
return cls(
id=node["id"],
title=node["title"],
stream_start=node["streamStart"],
stream_end=node["streamEnd"],
location=node.get("location"),
location=details.get("stadium"),
event_category=node.get("eventCategory", "GENERIC"),
event_sub_category=node.get("eventSubCategory", ""),
description=node.get("description"),
short_description=node.get("shortDescription"),
short_description=None,
logo_url=(
node.get("liveImages", {})
.get("artworkLandscape", {})
.get("url")
images.get("artworkLandscape", {}).get("url")
or images.get("artworkPortrait", {}).get("url")
),
required_permission=node.get(
"requiredPermission", RTLPlusDefaults.PERMISSION_PAY_TV
@@ -287,4 +288,4 @@ class RTLPlusStreamInfo:
def has_drm(self) -> bool:
"""Check if stream has DRM protection"""
return bool(self.drm_license_url and self.drm_key_id)
return bool(self.drm_license_url and self.drm_key_id)
@@ -209,13 +209,12 @@ class RTLPlusProvider(StreamingProvider):
return events
def _fetch_live_events(self) -> List[RTLPlusLiveEvent]:
access_token = self.authenticator.get_bearer_token()
headers = self.rtl_config.get_events_headers(access_token=access_token)
headers = self._get_rtlplus_authenticated_headers()
try:
response = self.http_manager.get(
self.rtl_config.graphql_endpoint,
params=RTLPlusGraphQL.explore_widget_watch(),
params=RTLPlusGraphQL.live_events_overview_page(),
headers=headers,
operation="api",
)
@@ -229,25 +228,30 @@ class RTLPlusProvider(StreamingProvider):
@staticmethod
def _parse_live_events(data: Dict[str, Any]) -> List[RTLPlusLiveEvent]:
"""
Extract LiveEvent nodes from the GraphQL response.
Extract LiveEvent nodes from the LiveEventsOverview GraphQL response.
Response shape: data.liveEventsOverview.teaserRows[].events[]
Static so it's unit-testable without provider state.
"""
events: List[RTLPlusLiveEvent] = []
widgets = (
seen: set = set()
teaser_rows = (
data.get("data", {})
.get("editorialView", {})
.get("widgets", {})
.get("items", [])
.get("liveEventsOverview", {})
.get("teaserRows", [])
)
for widget in widgets:
if widget.get("__typename") != "LiveEventWidget":
continue
for element in widget.get("elements", []):
for row in teaser_rows:
for element in (row.get("events") or []):
if element is None:
continue
if element.get("__typename") != "LiveEvent":
continue # skip ExternalTeaser etc.
continue
event_id = element.get("id", "")
if not event_id or event_id in seen:
continue
seen.add(event_id)
try:
events.append(RTLPlusLiveEvent.from_api_node(element))
except Exception as e:
@@ -139,6 +139,10 @@ class RTLPlusVodManager:
if category_path[0].startswith(RTLPlusDefaults.VOD_SERIES_ROOT_WATCH_PATH):
return self._dispatch_series(category_path)
# Shows branch — watch-path based IDs
if category_path[0].startswith(RTLPlusDefaults.VOD_SHOWS_ROOT_WATCH_PATH):
return self._dispatch_shows(category_path)
# TopicWorlds branch — server RRNs only (rrn:multipurpose:...)
if depth == 1 and category_path[0].startswith("rrn:"):
return self._list_formats_for_topic(category_path[0])
@@ -191,6 +195,12 @@ class RTLPlusVodManager:
provider="rtlplus",
description="Serien auf RTL+",
),
VodCategory(
name="Shows",
content_id=RTLPlusDefaults.VOD_SHOWS_ROOT_WATCH_PATH,
provider="rtlplus",
description="Shows auf RTL+",
),
]
results.extend(self._list_topic_worlds())
logger.debug(f"RTLPlusVodManager: _list_root() → {len(results)} nodes")
@@ -215,10 +225,14 @@ class RTLPlusVodManager:
element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_MOVIE,
genre_slug=genre_slug,
)
return self._list_genres(
genres = self._list_genres(
root_watch_path=RTLPlusDefaults.VOD_MOVIES_ROOT_WATCH_PATH,
genre_slugs=RTLPlusDefaults.VOD_MOVIE_GENRE_SLUGS,
)
items = self._list_overview_page_items(
element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_MOVIE,
)
return genres + items
if depth == 2:
genre_slug = category_path[1].rsplit("/", 1)[-1]
return self._list_overview_page_items(
@@ -245,10 +259,14 @@ class RTLPlusVodManager:
element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_SERIES,
genre_slug=genre_slug,
)
return self._list_genres(
genres = self._list_genres(
root_watch_path=RTLPlusDefaults.VOD_SERIES_ROOT_WATCH_PATH,
genre_slugs=RTLPlusDefaults.VOD_SERIES_GENRE_SLUGS,
)
items = self._list_overview_page_items(
element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_SERIES,
)
return genres + items
if depth == 2:
genre_slug = category_path[1].rsplit("/", 1)[-1]
return self._list_overview_page_items(
@@ -258,6 +276,39 @@ class RTLPlusVodManager:
logger.warning(f"RTLPlusVodManager: series path too deep: {category_path!r}")
return []
# ------------------------------------------------------------------
# Shows branch dispatcher
# ------------------------------------------------------------------
def _dispatch_shows(
self, category_path: List[str]
) -> List[Union[VodCategory, VodItem]]:
depth = len(category_path)
if depth == 1:
genre_prefix = RTLPlusDefaults.VOD_SHOWS_ROOT_WATCH_PATH + "/genre/"
if category_path[0].startswith(genre_prefix):
genre_slug = category_path[0][len(genre_prefix):].split("/")[0]
return self._list_overview_page_items(
element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_SHOW,
genre_slug=genre_slug,
)
genres = self._list_genres(
root_watch_path=RTLPlusDefaults.VOD_SHOWS_ROOT_WATCH_PATH,
genre_slugs=RTLPlusDefaults.VOD_SHOW_GENRE_SLUGS,
)
items = self._list_overview_page_items(
element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_SHOW,
)
return genres + items
if depth == 2:
genre_slug = category_path[1].rsplit("/", 1)[-1]
return self._list_overview_page_items(
element_type=RTLPlusDefaults.VOD_ELEMENT_TYPE_SHOW,
genre_slug=genre_slug,
)
logger.warning(f"RTLPlusVodManager: shows path too deep: {category_path!r}")
return []
# ------------------------------------------------------------------
# Shared genre list (movies + series, depth 1 of each branch)
# ------------------------------------------------------------------
@@ -323,13 +374,13 @@ class RTLPlusVodManager:
def _list_overview_page_items(
self,
element_type: str,
genre_slug: str,
genre_slug: Optional[str] = None,
) -> List[Union[VodCategory, VodItem]]:
"""
Paginate OverviewPage for a single genre slug.
Paginate OverviewPage for an element type, optionally filtered by genre.
element_type: RTLPlusDefaults.VOD_ELEMENT_TYPE_MOVIE or …SERIES
genre_slug: e.g. "drama", "action"
genre_slug: e.g. "drama", "action" — omit for unfiltered (all items)
Series items at this level are returned as VodCategory nodes (each
wraps a Format RRN) so the caller can drill into seasons/episodes via
@@ -344,7 +395,7 @@ class RTLPlusVodManager:
while True:
params = RTLPlusGraphQL.overview_page(
element_type=element_type,
genres=[genre_slug],
genres=[genre_slug] if genre_slug else None,
offset=offset,
limit=limit,
)
@@ -390,7 +441,7 @@ class RTLPlusVodManager:
logger.debug(
f"RTLPlusVodManager: _list_overview_page_items"
f"({element_type}, {genre_slug}) → {len(results)} items"
f"({element_type}, genre={genre_slug}) → {len(results)} items"
)
return results